Compare commits

..

No commits in common. "master" and "v1.0.0" have entirely different histories.

30 changed files with 4324 additions and 4763 deletions

View File

@ -1,17 +0,0 @@
version = 1
exclude_patterns = [
"pywidevine/license_protocol_pb2.py"
]
[[analyzers]]
name = "python"
enabled = true
[analyzers.meta]
runtime_version = "3.x.x"
max_line_length = 120
[[analyzers]]
name = "secrets"
enabled = false

View File

@ -1,15 +0,0 @@
root = true
[*]
end_of_line = lf
charset = utf-8
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.{feature,json,md,yaml,yml,toml}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false

1
.gitattributes vendored
View File

@ -1 +0,0 @@
* text=auto eol=lf

View File

@ -10,21 +10,25 @@ jobs:
name: Tagged Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v2
with:
python-version: "3.12"
python-version: '3.10.x'
- name: Install Poetry
uses: abatilo/actions-poetry@v2
uses: abatilo/actions-poetry@v2.1.0
with:
poetry-version: 1.6.1
- name: Install project
run: poetry install --only main
- name: Build project
poetry-version: '1.1.11'
- name: Configure poetry
run: poetry config virtualenvs.in-project true
- name: Install dependencies
run: |
python -m pip install --upgrade pip wheel
poetry install
- name: Build a wheel
run: poetry build
- name: Upload wheel
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v2.2.4
with:
name: Python Wheel
path: "dist/*.whl"

View File

@ -7,38 +7,39 @@ on:
branches: [ master ]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.12"
- name: Install poetry
uses: abatilo/actions-poetry@v2
with:
poetry-version: 1.6.1
- name: Install project
run: poetry install --all-extras
- name: Run pre-commit which does various checks
run: poetry run pre-commit run --all-files --show-diff-on-failure
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
python-version: ['3.7', '3.8', '3.9', '3.10']
poetry-version: [1.1.11]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install poetry
uses: abatilo/actions-poetry@v2
uses: abatilo/actions-poetry@v2.1.0
with:
poetry-version: 1.6.1
poetry-version: ${{ matrix.poetry-version }}
- name: Install project
run: poetry install --all-extras --only main
run: |
poetry install --no-dev
python -m pip install flake8 pytest
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
# - name: Test with pytest
# run: |
# pytest
- name: Build project
run: poetry build

43
.gitignore vendored
View File

@ -1,6 +1,3 @@
# pywidevine
*.wvd
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
@ -23,6 +20,7 @@ parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
@ -52,7 +50,6 @@ coverage.xml
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
@ -75,7 +72,6 @@ instance/
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
@ -86,9 +82,7 @@ profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
@ -97,22 +91,7 @@ ipython_config.py
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
@ -138,6 +117,9 @@ venv.bak/
# Rope project settings
.ropeproject
# Jetbrains project settings
.idea
# mkdocs documentation
/site
@ -148,16 +130,3 @@ dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/

View File

@ -1,20 +0,0 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
exclude: '_pb2.pyi?$'
repos:
- repo: https://github.com/mtkennerly/pre-commit-hooks
rev: v0.3.0
hooks:
- id: poetry-ruff
- id: poetry-mypy
- repo: https://github.com/pycqa/isort
rev: 5.11.5
hooks:
- id: isort
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]

View File

@ -1,12 +0,0 @@
{
"recommendations": [
"EditorConfig.EditorConfig",
"streetsidesoftware.code-spell-checker",
"ms-python.python",
"ms-python.vscode-pylance",
"charliermarsh.ruff",
"ms-python.isort",
"ms-python.mypy-type-checker",
"redhat.vscode-yaml"
]
}

View File

@ -1,516 +0,0 @@
# Changelog
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.8.0] - 2023-12-22
### Added
- Added `py.typed` file to support PEP561 and silence Mypy.
### Changed
- Dropped support for Python 3.7.
- Recompiled protobuffers for version 4.25.
### Fixed
- Missing `yaml` dependency as it was only installed alongside the `serve` extras group.
- Duplicate Concatenated SignedMessages no longer throw a verification failure in `Cdm.set_service_certificate()`.
To ensure security of the messages, verification will still fail if any of the SignedMessages do not match each other.
### New Contributors
- [sr0lle](https://github.com/sr0lle)
## [1.7.0] - 2023-11-21
- Supported Serve API: `v1.4.3` or newer
### Added
- Ability to specify output filename by specifying a full path or a relative file name in CLI command `create-device`.
- Add the staging privacy certificate (`staging.google.com`) to `Cdm.staging_privacy_cert`.
- Similar to `common_privacy_cert` which would be used on Google's production license server,
- Though this one is used on Google's staging license server (a production-ready testing server).
### Changed
- Raise an error if a file already exists at the output path in CLI command `create-device`.
- Use std-lib xml instead of lxml to reduce dependencies and support ARM (#35).
- Lessen restriction on Python version to any Python version `>=3.7`, but `<4.0`.
- I was hoping to do `^3.7`, but some dependencies also require `<4.0` therefore I cannot, for now.
- Move Key ID parsing to static `PSSH.parse_key_ids()` method.
- The `shaka-packager` subprocess call's return code is now returned from `Cdm.decrypt()`.
- The flags variable of a `Device` now defaults to a dict, even if not set.
- Heavily improve initializing of protobuf objects, improving readability, typing, and linting quite a bit.
- Renamed Device's `_Types` enum class to `DeviceTypes`.
### Removed
- Removed `Device.Types` class variable alias to `_Types` enum class as a static linter cannot recognize a class
variable as a type. Instead, the actual `_Types` (now named `DeviceTypes`) enum should be imported and used instead.
### Fixed
- Ensure output directory exists before creating new `.wvd` files in CLI command `create-device`.
- Ignore empty Key ID values in v4.0.0.0 PlayReadyHeaders.
- Remove `Cdm.system_id` class variable as it conflicted with the `cdm.system_id` class instance variable of the same
name. It's also generally not needed. The same data can be gotten via `Cdm.uuid.bytes`.
- Casting of `type_` when passed a non-int value in `Cdm.get_license_challenge()`.
- Pass a PSSH object in `test` CLI command instead of a string.
- Lower-case and setup `__all__` correctly, add missing `__all__` in some of the modules.
- For the longest time I thought it was `__ALL__` and an iterable of objects/variables.
- However, its actually `__all__` and explicitly a list of Strings...
### New Contributors
- [mediaminister](https://github.com/mediaminister)
## [1.6.0] - 2023-02-03
- Supported Serve API: `v1.4.3` or newer
### Added
- Support Python 3.11.
- New CLI command `export-device` to export WVD files back as files. I.e., a private key and client ID blob file.
## [1.5.3] - 2022-12-27
- Supported Serve API: `v1.4.3` or newer
### Added
- New utility `load_xml()` to parse XML data with lxml ignoring Namespaces.
- PSSH class now have `__str__` and `__repr__` methods to print the object in more Human-friendly ways.
- `str(pssh)` is now identical to `pssh.dumps()`.
- `repr(pssh)` or just `pssh` in some cases will result in a nice overview of the PSSHs contents.
- New `to_playready()` method to convert Widevine PSSH Data to PlayReady PSSH Data. Please note that the
Checksums for AES-CTR and COCKTAIL KIDs cannot be calculated as the Content Encryption Key would be needed.
### Changed
- The System ID must now be explicitly specified when creating a new PSSH box in `PSSH.new()`.
- This allows you to now create PlayReady PSSH boxes.
- The `playready_to_widevine()` method has been renamed to just `to_widevine()`.
### Fixed
- Correct capitalization of the `key_IDs` field when making the new box in `PSSH.new()`.
- Correct the value type of `key_IDs` value when creating a new box in `PSSH.new()`.
- Ensure Key IDs are list of UUIDs instead of bytes in `PSSH.new()`.
- Create v0 PSSH boxes by only setting the `key_IDs` field when the version is set to `1` in `PSSH.new()`.
- Fix loading of PlayReadyHeaders (and PlayReadyObjects) as PSSH boxes. It would previously load it under the
Widevine SystemID breaking all PlayReady-specific code after construction.
- Parse Key IDs within PlayReadyHeaders by using the new `load_xml()` utility to ignore namespaces so that `xpath` can
correctly locate any and all KID tags.
- Support parsing PlayReadyObjects with more than one PlayReadyHeader (more than one record).
## [1.5.2] - 2022-10-11
- Supported Serve API: `v1.4.3` or newer
### Fixed
- Fixed license signature calculation for newer Widevine Server licenses on OEM Crypto v16.0.0 or newer.
The `oemcrypto_core_message` data needed to be part of the HMAC ingest if available.
## [1.5.1] - 2022-10-23
- Supported Serve API: `v1.4.3` or newer
### Added
- Support for big-int Key IDs in `PSSH`. All integer values are converted to a UUID and are loaded big-endian.
- 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 the full direct way if you want.
- Parsing check to the raw DrmCertificate in `Cdm.set_service_certificate()`.
### Changed
- Service Certificates are now stored in the session as a `SignedDrmCertificate`.
- This is to keep the signature with the Certificate, without wrapping it in a SignedMessage unnecessarily.
- Reduced the maximum concurrent Cdm sessions from 50 to 16 as it seems to be a more common limit on more up-to-date
devices and versions of OEMCrypto. This also helps encourage people to close their sessions when they are no longer
required.
### Fixed
- Acquisition of the Certificate's provider_id in `Cdm.set_service_certificate()` in some edge cases, but also when you
try to remove the certificate by setting it to `None`.
- When exporting a PSSH object it will now do so in the same version it was initially 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.
- Improved reliability of computing License Signatures by verifying the signature against the original raw License
message instead of the re-serialized version of the message.
- Some license messages when parsed would be slightly different when re-serialized against my protobuf, therefore the
computed signature would have always mismatched.
## [1.5.0] - 2022-09-24
- Supported Serve API: `v1.4.3` or newer
### Changed
- Updated `protobuf` dependency to `v4.x` branch with recompiled proto-buffers, specifically `v4.21.6`.
## [1.4.4] - 2022-09-24
- Supported Serve API: `v1.4.3` or newer
### Security
- Updated `protobuf` dependency to `3.19.5` due to the Security Advisory [GHSA-8gq9-2x98-w8hf].
[GHSA-8gq9-2x98-w8hf]: <https://github.com/protocolbuffers/protobuf/security/advisories/GHSA-8gq9-2x98-w8hf>
## [1.4.3] - 2022-09-10
- Supported Serve API: `v1.4.3` or newer
### Added
- Serve's `/get_license_challenge` endpoint can now disable privacy mode per-request, even if a service certificate is
set, as long as privacy mode is not enforced in the Serve API config.
- New Cdm method `get_service_certificate()` to get the currently set service certificate of a Session.
### Changed
- All f-string formatting in log statements have been replaced with logging formatting to save performance when that
log wouldn't have been printed.
- The Serve APIs `/open` endpoint's function has been renamed from `open()` to `open_()` to prevent shadowing the
built-in `open`.
### Security
- Updated `lxml` dependency to `>=4.9.1` due to the Security Advisory [GHSA-wrxv-2j5q-m38w].
[GHSA-wrxv-2j5q-m38w]: <https://github.com/advisories/GHSA-wrxv-2j5q-m38w>
### Removed
- The Protocol image has been removed from the README as it is too broad to Browser scenarios and some stuff on it
is too broad. If the viewer is really interested they can Google it to get a much better view into the Protocol.
## [1.4.2] - 2022-09-05
- Supported Serve API: `v1.4.0` to `v1.4.2`
### Changed
- Sessions in `Cdm.open()` are now initialized with a unique session number.
- Android Cdm Devices now use a Request ID formula similar to OEMCrypto library when generating a Challenge.
This formula has yet to be fully confirmed and ironed out, but it is closer than the Chrome Cdm formula.
- `Device` no longer throws `ValueError` exceptions on `DecodeErrors` if it fails to parse the provided Client ID, or
it's VMP data if any. It will now re-raise `DecodeError`.
### Fixed
- Parsed Proto Messages now go through an elaborate yet efficient verification, it must parse and serialize back to it's
received form, byte-for-byte, or it will be rejected.
- This prevents protobuf from parsing a message that could be a different message depending on the starting bytes.
- It was possible to bypass some minor checks by providing specially crafted messages that parsed as other messages.
However, I haven't noticed any way where this would lead to a vulnerability or anything bad. It mostly just lead to
Serve API crashes or just rejected messages down the chain as they wouldn't have the right data within them.
## [1.4.1] - 2022-08-17
- Supported Serve API: `v1.4.0` to `v1.4.2`
### Changed
- Rework `PSSH.overwrite_key_ids()` as an instance method now named `PSSH.set_key_ids()`.
- Rework `PSSH.get_key_ids()` as a property method named `PSSH.key_ids`. This allows swift access to all the Key IDs of
the current PSSH object data.
- Rework `PSSH.from_playready_pssh()` as an instance method now named `PSSH.playready_to_widevine()` that now converts
the current instances values directly. This allows you to more easily instance as any PSSH, then convert after wards
and only if wanted and when needed.
## [1.4.0] - 2022-08-06
- Supported Serve API: `v1.4.0` to `v1.4.2`
### Added
- New PSSH boxes can now be manually crafted with `PSSH.new()`.
- The box can be crafted from arbitrary init_data and/or key_ids.
- If only key_ids is supplied a new Widevine CENC Header will be created and the key IDs will be put into it.
- This allows you to make compliant v0 or v1 boxes with as little data as just a Key ID.
- PSSH boxes can now be exported as MP4 Box objects using pymp4 with `PSSH.dump()`.
- PSSH boxes can now also be exported as Base64 strings with `PSSH.dumps()`.
- License Keys can now be obtained from a Cdm session with a parsed license using `Cdm.get_keys()`.
- This is the alternative to manually accessing the keys from the `Cdm._sessions` object.
- It is also available on the Serve API through the new `/get_keys` endpoint.
### Changed
- `PSSH.get_as_box()` has been merged into the PSSH constructor, simplifying usage of the PSSH class.
- `PSSH.from_playready_pssh()` is now a class method and returns as a PSSH object.
- Only PSSH objects are now accepted by `Cdm.get_license_challenge()`.
- You can no longer provide it anything else, that includes base64 or bytes form.
- You should first parse or make a new PSSH with the PSSH class, and then pass that object.
- This is to simplify typing and repetition across the codebase.
- Serve's `/challenge` endpoint has been changed to `/get_license_challenge`, and `/keys` to `/parse_license`.
- This is to be consistent with the method names of the underlying Cdm class.
- Serve now passes the license type value as-is (as a string) instead of parsing it to an integer.
- Serve now passes the key type value as-is (as a string) instead of parsing it to an integer.
- Serve no longer returns license keys in the response of the `/parse_license` endpoint.
- Once parsed, the `/get_keys` endpoint should be used to retrieve keys.
- Privatized the `Cdm._sessions` class instance variable even more to `Cdm.__sessions`.
- If you still need something from it, while not advised, you can call it via `cdm._Cdm__sessions`.
### Removed
- `PSSH.from_key_ids()` has been removed entirely, you should now use `PSSH.new(key_ids=...)` instead.
- Unnecessary parsing of the license message received by RemoteCdm is now skipped. Parsing should be done by the Serve
API as it will be able to actually decrypt and verify the message.
- All uses of a local `Session` object has been removed from `RemoteCdm`. The session is now fully controlled by the
remote API and de-synchronization by external alteration or unexpected exceptions is no longer a possibility.
### Fixed
- Correct the WidevinePsshData proto field name from `key_id` to `key_ids` in the PSSH class.
- Handle `DecodeError` and `SignatureMismatch` exceptions in the Serve `/set_service_certificate` endpoint.
- Handle `InvalidInitData` and `InvalidLicenseType` exceptions in the Serve `/get_license_challenge` endpoint.
- Handle various exceptions in the Serve `/parse_license` endpoint.
- Handle various client-side runtime errors in `RemoteCdm` with improved error handling.
## [1.3.1] - 2022-08-04
- Supported Serve API: `v1.3.0` to `v1.3.1`
### Added
- String value support to the `device_type` parameter in `Cdm`s constructor.
### Changed
- Serve no longer requires `force_privacy_mode` to be defined in the config file. It now assumes a default of false.
- Serve now uses `pywidevine serve ...` instead of the full project url in the Server header.
- `RemoteCdm`s Server version check is now case-insensitive.
### Fixed
- `RemoteCdm`s Server version check now ignores other Server/Proxy names prepended or appended to the Server header.
- For example, if reverse-proxied through Caddy it may have prepended "Caddy" to the Server header.
## [1.3.0] - 2022-08-04
- Supported Serve API: `v1.3.0` to `v1.3.1`
### Added
- New Client for using the Serve API; `RemoteCdm` class. It has an identical interface as the original `Cdm` class.
- However, the constructor is different. Instead of passing a Widevine device object, you need to pass information
about the API like its host (including port if not on a reverse-proxy), and info about the device like its name and
security level.
- Other than that, once the RemoteCdm object is created, you use it exactly the same. Magic!
- Any time there's a change or fix to `Cdm` in this update or any in the future, will also be done to RemoteCdm.
- New Serve endpoint `/set_service_certificate` as an improved way of setting (or unsetting) the service certificate.
### Changed
- `Cdm`s constructor now uses more direct values, so you don't have to use the Device class or `.wvd` files.
- To continue using `.wvd` files you must now use `Cdm.from_device()` instead.
- You can now unset the Service certificate by providing `None` to `Cdm.set_service_certificate().
### Removed
- Serve's `/challenge` endpoint no longer accepts a `service_certificate` item in the JSON payload.
- Instead, use the new `/set_service_certificate` endpoint before calling `/challenge`.
- You do not need to set it every time. Once per session is enough unless you now want to use a different certificate.
## [1.2.1] - 2022-08-02
### Added
- Support `SignedDrmCertificate` and `SignedMessages` messages in `Cdm.encrypt_client_id()`. This is mainly as a
convenience for any scripts wanting to encrypt their Client ID with a service certificate manually.
- All License Keys from Serve's `/keys` endpoint can now be received by providing `ALL` as the key type.
- This adds support for systems needing more than two types of keys from the license, e.g., Netflix MSL.
- For faster response times it is best to still ask for only `CONTENT` keys if that's all you need.
- Serve now has a `/close` endpoint to close a session. All clients should close the session once they are finished
with it or the user will eventually hit a limit of 50 sessions per user and the server will hog memory til it
restarts.
- Serve now verifies that all Devices in config actually exist before starting the server.
- Serve now responds with a `Server` header denoting that pywidevine serve is being used, and it's version.
- This allows Clients to selectively support APIs based on version; verify the API as being supported.
### Changed
- Lessened version pin on `lxml` from `^4.9.1` to `>=4.8.0` to support projects using pycaption.
- Service Certificate is now saved in the session as a `SignedMessage` with a `SignedDrmCertificate` instead of the raw
`DrmCertificate`. The `SignedMessage` is unsigned as the `SignedDrmCertificate` within it, is signed. This is so
anything inheriting or using the Cdm (e.g., `serve`) can verify the certificate down the chain and keep it signed.
- Serve now constructs one Cdm object for each user+device combination so one user cannot fill or overuse the CDM
session limit.
- All of Serve's endpoints now have a `/{device}` prefix. E.g., instead of `/challenge/STREAMING`, it's now
`/device_name/challenge/STREAMING`. This is to support the previous change.
### Fixed
- Handle server crash when the session limit is reached in Serve's `/open` endpoint by returning a 400 error.
- Serve now correctly updates (or rather now makes a new Cdm object) if a user switches from one Device to another.
- Previously it would reuse an existing Cdm object, but would forget to switch device if they changed.
- Note: It does still leave the previous Cdm with the older Device in memory.
- Handle IOError when parsing bytes as MP4 Box to allow arbitrary data to be made as new boxes in `PSSH.get_as_box()`.
## [1.2.0] - 2022-07-30
### Added
- New CLI command `serve` that hosts a CDM API that can be externally accessed with authentication. This can be used to
access and/or share your CDM without exposing your Widevine device private key, or even it's identity by enforcing
Privacy Mode.
- Requires installing with the `serve` extras, i.e., `pip install pywidevine[serve]`.
- The default host of `127.0.0.1` blocks access outside your network, even if port-forwarded. Use
`-h 0.0.0.0` to allow remote access.
- Setup requires the use of a config file for configuring the CDM and authentication. An example config file named
`serve.example.yml` in the project root folder has verbose documentation on available options.
- Batch migration of WVD files by passing a folder as the path to the CLI command `migrate`.
- Strict mode to `PSSH.get_as_box()` to raise an Exception if passed data is not already a box, as it has been improved
to create a new box if not detected as a box already.
### Changed
- Elevated the Development Status Classifier from 4 (Beta) to 5 (Production/Stable).
- License messages passed to `Cdm.parse_license()` are now rejected if they are not of `LICENSE` type.
- Service Certificates passed to `Cdm.set_service_certificate()` are now verified. This patches a trivial "exploit"
that allows an attacker to recover the plaintext Client ID from a license under Privacy Mode. See
<https://gist.github.com/rlaphoenix/74acabdd7269a21845e18b621c5860ef>.
- Data passed to `PSSH.get_as_box()` now supports arbitrary and box data automatically as it tries to detect if it is a
valid box, otherwise makes a new box.
- Renamed the `Cdm` constructor's parameter `pssh` to `init_data`, as that's what the Cdm actually wants and uses,
whereas a `PSSH` is an `mp4` atom (aka box) containing `init_data` (a Widevine CENC Header). The full PSSH is never
kept nor ever used. It still accepts PSSH box data.
- Service Certificate's Provider ID is now returned by `Cdm.set_service_certificate()` instead of the passed
certificate, of which they would already have.
- The Cdm class now works more closely to the official CDM model. Instead of using one Cdm object per-request having to
provide device information each time,
- You now initialize the Cdm with the Widevine device you wish to use and then open sessions with `Cdm.open()`.
- You will receive a session ID that are then passed to other methods of the same Cdm object.
- The PSSH/init_data that used to be passed to the constructor is now passed to `Cdm.get_license_challenge()`.
- This allows initializing one Cdm object with up to 50 sessions open at the same time.
Session limits seem to fluctuate between libraries and devices. 50 seems like a permissive value.
- Once you are finished with DRM operations, discard all session (and key) data by calling `Cdm.close(session_id)`.
- License Keys are no longer returned by `Cdm.parse_license()` and now must be obtained directly from `cdm._sessions`.
- For example, `for key in cdm._sessions[session_id].keys: print(f"[{key.type}] {key.kid.hex}:{key.key.hex()}")`.
- This is to detach the action of parsing a license as just for getting keys, as it isn't. It can be and should be
used for a lot more data like security requirements like HDCP, expiration, and more.
- It is also to detour users from directly using the keys over the `Cdm.decrypt()` method.
- Various std-lib exceptions have been replaced with custom exceptions under `pywidevine.exceptions`.
- License responses can now only be parsed once by `Cdm.parse_license()`. Any further attempts will raise an
`InvalidContext` exception.
- This is as license context data is cleared once used to reduce data lingering in memory, otherwise the more license
requests you make without closing the session, the more and more memory is taken up.
- Open multiple sessions in the same Cdm object if you need to request and parse multiple licenses on the same device.
### Removed
- Direct `DrmCertificate`s are no longer supported by `Cdm.set_service_certificate()` as they have no signature.
See the 3rd Change above. Provide either a `SignedDrmCertificate` or a `SignedMessage` containing a
`SignedDrmCertificate`. A `SignedMessage` containing a `DrmCertificate` will also be rejected.
- `PSSH.from_init_data()`, use `PSSH.get_as_box()`.
- `raw` parameter of `Cdm` constructor, as well as CLI commands as it is now handled upstream by the `PSSH` creation.
### Fixed
- Detection of Widevine CENC Header data encoded as bytes in `PSSH.get_as_box()`.
- Custom ValueError on missing contexts instead of the generic KeyError in `Cdm.parse_license()`.
- Typing of `type_` parameter in `Cdm.get_license_challenge()`.
- Value of `type_` parameter if is a string in `Cdm.get_license_challenge()`.
## [1.1.1] - 2022-07-22
### Fixed
- The `-v/--vmp` parameter of the `test` CLI command is now optional.
## [1.1.0] - 2022-07-21
### Added
- WVD (Widevine Device file) Version 2 bringing reduced file sizes by up to 30%~.
- New CLI command `create-device` to create `.wvd` files (Widevine Device files) from RSA PEM/DER Private Keys and
Client ID blobs. You can also provide VMP (FileHashes) data which will be merged into the Client ID blob.
- New CLI command `migrate` that uses `Device.migrate()` and `dump()` to migrate older v1 Widevine Device files to v2.
- New `Device` method `migrate()` to load an older Widevine Device file format. It is recommended to then use the
`dumps()` method to save it as a new v2 Widevine Device file, which can then be loaded normally.
- Support `SignedDrmCertificate` and `DrmCertificate` messages in `Cdm.set_service_certificate()`. Services can provide
the certificate as a `SignedMessage`, `SignedDrmCertificate`, or a `DrmCertificate`. Only `SignedMessage` and
`SignedDrmCertificate` are signed.
- Privacy Mode can now be used in the `test` CLI command with the `-p/--privacy` flag.
### Changed
- Moved all `.wvd` Widevine Device file structures from `Device` to a `_Structures` class in `device.py`. The
`_Structures` class can be imported and used directly, or via `Device.structures`.
- Moved the majority of Widevine Device file migration code from the CLI command `migrate` to `Device.migrate()`. The
CLI command `migrate` now internally uses `Device.migrate()`.
- Set Service Certificates are now stored as `DrmCertificate`s instead of a `SignedMessage` as the signature and other
data in the message is unused and unneeded.
### Removed
- Unused Widevine Device file flag `send_key_control_nonce` from v1 and v2 Structures as it was only used before initial
release, and isn't a necessary nor useful flag.
### Fixed
- Correct the type argument name from `type` to `type_` in `Device.dump()`.
### Security
- Even though support for more kinds of Service Certificate Signatures were added, they are still unverified as the
signing public key is Unknown.
## [1.0.1] - 2022-07-21
### Changed
- Moved the License Type parameter from the `Cdm` constructor to it's `get_license_challenge()` method.
- Every License request now uses a unique random value instead of the CDM Session ID.
- Only the Context Data of License requests are now stored in the Session instead of the full message.
- Session ID formula now uses a random 16-byte value for both Chrome and Android provisions.
### Removed
- Unused and unnecessary `Cdm.raw` class instance variable.
### Fixed
- Re-raise DecodeErrors instead of a new ValueError on DecodeErrors in `Cdm.set_service_certificate()`.
- Creating a new License request no longer overwrites the context data of the previous challenge.
## [1.0.0] - 2022-07-20
Initial Release.
### Security
- Service Certificate Signatures are unverified as the signing public key is Unknown.
[1.8.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.8.0
[1.7.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.7.0
[1.6.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.6.0
[1.5.3]: https://github.com/devine-dl/pywidevine/releases/tag/v1.5.3
[1.5.2]: https://github.com/devine-dl/pywidevine/releases/tag/v1.5.2
[1.5.1]: https://github.com/devine-dl/pywidevine/releases/tag/v1.5.1
[1.5.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.5.0
[1.4.4]: https://github.com/devine-dl/pywidevine/releases/tag/v1.4.4
[1.4.3]: https://github.com/devine-dl/pywidevine/releases/tag/v1.4.3
[1.4.2]: https://github.com/devine-dl/pywidevine/releases/tag/v1.4.2
[1.4.1]: https://github.com/devine-dl/pywidevine/releases/tag/v1.4.1
[1.4.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.4.0
[1.3.1]: https://github.com/devine-dl/pywidevine/releases/tag/v1.3.1
[1.3.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.3.0
[1.2.1]: https://github.com/devine-dl/pywidevine/releases/tag/v1.2.1
[1.2.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.2.0
[1.1.1]: https://github.com/devine-dl/pywidevine/releases/tag/v1.1.1
[1.1.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.1.0
[1.0.1]: https://github.com/devine-dl/pywidevine/releases/tag/v1.0.1
[1.0.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.0.0

View File

@ -1,49 +0,0 @@
# Development
This project is managed using [Poetry](https://python-poetry.org), a fantastic Python packaging and dependency manager.
Install the latest version of Poetry before continuing. Development currently requires Python 3.8+.
## Set up
Starting from Zero? Not sure where to begin? Here's steps on setting up this Python project using Poetry. Note that
Poetry installation instructions should be followed from the Poetry Docs: https://python-poetry.org/docs/#installation
1. While optional, It's recommended to configure Poetry to install Virtual environments within project folders:
```shell
poetry config virtualenvs.in-project true
```
This makes it easier for Visual Studio Code to detect the Virtual Environment, as well as other IDEs and systems.
I've also had issues with Poetry creating duplicate Virtual environments in the default folder for an unknown
reason which quickly filled up my System storage.
2. Clone the Repository:
```shell
git clone https://github.com/devine-dl/pywidevine
cd pywidevine
```
3. Install the Project with Poetry:
```shell
poetry install
```
This creates a Virtual environment and then installs all project dependencies and executables into the Virtual
environment. Your System Python environment is not affected at all.
4. Now activate the Virtual environment:
```shell
poetry shell
```
Note:
- You can alternatively just prefix `poetry run` to any command you wish to run under the Virtual environment.
- I recommend entering the Virtual environment and all further instructions will have assumed you did.
- JetBrains PyCharm has integrated support for Poetry and automatically enters Poetry Virtual environments, assuming
the Python Interpreter on the bottom right is set up correctly.
- For more information, see: https://python-poetry.org/docs/basic-usage/#using-your-virtual-environment
5. Install Pre-commit tooling to ensure safe and quality commits:
```shell
pre-commit install
```
## Building Source and Wheel distributions
poetry build
You can optionally specify `-f` to build `sdist` or `wheel` only.
Built files can be found in the `/dist` directory.

162
README.md
View File

@ -1,121 +1,37 @@
<p align="center">
<img src="docs/images/widevine_icon_24.png"> <a href="https://github.com/devine-dl/pywidevine">pywidevine</a>
<br/>
<sup><em>Python Widevine CDM implementation</em></sup>
</p>
# pywidevine
<p align="center">
<a href="https://github.com/devine-dl/pywidevine/actions/workflows/ci.yml">
<img src="https://github.com/devine-dl/pywidevine/actions/workflows/ci.yml/badge.svg" alt="Build status">
</a>
<a href="https://pypi.org/project/pywidevine">
<img src="https://img.shields.io/badge/python-3.8%2B-informational" alt="Python version">
</a>
<a href="https://deepsource.io/gh/devine-dl/pywidevine">
<img src="https://deepsource.io/gh/devine-dl/pywidevine.svg/?label=active+issues" alt="DeepSource">
</a>
</p>
<p align="center">
<a href="https://github.com/astral-sh/ruff">
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Linter: Ruff">
</a>
<a href="https://python-poetry.org">
<img src="https://img.shields.io/endpoint?url=https://python-poetry.org/badge/v0.json" alt="Dependency management: Poetry">
</a>
</p>
## Features
- 🚀 Seamless Installation via [pip](#installation)
- 🛡️ Robust Security with message signature verification
- 🙈 Privacy Mode with Service Certificates
- 🌐 Servable CDM API Server and Client with Authentication
- 📦 Custom provision serialization format (WVD v2)
- 🧰 Create, parse, or convert PSSH headers with ease
- 🗃️ User-friendly YAML configuration
- ❤️ Forever FOSS!
## Installation
```shell
$ pip install pywidevine
```
> **Note**
If pip gives you a warning about a path not being in your PATH environment variable then promptly add that path then
close all open command prompt/terminal windows, or `pywidevine` CLI won't work as it will not be found.
Voilà 🎉 — You now have the `pywidevine` package installed!
You can now import pywidevine in scripts ([see below](#usage)).
A command-line interface is also available, try `pywidevine --help`.
## Usage
The following is a minimal example of using pywidevine in a script to get a License for Bitmovin's
Art of Motion Demo.
```py
from pywidevine.cdm import Cdm
from pywidevine.device import Device
from pywidevine.pssh import PSSH
import requests
# prepare pssh
pssh = PSSH("AAAAW3Bzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAADsIARIQ62dqu8s0Xpa"
"7z2FmMPGj2hoNd2lkZXZpbmVfdGVzdCIQZmtqM2xqYVNkZmFsa3IzaioCSEQyAA==")
# load device
device = Device.load("C:/Path/To/A/Provision.wvd")
# load cdm
cdm = Cdm.from_device(device)
# open cdm session
session_id = cdm.open()
# get license challenge
challenge = cdm.get_license_challenge(session_id, pssh)
# send license challenge (assuming a generic license server SDK with no API front)
licence = requests.post("https://...", data=challenge)
licence.raise_for_status()
# parse license challenge
cdm.parse_license(session_id, licence.content)
# print keys
for key in cdm.get_keys(session_id):
print(f"[{key.type}] {key.kid.hex}:{key.key.hex()}")
# close session, disposes of session data
cdm.close(session_id)
```
> **Note**
> There are various features not shown in this specific example like:
>
> - Privacy Mode
> - Setting Service Certificates
> - Remote CDMs and Serving
> - Choosing a License Type to request
> - Creating WVD files
> - and much more!
>
> Take a look at the methods available in the [Cdm class](/pywidevine/cdm.py) and their doc-strings for
> further information. For more examples see the [CLI functions](/pywidevine/main.py) which uses a lot
> of previously mentioned features.
Widevine CDM (Content Decryption Module) implementation in Python.
## Disclaimer
1. This project requires a valid Google-provisioned Private Key and Client Identification blob which are not
provided by this project.
2. Public test provisions are available and provided by Google to use for testing projects such as this one.
3. License Servers have the ability to block requests from any provision, and are likely already blocking test
provisions on production endpoints.
4. This project does not condone piracy or any action against the terms of the DRM systems.
5. All efforts in this project have been the result of Reverse-Engineering, Publicly available research, and Trial
& Error.
1. This project requires the use of a Google provisioned private key and Client Identification blob.
2. Neither of them are provided by this project.
3. Public test provisions are available to use for testing this project.
4. License Servers have the ability to block requests from test provisions.
5. This project does not condone piracy or any action against the terms of the DRM systems.
6. All efforts in this project have been the result of Reverse Engineering and Trial & Error.
## Protocol
![widevine-overview](/docs/images/widevine_overview.svg)
*Credit*: w3.org
Almost the entire Widevine Protocol seen above is implemented except for Sessions which I will be implementing soon.
Currently, only one session can be made and used at a time or problems will happen.
### Web Server
This may be an API/Server in front of a License Server. For example, Netflix's Custom MSL-based API front.
This is evident by their custom Service Certificate which would only be needed if they had to read the License.
### Net, Media Stack and MediaKeySession
These generally refer to the Encrypted Media Extensions API on Browsers.
Under the assumption of the Android Widevine ecosystem, you can think of `Net` as the Application Code, `Media Stack`
as the OEM Crypto Library, and `MediaKeySession` as a Session. The orange wrapper titled `Browser` is effectively the
Application as a whole, while `Platform` (in Green at the bottom) would be the OS or Other libraries.
## Key and Output Security
@ -147,20 +63,6 @@ been improving its security using math and obscurity for years. It's getting har
versions only being beaten by Brute-force style methods. However, they have a huge team of very skilled workers, and
making a CDM in C++ has immediate security benefits and a lot of methods to obscure and obfuscate the code.
## Contributors
## License
<a href="https://github.com/rlaphoenix"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17136956?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt=""/></a>
<a href="https://github.com/mediaminister"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45148099?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt=""/></a>
<a href="https://github.com/sr0lle"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/111277375?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt=""/></a>
## Licensing
This software is licensed under the terms of [GNU General Public License, Version 3.0](LICENSE).
You can find a copy of the license in the LICENSE file in the root folder.
- Widevine Icon &copy; Google.
- Props to the awesome community for their shared research and insight into the Widevine Protocol and Key Derivation.
* * *
© rlaphoenix 2022-2023
[GNU General Public License, Version 3.0](LICENSE)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 885 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 420 KiB

1054
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,84 +4,19 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry]
name = "pywidevine"
version = "1.8.0"
version = "1.0.0"
description = "Widevine CDM (Content Decryption Module) implementation in Python."
license = "GPL-3.0-only"
authors = ["rlaphoenix <rlaphoenix@pm.me>"]
readme = "README.md"
repository = "https://github.com/devine-dl/pywidevine"
keywords = ["python", "drm", "widevine", "google"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Intended Audience :: End Users/Desktop",
"Natural Language :: English",
"Operating System :: OS Independent",
"Topic :: Multimedia :: Video",
"Topic :: Security :: Cryptography",
"Topic :: Software Development :: Libraries :: Python Modules"
]
include = [
{ path = "CHANGELOG.md", format = "sdist" },
{ path = "README.md", format = "sdist" },
{ path = "LICENSE", format = "sdist" },
]
[tool.poetry.urls]
"Issues" = "https://github.com/devine-dl/pywidevine/issues"
"Discussions" = "https://github.com/devine-dl/pywidevine/discussions"
"Changelog" = "https://github.com/devine-dl/pywidevine/blob/master/CHANGELOG.md"
license = "GPL-3.0-only"
[tool.poetry.dependencies]
python = ">=3.8,<4.0"
protobuf = "^4.25.1"
pymp4 = "^1.4.0"
pycryptodome = "^3.19.0"
click = "^8.1.7"
requests = "^2.31.0"
Unidecode = "^1.3.7"
PyYAML = "^6.0.1"
aiohttp = {version = "^3.9.1", optional = true}
[tool.poetry.group.dev.dependencies]
pre-commit = "^3.5.0"
mypy = "^1.7.1"
mypy-protobuf = "^3.5.0"
types-protobuf = "^4.24.0.4"
types-requests = "^2.31.0.10"
types-PyYAML = "^6.0.12.12"
isort = "^5.12.0"
ruff = "~0.1.7"
[tool.poetry.extras]
serve = ["aiohttp"]
python = ">=3.7,<3.11"
protobuf = "3.19.3"
pymp4 = "^1.2.0"
pycryptodome = "^3.15.0"
click = "^8.1.3"
requests = "^2.28.1"
lxml = "^4.9.1"
[tool.poetry.scripts]
pywidevine = "pywidevine.main:main"
[tool.ruff]
extend-exclude = [
"*_pb2.py",
"*.pyi",
]
force-exclude = true
line-length = 120
select = ["E4", "E7", "E9", "F", "W"]
[tool.ruff.extend-per-file-ignores]
"pywidevine/__init__.py" = ["F403"]
[tool.isort]
line_length = 118
extend_skip_glob = ["*_pb2.py", "*.pyi"]
[tool.mypy]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_untyped_defs = true
exclude = [
'_pb2.pyi?$' # generated protobuffer files
]
follow_imports = "silent"
ignore_missing_imports = true
no_implicit_optional = true

View File

@ -1,8 +1 @@
from .cdm import *
from .device import *
from .key import *
from .pssh import *
from .remotecdm import *
from .session import *
__version__ = "1.8.0"
__version__ = "1.0.0"

View File

@ -1,625 +1,323 @@
from __future__ import annotations
import base64
import binascii
import random
import subprocess
import sys
import time
from pathlib import Path
from typing import Optional, Union
from typing import Union, Optional
from uuid import UUID
from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.Hash import CMAC, HMAC, SHA1, SHA256
from Crypto.Hash import SHA1, HMAC, SHA256, CMAC
from Crypto.PublicKey import RSA
from Crypto.Random import get_random_bytes
from Crypto.Signature import pss
from Crypto.Util import Padding
from construct import Container
from google.protobuf.message import DecodeError
from pywidevine.device import Device, DeviceTypes
from pywidevine.exceptions import (InvalidContext, InvalidInitData, InvalidLicenseMessage, InvalidLicenseType,
InvalidSession, NoKeysLoaded, SignatureMismatch, TooManySessions)
from pywidevine.key import Key
from pywidevine.license_protocol_pb2 import (ClientIdentification, DrmCertificate, EncryptedClientIdentification,
License, LicenseRequest, LicenseType, SignedDrmCertificate,
SignedMessage)
from pywidevine.pssh import PSSH
from pywidevine.session import Session
from pywidevine.utils import get_binary_path
from pywidevine.license_protocol_pb2 import LicenseType, SignedMessage, LicenseRequest, ProtocolVersion, \
SignedDrmCertificate, DrmCertificate, EncryptedClientIdentification, ClientIdentification, License
from pywidevine.device import Device
from pywidevine.key import Key
from pywidevine.pssh import PSSH
class Cdm:
uuid = UUID(bytes=b"\xed\xef\x8b\xa9\x79\xd6\x4a\xce\xa3\xc8\x27\xdc\xd5\x1d\x21\xed")
system_id = b"\xed\xef\x8b\xa9\x79\xd6\x4a\xce\xa3\xc8\x27\xdc\xd5\x1d\x21\xed"
uuid = UUID(bytes=system_id)
urn = f"urn:uuid:{uuid}"
key_format = urn
service_certificate_challenge = b"\x08\x04"
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")
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)
common_privacy_cert = ("CAUSxwUKwQIIAxIQFwW5F8wSBIaLBjM6L3cqjBiCtIKSBSKOAjCCAQoCggEBAJntWzsyfateJO/DtiqVtZhSCtW8y"
"zdQPgZFuBTYdrjfQFEEQa2M462xG7iMTnJaXkqeB5UpHVhYQCOn4a8OOKkSeTkwCGELbxWMh4x+Ib/7/up34QGeHl"
"eB6KRfRiY9FOYOgFioYHrc4E+shFexN6jWfM3rM3BdmDoh+07svUoQykdJDKR+ql1DghjduvHK3jOS8T1v+2RC/TH"
"hv0CwxgTRxLpMlSCkv5fuvWCSmvzu9Vu69WTi0Ods18Vcc6CCuZYSC4NZ7c4kcHCCaA1vZ8bYLErF8xNEkKdO7Dev"
"Sy8BDFnoKEPiWC8La59dsPxebt9k+9MItHEbzxJQAZyfWgkCAwEAAToUbGljZW5zZS53aWRldmluZS5jb20SgAOuN"
"HMUtag1KX8nE4j7e7jLUnfSSYI83dHaMLkzOVEes8y96gS5RLknwSE0bv296snUE5F+bsF2oQQ4RgpQO8GVK5uk5M"
"4PxL/CCpgIqq9L/NGcHc/N9XTMrCjRtBBBbPneiAQwHL2zNMr80NQJeEI6ZC5UYT3wr8+WykqSSdhV5Cs6cD7xdn9"
"qm9Nta/gr52u/DLpP3lnSq8x2/rZCR7hcQx+8pSJmthn8NpeVQ/ypy727+voOGlXnVaPHvOZV+WRvWCq5z3CqCLl5"
"+Gf2Ogsrf9s2LFvE7NVV2FvKqcWTw4PIV9Sdqrd+QLeFHd/SSZiAjjWyWOddeOrAyhb3BHMEwg2T7eTo/xxvF+YkP"
"j89qPwXCYcOxF+6gjomPwzvofcJOxkJkoMmMzcFBDopvab5tDQsyN9UPLGhGC98X/8z8QSQ+spbJTYLdgFenFoGq4"
"7gLwDS6NWYYQSqzE3Udf2W7pzk4ybyG4PHBYV3s4cyzdq8amvtE/sNSdOKReuHpfQ=")
MAX_NUM_OF_SESSIONS = 16
NUM_OF_SESSIONS = 0
MAX_NUM_OF_SESSIONS = 50 # most common limit
def __init__(
self,
device_type: Union[DeviceTypes, str],
system_id: int,
security_level: int,
client_id: ClientIdentification,
rsa_key: RSA.RsaKey
device: Device,
pssh: Union[Container, bytes, str],
license_type: LicenseType = LicenseType.STREAMING,
raw: bool = False
):
"""Initialize a Widevine Content Decryption Module (CDM)."""
if not device_type:
raise ValueError("Device Type must be provided")
if isinstance(device_type, str):
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}")
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)
self.__sessions: dict[bytes, Session] = {}
@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
)
def open(self) -> bytes:
"""
Open a Widevine Content Decryption Module (CDM) session.
Raises:
TooManySessions: If the session cannot be opened as limit has been reached.
"""
if len(self.__sessions) > self.MAX_NUM_OF_SESSIONS:
raise TooManySessions(f"Too many Sessions open ({self.MAX_NUM_OF_SESSIONS}).")
session = Session(len(self.__sessions) + 1)
self.__sessions[session.id] = session
return session.id
def close(self, session_id: bytes) -> None:
"""
Close a Widevine Content Decryption Module (CDM) session.
Parameters:
session_id: Session identifier.
device: Widevine Device containing the Client ID, Device Private Key, and
more device-specific information.
pssh: Protection System Specific Header Box or Init Data. This should be a
compliant mp4 pssh box, or just the init data (Widevine Cenc Header).
license_type: Type of License you wish to exchange, often `STREAMING`.
The `OFFLINE` Licenses are for Offline licensing of Downloaded content.
raw: This should be set to True if the PSSH data provided is arbitrary data.
E.g., a PSSH Box where the init data is not a Widevine Cenc Header, or
is simply arbitrary data.
Raises:
InvalidSession: If the Session identifier is invalid.
Devices have a limit on how many sessions can be open and active concurrently.
The limit is different for each device and security level, most commonly 50.
This limit is handled by the OEM Crypto API. Multiple sessions can be open at
a time and sessions should be closed when no longer needed.
If an API or System requests a Widevine Session ID, it is best to provide it
the real Session ID created here (self.session_id) instead of an arbitrary or
random value.
"""
session = self.__sessions.get(session_id)
if not session:
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
del self.__sessions[session_id]
if not device:
raise ValueError("A Widevine Device must be provided.")
if not pssh:
raise ValueError("A PSSH Box must be provided.")
def set_service_certificate(self, session_id: bytes, certificate: Optional[Union[bytes, str]]) -> Optional[str]:
if self.NUM_OF_SESSIONS >= self.MAX_NUM_OF_SESSIONS:
raise ValueError(
f"Too many Sessions open {self.NUM_OF_SESSIONS}/{self.MAX_NUM_OF_SESSIONS}. "
f"Close some Sessions to be able to open more."
)
self.NUM_OF_SESSIONS += 1
self.device = device
self.init_data = pssh
self.license_type = license_type
self.raw = raw
if not self.raw:
# we only want the init_data of the pssh box
self.init_data = PSSH.get_as_box(pssh).init_data
self.session_id = self.create_session_id(self.device)
self.service_certificate: Optional[SignedMessage] = None
self.license_request: Optional[SignedMessage] = None
def set_service_certificate(self, certificate: Union[bytes, str]) -> SignedMessage:
"""
Set a Service Privacy Certificate for Privacy Mode. (optional but recommended)
Parameters:
certificate: Signed Message in Base64 or Bytes form obtained from the Service.
Some services have their own, but most use the common privacy cert,
(common_privacy_cert).
Returns the parsed Signed Message if successful, otherwise raises a DecodeError.
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).
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:
session_id: Session identifier.
certificate: SignedDrmCertificate (or SignedMessage containing one) in Base64
or Bytes form obtained from the Service. Some services have their own,
but most use the common privacy cert, (common_privacy_cert). If None, it
will remove the current certificate.
Raises:
InvalidSession: If the Session identifier is invalid.
DecodeError: If the certificate could not be parsed as a SignedDrmCertificate
nor a SignedMessage containing a SignedDrmCertificate.
SignatureMismatch: If the Signature of the SignedDrmCertificate does not
match the underlying DrmCertificate.
Returns the Service Provider ID of the verified DrmCertificate if successful.
If certificate is None, it will return the now-unset certificate's Provider ID,
or None if no certificate was set yet.
"""
session = self.__sessions.get(session_id)
if not session:
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
if certificate is None:
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 provider_id
if isinstance(certificate, str):
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}")
certificate = base64.b64decode(certificate) # assuming base64
signed_message = SignedMessage()
signed_drm_certificate = SignedDrmCertificate()
drm_certificate = DrmCertificate()
try:
signed_message.ParseFromString(certificate)
if all(
# See https://github.com/devine-dl/pywidevine/issues/41
bytes(chunk) == signed_message.SerializeToString()
for chunk in zip(*[iter(certificate)] * len(signed_message.SerializeToString()))
):
signed_drm_certificate.ParseFromString(signed_message.msg)
else:
signed_drm_certificate.ParseFromString(certificate)
if signed_drm_certificate.SerializeToString() != 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}")
except DecodeError:
raise ValueError("Could not parse certificate as a Signed Message.")
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):
raise SignatureMismatch("Signature Mismatch on SignedDrmCertificate, rejecting certificate")
self.service_certificate = signed_message
return signed_message
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]:
def get_license_challenge(self, privacy_mode: bool = True) -> bytes:
"""
Get the currently set Service Privacy Certificate of the Session.
Get a License Challenge to send to a License Server.
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
def get_license_challenge(
self,
session_id: bytes,
pssh: PSSH,
license_type: str = "STREAMING",
privacy_mode: bool = True
) -> bytes:
"""
Get a License Request (Challenge) to send to a License Server.
Parameters:
session_id: Session identifier.
pssh: PSSH Object to get the init data from.
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.
privacy_mode: Encrypt the Client ID using the Privacy Certificate. If the
privacy certificate is not set yet, this does nothing.
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.
Returns a SignedMessage containing a LicenseRequest message. It's signed with
the Private Key of the device provision.
"""
session = self.__sessions.get(session_id)
if not session:
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
license_request = LicenseRequest()
license_request.type = LicenseRequest.RequestType.Value("NEW")
license_request.request_time = int(time.time())
license_request.protocol_version = ProtocolVersion.Value("VERSION_2_1")
license_request.key_control_nonce = random.randrange(1, 2 ** 31)
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}")
license_request.content_id.widevine_pssh_data.pssh_data.append(self.init_data)
license_request.content_id.widevine_pssh_data.license_type = self.license_type
license_request.content_id.widevine_pssh_data.request_id = self.session_id
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()}"
)
if self.device_type == DeviceTypes.ANDROID:
# 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()
if self.service_certificate and privacy_mode:
# encrypt the client id for privacy mode
license_request.encrypted_client_id.CopyFrom(self.encrypt_client_id(
client_id=self.device.client_id,
service_certificate=self.service_certificate
))
else:
request_id = get_random_bytes(16)
license_request.client_id.CopyFrom(self.device.client_id)
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(
client_id=self.__client_id,
service_certificate=session.service_certificate
) 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()
license_message = SignedMessage()
license_message.type = SignedMessage.MessageType.Value("LICENSE_REQUEST")
license_message.msg = license_request.SerializeToString()
signed_license_request = SignedMessage(
type="LICENSE_REQUEST",
msg=license_request,
signature=self.__signer.sign(SHA1.new(license_request))
).SerializeToString()
license_message.signature = pss. \
new(self.device.private_key). \
sign(SHA1.new(license_message.msg))
session.context[request_id] = self.derive_context(license_request)
# store it for later, we need it for deriving keys when parsing a license
self.license_request = license_message
return signed_license_request
return license_message.SerializeToString()
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.
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.
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.
"""
session = self.__sessions.get(session_id)
if not session:
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
def parse_license(self, license_message: Union[bytes, str]) -> list[Key]:
# TODO: What if the CDM generates a 2nd challenge, overwriting the previous making it mismatch
# for deriving keys? We need to make self.license_request always match the license_message
if not self.license_request:
raise ValueError("Cannot parse a license message without first making a license request")
if not license_message:
raise InvalidLicenseMessage("Cannot parse an empty license_message")
raise ValueError("Cannot parse an empty license_message as a SignedMessage")
if isinstance(license_message, str):
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}")
license_message = base64.b64decode(license_message)
if isinstance(license_message, bytes):
signed_message = SignedMessage()
try:
signed_message.ParseFromString(license_message)
if signed_message.SerializeToString() != license_message:
raise DecodeError(license_message)
except DecodeError as e:
raise InvalidLicenseMessage(f"Could not parse license_message as a SignedMessage, {e}")
except DecodeError:
raise ValueError("Failed to parse license_message as a SignedMessage")
license_message = signed_message
if not isinstance(license_message, SignedMessage):
raise InvalidLicenseMessage(f"Expecting license_response to be a SignedMessage, got {license_message!r}")
if license_message.type != SignedMessage.MessageType.Value("LICENSE"):
raise InvalidLicenseMessage(
f"Expecting a LICENSE message, not a "
f"'{SignedMessage.MessageType.Name(license_message.type)}' message."
)
raise ValueError(f"Expecting license_response to be a SignedMessage, got {license_message!r}")
licence = License()
licence.ParseFromString(license_message.msg)
context = session.context.get(licence.id.request_id)
if not context:
raise InvalidContext("Cannot parse a license message without first making a license request")
session_key = PKCS1_OAEP. \
new(self.device.private_key). \
decrypt(license_message.session_key)
enc_key, mac_key_server, _ = self.derive_keys(
*context,
key=self.__decrypter.decrypt(license_message.session_key)
)
enc_key, mac_key_server, mac_key_client = self.derive_keys(self.license_request.msg, session_key)
# 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.
computed_signature = HMAC. \
license_signature = HMAC. \
new(mac_key_server, digestmod=SHA256). \
update(license_message.oemcrypto_core_message or b""). \
update(license_message.msg). \
update(licence.SerializeToString()). \
digest()
if license_message.signature != computed_signature:
raise SignatureMismatch("Signature Mismatch on License Message, rejecting license")
if license_message.signature != license_signature:
raise ValueError("The License Signature doesn't match the Signature listed in the Message")
session.keys = [
return [
Key.from_key_container(key, enc_key)
for key in licence.key
]
del session.context[licence.id.request_id]
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.
"""
session = self.__sessions.get(session_id)
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_)
]
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
) -> int:
@staticmethod
def decrypt(content_keys: dict[UUID, str], input_: Path, output: Path, temp: Optional[Path] = None):
"""
Decrypt a Widevine-encrypted file using Shaka-packager.
Shaka-packager is much more stable than mp4decrypt.
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.
Raises:
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.
EnvironmentError if the Shaka Packager executable could not be found.
ValueError if the track has not yet been downloaded.
SubprocessError if Shaka Packager returned a non-zero exit code.
"""
if not input_file:
if not content_keys:
raise ValueError("Cannot decrypt without any Content Keys")
if not input_:
raise ValueError("Cannot decrypt nothing, specify an input path")
if not output_file:
if not output:
raise ValueError("Cannot decrypt nowhere, specify an output path")
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)
temp_dir_ = Path(temp_dir) if temp_dir else None
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}")
session = self.__sessions.get(session_id)
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")
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 = [
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()}"
f"input={input_},stream=0,output={output}",
"--enable_raw_key_decryption", "--keys",
",".join([
*[
"label={}:key_id={}:key={}".format(i, kid.hex, key.lower())
for i, (kid, key) in enumerate(content_keys.items())
],
*[
# Apple TV+ needs this as their files do not use the KID supplied in the manifest
"label={}:key_id={}:key={}".format(i, "00" * 16, key.lower())
for i, (kid, key) in enumerate(content_keys.items(), len(content_keys))
]
if key.type == "CONTENT"
])
]),
]
if temp_dir_:
temp_dir_.mkdir(parents=True, exist_ok=True)
args.extend(["--temp_dir", str(temp_dir_)])
if temp:
temp.mkdir(parents=True, exist_ok=True)
args.extend(["--temp_dir", temp])
return subprocess.check_call([executable, *args])
try:
subprocess.check_call([executable, *args])
except subprocess.CalledProcessError as e:
raise subprocess.SubprocessError(f"Failed to Decrypt! Shaka Packager Error: {e}")
def create_session_id(self, device: Device) -> bytes:
"""Create a Session ID based on OEM Crypto API session values."""
if device.type == device.Types.ANDROID:
session_id = "{hex:16X}{counter}".format(
hex=random.getrandbits(64),
counter=f"{self.NUM_OF_SESSIONS:02}"
)
session_id.ljust(32, "0")
return session_id.encode("ascii")
if device.type == device.Types.CHROME:
return get_random_bytes(16)
raise ValueError(f"Device Type {device.type.name} is not implemented")
@staticmethod
def encrypt_client_id(
client_id: ClientIdentification,
service_certificate: Union[SignedDrmCertificate, DrmCertificate],
key: Optional[bytes] = None,
iv: Optional[bytes] = None
service_certificate: Union[SignedMessage, SignedDrmCertificate, DrmCertificate],
key: bytes = None,
iv: bytes = None
) -> 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)
if isinstance(service_certificate, SignedMessage):
signed_service_certificate = SignedDrmCertificate()
signed_service_certificate.ParseFromString(service_certificate.msg)
service_certificate = signed_service_certificate
if isinstance(service_certificate, SignedDrmCertificate):
drm_certificate = DrmCertificate()
drm_certificate.ParseFromString(service_certificate.drm_certificate)
service_certificate = drm_certificate
service_service_drm_certificate = DrmCertificate()
service_service_drm_certificate.ParseFromString(service_certificate.drm_certificate)
service_certificate = service_service_drm_certificate
if not isinstance(service_certificate, DrmCertificate):
raise ValueError(f"Expecting Service Certificate to be a DrmCertificate, not {service_certificate!r}")
raise ValueError(f"Service Certificate is in an unexpected type {service_certificate!r}")
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)).
enc_client_id = EncryptedClientIdentification()
enc_client_id.provider_id = service_certificate.provider_id
enc_client_id.service_certificate_serial_number = service_certificate.serial_number
enc_client_id.encrypted_client_id = AES. \
new(privacy_key, AES.MODE_CBC, privacy_iv). \
encrypt(Padding.pad(client_id.SerializeToString(), 16))
enc_client_id.encrypted_privacy_key = PKCS1_OAEP. \
new(RSA.importKey(service_certificate.public_key)). \
encrypt(privacy_key)
)
enc_client_id.encrypted_client_id_iv = privacy_iv
return encrypted_client_id
return enc_client_id
@staticmethod
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]:
def derive_keys(msg: bytes, key: bytes) -> tuple[bytes, bytes, bytes]:
"""
Returns 3 keys derived from the input message.
Key can either be a pre-provision device aes key, provision key, or a session key.
@ -640,12 +338,24 @@ class Cdm:
keys and verify licenses.
"""
def get_enc_context(message: bytes) -> bytes:
label = b"ENCRYPTION"
key_size = 16 * 8 # 128-bit
return label + b"\x00" + message + key_size.to_bytes(4, "big")
def get_mac_context(message: bytes) -> bytes:
label = b"AUTHENTICATION"
key_size = 32 * 8 * 2 # 512-bit
return label + b"\x00" + message + key_size.to_bytes(4, "big")
def _derive(session_key: bytes, context: bytes, counter: int) -> bytes:
return CMAC. \
new(session_key, ciphermod=AES). \
return CMAC.new(session_key, ciphermod=AES). \
update(counter.to_bytes(1, "big") + context). \
digest()
enc_context = get_enc_context(msg)
mac_context = get_mac_context(msg)
enc_key = _derive(key, enc_context, 1)
mac_key_server = _derive(key, mac_context, 1)
mac_key_server += _derive(key, mac_context, 2)
@ -655,4 +365,4 @@ class Cdm:
return enc_key, mac_key_server, mac_key_client
__all__ = ("Cdm",)
__ALL__ = (Cdm,)

View File

@ -1,47 +1,41 @@
from __future__ import annotations
import base64
import logging
from enum import Enum
from pathlib import Path
from typing import Any, Optional, Union
from construct import BitStruct, Bytes, Const, ConstructError, Container
from construct import BitStruct, Bytes, Const
from construct import Enum as CEnum
from construct import Int8ub, Int16ub
from construct import Flag, Int8ub, Int16ub
from construct import Optional as COptional
from construct import Padded, Padding, Struct, this
from Crypto.PublicKey import RSA
from google.protobuf.message import DecodeError
from pywidevine.license_protocol_pb2 import ClientIdentification, DrmCertificate, FileHashes, SignedDrmCertificate
from pywidevine.license_protocol_pb2 import ClientIdentification, FileHashes, SignedDrmCertificate, DrmCertificate
class DeviceTypes(Enum):
class _Types(Enum):
CHROME = 1
ANDROID = 2
class _Structures:
magic = Const(b"WVD")
class Device:
# needed so bin_format can enumerate the types
Types = _Types
header = Struct(
"signature" / magic,
"version" / Int8ub
)
# - Removed vmp and vmp_len as it should already be within the Client ID
v2 = Struct(
"signature" / magic,
bin_format = Struct(
"signature" / Const(b"WVD"),
"version" / Const(Int8ub, 2),
"type_" / CEnum(
Int8ub,
**{t.name: t.value for t in DeviceTypes}
**{t.name: t.value for t in _Types}
),
"security_level" / Int8ub,
"flags" / Padded(1, COptional(BitStruct(
# no per-device flags yet
Padding(8)
Padding(7),
"send_key_control_nonce" / Flag # deprecated, do not use
))),
"private_key_len" / Int16ub,
"private_key" / Bytes(this.private_key_len),
@ -49,36 +43,14 @@ class _Structures:
"client_id" / Bytes(this.client_id_len)
)
# - Removed system_id as it can be retrieved from the Client ID's DRM Certificate
v1 = Struct(
"signature" / magic,
"version" / Const(Int8ub, 1),
"type_" / CEnum(
Int8ub,
**{t.name: t.value for t in DeviceTypes}
),
"security_level" / Int8ub,
"flags" / Padded(1, COptional(BitStruct(
# no per-device flags yet
Padding(8)
))),
"private_key_len" / Int16ub,
"private_key" / Bytes(this.private_key_len),
"client_id_len" / Int16ub,
"client_id" / Bytes(this.client_id_len),
"vmp_len" / Int16ub,
"vmp" / Bytes(this.vmp_len)
)
class Device:
Structures = _Structures
supported_structure = Structures.v2
# == Bin Format Revisions == #
# Version 2: Removed vmp and vmp_len as it should already be within the Client ID
# Version 1: Removed system_id as it can be retrieved from the Client ID's DRM Certificate
def __init__(
self,
*_: Any,
type_: DeviceTypes,
type_: Types,
security_level: int,
flags: Optional[dict],
private_key: Optional[bytes],
@ -102,44 +74,27 @@ class Device:
if not private_key:
raise ValueError("Private Key is required, the WVD does not contain one or is malformed.")
self.type = DeviceTypes[type_] if isinstance(type_, str) else type_
self.type = self.Types[type_] if isinstance(type_, str) else type_
self.security_level = security_level
self.flags = flags or {}
self.flags = flags
self.private_key = RSA.importKey(private_key)
self.client_id = ClientIdentification()
try:
self.client_id.ParseFromString(client_id)
if self.client_id.SerializeToString() != client_id:
raise DecodeError("partial parse")
except DecodeError as e:
raise DecodeError(f"Failed to parse client_id as a ClientIdentification, {e}")
except DecodeError:
raise ValueError("Failed to parse client_id as a ClientIdentification")
self.vmp = FileHashes()
if self.client_id.vmp_data:
try:
self.vmp.ParseFromString(self.client_id.vmp_data)
if self.vmp.SerializeToString() != self.client_id.vmp_data:
raise DecodeError("partial parse")
except DecodeError as e:
raise DecodeError(f"Failed to parse Client ID's VMP data as a FileHashes, {e}")
except DecodeError:
raise ValueError("Failed to parse Client ID's VMP data as a FileHashes")
signed_drm_certificate = SignedDrmCertificate()
signed_drm_certificate.ParseFromString(self.client_id.token)
drm_certificate = DrmCertificate()
try:
signed_drm_certificate.ParseFromString(self.client_id.token)
if signed_drm_certificate.SerializeToString() != self.client_id.token:
raise DecodeError("partial parse")
except DecodeError as e:
raise DecodeError(f"Failed to parse the Signed DRM Certificate of the Client ID, {e}")
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"Failed to parse the DRM Certificate of the Client ID, {e}")
drm_certificate.ParseFromString(signed_drm_certificate.drm_certificate)
self.system_id = drm_certificate.system_id
def __repr__(self) -> str:
@ -154,20 +109,20 @@ class Device:
data = base64.b64decode(data)
if not isinstance(data, bytes):
raise ValueError(f"Expecting Bytes or Base64 input, got {data!r}")
return cls(**cls.supported_structure.parse(data))
return cls(**cls.bin_format.parse(data))
@classmethod
def load(cls, path: Union[Path, str]) -> Device:
if not isinstance(path, (Path, str)):
raise ValueError(f"Expecting Path object or path string, got {path!r}")
with Path(path).open(mode="rb") as f:
return cls(**cls.supported_structure.parse_stream(f))
return cls(**cls.bin_format.parse_stream(f))
def dumps(self) -> bytes:
private_key = self.private_key.export_key("DER") if self.private_key else None
return self.supported_structure.build(dict(
return self.bin_format.build(dict(
version=2,
type_=self.type.value,
type=self.type.value,
security_level=self.security_level,
flags=self.flags,
private_key_len=len(private_key) if private_key else 0,
@ -183,58 +138,5 @@ class Device:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(self.dumps())
@classmethod
def migrate(cls, data: Union[bytes, str]) -> Device:
if isinstance(data, str):
data = base64.b64decode(data)
if not isinstance(data, bytes):
raise ValueError(f"Expecting Bytes or Base64 input, got {data!r}")
header = _Structures.header.parse(data)
if header.version == 2:
raise ValueError("Device Data is already migrated to the latest version.")
if header.version == 0 or header.version > 2:
# we have never used version 0, likely data that just so happened to use the WVD magic
raise ValueError("Device Data does not seem to be a WVD file (v0).")
if header.version == 1: # v1 to v2
v1_struct = _Structures.v1.parse(data)
v1_struct.version = 2 # update version to 2 to allow loading
v1_struct.flags = Container() # blank flags that may have been used in v1
vmp = FileHashes()
if v1_struct.vmp:
try:
vmp.ParseFromString(v1_struct.vmp)
if vmp.SerializeToString() != v1_struct.vmp:
raise DecodeError("partial parse")
except DecodeError as e:
raise DecodeError(f"Failed to parse VMP data as FileHashes, {e}")
v1_struct.vmp = vmp
client_id = ClientIdentification()
try:
client_id.ParseFromString(v1_struct.client_id)
if client_id.SerializeToString() != v1_struct.client_id:
raise DecodeError("partial parse")
except DecodeError as e:
raise DecodeError(f"Failed to parse VMP data as FileHashes, {e}")
new_vmp_data = v1_struct.vmp.SerializeToString()
if client_id.vmp_data and client_id.vmp_data != new_vmp_data:
logging.getLogger("migrate").warning("Client ID already has Verified Media Path data")
client_id.vmp_data = new_vmp_data
v1_struct.client_id = client_id.SerializeToString()
try:
data = _Structures.v2.build(v1_struct)
except ConstructError as e:
raise ValueError(f"Migration failed, {e}")
try:
return cls.loads(data)
except ConstructError as e:
raise ValueError(f"Device Data seems to be corrupt or invalid, or migration failed, {e}")
__all__ = ("Device", "DeviceTypes")
__ALL__ = (Device,)

View File

@ -1,38 +0,0 @@
class PyWidevineException(Exception):
"""Exceptions used by pywidevine."""
class TooManySessions(PyWidevineException):
"""Too many Sessions are open."""
class InvalidSession(PyWidevineException):
"""No Session is open with the specified identifier."""
class InvalidInitData(PyWidevineException):
"""The Widevine Cenc Header Data is invalid or empty."""
class InvalidLicenseType(PyWidevineException):
"""The License Type is an Invalid Value."""
class InvalidLicenseMessage(PyWidevineException):
"""The License Message is Invalid or Missing."""
class InvalidContext(PyWidevineException):
"""The Context is Invalid or Missing."""
class SignatureMismatch(PyWidevineException):
"""The Signature did not match."""
class NoKeysLoaded(PyWidevineException):
"""No License was parsed for this Session, No Keys available."""
class DeviceMismatch(PyWidevineException):
"""The Remote CDMs Device information and the APIs Device information did not match."""

View File

@ -27,7 +27,7 @@ class Key:
def from_key_container(cls, key: License.KeyContainer, enc_key: bytes) -> Key:
"""Load Key from a KeyContainer object."""
permissions = []
if key.type == License.KeyContainer.KeyType.Value("OPERATOR_SESSION"):
if key.type == License.KeyContainer.KeyType.OPERATOR_SESSION:
for descriptor, value in key.operator_session_key_permissions.ListFields():
if value == 1:
permissions.append(descriptor.name)
@ -61,6 +61,3 @@ class Key:
kid += b"\x00" * (16 - len(kid))
return UUID(bytes=kid)
__all__ = ("Key",)

View File

@ -1,11 +1,11 @@
syntax = "proto2";
package pywidevine_license_protocol;
package video_widevine;
// need this if we are using libprotobuf-cpp-2.3.0-lite
option optimize_for = LITE_RUNTIME;
option java_package = "com.rlaphoenix.pywidevine.protos";
option java_package = "com.google.video.widevine.protos";
enum LicenseType {
STREAMING = 1;

File diff suppressed because one or more lines are too long

View File

@ -1,607 +0,0 @@
# mypy: ignore-errors
from google.protobuf.internal import containers as _containers
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
AUTOMATIC: LicenseType
DESCRIPTOR: _descriptor.FileDescriptor
HASH_ALGORITHM_SHA_1: HashAlgorithmProto
HASH_ALGORITHM_SHA_256: HashAlgorithmProto
HASH_ALGORITHM_SHA_384: HashAlgorithmProto
HASH_ALGORITHM_UNSPECIFIED: HashAlgorithmProto
OFFLINE: LicenseType
PLATFORM_HARDWARE_VERIFIED: PlatformVerificationStatus
PLATFORM_NO_VERIFICATION: PlatformVerificationStatus
PLATFORM_SECURE_STORAGE_SOFTWARE_VERIFIED: PlatformVerificationStatus
PLATFORM_SOFTWARE_VERIFIED: PlatformVerificationStatus
PLATFORM_TAMPERED: PlatformVerificationStatus
PLATFORM_UNVERIFIED: PlatformVerificationStatus
STREAMING: LicenseType
VERSION_2_0: ProtocolVersion
VERSION_2_1: ProtocolVersion
VERSION_2_2: ProtocolVersion
class ClientIdentification(_message.Message):
__slots__ = ["client_capabilities", "client_info", "device_credentials", "license_counter", "provider_client_token", "token", "type", "vmp_data"]
class TokenType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class ClientCapabilities(_message.Message):
__slots__ = ["analog_output_capabilities", "anti_rollback_usage_table", "can_disable_analog_output", "can_update_srm", "client_token", "max_hdcp_version", "oem_crypto_api_version", "resource_rating_tier", "session_token", "srm_version", "supported_certificate_key_type", "video_resolution_constraints"]
class AnalogOutputCapabilities(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class CertificateKeyType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class HdcpVersion(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
ANALOG_OUTPUT_CAPABILITIES_FIELD_NUMBER: _ClassVar[int]
ANALOG_OUTPUT_NONE: ClientIdentification.ClientCapabilities.AnalogOutputCapabilities
ANALOG_OUTPUT_SUPPORTED: ClientIdentification.ClientCapabilities.AnalogOutputCapabilities
ANALOG_OUTPUT_SUPPORTS_CGMS_A: ClientIdentification.ClientCapabilities.AnalogOutputCapabilities
ANALOG_OUTPUT_UNKNOWN: ClientIdentification.ClientCapabilities.AnalogOutputCapabilities
ANTI_ROLLBACK_USAGE_TABLE_FIELD_NUMBER: _ClassVar[int]
CAN_DISABLE_ANALOG_OUTPUT_FIELD_NUMBER: _ClassVar[int]
CAN_UPDATE_SRM_FIELD_NUMBER: _ClassVar[int]
CLIENT_TOKEN_FIELD_NUMBER: _ClassVar[int]
ECC_SECP256R1: ClientIdentification.ClientCapabilities.CertificateKeyType
ECC_SECP384R1: ClientIdentification.ClientCapabilities.CertificateKeyType
ECC_SECP521R1: ClientIdentification.ClientCapabilities.CertificateKeyType
HDCP_NONE: ClientIdentification.ClientCapabilities.HdcpVersion
HDCP_NO_DIGITAL_OUTPUT: ClientIdentification.ClientCapabilities.HdcpVersion
HDCP_V1: ClientIdentification.ClientCapabilities.HdcpVersion
HDCP_V2: ClientIdentification.ClientCapabilities.HdcpVersion
HDCP_V2_1: ClientIdentification.ClientCapabilities.HdcpVersion
HDCP_V2_2: ClientIdentification.ClientCapabilities.HdcpVersion
HDCP_V2_3: ClientIdentification.ClientCapabilities.HdcpVersion
MAX_HDCP_VERSION_FIELD_NUMBER: _ClassVar[int]
OEM_CRYPTO_API_VERSION_FIELD_NUMBER: _ClassVar[int]
RESOURCE_RATING_TIER_FIELD_NUMBER: _ClassVar[int]
RSA_2048: ClientIdentification.ClientCapabilities.CertificateKeyType
RSA_3072: ClientIdentification.ClientCapabilities.CertificateKeyType
SESSION_TOKEN_FIELD_NUMBER: _ClassVar[int]
SRM_VERSION_FIELD_NUMBER: _ClassVar[int]
SUPPORTED_CERTIFICATE_KEY_TYPE_FIELD_NUMBER: _ClassVar[int]
VIDEO_RESOLUTION_CONSTRAINTS_FIELD_NUMBER: _ClassVar[int]
analog_output_capabilities: ClientIdentification.ClientCapabilities.AnalogOutputCapabilities
anti_rollback_usage_table: bool
can_disable_analog_output: bool
can_update_srm: bool
client_token: bool
max_hdcp_version: ClientIdentification.ClientCapabilities.HdcpVersion
oem_crypto_api_version: int
resource_rating_tier: int
session_token: bool
srm_version: int
supported_certificate_key_type: _containers.RepeatedScalarFieldContainer[ClientIdentification.ClientCapabilities.CertificateKeyType]
video_resolution_constraints: bool
def __init__(self, client_token: bool = ..., session_token: bool = ..., video_resolution_constraints: bool = ..., max_hdcp_version: _Optional[_Union[ClientIdentification.ClientCapabilities.HdcpVersion, str]] = ..., oem_crypto_api_version: _Optional[int] = ..., anti_rollback_usage_table: bool = ..., srm_version: _Optional[int] = ..., can_update_srm: bool = ..., supported_certificate_key_type: _Optional[_Iterable[_Union[ClientIdentification.ClientCapabilities.CertificateKeyType, str]]] = ..., analog_output_capabilities: _Optional[_Union[ClientIdentification.ClientCapabilities.AnalogOutputCapabilities, str]] = ..., can_disable_analog_output: bool = ..., resource_rating_tier: _Optional[int] = ...) -> None: ...
class ClientCredentials(_message.Message):
__slots__ = ["token", "type"]
TOKEN_FIELD_NUMBER: _ClassVar[int]
TYPE_FIELD_NUMBER: _ClassVar[int]
token: bytes
type: ClientIdentification.TokenType
def __init__(self, type: _Optional[_Union[ClientIdentification.TokenType, str]] = ..., token: _Optional[bytes] = ...) -> None: ...
class NameValue(_message.Message):
__slots__ = ["name", "value"]
NAME_FIELD_NUMBER: _ClassVar[int]
VALUE_FIELD_NUMBER: _ClassVar[int]
name: str
value: str
def __init__(self, name: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ...
CLIENT_CAPABILITIES_FIELD_NUMBER: _ClassVar[int]
CLIENT_INFO_FIELD_NUMBER: _ClassVar[int]
DEVICE_CREDENTIALS_FIELD_NUMBER: _ClassVar[int]
DRM_DEVICE_CERTIFICATE: ClientIdentification.TokenType
KEYBOX: ClientIdentification.TokenType
LICENSE_COUNTER_FIELD_NUMBER: _ClassVar[int]
OEM_DEVICE_CERTIFICATE: ClientIdentification.TokenType
PROVIDER_CLIENT_TOKEN_FIELD_NUMBER: _ClassVar[int]
REMOTE_ATTESTATION_CERTIFICATE: ClientIdentification.TokenType
TOKEN_FIELD_NUMBER: _ClassVar[int]
TYPE_FIELD_NUMBER: _ClassVar[int]
VMP_DATA_FIELD_NUMBER: _ClassVar[int]
client_capabilities: ClientIdentification.ClientCapabilities
client_info: _containers.RepeatedCompositeFieldContainer[ClientIdentification.NameValue]
device_credentials: _containers.RepeatedCompositeFieldContainer[ClientIdentification.ClientCredentials]
license_counter: int
provider_client_token: bytes
token: bytes
type: ClientIdentification.TokenType
vmp_data: bytes
def __init__(self, type: _Optional[_Union[ClientIdentification.TokenType, str]] = ..., token: _Optional[bytes] = ..., client_info: _Optional[_Iterable[_Union[ClientIdentification.NameValue, _Mapping]]] = ..., provider_client_token: _Optional[bytes] = ..., license_counter: _Optional[int] = ..., client_capabilities: _Optional[_Union[ClientIdentification.ClientCapabilities, _Mapping]] = ..., vmp_data: _Optional[bytes] = ..., device_credentials: _Optional[_Iterable[_Union[ClientIdentification.ClientCredentials, _Mapping]]] = ...) -> None: ...
class DrmCertificate(_message.Message):
__slots__ = ["algorithm", "creation_time_seconds", "encryption_key", "expiration_time_seconds", "provider_id", "public_key", "rot_id", "serial_number", "service_types", "system_id", "test_device_deprecated", "type"]
class Algorithm(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class ServiceType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class Type(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class EncryptionKey(_message.Message):
__slots__ = ["algorithm", "public_key"]
ALGORITHM_FIELD_NUMBER: _ClassVar[int]
PUBLIC_KEY_FIELD_NUMBER: _ClassVar[int]
algorithm: DrmCertificate.Algorithm
public_key: bytes
def __init__(self, public_key: _Optional[bytes] = ..., algorithm: _Optional[_Union[DrmCertificate.Algorithm, str]] = ...) -> None: ...
ALGORITHM_FIELD_NUMBER: _ClassVar[int]
CAS_PROXY_SDK: DrmCertificate.ServiceType
CREATION_TIME_SECONDS_FIELD_NUMBER: _ClassVar[int]
DEVICE: DrmCertificate.Type
DEVICE_MODEL: DrmCertificate.Type
ECC_SECP256R1: DrmCertificate.Algorithm
ECC_SECP384R1: DrmCertificate.Algorithm
ECC_SECP521R1: DrmCertificate.Algorithm
ENCRYPTION_KEY_FIELD_NUMBER: _ClassVar[int]
EXPIRATION_TIME_SECONDS_FIELD_NUMBER: _ClassVar[int]
LICENSE_SERVER_PROXY_SDK: DrmCertificate.ServiceType
LICENSE_SERVER_SDK: DrmCertificate.ServiceType
PROVIDER_ID_FIELD_NUMBER: _ClassVar[int]
PROVISIONER: DrmCertificate.Type
PROVISIONING_SDK: DrmCertificate.ServiceType
PUBLIC_KEY_FIELD_NUMBER: _ClassVar[int]
ROOT: DrmCertificate.Type
ROT_ID_FIELD_NUMBER: _ClassVar[int]
RSA: DrmCertificate.Algorithm
SERIAL_NUMBER_FIELD_NUMBER: _ClassVar[int]
SERVICE: DrmCertificate.Type
SERVICE_TYPES_FIELD_NUMBER: _ClassVar[int]
SYSTEM_ID_FIELD_NUMBER: _ClassVar[int]
TEST_DEVICE_DEPRECATED_FIELD_NUMBER: _ClassVar[int]
TYPE_FIELD_NUMBER: _ClassVar[int]
UNKNOWN_ALGORITHM: DrmCertificate.Algorithm
UNKNOWN_SERVICE_TYPE: DrmCertificate.ServiceType
algorithm: DrmCertificate.Algorithm
creation_time_seconds: int
encryption_key: DrmCertificate.EncryptionKey
expiration_time_seconds: int
provider_id: str
public_key: bytes
rot_id: bytes
serial_number: bytes
service_types: _containers.RepeatedScalarFieldContainer[DrmCertificate.ServiceType]
system_id: int
test_device_deprecated: bool
type: DrmCertificate.Type
def __init__(self, type: _Optional[_Union[DrmCertificate.Type, str]] = ..., serial_number: _Optional[bytes] = ..., creation_time_seconds: _Optional[int] = ..., expiration_time_seconds: _Optional[int] = ..., public_key: _Optional[bytes] = ..., system_id: _Optional[int] = ..., test_device_deprecated: bool = ..., provider_id: _Optional[str] = ..., service_types: _Optional[_Iterable[_Union[DrmCertificate.ServiceType, str]]] = ..., algorithm: _Optional[_Union[DrmCertificate.Algorithm, str]] = ..., rot_id: _Optional[bytes] = ..., encryption_key: _Optional[_Union[DrmCertificate.EncryptionKey, _Mapping]] = ...) -> None: ...
class EncryptedClientIdentification(_message.Message):
__slots__ = ["encrypted_client_id", "encrypted_client_id_iv", "encrypted_privacy_key", "provider_id", "service_certificate_serial_number"]
ENCRYPTED_CLIENT_ID_FIELD_NUMBER: _ClassVar[int]
ENCRYPTED_CLIENT_ID_IV_FIELD_NUMBER: _ClassVar[int]
ENCRYPTED_PRIVACY_KEY_FIELD_NUMBER: _ClassVar[int]
PROVIDER_ID_FIELD_NUMBER: _ClassVar[int]
SERVICE_CERTIFICATE_SERIAL_NUMBER_FIELD_NUMBER: _ClassVar[int]
encrypted_client_id: bytes
encrypted_client_id_iv: bytes
encrypted_privacy_key: bytes
provider_id: str
service_certificate_serial_number: bytes
def __init__(self, provider_id: _Optional[str] = ..., service_certificate_serial_number: _Optional[bytes] = ..., encrypted_client_id: _Optional[bytes] = ..., encrypted_client_id_iv: _Optional[bytes] = ..., encrypted_privacy_key: _Optional[bytes] = ...) -> None: ...
class FileHashes(_message.Message):
__slots__ = ["signatures", "signer"]
class Signature(_message.Message):
__slots__ = ["SHA512Hash", "filename", "main_exe", "signature", "test_signing"]
FILENAME_FIELD_NUMBER: _ClassVar[int]
MAIN_EXE_FIELD_NUMBER: _ClassVar[int]
SHA512HASH_FIELD_NUMBER: _ClassVar[int]
SHA512Hash: bytes
SIGNATURE_FIELD_NUMBER: _ClassVar[int]
TEST_SIGNING_FIELD_NUMBER: _ClassVar[int]
filename: str
main_exe: bool
signature: bytes
test_signing: bool
def __init__(self, filename: _Optional[str] = ..., test_signing: bool = ..., SHA512Hash: _Optional[bytes] = ..., main_exe: bool = ..., signature: _Optional[bytes] = ...) -> None: ...
SIGNATURES_FIELD_NUMBER: _ClassVar[int]
SIGNER_FIELD_NUMBER: _ClassVar[int]
signatures: _containers.RepeatedCompositeFieldContainer[FileHashes.Signature]
signer: bytes
def __init__(self, signer: _Optional[bytes] = ..., signatures: _Optional[_Iterable[_Union[FileHashes.Signature, _Mapping]]] = ...) -> None: ...
class License(_message.Message):
__slots__ = ["group_ids", "id", "key", "license_start_time", "platform_verification_status", "policy", "protection_scheme", "provider_client_token", "remote_attestation_verified", "srm_requirement", "srm_update"]
class KeyContainer(_message.Message):
__slots__ = ["anti_rollback_usage_table", "id", "iv", "key", "key_control", "level", "operator_session_key_permissions", "requested_protection", "required_protection", "track_label", "type", "video_resolution_constraints"]
class KeyType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class SecurityLevel(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class KeyControl(_message.Message):
__slots__ = ["iv", "key_control_block"]
IV_FIELD_NUMBER: _ClassVar[int]
KEY_CONTROL_BLOCK_FIELD_NUMBER: _ClassVar[int]
iv: bytes
key_control_block: bytes
def __init__(self, key_control_block: _Optional[bytes] = ..., iv: _Optional[bytes] = ...) -> None: ...
class OperatorSessionKeyPermissions(_message.Message):
__slots__ = ["allow_decrypt", "allow_encrypt", "allow_sign", "allow_signature_verify"]
ALLOW_DECRYPT_FIELD_NUMBER: _ClassVar[int]
ALLOW_ENCRYPT_FIELD_NUMBER: _ClassVar[int]
ALLOW_SIGNATURE_VERIFY_FIELD_NUMBER: _ClassVar[int]
ALLOW_SIGN_FIELD_NUMBER: _ClassVar[int]
allow_decrypt: bool
allow_encrypt: bool
allow_sign: bool
allow_signature_verify: bool
def __init__(self, allow_encrypt: bool = ..., allow_decrypt: bool = ..., allow_sign: bool = ..., allow_signature_verify: bool = ...) -> None: ...
class OutputProtection(_message.Message):
__slots__ = ["cgms_flags", "disable_analog_output", "disable_digital_output", "hdcp", "hdcp_srm_rule"]
class CGMS(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class HDCP(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class HdcpSrmRule(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
CGMS_FLAGS_FIELD_NUMBER: _ClassVar[int]
CGMS_NONE: License.KeyContainer.OutputProtection.CGMS
COPY_FREE: License.KeyContainer.OutputProtection.CGMS
COPY_NEVER: License.KeyContainer.OutputProtection.CGMS
COPY_ONCE: License.KeyContainer.OutputProtection.CGMS
CURRENT_SRM: License.KeyContainer.OutputProtection.HdcpSrmRule
DISABLE_ANALOG_OUTPUT_FIELD_NUMBER: _ClassVar[int]
DISABLE_DIGITAL_OUTPUT_FIELD_NUMBER: _ClassVar[int]
HDCP_FIELD_NUMBER: _ClassVar[int]
HDCP_NONE: License.KeyContainer.OutputProtection.HDCP
HDCP_NO_DIGITAL_OUTPUT: License.KeyContainer.OutputProtection.HDCP
HDCP_SRM_RULE_FIELD_NUMBER: _ClassVar[int]
HDCP_SRM_RULE_NONE: License.KeyContainer.OutputProtection.HdcpSrmRule
HDCP_V1: License.KeyContainer.OutputProtection.HDCP
HDCP_V2: License.KeyContainer.OutputProtection.HDCP
HDCP_V2_1: License.KeyContainer.OutputProtection.HDCP
HDCP_V2_2: License.KeyContainer.OutputProtection.HDCP
HDCP_V2_3: License.KeyContainer.OutputProtection.HDCP
cgms_flags: License.KeyContainer.OutputProtection.CGMS
disable_analog_output: bool
disable_digital_output: bool
hdcp: License.KeyContainer.OutputProtection.HDCP
hdcp_srm_rule: License.KeyContainer.OutputProtection.HdcpSrmRule
def __init__(self, hdcp: _Optional[_Union[License.KeyContainer.OutputProtection.HDCP, str]] = ..., cgms_flags: _Optional[_Union[License.KeyContainer.OutputProtection.CGMS, str]] = ..., hdcp_srm_rule: _Optional[_Union[License.KeyContainer.OutputProtection.HdcpSrmRule, str]] = ..., disable_analog_output: bool = ..., disable_digital_output: bool = ...) -> None: ...
class VideoResolutionConstraint(_message.Message):
__slots__ = ["max_resolution_pixels", "min_resolution_pixels", "required_protection"]
MAX_RESOLUTION_PIXELS_FIELD_NUMBER: _ClassVar[int]
MIN_RESOLUTION_PIXELS_FIELD_NUMBER: _ClassVar[int]
REQUIRED_PROTECTION_FIELD_NUMBER: _ClassVar[int]
max_resolution_pixels: int
min_resolution_pixels: int
required_protection: License.KeyContainer.OutputProtection
def __init__(self, min_resolution_pixels: _Optional[int] = ..., max_resolution_pixels: _Optional[int] = ..., required_protection: _Optional[_Union[License.KeyContainer.OutputProtection, _Mapping]] = ...) -> None: ...
ANTI_ROLLBACK_USAGE_TABLE_FIELD_NUMBER: _ClassVar[int]
CONTENT: License.KeyContainer.KeyType
ENTITLEMENT: License.KeyContainer.KeyType
HW_SECURE_ALL: License.KeyContainer.SecurityLevel
HW_SECURE_CRYPTO: License.KeyContainer.SecurityLevel
HW_SECURE_DECODE: License.KeyContainer.SecurityLevel
ID_FIELD_NUMBER: _ClassVar[int]
IV_FIELD_NUMBER: _ClassVar[int]
KEY_CONTROL: License.KeyContainer.KeyType
KEY_CONTROL_FIELD_NUMBER: _ClassVar[int]
KEY_FIELD_NUMBER: _ClassVar[int]
LEVEL_FIELD_NUMBER: _ClassVar[int]
OEM_CONTENT: License.KeyContainer.KeyType
OPERATOR_SESSION: License.KeyContainer.KeyType
OPERATOR_SESSION_KEY_PERMISSIONS_FIELD_NUMBER: _ClassVar[int]
REQUESTED_PROTECTION_FIELD_NUMBER: _ClassVar[int]
REQUIRED_PROTECTION_FIELD_NUMBER: _ClassVar[int]
SIGNING: License.KeyContainer.KeyType
SW_SECURE_CRYPTO: License.KeyContainer.SecurityLevel
SW_SECURE_DECODE: License.KeyContainer.SecurityLevel
TRACK_LABEL_FIELD_NUMBER: _ClassVar[int]
TYPE_FIELD_NUMBER: _ClassVar[int]
VIDEO_RESOLUTION_CONSTRAINTS_FIELD_NUMBER: _ClassVar[int]
anti_rollback_usage_table: bool
id: bytes
iv: bytes
key: bytes
key_control: License.KeyContainer.KeyControl
level: License.KeyContainer.SecurityLevel
operator_session_key_permissions: License.KeyContainer.OperatorSessionKeyPermissions
requested_protection: License.KeyContainer.OutputProtection
required_protection: License.KeyContainer.OutputProtection
track_label: str
type: License.KeyContainer.KeyType
video_resolution_constraints: _containers.RepeatedCompositeFieldContainer[License.KeyContainer.VideoResolutionConstraint]
def __init__(self, id: _Optional[bytes] = ..., iv: _Optional[bytes] = ..., key: _Optional[bytes] = ..., type: _Optional[_Union[License.KeyContainer.KeyType, str]] = ..., level: _Optional[_Union[License.KeyContainer.SecurityLevel, str]] = ..., required_protection: _Optional[_Union[License.KeyContainer.OutputProtection, _Mapping]] = ..., requested_protection: _Optional[_Union[License.KeyContainer.OutputProtection, _Mapping]] = ..., key_control: _Optional[_Union[License.KeyContainer.KeyControl, _Mapping]] = ..., operator_session_key_permissions: _Optional[_Union[License.KeyContainer.OperatorSessionKeyPermissions, _Mapping]] = ..., video_resolution_constraints: _Optional[_Iterable[_Union[License.KeyContainer.VideoResolutionConstraint, _Mapping]]] = ..., anti_rollback_usage_table: bool = ..., track_label: _Optional[str] = ...) -> None: ...
class Policy(_message.Message):
__slots__ = ["always_include_client_id", "can_persist", "can_play", "can_renew", "license_duration_seconds", "play_start_grace_period_seconds", "playback_duration_seconds", "renew_with_usage", "renewal_delay_seconds", "renewal_recovery_duration_seconds", "renewal_retry_interval_seconds", "renewal_server_url", "rental_duration_seconds", "soft_enforce_playback_duration", "soft_enforce_rental_duration"]
ALWAYS_INCLUDE_CLIENT_ID_FIELD_NUMBER: _ClassVar[int]
CAN_PERSIST_FIELD_NUMBER: _ClassVar[int]
CAN_PLAY_FIELD_NUMBER: _ClassVar[int]
CAN_RENEW_FIELD_NUMBER: _ClassVar[int]
LICENSE_DURATION_SECONDS_FIELD_NUMBER: _ClassVar[int]
PLAYBACK_DURATION_SECONDS_FIELD_NUMBER: _ClassVar[int]
PLAY_START_GRACE_PERIOD_SECONDS_FIELD_NUMBER: _ClassVar[int]
RENEWAL_DELAY_SECONDS_FIELD_NUMBER: _ClassVar[int]
RENEWAL_RECOVERY_DURATION_SECONDS_FIELD_NUMBER: _ClassVar[int]
RENEWAL_RETRY_INTERVAL_SECONDS_FIELD_NUMBER: _ClassVar[int]
RENEWAL_SERVER_URL_FIELD_NUMBER: _ClassVar[int]
RENEW_WITH_USAGE_FIELD_NUMBER: _ClassVar[int]
RENTAL_DURATION_SECONDS_FIELD_NUMBER: _ClassVar[int]
SOFT_ENFORCE_PLAYBACK_DURATION_FIELD_NUMBER: _ClassVar[int]
SOFT_ENFORCE_RENTAL_DURATION_FIELD_NUMBER: _ClassVar[int]
always_include_client_id: bool
can_persist: bool
can_play: bool
can_renew: bool
license_duration_seconds: int
play_start_grace_period_seconds: int
playback_duration_seconds: int
renew_with_usage: bool
renewal_delay_seconds: int
renewal_recovery_duration_seconds: int
renewal_retry_interval_seconds: int
renewal_server_url: str
rental_duration_seconds: int
soft_enforce_playback_duration: bool
soft_enforce_rental_duration: bool
def __init__(self, can_play: bool = ..., can_persist: bool = ..., can_renew: bool = ..., rental_duration_seconds: _Optional[int] = ..., playback_duration_seconds: _Optional[int] = ..., license_duration_seconds: _Optional[int] = ..., renewal_recovery_duration_seconds: _Optional[int] = ..., renewal_server_url: _Optional[str] = ..., renewal_delay_seconds: _Optional[int] = ..., renewal_retry_interval_seconds: _Optional[int] = ..., renew_with_usage: bool = ..., always_include_client_id: bool = ..., play_start_grace_period_seconds: _Optional[int] = ..., soft_enforce_playback_duration: bool = ..., soft_enforce_rental_duration: bool = ...) -> None: ...
GROUP_IDS_FIELD_NUMBER: _ClassVar[int]
ID_FIELD_NUMBER: _ClassVar[int]
KEY_FIELD_NUMBER: _ClassVar[int]
LICENSE_START_TIME_FIELD_NUMBER: _ClassVar[int]
PLATFORM_VERIFICATION_STATUS_FIELD_NUMBER: _ClassVar[int]
POLICY_FIELD_NUMBER: _ClassVar[int]
PROTECTION_SCHEME_FIELD_NUMBER: _ClassVar[int]
PROVIDER_CLIENT_TOKEN_FIELD_NUMBER: _ClassVar[int]
REMOTE_ATTESTATION_VERIFIED_FIELD_NUMBER: _ClassVar[int]
SRM_REQUIREMENT_FIELD_NUMBER: _ClassVar[int]
SRM_UPDATE_FIELD_NUMBER: _ClassVar[int]
group_ids: _containers.RepeatedScalarFieldContainer[bytes]
id: LicenseIdentification
key: _containers.RepeatedCompositeFieldContainer[License.KeyContainer]
license_start_time: int
platform_verification_status: PlatformVerificationStatus
policy: License.Policy
protection_scheme: int
provider_client_token: bytes
remote_attestation_verified: bool
srm_requirement: bytes
srm_update: bytes
def __init__(self, id: _Optional[_Union[LicenseIdentification, _Mapping]] = ..., policy: _Optional[_Union[License.Policy, _Mapping]] = ..., key: _Optional[_Iterable[_Union[License.KeyContainer, _Mapping]]] = ..., license_start_time: _Optional[int] = ..., remote_attestation_verified: bool = ..., provider_client_token: _Optional[bytes] = ..., protection_scheme: _Optional[int] = ..., srm_requirement: _Optional[bytes] = ..., srm_update: _Optional[bytes] = ..., platform_verification_status: _Optional[_Union[PlatformVerificationStatus, str]] = ..., group_ids: _Optional[_Iterable[bytes]] = ...) -> None: ...
class LicenseIdentification(_message.Message):
__slots__ = ["provider_session_token", "purchase_id", "request_id", "session_id", "type", "version"]
PROVIDER_SESSION_TOKEN_FIELD_NUMBER: _ClassVar[int]
PURCHASE_ID_FIELD_NUMBER: _ClassVar[int]
REQUEST_ID_FIELD_NUMBER: _ClassVar[int]
SESSION_ID_FIELD_NUMBER: _ClassVar[int]
TYPE_FIELD_NUMBER: _ClassVar[int]
VERSION_FIELD_NUMBER: _ClassVar[int]
provider_session_token: bytes
purchase_id: bytes
request_id: bytes
session_id: bytes
type: LicenseType
version: int
def __init__(self, request_id: _Optional[bytes] = ..., session_id: _Optional[bytes] = ..., purchase_id: _Optional[bytes] = ..., type: _Optional[_Union[LicenseType, str]] = ..., version: _Optional[int] = ..., provider_session_token: _Optional[bytes] = ...) -> None: ...
class LicenseRequest(_message.Message):
__slots__ = ["client_id", "content_id", "encrypted_client_id", "key_control_nonce", "key_control_nonce_deprecated", "protocol_version", "request_time", "type"]
class RequestType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class ContentIdentification(_message.Message):
__slots__ = ["existing_license", "init_data", "webm_key_id", "widevine_pssh_data"]
class ExistingLicense(_message.Message):
__slots__ = ["license_id", "seconds_since_last_played", "seconds_since_started", "session_usage_table_entry"]
LICENSE_ID_FIELD_NUMBER: _ClassVar[int]
SECONDS_SINCE_LAST_PLAYED_FIELD_NUMBER: _ClassVar[int]
SECONDS_SINCE_STARTED_FIELD_NUMBER: _ClassVar[int]
SESSION_USAGE_TABLE_ENTRY_FIELD_NUMBER: _ClassVar[int]
license_id: LicenseIdentification
seconds_since_last_played: int
seconds_since_started: int
session_usage_table_entry: bytes
def __init__(self, license_id: _Optional[_Union[LicenseIdentification, _Mapping]] = ..., seconds_since_started: _Optional[int] = ..., seconds_since_last_played: _Optional[int] = ..., session_usage_table_entry: _Optional[bytes] = ...) -> None: ...
class InitData(_message.Message):
__slots__ = ["init_data", "init_data_type", "license_type", "request_id"]
class InitDataType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
CENC: LicenseRequest.ContentIdentification.InitData.InitDataType
INIT_DATA_FIELD_NUMBER: _ClassVar[int]
INIT_DATA_TYPE_FIELD_NUMBER: _ClassVar[int]
LICENSE_TYPE_FIELD_NUMBER: _ClassVar[int]
REQUEST_ID_FIELD_NUMBER: _ClassVar[int]
WEBM: LicenseRequest.ContentIdentification.InitData.InitDataType
init_data: bytes
init_data_type: LicenseRequest.ContentIdentification.InitData.InitDataType
license_type: LicenseType
request_id: bytes
def __init__(self, init_data_type: _Optional[_Union[LicenseRequest.ContentIdentification.InitData.InitDataType, str]] = ..., init_data: _Optional[bytes] = ..., license_type: _Optional[_Union[LicenseType, str]] = ..., request_id: _Optional[bytes] = ...) -> None: ...
class WebmKeyId(_message.Message):
__slots__ = ["header", "license_type", "request_id"]
HEADER_FIELD_NUMBER: _ClassVar[int]
LICENSE_TYPE_FIELD_NUMBER: _ClassVar[int]
REQUEST_ID_FIELD_NUMBER: _ClassVar[int]
header: bytes
license_type: LicenseType
request_id: bytes
def __init__(self, header: _Optional[bytes] = ..., license_type: _Optional[_Union[LicenseType, str]] = ..., request_id: _Optional[bytes] = ...) -> None: ...
class WidevinePsshData(_message.Message):
__slots__ = ["license_type", "pssh_data", "request_id"]
LICENSE_TYPE_FIELD_NUMBER: _ClassVar[int]
PSSH_DATA_FIELD_NUMBER: _ClassVar[int]
REQUEST_ID_FIELD_NUMBER: _ClassVar[int]
license_type: LicenseType
pssh_data: _containers.RepeatedScalarFieldContainer[bytes]
request_id: bytes
def __init__(self, pssh_data: _Optional[_Iterable[bytes]] = ..., license_type: _Optional[_Union[LicenseType, str]] = ..., request_id: _Optional[bytes] = ...) -> None: ...
EXISTING_LICENSE_FIELD_NUMBER: _ClassVar[int]
INIT_DATA_FIELD_NUMBER: _ClassVar[int]
WEBM_KEY_ID_FIELD_NUMBER: _ClassVar[int]
WIDEVINE_PSSH_DATA_FIELD_NUMBER: _ClassVar[int]
existing_license: LicenseRequest.ContentIdentification.ExistingLicense
init_data: LicenseRequest.ContentIdentification.InitData
webm_key_id: LicenseRequest.ContentIdentification.WebmKeyId
widevine_pssh_data: LicenseRequest.ContentIdentification.WidevinePsshData
def __init__(self, widevine_pssh_data: _Optional[_Union[LicenseRequest.ContentIdentification.WidevinePsshData, _Mapping]] = ..., webm_key_id: _Optional[_Union[LicenseRequest.ContentIdentification.WebmKeyId, _Mapping]] = ..., existing_license: _Optional[_Union[LicenseRequest.ContentIdentification.ExistingLicense, _Mapping]] = ..., init_data: _Optional[_Union[LicenseRequest.ContentIdentification.InitData, _Mapping]] = ...) -> None: ...
CLIENT_ID_FIELD_NUMBER: _ClassVar[int]
CONTENT_ID_FIELD_NUMBER: _ClassVar[int]
ENCRYPTED_CLIENT_ID_FIELD_NUMBER: _ClassVar[int]
KEY_CONTROL_NONCE_DEPRECATED_FIELD_NUMBER: _ClassVar[int]
KEY_CONTROL_NONCE_FIELD_NUMBER: _ClassVar[int]
NEW: LicenseRequest.RequestType
PROTOCOL_VERSION_FIELD_NUMBER: _ClassVar[int]
RELEASE: LicenseRequest.RequestType
RENEWAL: LicenseRequest.RequestType
REQUEST_TIME_FIELD_NUMBER: _ClassVar[int]
TYPE_FIELD_NUMBER: _ClassVar[int]
client_id: ClientIdentification
content_id: LicenseRequest.ContentIdentification
encrypted_client_id: EncryptedClientIdentification
key_control_nonce: int
key_control_nonce_deprecated: bytes
protocol_version: ProtocolVersion
request_time: int
type: LicenseRequest.RequestType
def __init__(self, client_id: _Optional[_Union[ClientIdentification, _Mapping]] = ..., content_id: _Optional[_Union[LicenseRequest.ContentIdentification, _Mapping]] = ..., type: _Optional[_Union[LicenseRequest.RequestType, str]] = ..., request_time: _Optional[int] = ..., key_control_nonce_deprecated: _Optional[bytes] = ..., protocol_version: _Optional[_Union[ProtocolVersion, str]] = ..., key_control_nonce: _Optional[int] = ..., encrypted_client_id: _Optional[_Union[EncryptedClientIdentification, _Mapping]] = ...) -> None: ...
class MetricData(_message.Message):
__slots__ = ["metric_data", "stage_name"]
class MetricType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class TypeValue(_message.Message):
__slots__ = ["type", "value"]
TYPE_FIELD_NUMBER: _ClassVar[int]
VALUE_FIELD_NUMBER: _ClassVar[int]
type: MetricData.MetricType
value: int
def __init__(self, type: _Optional[_Union[MetricData.MetricType, str]] = ..., value: _Optional[int] = ...) -> None: ...
LATENCY: MetricData.MetricType
METRIC_DATA_FIELD_NUMBER: _ClassVar[int]
STAGE_NAME_FIELD_NUMBER: _ClassVar[int]
TIMESTAMP: MetricData.MetricType
metric_data: _containers.RepeatedCompositeFieldContainer[MetricData.TypeValue]
stage_name: str
def __init__(self, stage_name: _Optional[str] = ..., metric_data: _Optional[_Iterable[_Union[MetricData.TypeValue, _Mapping]]] = ...) -> None: ...
class SignedDrmCertificate(_message.Message):
__slots__ = ["drm_certificate", "hash_algorithm", "signature", "signer"]
DRM_CERTIFICATE_FIELD_NUMBER: _ClassVar[int]
HASH_ALGORITHM_FIELD_NUMBER: _ClassVar[int]
SIGNATURE_FIELD_NUMBER: _ClassVar[int]
SIGNER_FIELD_NUMBER: _ClassVar[int]
drm_certificate: bytes
hash_algorithm: HashAlgorithmProto
signature: bytes
signer: SignedDrmCertificate
def __init__(self, drm_certificate: _Optional[bytes] = ..., signature: _Optional[bytes] = ..., signer: _Optional[_Union[SignedDrmCertificate, _Mapping]] = ..., hash_algorithm: _Optional[_Union[HashAlgorithmProto, str]] = ...) -> None: ...
class SignedMessage(_message.Message):
__slots__ = ["metric_data", "msg", "oemcrypto_core_message", "remote_attestation", "service_version_info", "session_key", "session_key_type", "signature", "type"]
class MessageType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class SessionKeyType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
CAS_LICENSE: SignedMessage.MessageType
CAS_LICENSE_REQUEST: SignedMessage.MessageType
EPHERMERAL_ECC_PUBLIC_KEY: SignedMessage.SessionKeyType
ERROR_RESPONSE: SignedMessage.MessageType
EXTERNAL_LICENSE: SignedMessage.MessageType
EXTERNAL_LICENSE_REQUEST: SignedMessage.MessageType
LICENSE: SignedMessage.MessageType
LICENSE_REQUEST: SignedMessage.MessageType
METRIC_DATA_FIELD_NUMBER: _ClassVar[int]
MSG_FIELD_NUMBER: _ClassVar[int]
OEMCRYPTO_CORE_MESSAGE_FIELD_NUMBER: _ClassVar[int]
REMOTE_ATTESTATION_FIELD_NUMBER: _ClassVar[int]
SERVICE_CERTIFICATE: SignedMessage.MessageType
SERVICE_CERTIFICATE_REQUEST: SignedMessage.MessageType
SERVICE_VERSION_INFO_FIELD_NUMBER: _ClassVar[int]
SESSION_KEY_FIELD_NUMBER: _ClassVar[int]
SESSION_KEY_TYPE_FIELD_NUMBER: _ClassVar[int]
SIGNATURE_FIELD_NUMBER: _ClassVar[int]
SUB_LICENSE: SignedMessage.MessageType
TYPE_FIELD_NUMBER: _ClassVar[int]
UNDEFINED: SignedMessage.SessionKeyType
WRAPPED_AES_KEY: SignedMessage.SessionKeyType
metric_data: _containers.RepeatedCompositeFieldContainer[MetricData]
msg: bytes
oemcrypto_core_message: bytes
remote_attestation: bytes
service_version_info: VersionInfo
session_key: bytes
session_key_type: SignedMessage.SessionKeyType
signature: bytes
type: SignedMessage.MessageType
def __init__(self, type: _Optional[_Union[SignedMessage.MessageType, str]] = ..., msg: _Optional[bytes] = ..., signature: _Optional[bytes] = ..., session_key: _Optional[bytes] = ..., remote_attestation: _Optional[bytes] = ..., metric_data: _Optional[_Iterable[_Union[MetricData, _Mapping]]] = ..., service_version_info: _Optional[_Union[VersionInfo, _Mapping]] = ..., session_key_type: _Optional[_Union[SignedMessage.SessionKeyType, str]] = ..., oemcrypto_core_message: _Optional[bytes] = ...) -> None: ...
class VersionInfo(_message.Message):
__slots__ = ["license_sdk_version", "license_service_version"]
LICENSE_SDK_VERSION_FIELD_NUMBER: _ClassVar[int]
LICENSE_SERVICE_VERSION_FIELD_NUMBER: _ClassVar[int]
license_sdk_version: str
license_service_version: str
def __init__(self, license_sdk_version: _Optional[str] = ..., license_service_version: _Optional[str] = ...) -> None: ...
class WidevinePsshData(_message.Message):
__slots__ = ["algorithm", "content_id", "crypto_period_index", "crypto_period_seconds", "entitled_keys", "group_ids", "grouped_license", "key_ids", "key_sequence", "policy", "protection_scheme", "provider", "track_type", "type", "video_feature"]
class Algorithm(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class Type(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class EntitledKey(_message.Message):
__slots__ = ["entitlement_key_id", "entitlement_key_size_bytes", "iv", "key", "key_id"]
ENTITLEMENT_KEY_ID_FIELD_NUMBER: _ClassVar[int]
ENTITLEMENT_KEY_SIZE_BYTES_FIELD_NUMBER: _ClassVar[int]
IV_FIELD_NUMBER: _ClassVar[int]
KEY_FIELD_NUMBER: _ClassVar[int]
KEY_ID_FIELD_NUMBER: _ClassVar[int]
entitlement_key_id: bytes
entitlement_key_size_bytes: int
iv: bytes
key: bytes
key_id: bytes
def __init__(self, entitlement_key_id: _Optional[bytes] = ..., key_id: _Optional[bytes] = ..., key: _Optional[bytes] = ..., iv: _Optional[bytes] = ..., entitlement_key_size_bytes: _Optional[int] = ...) -> None: ...
AESCTR: WidevinePsshData.Algorithm
ALGORITHM_FIELD_NUMBER: _ClassVar[int]
CONTENT_ID_FIELD_NUMBER: _ClassVar[int]
CRYPTO_PERIOD_INDEX_FIELD_NUMBER: _ClassVar[int]
CRYPTO_PERIOD_SECONDS_FIELD_NUMBER: _ClassVar[int]
ENTITLED_KEY: WidevinePsshData.Type
ENTITLED_KEYS_FIELD_NUMBER: _ClassVar[int]
ENTITLEMENT: WidevinePsshData.Type
GROUPED_LICENSE_FIELD_NUMBER: _ClassVar[int]
GROUP_IDS_FIELD_NUMBER: _ClassVar[int]
KEY_IDS_FIELD_NUMBER: _ClassVar[int]
KEY_SEQUENCE_FIELD_NUMBER: _ClassVar[int]
POLICY_FIELD_NUMBER: _ClassVar[int]
PROTECTION_SCHEME_FIELD_NUMBER: _ClassVar[int]
PROVIDER_FIELD_NUMBER: _ClassVar[int]
SINGLE: WidevinePsshData.Type
TRACK_TYPE_FIELD_NUMBER: _ClassVar[int]
TYPE_FIELD_NUMBER: _ClassVar[int]
UNENCRYPTED: WidevinePsshData.Algorithm
VIDEO_FEATURE_FIELD_NUMBER: _ClassVar[int]
algorithm: WidevinePsshData.Algorithm
content_id: bytes
crypto_period_index: int
crypto_period_seconds: int
entitled_keys: _containers.RepeatedCompositeFieldContainer[WidevinePsshData.EntitledKey]
group_ids: _containers.RepeatedScalarFieldContainer[bytes]
grouped_license: bytes
key_ids: _containers.RepeatedScalarFieldContainer[bytes]
key_sequence: int
policy: str
protection_scheme: int
provider: str
track_type: str
type: WidevinePsshData.Type
video_feature: str
def __init__(self, key_ids: _Optional[_Iterable[bytes]] = ..., content_id: _Optional[bytes] = ..., crypto_period_index: _Optional[int] = ..., protection_scheme: _Optional[int] = ..., crypto_period_seconds: _Optional[int] = ..., type: _Optional[_Union[WidevinePsshData.Type, str]] = ..., key_sequence: _Optional[int] = ..., group_ids: _Optional[_Iterable[bytes]] = ..., entitled_keys: _Optional[_Iterable[_Union[WidevinePsshData.EntitledKey, _Mapping]]] = ..., video_feature: _Optional[str] = ..., algorithm: _Optional[_Union[WidevinePsshData.Algorithm, str]] = ..., provider: _Optional[str] = ..., track_type: _Optional[str] = ..., policy: _Optional[str] = ..., grouped_license: _Optional[bytes] = ...) -> None: ...
class LicenseType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class PlatformVerificationStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class ProtocolVersion(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
class HashAlgorithmProto(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []

View File

@ -1,21 +1,14 @@
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
from zlib import crc32
import click
import requests
import yaml
from construct import ConstructError
from google.protobuf.json_format import MessageToDict
from unidecode import UnidecodeError, unidecode
from pywidevine import __version__
from pywidevine.cdm import Cdm
from pywidevine.device import Device, DeviceTypes
from pywidevine.license_protocol_pb2 import FileHashes, LicenseType
from pywidevine.pssh import PSSH
from pywidevine.device import Device
from pywidevine.license_protocol_pb2 import LicenseType
@click.group(invoke_without_command=True)
@ -26,25 +19,29 @@ def main(version: bool, debug: bool) -> None:
logging.basicConfig(level=logging.DEBUG if debug else logging.INFO)
log = logging.getLogger()
copyright_years = 2022
current_year = datetime.now().year
copyright_years = f"2022-{current_year}"
if copyright_years != current_year:
copyright_years = f"{copyright_years}-{current_year}"
log.info("pywidevine version %s Copyright (c) %s rlaphoenix", __version__, copyright_years)
log.info("https://github.com/devine-dl/pywidevine")
log.info(f"pywidevine version {__version__} Copyright (c) {copyright_years} rlaphoenix")
log.info("https://github.com/rlaphoenix/pywidevine")
if version:
return
@main.command(name="license")
@click.argument("device_path", type=Path)
@click.argument("pssh", type=PSSH)
@click.argument("device", type=Path)
@click.argument("pssh", type=str)
@click.argument("server", type=str)
@click.option("-t", "--type", "license_type", type=click.Choice(LicenseType.keys(), case_sensitive=False),
@click.option("-t", "--type", "type_", type=click.Choice(LicenseType.keys(), case_sensitive=False),
default="STREAMING",
help="License Type to Request.")
@click.option("-r", "--raw", is_flag=True, default=False,
help="PSSH is Raw.")
@click.option("-p", "--privacy", is_flag=True, default=False,
help="Use Privacy Mode, off by default.")
def license_(device_path: Path, pssh: PSSH, server: str, license_type: str, privacy: bool) -> None:
def license_(device: Path, pssh: str, server: str, type_: str, raw: bool, privacy: bool):
"""
Make a License Request for PSSH to SERVER using DEVICE.
It will return a list of all keys within the returned license.
@ -63,73 +60,62 @@ def license_(device_path: Path, pssh: PSSH, server: str, license_type: str, priv
"""
log = logging.getLogger("license")
type_ = LicenseType.Value(type_)
# load device
device = Device.load(device_path)
log.info("[+] Loaded Device (%s L%s)", device.system_id, device.security_level)
device = Device.load(device)
log.info(f"[+] Loaded Device ({device.system_id} L{device.security_level})")
log.debug(device)
# load cdm
cdm = Cdm.from_device(device)
log.info("[+] Loaded CDM")
cdm = Cdm(device, pssh, type_, raw)
log.info(f"[+] Loaded CDM with PSSH: {pssh}")
log.debug(cdm)
# open cdm session
session_id = cdm.open()
log.info("[+] Opened CDM Session: %s", session_id.hex())
if privacy:
# get service cert for license server via cert challenge
service_cert_res = requests.post(
service_cert = requests.post(
url=server,
data=cdm.service_certificate_challenge
)
if service_cert_res.status_code != 200:
log.error(
"[-] Failed to get Service Privacy Certificate: [%s] %s",
service_cert_res.status_code,
service_cert_res.text
)
if service_cert.status_code != 200:
log.error(f"[-] Failed to get Service Privacy Certificate: [{service_cert.status_code}] {service_cert.text}")
return
service_cert = service_cert_res.content
provider_id = cdm.set_service_certificate(session_id, service_cert)
log.info("[+] Set Service Privacy Certificate: %s", provider_id)
service_cert = service_cert.content
cdm.set_service_certificate(service_cert)
log.info("[+] Set Service Privacy Certificate")
log.debug(service_cert)
# get license challenge
challenge = cdm.get_license_challenge(session_id, pssh, license_type, privacy_mode=True)
challenge = cdm.get_license_challenge(privacy_mode=True)
log.info("[+] Created License Request Message (Challenge)")
log.debug(challenge)
# send license challenge
license_res = requests.post(
licence = requests.post(
url=server,
data=challenge
)
if license_res.status_code != 200:
log.error("[-] Failed to send challenge: [%s] %s", license_res.status_code, license_res.text)
if licence.status_code != 200:
log.error(f"[-] Failed to send challenge: [{licence.status_code}] {licence.text}")
return
licence = license_res.content
licence = licence.content
log.info("[+] Got License Message")
log.debug(licence)
# parse license challenge
cdm.parse_license(session_id, licence)
keys = cdm.parse_license(licence)
log.info("[+] License Parsed Successfully")
# print keys
for key in cdm.get_keys(session_id):
log.info("[%s] %s:%s", key.type, key.kid.hex, key.key.hex())
# close session, disposes of session data
cdm.close(session_id)
for key in keys:
log.info(f"[{key.type}] {key.kid.hex}:{key.key.hex()}")
@main.command()
@click.argument("device", type=Path)
@click.option("-p", "--privacy", is_flag=True, default=False,
help="Use Privacy Mode, off by default.")
@click.pass_context
def test(ctx: click.Context, device: Path, privacy: bool) -> None:
def test(ctx: click.Context, device: Path):
"""
Test the CDM code by getting Content Keys for Bitmovin's Art of Motion example.
https://bitmovin.com/demos/drm
@ -140,8 +126,8 @@ def test(ctx: click.Context, device: Path, privacy: bool) -> None:
"""
# The PSSH is the same for all tracks both video and audio.
# However, this might not be the case for all services/manifests.
pssh = PSSH("AAAAW3Bzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAADsIARIQ62dqu8s0Xpa"
"7z2FmMPGj2hoNd2lkZXZpbmVfdGVzdCIQZmtqM2xqYVNkZmFsa3IzaioCSEQyAA==")
pssh = "AAAAW3Bzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAADsIARIQ62dqu8s0Xpa" \
"7z2FmMPGj2hoNd2lkZXZpbmVfdGVzdCIQZmtqM2xqYVNkZmFsa3IzaioCSEQyAA=="
# This License Server requires no authorization at all, no cookies, no credentials
# nothing. This is often not the case for real services.
@ -149,250 +135,19 @@ def test(ctx: click.Context, device: Path, privacy: bool) -> None:
# Specify OFFLINE if it's a PSSH for a download/offline mode title, e.g., the
# Download feature on Netflix Apps. Otherwise, use STREAMING or AUTOMATIC.
license_type = "STREAMING"
license_type = LicenseType.STREAMING
# If the PSSH is not a valid mp4 pssh box, nor a valid CENC Header (init data) then
# set this to True, otherwise leave it False.
raw = False
# this runs the `cdm license` CLI-command code with the data we set above
# it will print information as it goes to the terminal
ctx.invoke(
license_,
device_path=device,
device=device,
pssh=pssh,
server=license_server,
license_type=license_type,
privacy=privacy
type_=LicenseType.Name(license_type),
raw=raw
)
@main.command()
@click.option("-t", "--type", "type_", type=click.Choice([x.name for x in DeviceTypes], case_sensitive=False),
required=True, help="Device Type")
@click.option("-l", "--level", type=click.IntRange(1, 3), required=True, help="Device Security Level")
@click.option("-k", "--key", type=Path, required=True, help="Device RSA Private Key in PEM or DER format")
@click.option("-c", "--client_id", type=Path, required=True, help="Widevine ClientIdentification Blob file")
@click.option("-v", "--vmp", type=Path, default=None, help="Widevine FileHashes Blob file")
@click.option("-o", "--output", type=Path, default=None, help="Output Path or Directory")
@click.pass_context
def create_device(
ctx: click.Context,
type_: str,
level: int,
key: Path,
client_id: Path,
vmp: Optional[Path] = None,
output: Optional[Path] = None
) -> None:
"""
Create a Widevine Device (.wvd) file from an RSA Private Key (PEM or DER) and Client ID Blob.
Optionally also a VMP (Verified Media Path) Blob, which will be stored in the Client ID.
"""
if not key.is_file():
raise click.UsageError("key: Not a path to a file, or it doesn't exist.", ctx)
if not client_id.is_file():
raise click.UsageError("client_id: Not a path to a file, or it doesn't exist.", ctx)
if vmp and not vmp.is_file():
raise click.UsageError("vmp: Not a path to a file, or it doesn't exist.", ctx)
log = logging.getLogger("create-device")
device = Device(
type_=DeviceTypes[type_.upper()],
security_level=level,
flags=None,
private_key=key.read_bytes(),
client_id=client_id.read_bytes()
)
if vmp:
new_vmp_data = vmp.read_bytes()
if device.client_id.vmp_data and device.client_id.vmp_data != new_vmp_data:
log.warning("Client ID already has Verified Media Path data")
device.client_id.vmp_data = new_vmp_data
client_info = {}
for entry in device.client_id.client_info:
client_info[entry.name] = entry.value
wvd_bin = device.dumps()
name = f"{client_info['company_name']} {client_info['model_name']}"
if client_info.get("widevine_cdm_version"):
name += f" {client_info['widevine_cdm_version']}"
name += f" {crc32(wvd_bin).to_bytes(4, 'big').hex()}"
try:
name = unidecode(name.strip().lower().replace(" ", "_"))
except UnidecodeError as e:
raise click.ClickException(f"Failed to sanitize name, {e}")
if output and output.suffix:
if output.suffix.lower() != ".wvd":
log.warning(f"Saving WVD with the file extension '{output.suffix}' but '.wvd' is recommended.")
out_path = output
else:
out_dir = output or Path.cwd()
out_path = out_dir / f"{name}_{device.system_id}_l{device.security_level}.wvd"
if out_path.exists():
log.error(f"A file already exists at the path '{out_path}', cannot overwrite.")
return
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(wvd_bin)
log.info("Created Widevine Device (.wvd) file, %s", out_path.name)
log.info(" + Type: %s", device.type.name)
log.info(" + System ID: %s", device.system_id)
log.info(" + Security Level: %s", device.security_level)
log.info(" + Flags: %s", device.flags)
log.info(" + Private Key: %s (%s bit)", bool(device.private_key), device.private_key.size_in_bits())
log.info(" + Client ID: %s (%s bytes)", bool(device.client_id), len(device.client_id.SerializeToString()))
if device.client_id.vmp_data:
file_hashes_ = FileHashes()
file_hashes_.ParseFromString(device.client_id.vmp_data)
log.info(" + VMP: True (%s signatures)", len(file_hashes_.signatures))
else:
log.info(" + VMP: False")
log.info(" + Saved to: %s", out_path.absolute())
@main.command()
@click.argument("wvd_path", type=Path)
@click.option("-o", "--out_dir", type=Path, default=None, help="Output Directory")
@click.pass_context
def export_device(ctx: click.Context, wvd_path: Path, out_dir: Optional[Path] = None) -> None:
"""
Export a Widevine Device (.wvd) file to an RSA Private Key (PEM and DER) and Client ID Blob.
Optionally also a VMP (Verified Media Path) Blob, which will be stored in the Client ID.
If an output directory is not specified, it will be stored in the current working directory.
"""
if not wvd_path.is_file():
raise click.UsageError("wvd_path: Not a path to a file, or it doesn't exist.", ctx)
log = logging.getLogger("export-device")
log.info("Exporting Widevine Device (.wvd) file, %s", wvd_path.stem)
if not out_dir:
out_dir = Path.cwd()
out_path = out_dir / wvd_path.stem
if out_path.exists():
if any(out_path.iterdir()):
log.error("Output directory is not empty, cannot overwrite.")
return
else:
log.warning("Output directory already exists, but is empty.")
else:
out_path.mkdir(parents=True)
device = Device.load(wvd_path)
log.info(f"L{device.security_level} {device.system_id} {device.type.name}")
log.info(f"Saving to: {out_path}")
device_meta = {
"wvd": {
"device_type": device.type.name,
"security_level": device.security_level,
**device.flags
},
"client_info": {},
"capabilities": MessageToDict(device.client_id, preserving_proto_field_name=True)["client_capabilities"]
}
for client_info in device.client_id.client_info:
device_meta["client_info"][client_info.name] = client_info.value
device_meta_path = out_path / "metadata.yml"
device_meta_path.write_text(yaml.dump(device_meta), encoding="utf8")
log.info("Exported Device Metadata as metadata.yml")
if device.private_key:
private_key_path = out_path / "private_key.pem"
private_key_path.write_text(
data=device.private_key.export_key().decode(),
encoding="utf8"
)
private_key_path.with_suffix(".der").write_bytes(
device.private_key.export_key(format="DER")
)
log.info("Exported Private Key as private_key.der and private_key.pem")
else:
log.warning("No Private Key available")
if device.client_id:
client_id_path = out_path / "client_id.bin"
client_id_path.write_bytes(device.client_id.SerializeToString())
log.info("Exported Client ID as client_id.bin")
else:
log.warning("No Client ID available")
if device.client_id.vmp_data:
vmp_path = out_path / "vmp.bin"
vmp_path.write_bytes(device.client_id.vmp_data)
log.info("Exported VMP (File Hashes) as vmp.bin")
else:
log.info("No VMP (File Hashes) available")
@main.command()
@click.argument("path", type=Path)
@click.pass_context
def migrate(ctx: click.Context, path: Path) -> None:
"""
Upgrade from earlier versions of the Widevine Device (.wvd) format.
The path argument can be a direct path to a Widevine Device (.wvd) file, or a path
to a folder of Widevine Devices files.
The migrated devices are saved to its original location, overwriting the old version.
"""
if not path.exists():
raise click.UsageError(f"path: The path '{path}' does not exist.", ctx)
log = logging.getLogger("migrate")
if path.is_dir():
devices = list(path.glob("*.wvd"))
else:
devices = [path]
migrated = 0
for device in devices:
log.info("Migrating %s...", device.name)
try:
new_device = Device.migrate(device.read_bytes())
except (ConstructError, ValueError) as e:
log.error(" - %s", e)
continue
log.debug(new_device)
new_device.dump(device)
log.info(" + Success")
migrated += 1
log.info("Migrated %s/%s devices!", migrated, len(devices))
@main.command("serve", short_help="Serve your local CDM and Widevine Devices Remotely.")
@click.argument("config_path", type=Path)
@click.option("-h", "--host", type=str, default="127.0.0.1", help="Host to serve from.")
@click.option("-p", "--port", type=int, default=8786, help="Port to serve from.")
def serve_(config_path: Path, host: str, port: int) -> None:
"""
Serve your local CDM and Widevine Devices Remotely.
\b
[CONFIG] is a path to a serve config file.
See `serve.example.yml` for an example config file.
\b
Host as 127.0.0.1 may block remote access even if port-forwarded.
Instead, use 0.0.0.0 and ensure the TCP port you choose is forwarded.
"""
from pywidevine import serve # isort:skip
import yaml # isort:skip
config = yaml.safe_load(config_path.read_text(encoding="utf8"))
serve.run(config, host, port)

View File

@ -1,442 +1,201 @@
from __future__ import annotations
import base64
import binascii
import string
from io import BytesIO
from typing import Optional, Union
from typing import Union
from uuid import UUID
from xml.etree.ElementTree import XML
import construct
from construct import Container
from google.protobuf.message import DecodeError
from lxml import etree
from pymp4.parser import Box
from pywidevine.license_protocol_pb2 import WidevinePsshData
class PSSH:
"""
MP4 PSSH Box-related utilities.
Allows you to load, create, and modify various kinds of DRM system headers.
"""
"""PSSH-related utilities. Somewhat Widevine-biased."""
class SystemId:
Widevine = UUID(hex="edef8ba979d64acea3c827dcd51d21ed")
PlayReady = UUID(hex="9a04f07998404286ab92e65be0885f95")
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")
def __init__(self, data: Union[Container, str, bytes], strict: bool = False):
def __init__(self, box: Container):
self._box = box
@staticmethod
def from_init_data(init_data: Union[str, bytes, WidevinePsshData]) -> Container:
"""Craft a new PSSH Box from just Widevine PSSH Data (init data)."""
if isinstance(init_data, str):
init_data = base64.b64decode(init_data)
if isinstance(init_data, bytes):
cenc_header = WidevinePsshData()
cenc_header.ParseFromString(init_data)
init_data = cenc_header
if not isinstance(init_data, WidevinePsshData):
raise ValueError(f"Unexpected value for init_data, {init_data!r}")
box = Box.parse(Box.build(dict(
type=b"pssh",
version=0,
flags=0,
system_ID=PSSH.SystemId.Widevine,
init_data=init_data.SerializeToString()
)))
return box
@staticmethod
def from_playready_pssh(box: Container) -> Container:
"""
Load a PSSH box, WidevineCencHeader, or PlayReadyHeader.
Convert a PlayReady PSSH to a Widevine PSSH.
When loading a WidevineCencHeader or PlayReadyHeader, a new v0 PSSH box will be
created and the header will be parsed and stored in the init_data field. However,
PlayReadyHeaders (and PlayReadyObjects) are not yet currently parsed and are
stored as bytes.
Note: The resulting Widevine PSSH will likely not be usable for Licensing. This
is because there is some data for a Widevine CENC Header that is not going to be
listed in a PlayReady PSSH.
[Strict mode (strict=True)]
Supports the following forms of input data in either Base64 or Bytes form:
- Full PSSH mp4 boxes (as defined by pymp4 Box).
- Full Widevine Cenc Headers (as defined by WidevinePsshData proto).
- Full PlayReady Objects and Headers (as defined by Microsoft Docs).
[Lenient mode (strict=False, default)]
If the data is not supported in Strict mode, and is assumed not to be corrupt or
parsed incorrectly, the License Server likely accepts a custom init_data value
during a License Request call. This is uncommon behavior but not out of realm of
possibilities. For example, Netflix does this with it's MSL WidevineExchange
scheme.
Lenient mode will craft a new v0 PSSH box with the init_data field set to
the provided data as-is. The data will first be base64 decoded. This behavior
may not work in your scenario and if that's the case please manually craft
your own PSSH box with the init_data field to be used in License Requests.
Raises:
ValueError: If the data is empty.
TypeError: If the data is an unexpected type.
binascii.Error: If the data could not be decoded as Base64 if provided as a
string.
DecodeError: If the data could not be parsed as a PSSH mp4 box nor a Widevine
Cenc Header and strict mode is enabled.
This converted PSSH will only be useful for it's Key IDs, so realistically only
for matching Key IDs with a Track. As a fallback.
"""
if not data:
raise ValueError("Data must not be empty.")
if box.type != b"pssh":
raise ValueError(f"Box must be a PSSH box, not {box.type}")
if box.system_ID != PSSH.SystemId.PlayReady:
raise ValueError(f"This is not a PlayReady PSSH Box, {box.system_ID}")
if isinstance(data, Container):
box = data
else:
if isinstance(data, str):
key_ids = PSSH.get_key_ids(box)
cenc_header = WidevinePsshData()
cenc_header.algorithm = 1 # 0=Clear, 1=AES-CTR
for key_id in key_ids:
cenc_header.key_id.append(key_id.bytes)
if box.version == 1:
# ensure both cenc header and box has same Key IDs
# v1 uses both this and within init data for basically no reason
box.key_IDs = key_ids
box.init_data = cenc_header.SerializeToString()
box.system_ID = PSSH.SystemId.Widevine
return box
@staticmethod
def from_key_ids(key_ids: list[UUID]) -> Container:
"""
Craft a new PSSH Box from just Key IDs.
This should only be used as a very last measure.
"""
cenc_header = WidevinePsshData()
for key_id in key_ids:
cenc_header.key_id.append(key_id.bytes)
cenc_header.algorithm = 1 # 0=Clear, 1=AES-CTR
box = Box.parse(Box.build(dict(
type=b"pssh",
version=0,
flags=0,
system_ID=PSSH.SystemId.Widevine,
init_data=cenc_header.SerializeToString()
)))
return box
@staticmethod
def get_as_box(data: Union[Container, bytes, str]) -> Container:
"""
Get the possibly arbitrary data as a parsed PSSH mp4 box.
If the data is just Widevine PSSH Data (init data) then it will be crafted
into a new PSSH mp4 box.
If the data could not be recognized as a PSSH box of some form of encoding
it will raise a ValueError.
"""
if isinstance(data, str):
data = base64.b64decode(data)
if isinstance(data, bytes):
if base64.b64encode(data) == b"CAES": # likely widevine pssh data
try:
data = base64.b64decode(data)
except (binascii.Error, binascii.Incomplete) as e:
raise binascii.Error(f"Could not decode data as Base64, {e}")
if not isinstance(data, bytes):
raise TypeError(f"Expected data to be a {Container}, bytes, or base64, not {data!r}")
try:
box = Box.parse(data)
except (IOError, construct.ConstructError): # not a box
try:
widevine_pssh_data = WidevinePsshData()
widevine_pssh_data.ParseFromString(data)
data_serialized = widevine_pssh_data.SerializeToString()
if data_serialized != data: # not actually a WidevinePsshData
raise DecodeError()
box = Box.parse(Box.build(dict(
cenc_header = WidevinePsshData()
cenc_header.ParseFromString(data)
except DecodeError:
# not actually init data after all
pass
else:
data = Box.parse(Box.build(dict(
type=b"pssh",
version=0,
flags=0,
system_ID=PSSH.SystemId.Widevine,
init_data=data_serialized
init_data=cenc_header.SerializeToString()
)))
except DecodeError: # not a widevine cenc header
if "</WRMHEADER>".encode("utf-16-le") in data:
# TODO: Actually parse `data` as a PlayReadyHeader object and store that instead
box = Box.parse(Box.build(dict(
type=b"pssh",
version=0,
flags=0,
system_ID=PSSH.SystemId.PlayReady,
init_data=data
)))
elif strict:
raise DecodeError(f"Could not parse data as a {Container} nor a {WidevinePsshData}.")
else:
# Data is not a WidevineCencHeader nor a PlayReadyHeader.
# The license server likely has something custom to parse it.
# See doc-string about Lenient mode for more information.
box = Box.parse(Box.build(dict(
type=b"pssh",
version=0,
flags=0,
system_ID=PSSH.SystemId.Widevine,
init_data=data
)))
self.version = box.version
self.flags = box.flags
self.system_id = box.system_ID
self.__key_ids = box.key_IDs
self.init_data = box.init_data
def __repr__(self) -> str:
return f"PSSH<{self.system_id}>(v{self.version}; {self.flags}, {self.key_ids}, {self.init_data})"
def __str__(self) -> str:
return self.dumps()
@classmethod
def new(
cls,
system_id: UUID,
key_ids: Optional[list[Union[UUID, str, bytes]]] = None,
init_data: Optional[Union[WidevinePsshData, str, bytes]] = None,
version: int = 0,
flags: int = 0
) -> PSSH:
"""Craft a new version 0 or 1 PSSH Box."""
if not system_id:
raise ValueError("A System ID must be specified.")
if not isinstance(system_id, UUID):
raise TypeError(f"Expected system_id to be a UUID, not {system_id!r}")
if key_ids is not None and not isinstance(key_ids, list):
raise TypeError(f"Expected key_ids to be a list not {key_ids!r}")
if init_data is not None and not isinstance(init_data, (WidevinePsshData, str, bytes)):
raise TypeError(f"Expected init_data to be a {WidevinePsshData}, base64, or bytes, not {init_data!r}")
if not isinstance(version, int):
raise TypeError(f"Expected version to be an int not {version!r}")
if version not in (0, 1):
raise ValueError(f"Invalid version, must be either 0 or 1, not {version}.")
if not isinstance(flags, int):
raise TypeError(f"Expected flags to be an int not {flags!r}")
if flags < 0:
raise ValueError("Invalid flags, cannot be less than 0.")
if version == 0 and key_ids is not None and init_data is not None:
# v0 boxes use only init_data in the pssh field, but we can use the key_ids within the init_data
raise ValueError("Version 0 PSSH boxes must use only init_data, not init_data and key_ids.")
elif version == 1:
# TODO: I cannot tell if they need either init_data or key_ids exclusively, or both is fine
# So for now I will just make sure at least one is supplied
if init_data is None and key_ids is None:
raise ValueError("Version 1 PSSH boxes must use either init_data or key_ids but neither were provided")
if init_data is not None:
if isinstance(init_data, WidevinePsshData):
init_data = init_data.SerializeToString()
elif isinstance(init_data, str):
if all(c in string.hexdigits for c in init_data):
init_data = bytes.fromhex(init_data)
else:
init_data = base64.b64decode(init_data)
elif not isinstance(init_data, bytes):
raise TypeError(
f"Expecting init_data to be {WidevinePsshData}, hex, base64, or bytes, not {init_data!r}"
)
pssh = cls(Box.parse(Box.build(dict(
type=b"pssh",
version=version,
flags=flags,
system_ID=system_id,
init_data=[init_data, b""][init_data is None]
# key_IDs should not be set yet
))))
if key_ids:
# We must reinforce the version because pymp4 forces v0 if key_IDs is not set.
# The set_key_ids() func will set it efficiently in both init_data and the box where needed.
# The version must be reinforced ONLY if we have key_id data or there's a possibility of making
# a v1 PSSH box, that did not have key_IDs set in the PSSH box.
pssh.version = version
pssh.set_key_ids(key_ids)
return pssh
@property
def key_ids(self) -> list[UUID]:
"""
Get all Key IDs from within the Box or Init Data, wherever possible.
Supports:
- Version 1 PSSH Boxes
- WidevineCencHeaders
- PlayReadyHeaders (4.0.0.0->4.3.0.0)
"""
if self.version == 1 and self.__key_ids:
return self.__key_ids
if self.system_id == PSSH.SystemId.Widevine:
# TODO: What if its not a Widevine Cenc Header but the System ID is set as Widevine?
cenc_header = WidevinePsshData()
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 # 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
]
if self.system_id == PSSH.SystemId.PlayReady:
# Assuming init data is a PRO (PlayReadyObject)
# https://learn.microsoft.com/en-us/playready/specifications/playready-header-specification
pro_data = BytesIO(self.init_data)
pro_length = int.from_bytes(pro_data.read(4), "little")
if pro_length != len(self.init_data):
raise ValueError("The PlayReadyObject seems to be corrupt (too big or small, or missing data).")
pro_record_count = int.from_bytes(pro_data.read(2), "little")
for _ in range(pro_record_count):
prr_type = int.from_bytes(pro_data.read(2), "little")
prr_length = int.from_bytes(pro_data.read(2), "little")
prr_value = pro_data.read(prr_length)
if prr_type != 0x01:
# No PlayReady Header, skip and hope for something else
# TODO: Add support for Embedded License Stores (0x03)
continue
wrm_ns = {"wrm": "http://schemas.microsoft.com/DRM/2007/03/PlayReadyHeader"}
prr_header = XML(prr_value.decode("utf-16-le"))
prr_header_version = prr_header.get("version")
if prr_header_version == "4.0.0.0":
key_ids = [
x.text
for x in prr_header.findall("./wrm:DATA/wrm:KID", wrm_ns)
if x.text
]
elif prr_header_version == "4.1.0.0":
key_ids = [
x.attrib["VALUE"]
for x in prr_header.findall("./wrm:DATA/wrm:PROTECTINFO/wrm:KID", wrm_ns)
]
elif prr_header_version in ("4.2.0.0", "4.3.0.0"):
# TODO: Retain the Encryption Scheme information in v4.3.0.0
# This is because some Key IDs can be AES-CTR while some are AES-CBC.
# Conversion to WidevineCencHeader could use this information.
key_ids = [
x.attrib["VALUE"]
for x in prr_header.findall("./wrm:DATA/wrm:PROTECTINFO/wrm:KIDS/wrm:KID", wrm_ns)
]
else:
raise ValueError(f"Unsupported PlayReadyHeader version {prr_header_version}")
return [
UUID(bytes=base64.b64decode(key_id))
for key_id in key_ids
]
raise ValueError("Unsupported PlayReadyObject, no PlayReadyHeader within the object.")
raise ValueError(f"This PSSH is not supported by key_ids() property, {self.dumps()}")
def dump(self) -> bytes:
"""Export the PSSH object as a full PSSH box in bytes form."""
return Box.build(dict(
type=b"pssh",
version=self.version,
flags=self.flags,
system_ID=self.system_id,
key_IDs=self.key_ids if self.version == 1 and self.key_ids else None,
init_data=self.init_data
))
def dumps(self) -> str:
"""Export the PSSH object as a full PSSH box in base64 form."""
return base64.b64encode(self.dump()).decode()
def to_widevine(self) -> None:
"""
Convert PlayReady PSSH data to Widevine PSSH data.
There's only a limited amount of information within a PlayReady PSSH header that
can be used in a Widevine PSSH Header. The converted data may or may not result
in an accepted PSSH. It depends on what the License Server is expecting.
"""
if self.system_id == PSSH.SystemId.Widevine:
raise ValueError("This is already a Widevine PSSH")
widevine_pssh_data = WidevinePsshData(
key_ids=[x.bytes for x in self.key_ids],
algorithm="AESCTR"
)
if self.version == 1:
# ensure both cenc header and box has same Key IDs
# v1 uses both this and within init data for basically no reason
self.__key_ids = self.key_ids
self.init_data = widevine_pssh_data.SerializeToString()
self.system_id = PSSH.SystemId.Widevine
def to_playready(
self,
la_url: Optional[str] = None,
lui_url: Optional[str] = None,
ds_id: Optional[bytes] = None,
decryptor_setup: Optional[str] = None,
custom_data: Optional[str] = None
) -> None:
"""
Convert Widevine PSSH data to PlayReady v4.3.0.0 PSSH data.
Note that it is impossible to create the CHECKSUM values for AES-CTR Key IDs
as you must encrypt the Key ID with the Content Encryption Key using AES-ECB.
This may cause software incompatibilities.
Parameters:
la_url: Contains the URL for the license acquisition Web service.
Only absolute URLs are allowed.
lui_url: Contains the URL for the license acquisition Web service.
Only absolute URLs are allowed.
ds_id: Service ID for the domain service.
decryptor_setup: This tag may only contain the value "ONDEMAND". It
indicates to an application that it should not expect the full
license chain for the content to be available for acquisition, or
already present on the client machine, prior to setting up the
media graph. If this tag is not set then it indicates that an
application can enforce the license to be acquired, or already
present on the client machine, prior to setting up the media graph.
custom_data: The content author can add custom XML inside this
element. Microsoft code does not act on any data contained inside
this element. The Syntax of this params XML is not validated.
"""
if self.system_id == PSSH.SystemId.PlayReady:
raise ValueError("This is already a PlayReady PSSH")
key_ids_xml = ""
for key_id in self.key_ids:
# Note that it's impossible to create the CHECKSUM value without the Key for the KID
key_ids_xml += f"""
<KID ALGID="AESCTR" VALUE="{base64.b64encode(key_id.bytes).decode()}"></KID>
"""
prr_value = f"""
<WRMHEADER xmlns="http://schemas.microsoft.com/DRM/2007/03/PlayReadyHeader" version="4.3.0.0">
<DATA>
<PROTECTINFO>
<KIDS>{key_ids_xml}</KIDS>
</PROTECTINFO>
{'<LA_URL>%s</LA_URL>' % la_url if la_url else ''}
{'<LUI_URL>%s</LUI_URL>' % lui_url if lui_url else ''}
{'<DS_ID>%s</DS_ID>' % base64.b64encode(ds_id).decode() if ds_id else ''}
{'<DECRYPTORSETUP>%s</DECRYPTORSETUP>' % decryptor_setup if decryptor_setup else ''}
{'<CUSTOMATTRIBUTES xmlns="">%s</CUSTOMATTRIBUTES>' % custom_data if custom_data else ''}
</DATA>
</WRMHEADER>
""".encode("utf-16-le")
prr_length = len(prr_value).to_bytes(2, "little")
prr_type = (1).to_bytes(2, "little") # Has PlayReadyHeader
pro_record_count = (1).to_bytes(2, "little")
pro = pro_record_count + prr_type + prr_length + prr_value
pro = (len(pro) + 4).to_bytes(4, "little") + pro
self.init_data = pro
self.system_id = PSSH.SystemId.PlayReady
def set_key_ids(self, key_ids: list[Union[UUID, str, bytes]]) -> None:
"""Overwrite all Key IDs with the specified Key IDs."""
if self.system_id != PSSH.SystemId.Widevine:
# TODO: Add support for setting the Key IDs in a PlayReady Header
raise ValueError(f"Only Widevine PSSH Boxes are supported, not {self.system_id}.")
key_id_uuids = self.parse_key_ids(key_ids)
if self.version == 1 or self.__key_ids:
# only use v1 box key_ids if version is 1, or it's already being used
# this is in case the service stupidly expects it for version 0
self.__key_ids = key_id_uuids
cenc_header = WidevinePsshData()
cenc_header.ParseFromString(self.init_data)
cenc_header.key_ids[:] = [
key_id.bytes
for key_id in key_id_uuids
]
self.init_data = cenc_header.SerializeToString()
data = Box.parse(data)
if isinstance(data, Container):
return data
raise ValueError(f"Unrecognized PSSH data: {data!r}")
@staticmethod
def parse_key_ids(key_ids: list[Union[UUID, str, bytes]]) -> list[UUID]:
def get_key_ids(box: Container) -> list[UUID]:
"""
Parse a list of Key IDs in hex, base64, or bytes to UUIDs.
Get Key IDs from a PSSH Box from within the Box or Init Data where possible.
Raises TypeError if `key_ids` is not a list, or the list contains one
or more items that are not a UUID, str, or bytes object.
Supports:
- Version 1 Boxes
- Widevine Headers
- PlayReady Headers (4.0.0.0->4.3.0.0)
"""
if not isinstance(key_ids, list):
raise TypeError(f"Expected key_ids to be a list, not {key_ids!r}")
if box.version == 1 and box.key_IDs:
return box.key_IDs
if not all(isinstance(x, (UUID, str, bytes)) for x in key_ids):
raise TypeError("Some items of key_ids are not a UUID, str, or bytes. Unsure how to continue...")
uuids = [
UUID(bytes=key_id_b)
for key_id in key_ids
for key_id_b in [
key_id.bytes if isinstance(key_id, UUID) else
(
bytes.fromhex(key_id) if all(c in string.hexdigits for c in key_id) else
base64.b64decode(key_id)
) if isinstance(key_id, str) else
key_id
if box.system_ID == PSSH.SystemId.Widevine:
init = WidevinePsshData()
init.ParseFromString(box.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())
for key_id in init.key_id
]
]
return uuids
if box.system_ID == PSSH.SystemId.PlayReady:
xml_string = box.init_data.decode("utf-16-le")
# some of these init data has garbage(?) in front of it
xml_string = xml_string[xml_string.index("<"):]
xml = etree.fromstring(xml_string)
header_version = xml.attrib["version"]
if header_version == "4.0.0.0":
key_ids = xml.xpath("DATA/KID/text()")
elif header_version == "4.1.0.0":
key_ids = xml.xpath("DATA/PROTECTINFO/KID/@VALUE")
elif header_version in ("4.2.0.0", "4.3.0.0"):
key_ids = xml.xpath("DATA/PROTECTINFO/KIDS/KID/@VALUE")
else:
raise ValueError(f"Unsupported PlayReady header version {header_version}")
return [
UUID(bytes=base64.b64decode(key_id))
for key_id in key_ids
]
raise ValueError(f"Unsupported Box {box!r}")
__all__ = ("PSSH",)
@staticmethod
def overwrite_key_ids(box: Container, key_ids: list[UUID]) -> Container:
"""Overwrite all Key IDs in PSSH box with the specified Key IDs."""
if box.system_ID != PSSH.SystemId.Widevine:
raise ValueError(f"Only Widevine PSSH Boxes are supported, not {box.system_ID}.")
if box.version == 1 or box.key_IDs:
# only use key_IDs if version is 1, or it's already being used
# this is in case the service stupidly expects it for version 0
box.key_IDs = key_ids
init = WidevinePsshData()
init.ParseFromString(box.init_data)
# TODO: Is there a better way to clear the Key IDs?
for _ in range(len(init.key_id or [])):
init.key_id.pop(0)
# TODO: Is there a .extend or a way to add all without a loop?
for key_id in key_ids:
init.key_id.append(key_id.bytes)
box.init_data = init.SerializeToString()
return box

View File

View File

@ -1,300 +0,0 @@
from __future__ import annotations
import base64
import binascii
import re
from typing import Optional, Union
import requests
from Crypto.Hash import SHA1
from Crypto.PublicKey import RSA
from Crypto.Signature import pss
from google.protobuf.message import DecodeError
from pywidevine.cdm import Cdm
from pywidevine.device import Device, DeviceTypes
from pywidevine.exceptions import (DeviceMismatch, InvalidInitData, InvalidLicenseMessage, InvalidLicenseType,
SignatureMismatch)
from pywidevine.key import Key
from pywidevine.license_protocol_pb2 import (ClientIdentification, License, LicenseType, SignedDrmCertificate,
SignedMessage)
from pywidevine.pssh import PSSH
class RemoteCdm(Cdm):
"""Remote Accessible CDM using pywidevine's serve schema."""
def __init__(
self,
device_type: Union[DeviceTypes, str],
system_id: int,
security_level: int,
host: str,
secret: str,
device_name: str
):
"""Initialize a Widevine Content Decryption Module (CDM)."""
if not device_type:
raise ValueError("Device Type must be provided")
if isinstance(device_type, str):
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}")
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 host:
raise ValueError("API Host must be provided")
if not isinstance(host, str):
raise TypeError(f"Expected host to be a {str} not {host!r}")
if not secret:
raise ValueError("API Secret must be provided")
if not isinstance(secret, str):
raise TypeError(f"Expected secret to be a {str} not {secret!r}")
if not device_name:
raise ValueError("API Device name must be provided")
if not isinstance(device_name, str):
raise TypeError(f"Expected device_name to be a {str} not {device_name!r}")
self.device_type = device_type
self.system_id = system_id
self.security_level = security_level
self.host = host
self.device_name = device_name
# spoof client_id and rsa_key just so we can construct via super call
super().__init__(device_type, system_id, security_level, ClientIdentification(), RSA.generate(2048))
self.__session = requests.Session()
self.__session.headers.update({
"X-Secret-Key": secret
})
r = requests.head(self.host)
if r.status_code != 200:
raise ValueError(f"Could not test Remote API version [{r.status_code}]")
server = r.headers.get("Server")
if not server or "pywidevine serve" not in server.lower():
raise ValueError(f"This Remote CDM API does not seem to be a pywidevine serve API ({server}).")
server_version_re = re.search(r"pywidevine serve v([\d.]+)", server, re.IGNORECASE)
if not server_version_re:
raise ValueError("The pywidevine server API is not stating the version correctly, cannot continue.")
server_version = server_version_re.group(1)
if server_version < "1.4.3":
raise ValueError(f"This pywidevine serve API version ({server_version}) is not supported.")
@classmethod
def from_device(cls, device: Device) -> RemoteCdm:
raise NotImplementedError("You cannot load a RemoteCdm from a local Device file.")
def open(self) -> bytes:
r = self.__session.get(
url=f"{self.host}/{self.device_name}/open"
).json()
if r['status'] != 200:
raise ValueError(f"Cannot Open CDM Session, {r['message']} [{r['status']}]")
r = r["data"]
if int(r["device"]["system_id"]) != self.system_id:
raise DeviceMismatch("The System ID specified does not match the one specified in the API response.")
if int(r["device"]["security_level"]) != self.security_level:
raise DeviceMismatch("The Security Level specified does not match the one specified in the API response.")
return bytes.fromhex(r["session_id"])
def close(self, session_id: bytes) -> None:
r = self.__session.get(
url=f"{self.host}/{self.device_name}/close/{session_id.hex()}"
).json()
if r["status"] != 200:
raise ValueError(f"Cannot Close CDM Session, {r['message']} [{r['status']}]")
def set_service_certificate(self, session_id: bytes, certificate: Optional[Union[bytes, str]]) -> str:
if certificate is None:
certificate_b64 = None
elif isinstance(certificate, str):
certificate_b64 = certificate # assuming base64
elif isinstance(certificate, bytes):
certificate_b64 = base64.b64encode(certificate).decode()
else:
raise DecodeError(f"Expecting Certificate to be base64 or bytes, not {certificate!r}")
r = self.__session.post(
url=f"{self.host}/{self.device_name}/set_service_certificate",
json={
"session_id": session_id.hex(),
"certificate": certificate_b64
}
).json()
if r["status"] != 200:
raise ValueError(f"Cannot Set CDMs Service Certificate, {r['message']} [{r['status']}]")
r = r["data"]
return r["provider_id"]
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={
"session_id": session_id.hex()
}
).json()
if r["status"] != 200:
raise ValueError(f"Cannot Get CDMs Service Certificate, {r['message']} [{r['status']}]")
r = r["data"]
service_certificate = r["service_certificate"]
if not service_certificate:
return None
service_certificate = base64.b64decode(service_certificate)
signed_drm_certificate = SignedDrmCertificate()
try:
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}")
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):
raise SignatureMismatch("Signature Mismatch on SignedDrmCertificate, rejecting certificate")
return signed_drm_certificate
def get_license_challenge(
self,
session_id: bytes,
pssh: PSSH,
license_type: str = "STREAMING",
privacy_mode: bool = True
) -> bytes:
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}")
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()}"
)
r = self.__session.post(
url=f"{self.host}/{self.device_name}/get_license_challenge/{license_type}",
json={
"session_id": session_id.hex(),
"init_data": pssh.dumps(),
"privacy_mode": privacy_mode
}
).json()
if r["status"] != 200:
raise ValueError(f"Cannot get Challenge, {r['message']} [{r['status']}]")
r = r["data"]
try:
challenge = base64.b64decode(r["challenge_b64"])
license_message = SignedMessage()
license_message.ParseFromString(challenge)
if license_message.SerializeToString() != challenge:
raise DecodeError("partial parse")
except DecodeError as e:
raise InvalidLicenseMessage(f"Failed to parse license request, {e}")
return license_message.SerializeToString()
def parse_license(self, session_id: bytes, license_message: Union[SignedMessage, bytes, str]) -> None:
if not license_message:
raise InvalidLicenseMessage("Cannot parse an empty license_message")
if isinstance(license_message, str):
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}")
if isinstance(license_message, bytes):
signed_message = SignedMessage()
try:
signed_message.ParseFromString(license_message)
if signed_message.SerializeToString() != license_message:
raise DecodeError("partial parse")
except DecodeError as e:
raise InvalidLicenseMessage(f"Could not parse license_message as a SignedMessage, {e}")
license_message = signed_message
if not isinstance(license_message, SignedMessage):
raise InvalidLicenseMessage(f"Expecting license_response to be a SignedMessage, got {license_message!r}")
if license_message.type != SignedMessage.MessageType.Value("LICENSE"):
raise InvalidLicenseMessage(
f"Expecting a LICENSE message, not a "
f"'{SignedMessage.MessageType.Name(license_message.type)}' message."
)
r = self.__session.post(
url=f"{self.host}/{self.device_name}/parse_license",
json={
"session_id": session_id.hex(),
"license_message": base64.b64encode(license_message.SerializeToString()).decode()
}
).json()
if r["status"] != 200:
raise ValueError(f"Cannot parse License, {r['message']} [{r['status']}]")
def get_keys(self, session_id: bytes, type_: Optional[Union[int, str]] = None) -> list[Key]:
try:
if isinstance(type_, str):
License.KeyContainer.KeyType.Value(type_) # only test
elif isinstance(type_, int):
type_ = License.KeyContainer.KeyType.Name(type_)
elif type_ is None:
type_ = "ALL"
else:
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}")
r = self.__session.post(
url=f"{self.host}/{self.device_name}/get_keys/{type_}",
json={
"session_id": session_id.hex()
}
).json()
if r["status"] != 200:
raise ValueError(f"Could not get {type_} Keys, {r['message']} [{r['status']}]")
r = r["data"]
return [
Key(
type_=key["type"],
kid=Key.kid_to_uuid(bytes.fromhex(key["key_id"])),
key=bytes.fromhex(key["key"]),
permissions=key["permissions"]
)
for key in r["keys"]
]
__all__ = ("RemoteCdm",)

View File

@ -1,458 +0,0 @@
import base64
import sys
from pathlib import Path
from typing import Any, Optional, Union
from aiohttp.typedefs import Handler
from google.protobuf.message import DecodeError
from pywidevine.pssh import PSSH
try:
from aiohttp import web
except ImportError:
print(
"Missing the extra dependencies for serve functionality. "
"You may install them under poetry with `poetry install -E serve`, "
"or under pip with `pip install pywidevine[serve]`."
)
sys.exit(1)
from pywidevine import __version__
from pywidevine.cdm import Cdm
from pywidevine.device import Device
from pywidevine.exceptions import (InvalidContext, InvalidInitData, InvalidLicenseMessage, InvalidLicenseType,
InvalidSession, SignatureMismatch, TooManySessions)
routes = web.RouteTableDef()
async def _startup(app: web.Application) -> None:
app["cdms"] = {}
app["config"]["devices"] = {
path.stem: path
for x in app["config"]["devices"]
for path in [Path(x)]
}
for device in app["config"]["devices"].values():
if not device.is_file():
raise FileNotFoundError(f"Device file does not exist: {device}")
async def _cleanup(app: web.Application) -> None:
app["cdms"].clear()
del app["cdms"]
app["config"].clear()
del app["config"]
@routes.get("/")
async def ping(_: Any) -> web.Response:
return web.json_response({
"status": 200,
"message": "Pong!"
})
@routes.get("/{device}/open")
async def open_(request: web.Request) -> web.Response:
secret_key = request.headers["X-Secret-Key"]
device_name = request.match_info["device"]
user = request.app["config"]["users"][secret_key]
if device_name not in user["devices"] or device_name not in request.app["config"]["devices"]:
# we don't want to be verbose with the error as to not reveal device names
# by trial and error to users that are not authorized to use them
return web.json_response({
"status": 403,
"message": f"Device '{device_name}' is not found or you are not authorized to use it."
}, status=403)
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
if not cdm:
device = Device.load(request.app["config"]["devices"][device_name])
cdm = request.app["cdms"][(secret_key, device_name)] = Cdm.from_device(device)
try:
session_id = cdm.open()
except TooManySessions as e:
return web.json_response({
"status": 400,
"message": str(e)
}, status=400)
return web.json_response({
"status": 200,
"message": "Success",
"data": {
"session_id": session_id.hex(),
"device": {
"system_id": cdm.system_id,
"security_level": cdm.security_level
}
}
})
@routes.get("/{device}/close/{session_id}")
async def close(request: web.Request) -> web.Response:
secret_key = request.headers["X-Secret-Key"]
device_name = request.match_info["device"]
session_id = bytes.fromhex(request.match_info["session_id"])
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
if not cdm:
return web.json_response({
"status": 400,
"message": f"No Cdm session for {device_name} has been opened yet. No session to close."
}, status=400)
try:
cdm.close(session_id)
except InvalidSession:
return web.json_response({
"status": 400,
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
}, status=400)
return web.json_response({
"status": 200,
"message": f"Successfully closed Session '{session_id.hex()}'."
})
@routes.post("/{device}/set_service_certificate")
async def set_service_certificate(request: web.Request) -> web.Response:
secret_key = request.headers["X-Secret-Key"]
device_name = request.match_info["device"]
body = await request.json()
for required_field in ("session_id", "certificate"):
if required_field == "certificate":
has_field = required_field in body # it needs the key, but can be empty/null
else:
has_field = body.get(required_field)
if not has_field:
return web.json_response({
"status": 400,
"message": f"Missing required field '{required_field}' in JSON body."
}, status=400)
# get session id
session_id = bytes.fromhex(body["session_id"])
# get cdm
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
if not cdm:
return web.json_response({
"status": 400,
"message": f"No Cdm session for {device_name} has been opened yet. No session to use."
}, status=400)
# set service certificate
certificate = body.get("certificate")
try:
provider_id = cdm.set_service_certificate(session_id, certificate)
except InvalidSession:
return web.json_response({
"status": 400,
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
}, status=400)
except DecodeError as e:
return web.json_response({
"status": 400,
"message": f"Invalid Service Certificate, {e}"
}, status=400)
except SignatureMismatch:
return web.json_response({
"status": 400,
"message": "Signature Validation failed on the Service Certificate, rejecting."
}, status=400)
return web.json_response({
"status": 200,
"message": f"Successfully {['set', 'unset'][not certificate]} the Service Certificate.",
"data": {
"provider_id": provider_id
}
})
@routes.post("/{device}/get_service_certificate")
async def get_service_certificate(request: web.Request) -> web.Response:
secret_key = request.headers["X-Secret-Key"]
device_name = request.match_info["device"]
body = await request.json()
for required_field in ("session_id",):
if not body.get(required_field):
return web.json_response({
"status": 400,
"message": f"Missing required field '{required_field}' in JSON body."
}, status=400)
# get session id
session_id = bytes.fromhex(body["session_id"])
# get cdm
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
if not cdm:
return web.json_response({
"status": 400,
"message": f"No Cdm session for {device_name} has been opened yet. No session to use."
}, status=400)
# get service certificate
try:
service_certificate = cdm.get_service_certificate(session_id)
except InvalidSession:
return web.json_response({
"status": 400,
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
}, status=400)
if service_certificate:
service_certificate_b64 = base64.b64encode(service_certificate.SerializeToString()).decode()
else:
service_certificate_b64 = None
return web.json_response({
"status": 200,
"message": "Successfully got the Service Certificate.",
"data": {
"service_certificate": service_certificate_b64
}
})
@routes.post("/{device}/get_license_challenge/{license_type}")
async def get_license_challenge(request: web.Request) -> web.Response:
secret_key = request.headers["X-Secret-Key"]
device_name = request.match_info["device"]
license_type = request.match_info["license_type"]
body = await request.json()
for required_field in ("session_id", "init_data"):
if not body.get(required_field):
return web.json_response({
"status": 400,
"message": f"Missing required field '{required_field}' in JSON body."
}, status=400)
# get session id
session_id = bytes.fromhex(body["session_id"])
# get privacy mode flag
privacy_mode = body.get("privacy_mode", True)
# get cdm
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
if not cdm:
return web.json_response({
"status": 400,
"message": f"No Cdm session for {device_name} has been opened yet. No session to use."
}, status=400)
# enforce service certificate (opt-in)
if request.app["config"].get("force_privacy_mode"):
privacy_mode = True
if not cdm.get_service_certificate(session_id):
return web.json_response({
"status": 403,
"message": "No Service Certificate set but Privacy Mode is Enforced."
}, status=403)
# get init data
init_data = PSSH(body["init_data"])
# get challenge
try:
license_request = cdm.get_license_challenge(
session_id=session_id,
pssh=init_data,
license_type=license_type,
privacy_mode=privacy_mode
)
except InvalidSession:
return web.json_response({
"status": 400,
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
}, status=400)
except InvalidInitData as e:
return web.json_response({
"status": 400,
"message": f"Invalid Init Data, {e}"
}, status=400)
except InvalidLicenseType:
return web.json_response({
"status": 400,
"message": f"Invalid License Type '{license_type}'"
}, status=400)
return web.json_response({
"status": 200,
"message": "Success",
"data": {
"challenge_b64": base64.b64encode(license_request).decode()
}
}, status=200)
@routes.post("/{device}/parse_license")
async def parse_license(request: web.Request) -> web.Response:
secret_key = request.headers["X-Secret-Key"]
device_name = request.match_info["device"]
body = await request.json()
for required_field in ("session_id", "license_message"):
if not body.get(required_field):
return web.json_response({
"status": 400,
"message": f"Missing required field '{required_field}' in JSON body."
}, status=400)
# get session id
session_id = bytes.fromhex(body["session_id"])
# get cdm
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
if not cdm:
return web.json_response({
"status": 400,
"message": f"No Cdm session for {device_name} has been opened yet. No session to use."
}, status=400)
# parse the license message
try:
cdm.parse_license(session_id, body["license_message"])
except InvalidSession:
return web.json_response({
"status": 400,
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
}, status=400)
except InvalidLicenseMessage as e:
return web.json_response({
"status": 400,
"message": f"Invalid License Message, {e}"
}, status=400)
except InvalidContext as e:
return web.json_response({
"status": 400,
"message": f"Invalid Context, {e}"
}, status=400)
except SignatureMismatch:
return web.json_response({
"status": 400,
"message": "Signature Validation failed on the License Message, rejecting."
}, status=400)
return web.json_response({
"status": 200,
"message": "Successfully parsed and loaded the Keys from the License message."
})
@routes.post("/{device}/get_keys/{key_type}")
async def get_keys(request: web.Request) -> web.Response:
secret_key = request.headers["X-Secret-Key"]
device_name = request.match_info["device"]
body = await request.json()
for required_field in ("session_id",):
if not body.get(required_field):
return web.json_response({
"status": 400,
"message": f"Missing required field '{required_field}' in JSON body."
}, status=400)
# get session id
session_id = bytes.fromhex(body["session_id"])
# get key type
key_type: Optional[str] = request.match_info["key_type"]
if key_type == "ALL":
key_type = None
# get cdm
cdm = request.app["cdms"].get((secret_key, device_name))
if not cdm:
return web.json_response({
"status": 400,
"message": f"No Cdm session for {device_name} has been opened yet. No session to use."
}, status=400)
# get keys
try:
keys = cdm.get_keys(session_id, key_type)
except InvalidSession:
return web.json_response({
"status": 400,
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
}, status=400)
except ValueError as e:
return web.json_response({
"status": 400,
"message": f"The Key Type value '{key_type}' is invalid, {e}"
}, status=400)
# get the keys in json form
keys_json = [
{
"key_id": key.kid.hex,
"key": key.key.hex(),
"type": key.type,
"permissions": key.permissions,
}
for key in keys
if not key_type or key.type == key_type
]
return web.json_response({
"status": 200,
"message": "Success",
"data": {
"keys": keys_json
}
})
@web.middleware
async def authentication(request: web.Request, handler: Handler) -> web.Response:
secret_key = request.headers.get("X-Secret-Key")
if request.path != "/" and not secret_key:
request.app.logger.debug(f"{request.remote} did not provide authorization.")
response = web.json_response({
"status": "401",
"message": "Secret Key is Empty."
}, status=401)
elif request.path != "/" and secret_key not in request.app["config"]["users"]:
request.app.logger.debug(f"{request.remote} failed authentication with '{secret_key}'.")
response = web.json_response({
"status": "401",
"message": "Secret Key is Invalid, the Key is case-sensitive."
}, status=401)
else:
try:
response = await handler(request) # type: ignore[assignment]
except web.HTTPException as e:
request.app.logger.error(f"An unexpected error has occurred, {e}")
response = web.json_response({
"status": 500,
"message": e.reason
}, status=500)
response.headers.update({
"Server": f"https://github.com/devine-dl/pywidevine serve v{__version__}"
})
return response
def run(config: dict, host: Optional[Union[str, web.HostSequence]] = None, port: Optional[int] = None) -> None:
app = web.Application(middlewares=[authentication])
app.on_startup.append(_startup)
app.on_cleanup.append(_cleanup)
app.add_routes(routes)
app["config"] = config
web.run_app(app, host=host, port=port)

View File

@ -1,18 +0,0 @@
from typing import Optional
from Crypto.Random import get_random_bytes
from pywidevine.key import Key
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[SignedDrmCertificate] = None
self.context: dict[bytes, tuple[bytes, bytes]] = {}
self.keys: list[Key] = []
__all__ = ("Session",)

View File

@ -1,20 +0,0 @@
# This data serves as an example configuration file for the `serve` command.
# None of the sensitive data should be re-used.
# List of Widevine Device (.wvd) file paths to use with serve.
# Note: Each individual user needs explicit permission to use a device listed.
devices:
- 'C:\Users\devine-dl\Documents\WVDs\test_device_001.wvd'
# List of User's by Secret Key. The Secret Key must be supplied by the User to use the API.
users:
fvYBh0C3fRAxlvyJcynD1see3GmNbIiC: # secret key, a-zA-Z-09{32} is recommended, case-sensitive
username: jane # only for internal logging, user will not see this name
devices: # list of allowed devices by filename
- test_key_001
# ...
# All clients must provide a service certificate for privacy mode.
# If the client does not provide a certificate, privacy mode may or may not be used.
# Enforcing Privacy Mode helps protect the identity of the device and is recommended.
force_privacy_mode: true