mirror of
https://github.com/devine-dl/pywidevine.git
synced 2025-04-29 22:24:36 +02:00
Compare commits
84 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
7ea2a72a8c | ||
![]() |
84d30a69a9 | ||
![]() |
c39dd6df5d | ||
![]() |
94f8eba960 | ||
![]() |
25e03529f6 | ||
![]() |
a04e751aa1 | ||
![]() |
17cefbf1d8 | ||
![]() |
bcb2185f75 | ||
![]() |
532e68aba9 | ||
![]() |
e348fc5df2 | ||
![]() |
4fc8216c4a | ||
![]() |
81fd2649a4 | ||
![]() |
00532979b6 | ||
![]() |
9479c069b5 | ||
![]() |
ba83e29147 | ||
![]() |
49315eceb8 | ||
![]() |
5087da31a0 | ||
![]() |
79cdbc007c | ||
![]() |
c362192c11 | ||
![]() |
0e6aa1d5e8 | ||
![]() |
97ec2e1c60 | ||
![]() |
0c31f88d23 | ||
![]() |
2d8163f76d | ||
![]() |
797799a5aa | ||
![]() |
dfdba71caf | ||
![]() |
65d8135e2a | ||
![]() |
2fb3b21e4a | ||
![]() |
cd990e0f4e | ||
![]() |
52fd5e74ba | ||
![]() |
2656a795c3 | ||
![]() |
bbbaeafbb6 | ||
![]() |
c71f867a72 | ||
![]() |
dad32e728b | ||
![]() |
db7bf977a1 | ||
![]() |
bfaae20e81 | ||
![]() |
728a3e7575 | ||
![]() |
29693bedf6 | ||
![]() |
db6eaef450 | ||
![]() |
6a7f8b9a39 | ||
![]() |
e4a8316227 | ||
![]() |
9568d7fdb9 | ||
![]() |
ece0914920 | ||
![]() |
2ab659eab6 | ||
![]() |
99aef63354 | ||
![]() |
fd3df13e9c | ||
![]() |
2e9c09d5f1 | ||
![]() |
2e25f9c7bd | ||
![]() |
ddc66f0a2b | ||
![]() |
c9f55c6e6b | ||
![]() |
2648d1c669 | ||
![]() |
bc2b5beef4 | ||
![]() |
11284eddfb | ||
![]() |
61097ce6de | ||
![]() |
3a910bd03a | ||
![]() |
e31ba61302 | ||
![]() |
0e4275bd1e | ||
![]() |
e0365ff2bb | ||
![]() |
ae95aeec96 | ||
![]() |
1b40c2b369 | ||
![]() |
05b30b3a89 | ||
![]() |
7a993206a1 | ||
![]() |
2d2359f9a2 | ||
![]() |
8146e055e6 | ||
![]() |
58208ab68f | ||
![]() |
7996a3d91c | ||
![]() |
37d466b9a8 | ||
![]() |
05b6753aa6 | ||
![]() |
ada7cb009e | ||
![]() |
7c91f2c59a | ||
![]() |
eaa26399e0 | ||
![]() |
74f960aeba | ||
![]() |
42b825dcd5 | ||
![]() |
fa00bbd8e4 | ||
![]() |
a4c6f98650 | ||
![]() |
24297d577e | ||
![]() |
e90371922c | ||
![]() |
c5c620ea84 | ||
![]() |
d698b1d3c4 | ||
![]() |
e585102798 | ||
![]() |
e001ef0291 | ||
![]() |
34eeaf746f | ||
![]() |
272bb419b1 | ||
![]() |
cef7b7a890 | ||
![]() |
0caccfd014 |
15
.editorconfig
Normal file
15
.editorconfig
Normal file
@ -0,0 +1,15 @@
|
||||
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
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
22
.github/workflows/cd.yml
vendored
22
.github/workflows/cd.yml
vendored
@ -10,25 +10,21 @@ jobs:
|
||||
name: Tagged Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10.x'
|
||||
python-version: "3.12"
|
||||
- name: Install Poetry
|
||||
uses: abatilo/actions-poetry@v2.1.0
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
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
|
||||
poetry-version: 1.6.1
|
||||
- name: Install project
|
||||
run: poetry install --only main
|
||||
- name: Build project
|
||||
run: poetry build
|
||||
- name: Upload wheel
|
||||
uses: actions/upload-artifact@v2.2.4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: Python Wheel
|
||||
path: "dist/*.whl"
|
||||
|
45
.github/workflows/ci.yml
vendored
45
.github/workflows/ci.yml
vendored
@ -7,39 +7,38 @@ 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.7', '3.8', '3.9', '3.10']
|
||||
poetry-version: [1.1.11]
|
||||
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install poetry
|
||||
uses: abatilo/actions-poetry@v2.1.0
|
||||
uses: abatilo/actions-poetry@v2
|
||||
with:
|
||||
poetry-version: ${{ matrix.poetry-version }}
|
||||
poetry-version: 1.6.1
|
||||
- name: Install project
|
||||
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
|
||||
|
||||
run: poetry install --all-extras --only main
|
||||
- name: Build project
|
||||
run: poetry build
|
||||
|
40
.gitignore
vendored
40
.gitignore
vendored
@ -23,7 +23,6 @@ parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
@ -53,6 +52,7 @@ coverage.xml
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
@ -75,6 +75,7 @@ instance/
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
@ -85,7 +86,9 @@ profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
# 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
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
@ -94,7 +97,22 @@ ipython_config.py
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
# 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
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
@ -120,9 +138,6 @@ venv.bak/
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# Jetbrains project settings
|
||||
.idea
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
@ -133,3 +148,16 @@ 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/
|
||||
|
20
.pre-commit-config.yaml
Normal file
20
.pre-commit-config.yaml
Normal file
@ -0,0 +1,20 @@
|
||||
# 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]
|
12
.vscode/extensions.json
vendored
Normal file
12
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
579
CHANGELOG.md
579
CHANGELOG.md
@ -5,289 +5,512 @@ 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.4.3] - 2022-09-10
|
||||
|
||||
RemoteCdm minimum supported Serve API version is now v1.4.3.
|
||||
## [1.8.0] - 2023-12-22
|
||||
|
||||
### Added
|
||||
|
||||
- Cdm now has a `get_service_certificate()` endpoint to get the currently set service certificate of a Session.
|
||||
RemoteCdm and Serve also has support for these endpoints.
|
||||
- Added `py.typed` file to support PEP561 and silence Mypy.
|
||||
|
||||
### Changed
|
||||
|
||||
- Added installation instructions, troubleshooting steps, a minimal example, and a list of features to the README.
|
||||
- The minimum version for lxml has been upped to >=4.9.1. This is due to some vulnerabilities present in all older
|
||||
versions.
|
||||
- All f-string formatting in log statements have been replaced with logging formatting to improve performance when
|
||||
logging is disabled.
|
||||
- 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.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Serve's get_license_challenge can now disable privacy mode even if a service certificate is set, as long as privacy
|
||||
mode is not enforced in settings.
|
||||
|
||||
## [1.4.2] - 2022-09-05
|
||||
|
||||
- Supported Serve API: `v1.4.0` to `v1.4.2`
|
||||
|
||||
### Changed
|
||||
|
||||
- Device's constructor no longer throws `ValueError` exceptions if it fails to parse the provided Client ID or it's
|
||||
VMP data if any. It will now raise a `DecodeError`.
|
||||
- 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
|
||||
|
||||
- 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 better than the Chrome Cdm formula.
|
||||
- Various Proto Message Parsing now has full verification and expects the parsed response to be the same length
|
||||
as the serialized input, or it will throw an error. For example, this prevents vague errors to happen when you
|
||||
provide a bad License to `Cdm.parse_license`. It also prevents possibilities of it going past various other checks
|
||||
depending on the first few bytes provided.
|
||||
- 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
|
||||
|
||||
Small patch release for some fixes to the PSSH classes recent face-lift.
|
||||
- Supported Serve API: `v1.4.0` to `v1.4.2`
|
||||
|
||||
### Changed
|
||||
|
||||
- `PSSH.overwrite_key_ids` static method is now an instance method named `set_key_ids` and works on the current
|
||||
instance instead of making and returning a new one.
|
||||
- `PSSH.get_key_ids` static method is now a property method named `key_ids`. This allows swift access to all the
|
||||
Key IDs of the current access.
|
||||
- `PSSH.from_playready_pssh` class method is now an instance method named `playready_to_widevine` and now converts
|
||||
the current instances values directly. This allows you to more easily instance as any PSSH, then convert afterwards.
|
||||
- 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
|
||||
|
||||
This release is a face-lift for the PSSH class with a moderate amount of Cdm and Serve interface changes.
|
||||
You will likely need to make a moderate amount of changes in your client code, please study the changelog.
|
||||
|
||||
Please note that while it was always privatized as `_sessions`, accessing the Session directly for any purpose was
|
||||
never recommended or supported. With v1.4.0, there will be drastic problems if you continue to do so. One of the
|
||||
few reasons to do that was to get the license keys which is no longer required with CDMs new `get_keys()` method.
|
||||
|
||||
RemoteCdm minimum supported Serve API version is now v1.4.0.
|
||||
- Supported Serve API: `v1.4.0` to `v1.4.2`
|
||||
|
||||
### Added
|
||||
|
||||
- The PSSH class now has a `new()` method to craft a new PSSH box. 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.
|
||||
- The PSSH class now has `dump()` and `dumps()` methods to serialize the data as binary or base64 respectively. It will
|
||||
be serialized as a pymp4 PSSH box, ready to be used in an MP4 file.
|
||||
- Cdm now has a method `get_keys()` to get the keys of the loaded license. This is the alternative to manually
|
||||
accessing the keys by navigating the `_sessions` class instance variable.
|
||||
- Serve API now also has a `/get_keys` endpoint to call the `get_keys()` method of the underlying Cdm session.
|
||||
- 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
|
||||
|
||||
- Cdm and RemoteCdm now expect a PSSH object as the `init_data` param for `get_license_challenge`. You can no longer
|
||||
provide it anything else, that includes base64 or bytes form. It must be a PSSH object.
|
||||
- Serve no longer returns license keys in the response of the `/keys` endpoint.
|
||||
- Serve has changed the endpoint `/challenge` to `/get_license_challenge` and `/keys` to `/parse_license`. This is to
|
||||
be consistent with the method names of the underlying Cdm class.
|
||||
- The PSSH class has been reworked from being a static helper class to a proper PSSH class.
|
||||
- PSSH.from_playready_pssh is now a class method and returns as a PSSH object.
|
||||
- `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.get_as_box has been removed and merged into the PSSH constructor.
|
||||
- PSSH.from_key_ids has been removed entirely, you should now use `PSSH.new(key_ids=...)` instead.
|
||||
- All uses of a local Session() object has been removed from RemoteCdm. The session is now fully controlled by the
|
||||
- `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
|
||||
|
||||
- Various uses of the `key_ids` field of WidevinePsshData proto has been fixed in the PSSH class.
|
||||
- Fixed a few Serve API crashes in edge cases with improved error handling on Cdm method calls.
|
||||
- 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
|
||||
|
||||
- Cdm and RemoteCdm can now be supplied a string value for `device_type` for scenarios where providing it as a string
|
||||
is more convenient (e.g., from Config files).
|
||||
- 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
|
||||
|
||||
- The `force_privacy_mode` key no longer needs to be defined at all in the configuration file. This was previously
|
||||
crashing serve APIs if it wasn't set before starting.
|
||||
- RemoteCdm's Server version check will no longer fail under certain serving conditions e.g., Caddy prepending `Caddy`
|
||||
to the Server header value. It also fixes case sensitivity and removed the full url from the header.
|
||||
- `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 RemoteCdm class to be used as Client code for the `serve` Remote CDM API server. The RemoteCdm should be used
|
||||
entirely separately from the normal Cdm class. All serve APIs must update to v1.3.0 to be compatible. The RemoteCdm
|
||||
verifies the server version to ensure compatibility. Changes to the serve API schema will be immediately reflected in
|
||||
the RemoteCdm code in the future.
|
||||
- Implemented `/set_service_certificate` endpoint in serve schema as an improved way of setting the service certificate
|
||||
than passing it to `/challenge`.
|
||||
- You can now unset the service certificate by providing an empty service certificate value (or None or null). This
|
||||
includes support for doing so even in serve API and the new RemoteCdm.
|
||||
- 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
|
||||
|
||||
- The Construction of the Cdm object has changed. You can now initialize it with more direct values if you don't want
|
||||
to use the Device class or don't want to use `.wvd` files. To use Device classes, you must now use the
|
||||
`Cdm.from_device()` class method.
|
||||
- The ability to pass the certificate to `/challenge` has been removed. Please 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.
|
||||
- `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
|
||||
|
||||
This release is primarily a maintenance release for `serve` functionality but some Cdm fixes are also present.
|
||||
|
||||
### Added
|
||||
|
||||
- You can now return all License Keys from Serve's `/keys` endpoint by supplying `ALL` as the key type.
|
||||
This adds support for Exchange Systems like Netflix's WidevineExchange MSL scheme. I recommend using `ALL` unless
|
||||
you only want `CONTENT` keys and will not be using any other type of keys including `SIGNING` and `OPERATOR_SESSION`.
|
||||
- Serve now has a `/close` endpoint to close a session. The Cdm has a limit of 50 sessions per user.
|
||||
- Serve now responds with a `Server` header denoting that pywidevine serve is being used, also specifying the version.
|
||||
This allows Clients to selectively support APIs based on version, and also verify the API as being supported at all.
|
||||
- Serve now verifies that all Devices in config actually exist before letting you start serving.
|
||||
- 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
|
||||
|
||||
- Downgraded lxml to >=4.8.0 to support projects using pycaption, which is likely considering the project's topic.
|
||||
- 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 a multi-device per-user Cdm setup, see Fixed below regarding
|
||||
Serve's Cdm objects.
|
||||
`/device_name/challenge/STREAMING`. This is to support the previous change.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed support for Raw PSSH values, e.g., Netflix's WidevineExchange MSL Scheme arbitrary init_data value.
|
||||
- The Service Certificate is now saved to the Session in full SignedMessage form instead of just the underlying
|
||||
DrmCertificate. This is so any class inheriting the Cdm (e.g., for Remote capabilities) can sufficiently use
|
||||
and supply the service certificate while being signed.
|
||||
- Serve's /open endpoint will now return a 400 error if there's too many sessions opened.
|
||||
- Serve's Cdm objects with Device initialized are now stored per-user and device name. This fixes the issue where the
|
||||
entire user base has only 50 sessions available to be used. Effectively rate limiting to only 50 users at a time.
|
||||
Since /close endpoint was not implemented yet, there was no way to even close effectively meaning only 50 uses could
|
||||
be done.
|
||||
- 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` to serve local WVD devices and CDM sessions remotely as a JSON API.
|
||||
- The CLI command `migrate` can now accept a folder path to batch migrate WVD files.
|
||||
- The Cdm now uses custom exceptions where the use case is justified. All custom exceptions are under a parent custom
|
||||
exception to allow catching of any Pywidevine exception.
|
||||
- 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
|
||||
|
||||
- The Cdm has been reworked as a session-based Cdm. You now initialize the Cdm with just the device you wish to use,
|
||||
and now you open sessions with `Cdm.open()` to get a session ID. For usage example see `license` CLI command in
|
||||
`main.py`.
|
||||
- The Cdm no longer requires you to specify `raw` bool parameter. It now supports arbitrary and valid Widevine Cenc
|
||||
Header Data without needing to explicitly specify which it is.
|
||||
- The Cdm `pssh` param has been renamed as `init_data`. Doc-strings have been changed to prioritize explanation of it
|
||||
referring to Widevine Cenc Header rather than PSSH Boxes. This is to show that the Cdm more-so wants Init Data than
|
||||
a PSSH box. The full PSSH is never kept nor ever used, only it's init data is. It still supports PSSH box data.
|
||||
- Cdm `set_service_certificate()` now returns the provider ID string rather than the underlying (and now verified)
|
||||
DrmCertificate. This is because the DrmCertificate is not likely useful and would still be possible to obtain in full
|
||||
but quick access to the Provider ID may be more useful.
|
||||
- License responses can now be only be parsed once by `Cdm.parse_license()`. Any further attempts will raise an
|
||||
InvalidContext exception. This is because context data is now cleared for it's respective License Request once it's
|
||||
parsed to reduce data lingering in memory.
|
||||
- Trove Classifier for Development Status is now 5 (Production/Stable).
|
||||
- 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
|
||||
|
||||
- You can no longer provide a direct `DrmCertificate` to `Cdm.set_service_certificate()` for security reasons.
|
||||
You must provide either a `SignedDrmCertificate` or a `SignedMessage` containing a `SignedDrmCertificate`.
|
||||
- PSSH `from_init_data()` has been removed. It was unused and is unnecessary with improvements to `get_as_box()`.
|
||||
- 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
|
||||
|
||||
- Cdm `set_service_certificate()` now verifies the signature of the provided Certificate. This patches a trivial
|
||||
exploit/workaround that allows an attacker to recover the plaintext Client ID from an encrypted Client ID.
|
||||
- Cdm `parse_license()` now verifies the input message type as a `LICENSE` message.
|
||||
- Cdm `parse_license()` now clears context for the License Request once it's License Response message has been parsed.
|
||||
This reduces data lingering in the `context` dictionary when it may only be needed once.
|
||||
- The Context Availability error handler in Cdm `parse_license()` has been fixed.
|
||||
- Typing of `type_` param of `Cdm.get_license_challenge()` has been 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 --vmp argument of the create-device command is now optional.
|
||||
- The `-v/--vmp` parameter of the `test` CLI command is now optional.
|
||||
|
||||
## [1.1.0] - 2022-07-21
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for setting a Service Certificate in SignedDrmCertificate form as well as raw DrmCertificate form.
|
||||
However, It's unlikely for the service to provide the certificate in raw DrmCertificate form without a signature.
|
||||
- Added a CLI command `create-device` to create Widevine Device (`.wvd`) files from RSA PEM/DER Private Keys and
|
||||
- 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.
|
||||
- Added a CLI command `migrate` that uses `Device.migrate()` and `dump()` to migrate older v1 Widevine Device files
|
||||
to v2.
|
||||
- Added the v1 Structure of Widevine Devices for migration use.
|
||||
- Added `Device.migrate()` class method that effectively loads older format WVD data. You can then use `dumps()` to
|
||||
get back the WVD data in the latest supported format.
|
||||
- Added ability to use Privacy mode on the test command.
|
||||
- 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
|
||||
|
||||
- Set Service Certificates are now stored as the raw underlying DrmCertificate as the signature data is unused by
|
||||
the CDM.
|
||||
- Moved all Widevine Device structures under a Structures class.
|
||||
- I removed the `send_key_control_nonce` flag from all Structures even though it was technically used.
|
||||
This is because the flag was never used as of this project, and I do not want to take up the flag slot.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Devices `dump()` function now uses the correct `type_` parameter when building the struct.
|
||||
- Fixed release date year of v1.0.0 and v1.0.1 in the changelog.
|
||||
|
||||
## [1.0.1] - 2022-07-21
|
||||
|
||||
### Added
|
||||
|
||||
- More information to the PyPI meta information, e.g., classifiers, readme, some URLs.
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the License Type parameter from the Cdm constructor to `get_license_challenge()`.
|
||||
- The Session ID is no longer used as the Request ID which could help with blocks or replay checks due
|
||||
to it being the same Session ID for each request. It's now a random 16 byte value each time.
|
||||
- Only the Context Data of each license request is now stored instead of the full message.
|
||||
- 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
|
||||
|
||||
- Removed unnecessary and unused `raw` Cdm class instance variable.
|
||||
- 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
|
||||
|
||||
- CDMs `set_service_certificate()` now correctly raises a DecodeError on Decode Error instead of a ValueError.
|
||||
- Context Data will now always match to their corresponding License Responses. This fixes an issue where creating
|
||||
a second challenge would overwrite the context data of the first challenge. Parsing the first challenge after
|
||||
would result in either a key decrypt error, or garbage key data.
|
||||
- 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.
|
||||
|
||||
[1.4.3]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.4.3
|
||||
[1.4.2]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.4.2
|
||||
[1.4.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.4.1
|
||||
[1.4.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.4.0
|
||||
[1.3.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.3.1
|
||||
[1.3.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.3.0
|
||||
[1.2.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.2.1
|
||||
[1.2.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.2.0
|
||||
[1.1.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.1.1
|
||||
[1.1.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.1.0
|
||||
[1.0.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.0.1
|
||||
[1.0.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.0.0
|
||||
### 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
|
||||
|
49
CONTRIBUTING.md
Normal file
49
CONTRIBUTING.md
Normal file
@ -0,0 +1,49 @@
|
||||
# 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.
|
115
README.md
115
README.md
@ -1,74 +1,58 @@
|
||||
<p align="center">
|
||||
<img src="docs/images/widevine_icon_24.png"> <a href="https://github.com/rlaphoenix/pywidevine">pywidevine</a>
|
||||
<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>
|
||||
<sup><em>Python Widevine CDM implementation</em></sup>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/rlaphoenix/pywidevine/actions/workflows/ci.yml">
|
||||
<img src="https://github.com/rlaphoenix/pywidevine/actions/workflows/ci.yml/badge.svg" alt="Build status">
|
||||
<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.7%2B-informational" alt="Python version">
|
||||
<img src="https://img.shields.io/badge/python-3.8%2B-informational" alt="Python version">
|
||||
</a>
|
||||
<a href="https://deepsource.io/gh/rlaphoenix/pywidevine">
|
||||
<img src="https://deepsource.io/gh/rlaphoenix/pywidevine.svg/?label=active+issues" alt="DeepSource">
|
||||
<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
|
||||
|
||||
- 🛡️ Security-first approach; All user input has Signatures verified
|
||||
- 👥 Remotely accessible Server/Client CDM code
|
||||
- 📦 Supports parsing and serialization of WVD (v2) provisions
|
||||
- 🛠️ Class for creation, parsing, and conversion of PSSH data
|
||||
- 🧩 Plug-and-play installation via PIP/PyPI
|
||||
- 🗃️ YAML configuration files
|
||||
- 🚀 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
|
||||
|
||||
*Note: Requires [Python] 3.7.0 or newer with PIP installed.*
|
||||
|
||||
```shell
|
||||
$ pip install pywidevine
|
||||
```
|
||||
|
||||
You now have the `pywidevine` package installed and a `pywidevine` executable is now available.
|
||||
Check it out with `pywidevine --help` - Voilà 🎉!
|
||||
> **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.
|
||||
|
||||
### From Source Code
|
||||
|
||||
The following steps are instructions on download, preparing, and running the code under a Poetry environment.
|
||||
You can skip steps 3-5 with a simple `pip install .` call instead, but you miss out on a wide array of benefits.
|
||||
|
||||
1. `git clone https://github.com/rlaphoenix/pywidevine`
|
||||
2. `cd pywidevine`
|
||||
3. (optional) `poetry config virtualenvs.in-project true`
|
||||
4. `poetry install`
|
||||
5. `poetry run pywidevine --help`
|
||||
|
||||
As seen in Step 5, running the `pywidevine` executable is somewhat different to a normal PIP installation.
|
||||
See [Poetry's Docs] on various ways of making calls under the virtual-environment.
|
||||
|
||||
[Python]: <https://python.org>
|
||||
[Poetry]: <https://python-poetry.org>
|
||||
[Poetry's Docs]: <https://python-poetry.org/docs/basic-usage/#using-your-virtual-environment>
|
||||
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. It gets a License for Bitmovin's
|
||||
Art of Motion Demo. There's various stuff 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!
|
||||
|
||||
Just take a look around the Cdm code to see what stuff does. Everything is documented quite well.
|
||||
There's also various functions in `main.py` that showcases a lot of features.
|
||||
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
|
||||
@ -108,15 +92,19 @@ for key in cdm.get_keys(session_id):
|
||||
cdm.close(session_id)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Executable `pywidevine` was not found
|
||||
|
||||
Make sure the Python installation's Scripts directory is added to your Path Environment Variable.
|
||||
|
||||
If this happened under a Poetry environment, make sure you use the appropriate Poetry-specific way of calling
|
||||
the executable. You may make this executable available globally by adding the .venv's Scripts folder to your
|
||||
Path Environment Variable.
|
||||
> **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.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
@ -159,11 +147,20 @@ 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.
|
||||
|
||||
## Credit
|
||||
## Contributors
|
||||
|
||||
<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 © Google.
|
||||
- The awesome community for their shared research and insight into the Widevine Protocol and Key Derivation.
|
||||
- Props to the awesome community for their shared research and insight into the Widevine Protocol and Key Derivation.
|
||||
|
||||
## License
|
||||
* * *
|
||||
|
||||
[GNU General Public License, Version 3.0](LICENSE)
|
||||
© rlaphoenix 2022-2023
|
||||
|
1536
poetry.lock
generated
1536
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -4,42 +4,84 @@ build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry]
|
||||
name = "pywidevine"
|
||||
version = "1.4.3"
|
||||
version = "1.8.0"
|
||||
description = "Widevine CDM (Content Decryption Module) implementation in Python."
|
||||
authors = ["rlaphoenix <rlaphoenix@pm.me>"]
|
||||
license = "GPL-3.0-only"
|
||||
authors = ["rlaphoenix <rlaphoenix@pm.me>"]
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/rlaphoenix/pywidevine"
|
||||
keywords = ["widevine", "drm", "google"]
|
||||
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"
|
||||
"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]
|
||||
"Bug Tracker" = "https://github.com/rlaphoenix/pywidevine/issues"
|
||||
"Forums" = "https://github.com/rlaphoenix/pywidevine/discussions"
|
||||
"Changelog" = "https://github.com/rlaphoenix/pywidevine/blob/master/CHANGELOG.md"
|
||||
"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"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
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"
|
||||
Unidecode = "^1.3.4"
|
||||
aiohttp = {version = "^3.8.1", optional = true}
|
||||
PyYAML = {version = "^6.0", optional = true}
|
||||
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", "PyYAML"]
|
||||
serve = ["aiohttp"]
|
||||
|
||||
[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
|
||||
|
@ -1 +1,8 @@
|
||||
__version__ = "1.4.3"
|
||||
from .cdm import *
|
||||
from .device import *
|
||||
from .key import *
|
||||
from .pssh import *
|
||||
from .remotecdm import *
|
||||
from .session import *
|
||||
|
||||
__version__ = "1.8.0"
|
||||
|
@ -7,45 +7,58 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Union, Optional
|
||||
from typing import Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
from Crypto.Cipher import AES, PKCS1_OAEP
|
||||
from Crypto.Hash import SHA1, HMAC, SHA256, CMAC
|
||||
from Crypto.Hash import CMAC, HMAC, SHA1, SHA256
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Random import get_random_bytes
|
||||
from Crypto.Signature import pss
|
||||
from Crypto.Util import Padding
|
||||
from google.protobuf.message import DecodeError
|
||||
|
||||
from pywidevine.device import Device
|
||||
from pywidevine.exceptions import TooManySessions, InvalidSession, InvalidLicenseType, SignatureMismatch, \
|
||||
InvalidInitData, InvalidLicenseMessage, NoKeysLoaded, InvalidContext
|
||||
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 DrmCertificate, SignedMessage, SignedDrmCertificate, LicenseType, \
|
||||
LicenseRequest, ProtocolVersion, ClientIdentification, EncryptedClientIdentification, License
|
||||
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
|
||||
|
||||
|
||||
class Cdm:
|
||||
system_id = b"\xed\xef\x8b\xa9\x79\xd6\x4a\xce\xa3\xc8\x27\xdc\xd5\x1d\x21\xed"
|
||||
uuid = UUID(bytes=system_id)
|
||||
uuid = UUID(bytes=b"\xed\xef\x8b\xa9\x79\xd6\x4a\xce\xa3\xc8\x27\xdc\xd5\x1d\x21\xed")
|
||||
urn = f"urn:uuid:{uuid}"
|
||||
key_format = urn
|
||||
service_certificate_challenge = b"\x08\x04"
|
||||
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=")
|
||||
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"
|
||||
@ -62,11 +75,11 @@ class Cdm:
|
||||
root_cert = DrmCertificate()
|
||||
root_cert.ParseFromString(root_signed_cert.drm_certificate)
|
||||
|
||||
MAX_NUM_OF_SESSIONS = 50 # most common limit
|
||||
MAX_NUM_OF_SESSIONS = 16
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_type: Union[Device.Types, str],
|
||||
device_type: Union[DeviceTypes, str],
|
||||
system_id: int,
|
||||
security_level: int,
|
||||
client_id: ClientIdentification,
|
||||
@ -76,9 +89,9 @@ class Cdm:
|
||||
if not device_type:
|
||||
raise ValueError("Device Type must be provided")
|
||||
if isinstance(device_type, str):
|
||||
device_type = Device.Types[device_type]
|
||||
if not isinstance(device_type, Device.Types):
|
||||
raise TypeError(f"Expected device_type to be a {Device.Types!r} not {device_type!r}")
|
||||
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")
|
||||
@ -151,7 +164,7 @@ class Cdm:
|
||||
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||
del self.__sessions[session_id]
|
||||
|
||||
def set_service_certificate(self, session_id: bytes, certificate: Optional[Union[bytes, str]]) -> str:
|
||||
def set_service_certificate(self, session_id: bytes, certificate: Optional[Union[bytes, str]]) -> Optional[str]:
|
||||
"""
|
||||
Set a Service Privacy Certificate for Privacy Mode. (optional but recommended)
|
||||
|
||||
@ -178,17 +191,22 @@ class Cdm:
|
||||
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.
|
||||
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:
|
||||
drm_certificate = DrmCertificate()
|
||||
drm_certificate.ParseFromString(session.service_certificate.drm_certificate)
|
||||
if session.service_certificate:
|
||||
drm_certificate = DrmCertificate()
|
||||
drm_certificate.ParseFromString(session.service_certificate.drm_certificate)
|
||||
provider_id = drm_certificate.provider_id
|
||||
else:
|
||||
provider_id = None
|
||||
session.service_certificate = None
|
||||
return drm_certificate.provider_id
|
||||
return provider_id
|
||||
|
||||
if isinstance(certificate, str):
|
||||
try:
|
||||
@ -200,19 +218,20 @@ class Cdm:
|
||||
|
||||
signed_message = SignedMessage()
|
||||
signed_drm_certificate = SignedDrmCertificate()
|
||||
drm_certificate = DrmCertificate()
|
||||
|
||||
try:
|
||||
signed_message.ParseFromString(certificate)
|
||||
if signed_message.SerializeToString() == 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")
|
||||
# Craft a SignedMessage as it's stored as a SignedMessage
|
||||
signed_message.Clear()
|
||||
signed_message.msg = signed_drm_certificate.SerializeToString()
|
||||
# we don't need to sign this message, this is normal
|
||||
except DecodeError as e:
|
||||
# could be a direct unsigned DrmCertificate, but reject those anyway
|
||||
raise DecodeError(f"Could not parse certificate as a SignedDrmCertificate, {e}")
|
||||
@ -226,13 +245,20 @@ class Cdm:
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
raise SignatureMismatch("Signature Mismatch on SignedDrmCertificate, rejecting certificate")
|
||||
else:
|
||||
session.service_certificate = signed_message
|
||||
drm_certificate = DrmCertificate()
|
||||
drm_certificate.ParseFromString(signed_drm_certificate.drm_certificate)
|
||||
return drm_certificate.provider_id
|
||||
|
||||
def get_service_certificate(self, session_id: bytes) -> Optional[SignedMessage]:
|
||||
try:
|
||||
drm_certificate.ParseFromString(signed_drm_certificate.drm_certificate)
|
||||
if drm_certificate.SerializeToString() != signed_drm_certificate.drm_certificate:
|
||||
raise DecodeError("partial parse")
|
||||
except DecodeError as e:
|
||||
raise DecodeError(f"Could not parse signed certificate's message as a DrmCertificate, {e}")
|
||||
|
||||
# must be stored as a SignedDrmCertificate as the signature needs to be kept for RemoteCdm
|
||||
# if we store as DrmCertificate (no signature) then RemoteCdm cannot verify the Certificate
|
||||
session.service_certificate = signed_drm_certificate
|
||||
return drm_certificate.provider_id
|
||||
|
||||
def get_service_certificate(self, session_id: bytes) -> Optional[SignedDrmCertificate]:
|
||||
"""
|
||||
Get the currently set Service Privacy Certificate of the Session.
|
||||
|
||||
@ -254,7 +280,7 @@ class Cdm:
|
||||
self,
|
||||
session_id: bytes,
|
||||
pssh: PSSH,
|
||||
type_: Union[int, str] = LicenseType.STREAMING,
|
||||
license_type: str = "STREAMING",
|
||||
privacy_mode: bool = True
|
||||
) -> bytes:
|
||||
"""
|
||||
@ -263,8 +289,10 @@ class Cdm:
|
||||
Parameters:
|
||||
session_id: Session identifier.
|
||||
pssh: PSSH Object to get the init data from.
|
||||
type_: Type of License you wish to exchange, often `STREAMING`. The `OFFLINE`
|
||||
Licenses are for Offline licensing of Downloaded content.
|
||||
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.
|
||||
|
||||
@ -287,17 +315,15 @@ class Cdm:
|
||||
if not isinstance(pssh, PSSH):
|
||||
raise InvalidInitData(f"Expected pssh to be a {PSSH}, not {pssh!r}")
|
||||
|
||||
try:
|
||||
if isinstance(type_, int):
|
||||
LicenseType.Name(int(type_))
|
||||
elif isinstance(type_, str):
|
||||
type_ = LicenseType.Value(type_)
|
||||
elif not isinstance(type_, LicenseType):
|
||||
raise InvalidLicenseType()
|
||||
except ValueError:
|
||||
raise InvalidLicenseType(f"License Type {type_!r} is invalid")
|
||||
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 == Device.Types.ANDROID:
|
||||
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
|
||||
@ -309,35 +335,36 @@ class Cdm:
|
||||
else:
|
||||
request_id = get_random_bytes(16)
|
||||
|
||||
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)
|
||||
|
||||
# pssh_data may be either a WidevineCencHeader or custom data
|
||||
# we have to assume the pssh.init_data value is valid, we cannot test
|
||||
license_request.content_id.widevine_pssh_data.pssh_data.append(pssh.init_data)
|
||||
license_request.content_id.widevine_pssh_data.license_type = type_
|
||||
license_request.content_id.widevine_pssh_data.request_id = request_id
|
||||
|
||||
if session.service_certificate and privacy_mode:
|
||||
# encrypt the client id for privacy mode
|
||||
license_request.encrypted_client_id.CopyFrom(self.encrypt_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
|
||||
))
|
||||
else:
|
||||
license_request.client_id.CopyFrom(self.__client_id)
|
||||
) 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.LICENSE_REQUEST
|
||||
license_message.msg = license_request.SerializeToString()
|
||||
license_message.signature = self.__signer.sign(SHA1.new(license_message.msg))
|
||||
signed_license_request = SignedMessage(
|
||||
type="LICENSE_REQUEST",
|
||||
msg=license_request,
|
||||
signature=self.__signer.sign(SHA1.new(license_request))
|
||||
).SerializeToString()
|
||||
|
||||
session.context[request_id] = self.derive_context(license_message.msg)
|
||||
session.context[request_id] = self.derive_context(license_request)
|
||||
|
||||
return license_message.SerializeToString()
|
||||
return signed_license_request
|
||||
|
||||
def parse_license(self, session_id: bytes, license_message: Union[SignedMessage, bytes, str]) -> None:
|
||||
"""
|
||||
@ -387,7 +414,7 @@ class Cdm:
|
||||
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.LICENSE:
|
||||
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."
|
||||
@ -405,9 +432,15 @@ class Cdm:
|
||||
key=self.__decrypter.decrypt(license_message.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. \
|
||||
new(mac_key_server, digestmod=SHA256). \
|
||||
update(licence.SerializeToString()). \
|
||||
update(license_message.oemcrypto_core_message or b""). \
|
||||
update(license_message.msg). \
|
||||
digest()
|
||||
|
||||
if license_message.signature != computed_signature:
|
||||
@ -460,7 +493,7 @@ class Cdm:
|
||||
output_file: Union[Path, str],
|
||||
temp_dir: Optional[Union[Path, str]] = None,
|
||||
exists_ok: bool = False
|
||||
):
|
||||
) -> int:
|
||||
"""
|
||||
Decrypt a Widevine-encrypted file using Shaka-packager.
|
||||
Shaka-packager is much more stable than mp4decrypt.
|
||||
@ -497,8 +530,7 @@ class Cdm:
|
||||
|
||||
input_file = Path(input_file)
|
||||
output_file = Path(output_file)
|
||||
if temp_dir:
|
||||
temp_dir = Path(temp_dir)
|
||||
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}")
|
||||
@ -532,27 +564,23 @@ class Cdm:
|
||||
])
|
||||
]
|
||||
|
||||
if temp_dir:
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
args.extend(["--temp_dir", temp_dir])
|
||||
if temp_dir_:
|
||||
temp_dir_.mkdir(parents=True, exist_ok=True)
|
||||
args.extend(["--temp_dir", str(temp_dir_)])
|
||||
|
||||
subprocess.check_call([executable, *args])
|
||||
return subprocess.check_call([executable, *args])
|
||||
|
||||
@staticmethod
|
||||
def encrypt_client_id(
|
||||
client_id: ClientIdentification,
|
||||
service_certificate: Union[SignedMessage, SignedDrmCertificate, DrmCertificate],
|
||||
key: bytes = None,
|
||||
iv: bytes = None
|
||||
service_certificate: Union[SignedDrmCertificate, DrmCertificate],
|
||||
key: Optional[bytes] = None,
|
||||
iv: Optional[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_drm_certificate = SignedDrmCertificate()
|
||||
signed_drm_certificate.ParseFromString(service_certificate.msg)
|
||||
service_certificate = signed_drm_certificate
|
||||
if isinstance(service_certificate, SignedDrmCertificate):
|
||||
drm_certificate = DrmCertificate()
|
||||
drm_certificate.ParseFromString(service_certificate.drm_certificate)
|
||||
@ -560,20 +588,19 @@ class Cdm:
|
||||
if not isinstance(service_certificate, DrmCertificate):
|
||||
raise ValueError(f"Expecting Service Certificate to be a DrmCertificate, not {service_certificate!r}")
|
||||
|
||||
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)). \
|
||||
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)).
|
||||
encrypt(privacy_key)
|
||||
enc_client_id.encrypted_client_id_iv = privacy_iv
|
||||
)
|
||||
|
||||
return enc_client_id
|
||||
return encrypted_client_id
|
||||
|
||||
@staticmethod
|
||||
def derive_context(message: bytes) -> tuple[bytes, bytes]:
|
||||
@ -628,4 +655,4 @@ class Cdm:
|
||||
return enc_key, mac_key_server, mac_key_client
|
||||
|
||||
|
||||
__ALL__ = (Cdm,)
|
||||
__all__ = ("Cdm",)
|
||||
|
@ -14,10 +14,10 @@ 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, FileHashes, SignedDrmCertificate, DrmCertificate
|
||||
from pywidevine.license_protocol_pb2 import ClientIdentification, DrmCertificate, FileHashes, SignedDrmCertificate
|
||||
|
||||
|
||||
class _Types(Enum):
|
||||
class DeviceTypes(Enum):
|
||||
CHROME = 1
|
||||
ANDROID = 2
|
||||
|
||||
@ -36,7 +36,7 @@ class _Structures:
|
||||
"version" / Const(Int8ub, 2),
|
||||
"type_" / CEnum(
|
||||
Int8ub,
|
||||
**{t.name: t.value for t in _Types}
|
||||
**{t.name: t.value for t in DeviceTypes}
|
||||
),
|
||||
"security_level" / Int8ub,
|
||||
"flags" / Padded(1, COptional(BitStruct(
|
||||
@ -55,7 +55,7 @@ class _Structures:
|
||||
"version" / Const(Int8ub, 1),
|
||||
"type_" / CEnum(
|
||||
Int8ub,
|
||||
**{t.name: t.value for t in _Types}
|
||||
**{t.name: t.value for t in DeviceTypes}
|
||||
),
|
||||
"security_level" / Int8ub,
|
||||
"flags" / Padded(1, COptional(BitStruct(
|
||||
@ -72,14 +72,13 @@ class _Structures:
|
||||
|
||||
|
||||
class Device:
|
||||
Types = _Types
|
||||
Structures = _Structures
|
||||
supported_structure = Structures.v2
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*_: Any,
|
||||
type_: Types,
|
||||
type_: DeviceTypes,
|
||||
security_level: int,
|
||||
flags: Optional[dict],
|
||||
private_key: Optional[bytes],
|
||||
@ -103,9 +102,9 @@ class Device:
|
||||
if not private_key:
|
||||
raise ValueError("Private Key is required, the WVD does not contain one or is malformed.")
|
||||
|
||||
self.type = self.Types[type_] if isinstance(type_, str) else type_
|
||||
self.type = DeviceTypes[type_] if isinstance(type_, str) else type_
|
||||
self.security_level = security_level
|
||||
self.flags = flags
|
||||
self.flags = flags or {}
|
||||
self.private_key = RSA.importKey(private_key)
|
||||
self.client_id = ClientIdentification()
|
||||
try:
|
||||
@ -199,36 +198,36 @@ class Device:
|
||||
raise ValueError("Device Data does not seem to be a WVD file (v0).")
|
||||
|
||||
if header.version == 1: # v1 to v2
|
||||
data = _Structures.v1.parse(data)
|
||||
data.version = 2 # update version to 2 to allow loading
|
||||
data.flags = Container() # blank flags that may have been used in v1
|
||||
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 data.vmp:
|
||||
if v1_struct.vmp:
|
||||
try:
|
||||
vmp.ParseFromString(data.vmp)
|
||||
if vmp.SerializeToString() != data.vmp:
|
||||
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}")
|
||||
data.vmp = vmp
|
||||
v1_struct.vmp = vmp
|
||||
|
||||
client_id = ClientIdentification()
|
||||
try:
|
||||
client_id.ParseFromString(data.client_id)
|
||||
if client_id.SerializeToString() != data.client_id:
|
||||
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 = data.vmp.SerializeToString()
|
||||
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
|
||||
data.client_id = client_id.SerializeToString()
|
||||
v1_struct.client_id = client_id.SerializeToString()
|
||||
|
||||
try:
|
||||
data = _Structures.v2.build(data)
|
||||
data = _Structures.v2.build(v1_struct)
|
||||
except ConstructError as e:
|
||||
raise ValueError(f"Migration failed, {e}")
|
||||
|
||||
@ -238,4 +237,4 @@ class Device:
|
||||
raise ValueError(f"Device Data seems to be corrupt or invalid, or migration failed, {e}")
|
||||
|
||||
|
||||
__ALL__ = (Device,)
|
||||
__all__ = ("Device", "DeviceTypes")
|
||||
|
@ -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.OPERATOR_SESSION:
|
||||
if key.type == License.KeyContainer.KeyType.Value("OPERATOR_SESSION"):
|
||||
for descriptor, value in key.operator_session_key_permissions.ListFields():
|
||||
if value == 1:
|
||||
permissions.append(descriptor.name)
|
||||
@ -61,3 +61,6 @@ class Key:
|
||||
kid += b"\x00" * (16 - len(kid))
|
||||
|
||||
return UUID(bytes=kid)
|
||||
|
||||
|
||||
__all__ = ("Key",)
|
||||
|
@ -1,11 +1,11 @@
|
||||
syntax = "proto2";
|
||||
|
||||
package video_widevine;
|
||||
package pywidevine_license_protocol;
|
||||
|
||||
// need this if we are using libprotobuf-cpp-2.3.0-lite
|
||||
option optimize_for = LITE_RUNTIME;
|
||||
|
||||
option java_package = "com.google.video.widevine.protos";
|
||||
option java_package = "com.rlaphoenix.pywidevine.protos";
|
||||
|
||||
enum LicenseType {
|
||||
STREAMING = 1;
|
||||
|
File diff suppressed because one or more lines are too long
607
pywidevine/license_protocol_pb2.pyi
Normal file
607
pywidevine/license_protocol_pb2.pyi
Normal file
@ -0,0 +1,607 @@
|
||||
# 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__ = []
|
@ -6,13 +6,15 @@ from zlib import crc32
|
||||
|
||||
import click
|
||||
import requests
|
||||
import yaml
|
||||
from construct import ConstructError
|
||||
from unidecode import unidecode, UnidecodeError
|
||||
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
|
||||
from pywidevine.license_protocol_pb2 import LicenseType, FileHashes
|
||||
from pywidevine.device import Device, DeviceTypes
|
||||
from pywidevine.license_protocol_pb2 import FileHashes, LicenseType
|
||||
from pywidevine.pssh import PSSH
|
||||
|
||||
|
||||
@ -24,27 +26,25 @@ 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
|
||||
if copyright_years != current_year:
|
||||
copyright_years = f"{copyright_years}-{current_year}"
|
||||
copyright_years = f"2022-{current_year}"
|
||||
|
||||
log.info("pywidevine version %s Copyright (c) %s rlaphoenix", __version__, copyright_years)
|
||||
log.info("https://github.com/rlaphoenix/pywidevine")
|
||||
log.info("https://github.com/devine-dl/pywidevine")
|
||||
if version:
|
||||
return
|
||||
|
||||
|
||||
@main.command(name="license")
|
||||
@click.argument("device", type=Path)
|
||||
@click.argument("pssh", type=str)
|
||||
@click.argument("device_path", type=Path)
|
||||
@click.argument("pssh", type=PSSH)
|
||||
@click.argument("server", type=str)
|
||||
@click.option("-t", "--type", "type_", type=click.Choice(LicenseType.keys(), case_sensitive=False),
|
||||
@click.option("-t", "--type", "license_type", type=click.Choice(LicenseType.keys(), case_sensitive=False),
|
||||
default="STREAMING",
|
||||
help="License Type to Request.")
|
||||
@click.option("-p", "--privacy", is_flag=True, default=False,
|
||||
help="Use Privacy Mode, off by default.")
|
||||
def license_(device: Path, pssh: str, server: str, type_: str, privacy: bool):
|
||||
def license_(device_path: Path, pssh: PSSH, server: str, license_type: str, privacy: bool) -> None:
|
||||
"""
|
||||
Make a License Request for PSSH to SERVER using DEVICE.
|
||||
It will return a list of all keys within the returned license.
|
||||
@ -63,11 +63,8 @@ def license_(device: Path, pssh: str, server: str, type_: str, privacy: bool):
|
||||
"""
|
||||
log = logging.getLogger("license")
|
||||
|
||||
# prepare pssh
|
||||
pssh = PSSH(pssh)
|
||||
|
||||
# load device
|
||||
device = Device.load(device)
|
||||
device = Device.load(device_path)
|
||||
log.info("[+] Loaded Device (%s L%s)", device.system_id, device.security_level)
|
||||
log.debug(device)
|
||||
|
||||
@ -82,37 +79,36 @@ def license_(device: Path, pssh: str, server: str, type_: str, privacy: bool):
|
||||
|
||||
if privacy:
|
||||
# get service cert for license server via cert challenge
|
||||
service_cert = requests.post(
|
||||
service_cert_res = requests.post(
|
||||
url=server,
|
||||
data=cdm.service_certificate_challenge
|
||||
)
|
||||
if service_cert.status_code != 200:
|
||||
if service_cert_res.status_code != 200:
|
||||
log.error(
|
||||
"[-] Failed to get Service Privacy Certificate: [%s] %s",
|
||||
service_cert.status_code,
|
||||
service_cert.text
|
||||
service_cert_res.status_code,
|
||||
service_cert_res.text
|
||||
)
|
||||
return
|
||||
service_cert = service_cert.content
|
||||
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)
|
||||
log.debug(service_cert)
|
||||
|
||||
# get license challenge
|
||||
license_type = LicenseType.Value(type_)
|
||||
challenge = cdm.get_license_challenge(session_id, pssh, license_type, privacy_mode=True)
|
||||
log.info("[+] Created License Request Message (Challenge)")
|
||||
log.debug(challenge)
|
||||
|
||||
# send license challenge
|
||||
licence = requests.post(
|
||||
license_res = requests.post(
|
||||
url=server,
|
||||
data=challenge
|
||||
)
|
||||
if licence.status_code != 200:
|
||||
log.error("[-] Failed to send challenge: [%s] %s", licence.status_code, licence.text)
|
||||
if license_res.status_code != 200:
|
||||
log.error("[-] Failed to send challenge: [%s] %s", license_res.status_code, license_res.text)
|
||||
return
|
||||
licence = licence.content
|
||||
licence = license_res.content
|
||||
log.info("[+] Got License Message")
|
||||
log.debug(licence)
|
||||
|
||||
@ -133,7 +129,7 @@ def license_(device: Path, pssh: str, server: str, type_: str, privacy: bool):
|
||||
@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):
|
||||
def test(ctx: click.Context, device: Path, privacy: bool) -> None:
|
||||
"""
|
||||
Test the CDM code by getting Content Keys for Bitmovin's Art of Motion example.
|
||||
https://bitmovin.com/demos/drm
|
||||
@ -144,8 +140,8 @@ def test(ctx: click.Context, device: Path, privacy: bool):
|
||||
"""
|
||||
# The PSSH is the same for all tracks both video and audio.
|
||||
# However, this might not be the case for all services/manifests.
|
||||
pssh = "AAAAW3Bzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAADsIARIQ62dqu8s0Xpa" \
|
||||
"7z2FmMPGj2hoNd2lkZXZpbmVfdGVzdCIQZmtqM2xqYVNkZmFsa3IzaioCSEQyAA=="
|
||||
pssh = 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.
|
||||
@ -153,28 +149,28 @@ def test(ctx: click.Context, device: Path, privacy: bool):
|
||||
|
||||
# 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 = LicenseType.STREAMING
|
||||
license_type = "STREAMING"
|
||||
|
||||
# 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=device,
|
||||
device_path=device,
|
||||
pssh=pssh,
|
||||
server=license_server,
|
||||
type_=LicenseType.Name(license_type),
|
||||
license_type=license_type,
|
||||
privacy=privacy
|
||||
)
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.option("-t", "--type", "type_", type=click.Choice([x.name for x in Device.Types], case_sensitive=False),
|
||||
@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 Directory")
|
||||
@click.option("-o", "--output", type=Path, default=None, help="Output Path or Directory")
|
||||
@click.pass_context
|
||||
def create_device(
|
||||
ctx: click.Context,
|
||||
@ -199,7 +195,7 @@ def create_device(
|
||||
log = logging.getLogger("create-device")
|
||||
|
||||
device = Device(
|
||||
type_=Device.Types[type_.upper()],
|
||||
type_=DeviceTypes[type_.upper()],
|
||||
security_level=level,
|
||||
flags=None,
|
||||
private_key=key.read_bytes(),
|
||||
@ -228,7 +224,19 @@ def create_device(
|
||||
except UnidecodeError as e:
|
||||
raise click.ClickException(f"Failed to sanitize name, {e}")
|
||||
|
||||
out_path = (output or Path.cwd()) / f"{name}_{device.system_id}_l{device.security_level}.wvd"
|
||||
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)
|
||||
@ -247,6 +255,85 @@ def create_device(
|
||||
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
|
||||
@ -289,10 +376,10 @@ def migrate(ctx: click.Context, path: Path) -> None:
|
||||
|
||||
|
||||
@main.command("serve", short_help="Serve your local CDM and Widevine Devices Remotely.")
|
||||
@click.argument("config", type=Path)
|
||||
@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, host: str, port: int):
|
||||
def serve_(config_path: Path, host: str, port: int) -> None:
|
||||
"""
|
||||
Serve your local CDM and Widevine Devices Remotely.
|
||||
|
||||
@ -304,8 +391,8 @@ def serve_(config: Path, host: str, port: int):
|
||||
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
|
||||
import yaml
|
||||
from pywidevine import serve # isort:skip
|
||||
import yaml # isort:skip
|
||||
|
||||
config = yaml.safe_load(config.read_text(encoding="utf8"))
|
||||
config = yaml.safe_load(config_path.read_text(encoding="utf8"))
|
||||
serve.run(config, host, port)
|
||||
|
@ -3,34 +3,44 @@ from __future__ import annotations
|
||||
import base64
|
||||
import binascii
|
||||
import string
|
||||
from typing import Union, Optional
|
||||
from io import BytesIO
|
||||
from typing import Optional, 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:
|
||||
"""PSSH-related utilities. Somewhat Widevine-biased."""
|
||||
"""
|
||||
MP4 PSSH Box-related utilities.
|
||||
Allows you to load, create, and modify various kinds of DRM system headers.
|
||||
"""
|
||||
|
||||
class SystemId:
|
||||
Widevine = UUID(bytes=b"\xed\xef\x8b\xa9\x79\xd6\x4a\xce\xa3\xc8\x27\xdc\xd5\x1d\x21\xed")
|
||||
PlayReady = UUID(bytes=b"\x9a\x04\xf0\x79\x98\x40\x42\x86\xab\x92\xe6\x5b\xe0\x88\x5f\x95")
|
||||
Widevine = UUID(hex="edef8ba979d64acea3c827dcd51d21ed")
|
||||
PlayReady = UUID(hex="9a04f07998404286ab92e65be0885f95")
|
||||
|
||||
def __init__(self, data: Union[Container, str, bytes], strict: bool = False):
|
||||
"""
|
||||
Load a PSSH box or Widevine Cenc Header data as a new v0 PSSH box.
|
||||
Load a PSSH box, WidevineCencHeader, or PlayReadyHeader.
|
||||
|
||||
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.
|
||||
|
||||
[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)]
|
||||
|
||||
@ -72,26 +82,41 @@ class PSSH:
|
||||
box = Box.parse(data)
|
||||
except (IOError, construct.ConstructError): # not a box
|
||||
try:
|
||||
cenc_header = WidevinePsshData()
|
||||
cenc_header.ParseFromString(data)
|
||||
cenc_header = cenc_header.SerializeToString()
|
||||
if cenc_header != data: # not actually a WidevinePsshData
|
||||
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(
|
||||
type=b"pssh",
|
||||
version=0,
|
||||
flags=0,
|
||||
system_ID=PSSH.SystemId.Widevine,
|
||||
init_data=data_serialized
|
||||
)))
|
||||
except DecodeError: # not a widevine cenc header
|
||||
if strict:
|
||||
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}.")
|
||||
# Data is not a Widevine Cenc Header, it's something custom.
|
||||
# The license server likely has something custom to parse it.
|
||||
# See doc-string about Lenient mode for more information.
|
||||
cenc_header = data
|
||||
|
||||
box = Box.parse(Box.build(dict(
|
||||
type=b"pssh",
|
||||
version=0,
|
||||
flags=0,
|
||||
system_ID=PSSH.SystemId.Widevine,
|
||||
init_data=cenc_header
|
||||
)))
|
||||
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
|
||||
@ -99,15 +124,27 @@ class PSSH:
|
||||
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}")
|
||||
|
||||
@ -133,24 +170,6 @@ class PSSH:
|
||||
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 key_ids is not None:
|
||||
# ensure key_ids are bytes, supports hex, base64, and bytes
|
||||
key_ids = [
|
||||
(
|
||||
x.bytes if isinstance(x, UUID) else
|
||||
bytes.fromhex(x) if all(c in string.hexdigits for c in x) else
|
||||
base64.b64decode(x) if isinstance(x, str) else
|
||||
x
|
||||
)
|
||||
for x in key_ids
|
||||
]
|
||||
if not all(isinstance(x, bytes) for x in key_ids):
|
||||
not_bytes = [x for x in key_ids if not isinstance(x, bytes)]
|
||||
raise TypeError(
|
||||
"Expected all of key_ids to be a UUID, hex, base64, or bytes, but one or more are not, "
|
||||
f"{not_bytes!r}"
|
||||
)
|
||||
|
||||
if init_data is not None:
|
||||
if isinstance(init_data, WidevinePsshData):
|
||||
init_data = init_data.SerializeToString()
|
||||
@ -164,19 +183,22 @@ class PSSH:
|
||||
f"Expecting init_data to be {WidevinePsshData}, hex, base64, or bytes, not {init_data!r}"
|
||||
)
|
||||
|
||||
box = Box.parse(Box.build(dict(
|
||||
pssh = cls(Box.parse(Box.build(dict(
|
||||
type=b"pssh",
|
||||
version=version,
|
||||
flags=flags,
|
||||
system_ID=PSSH.SystemId.Widevine,
|
||||
key_ids=[key_ids, b""][key_ids is None],
|
||||
system_ID=system_id,
|
||||
init_data=[init_data, b""][init_data is None]
|
||||
)))
|
||||
# key_IDs should not be set yet
|
||||
))))
|
||||
|
||||
pssh = cls(box)
|
||||
|
||||
if key_ids and version == 0:
|
||||
pssh.set_key_ids([UUID(bytes=x) for x in key_ids])
|
||||
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
|
||||
|
||||
@ -186,9 +208,9 @@ class PSSH:
|
||||
Get all Key IDs from within the Box or Init Data, wherever possible.
|
||||
|
||||
Supports:
|
||||
- Version 1 Boxes
|
||||
- Widevine Headers
|
||||
- PlayReady Headers (4.0.0.0->4.3.0.0)
|
||||
- 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
|
||||
@ -199,28 +221,63 @@ class PSSH:
|
||||
cenc_header.ParseFromString(self.init_data)
|
||||
return [
|
||||
# the key_ids value may or may not be hex underlying
|
||||
UUID(bytes=key_id) if len(key_id) == 16 else UUID(hex=key_id.decode())
|
||||
(
|
||||
UUID(bytes=key_id) if len(key_id) == 16 else # normal
|
||||
UUID(hex=key_id.decode()) if len(key_id) == 32 else # stored as hex
|
||||
UUID(int=int.from_bytes(key_id, "big")) # assuming as number
|
||||
)
|
||||
for key_id in cenc_header.key_ids
|
||||
]
|
||||
|
||||
if self.system_id == PSSH.SystemId.PlayReady:
|
||||
xml_string = self.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
|
||||
]
|
||||
# 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()}")
|
||||
|
||||
@ -231,7 +288,7 @@ class PSSH:
|
||||
version=self.version,
|
||||
flags=self.flags,
|
||||
system_ID=self.system_id,
|
||||
key_IDs=self.key_ids,
|
||||
key_IDs=self.key_ids if self.version == 1 and self.key_ids else None,
|
||||
init_data=self.init_data
|
||||
))
|
||||
|
||||
@ -239,7 +296,7 @@ class PSSH:
|
||||
"""Export the PSSH object as a full PSSH box in base64 form."""
|
||||
return base64.b64encode(self.dump()).decode()
|
||||
|
||||
def playready_to_widevine(self) -> None:
|
||||
def to_widevine(self) -> None:
|
||||
"""
|
||||
Convert PlayReady PSSH data to Widevine PSSH data.
|
||||
|
||||
@ -247,45 +304,139 @@ class PSSH:
|
||||
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.PlayReady:
|
||||
raise ValueError(f"This is not a PlayReady PSSH, {self.system_id}")
|
||||
if self.system_id == PSSH.SystemId.Widevine:
|
||||
raise ValueError("This is already a Widevine PSSH")
|
||||
|
||||
cenc_header = WidevinePsshData()
|
||||
cenc_header.algorithm = 1 # 0=Clear, 1=AES-CTR
|
||||
cenc_header.key_ids[:] = [x.bytes for x in self.key_ids]
|
||||
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 = cenc_header.SerializeToString()
|
||||
self.init_data = widevine_pssh_data.SerializeToString()
|
||||
self.system_id = PSSH.SystemId.Widevine
|
||||
|
||||
def set_key_ids(self, key_ids: list[UUID]) -> None:
|
||||
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}.")
|
||||
|
||||
if not isinstance(key_ids, list):
|
||||
raise TypeError(f"Expecting key_ids to be a list, not {key_ids!r}")
|
||||
|
||||
if not all(isinstance(x, UUID) for x in key_ids):
|
||||
not_uuid = [x for x in key_ids if not isinstance(x, UUID)]
|
||||
raise TypeError(f"All Key IDs in key_ids must be a {UUID}, not {not_uuid}")
|
||||
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_ids
|
||||
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_ids
|
||||
for key_id in key_id_uuids
|
||||
]
|
||||
|
||||
self.init_data = cenc_header.SerializeToString()
|
||||
|
||||
@staticmethod
|
||||
def parse_key_ids(key_ids: list[Union[UUID, str, bytes]]) -> list[UUID]:
|
||||
"""
|
||||
Parse a list of Key IDs in hex, base64, or bytes to UUIDs.
|
||||
|
||||
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.
|
||||
"""
|
||||
if not isinstance(key_ids, list):
|
||||
raise TypeError(f"Expected key_ids to be a list, not {key_ids!r}")
|
||||
|
||||
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
|
||||
]
|
||||
]
|
||||
|
||||
return uuids
|
||||
|
||||
|
||||
__all__ = ("PSSH",)
|
||||
|
0
pywidevine/py.typed
Normal file
0
pywidevine/py.typed
Normal file
@ -3,21 +3,21 @@ from __future__ import annotations
|
||||
import base64
|
||||
import binascii
|
||||
import re
|
||||
from typing import Union, Optional
|
||||
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
|
||||
from pywidevine.exceptions import InvalidInitData, InvalidLicenseType, InvalidLicenseMessage, DeviceMismatch, \
|
||||
SignatureMismatch
|
||||
from pywidevine.key import Key
|
||||
|
||||
from pywidevine.license_protocol_pb2 import LicenseType, SignedMessage, License, ClientIdentification, \
|
||||
SignedDrmCertificate
|
||||
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
|
||||
|
||||
|
||||
@ -26,7 +26,7 @@ class RemoteCdm(Cdm):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_type: Union[Device.Types, str],
|
||||
device_type: Union[DeviceTypes, str],
|
||||
system_id: int,
|
||||
security_level: int,
|
||||
host: str,
|
||||
@ -37,9 +37,9 @@ class RemoteCdm(Cdm):
|
||||
if not device_type:
|
||||
raise ValueError("Device Type must be provided")
|
||||
if isinstance(device_type, str):
|
||||
device_type = Device.Types[device_type]
|
||||
if not isinstance(device_type, Device.Types):
|
||||
raise TypeError(f"Expected device_type to be a {Device.Types!r} not {device_type!r}")
|
||||
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")
|
||||
@ -86,10 +86,10 @@ class RemoteCdm(Cdm):
|
||||
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.search(r"pywidevine serve v([\d.]+)", server, re.IGNORECASE)
|
||||
if not server_version:
|
||||
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.group(1)
|
||||
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.")
|
||||
|
||||
@ -143,7 +143,7 @@ class RemoteCdm(Cdm):
|
||||
|
||||
return r["provider_id"]
|
||||
|
||||
def get_service_certificate(self, session_id: bytes) -> Optional[SignedMessage]:
|
||||
def get_service_certificate(self, session_id: bytes) -> Optional[SignedDrmCertificate]:
|
||||
r = self.__session.post(
|
||||
url=f"{self.host}/{self.device_name}/get_service_certificate",
|
||||
json={
|
||||
@ -159,21 +159,12 @@ class RemoteCdm(Cdm):
|
||||
return None
|
||||
|
||||
service_certificate = base64.b64decode(service_certificate)
|
||||
signed_message = SignedMessage()
|
||||
signed_drm_certificate = SignedDrmCertificate()
|
||||
|
||||
try:
|
||||
signed_message.ParseFromString(service_certificate)
|
||||
if signed_message.SerializeToString() == service_certificate:
|
||||
signed_drm_certificate.ParseFromString(signed_message.msg)
|
||||
else:
|
||||
signed_drm_certificate.ParseFromString(service_certificate)
|
||||
if signed_drm_certificate.SerializeToString() != service_certificate:
|
||||
raise DecodeError("partial parse")
|
||||
# Craft a SignedMessage as it's stored as a SignedMessage
|
||||
signed_message.Clear()
|
||||
signed_message.msg = signed_drm_certificate.SerializeToString()
|
||||
# we don't need to sign this message, this is normal
|
||||
signed_drm_certificate.ParseFromString(service_certificate)
|
||||
if signed_drm_certificate.SerializeToString() != service_certificate:
|
||||
raise DecodeError("partial parse")
|
||||
except DecodeError as e:
|
||||
# could be a direct unsigned DrmCertificate, but reject those anyway
|
||||
raise DecodeError(f"Could not parse certificate as a SignedDrmCertificate, {e}")
|
||||
@ -187,14 +178,14 @@ class RemoteCdm(Cdm):
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
raise SignatureMismatch("Signature Mismatch on SignedDrmCertificate, rejecting certificate")
|
||||
else:
|
||||
return signed_message
|
||||
|
||||
return signed_drm_certificate
|
||||
|
||||
def get_license_challenge(
|
||||
self,
|
||||
session_id: bytes,
|
||||
pssh: PSSH,
|
||||
type_: Union[int, str] = LicenseType.STREAMING,
|
||||
license_type: str = "STREAMING",
|
||||
privacy_mode: bool = True
|
||||
) -> bytes:
|
||||
if not pssh:
|
||||
@ -202,20 +193,16 @@ class RemoteCdm(Cdm):
|
||||
if not isinstance(pssh, PSSH):
|
||||
raise InvalidInitData(f"Expected pssh to be a {PSSH}, not {pssh!r}")
|
||||
|
||||
try:
|
||||
if isinstance(type_, int):
|
||||
type_ = LicenseType.Name(int(type_))
|
||||
elif isinstance(type_, str):
|
||||
type_ = LicenseType.Name(LicenseType.Value(type_))
|
||||
elif isinstance(type_, LicenseType):
|
||||
type_ = LicenseType.Name(type_)
|
||||
else:
|
||||
raise InvalidLicenseType()
|
||||
except ValueError:
|
||||
raise InvalidLicenseType(f"License Type {type_!r} is invalid")
|
||||
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/{type_}",
|
||||
url=f"{self.host}/{self.device_name}/get_license_challenge/{license_type}",
|
||||
json={
|
||||
"session_id": session_id.hex(),
|
||||
"init_data": pssh.dumps(),
|
||||
@ -260,7 +247,7 @@ class RemoteCdm(Cdm):
|
||||
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.LICENSE:
|
||||
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."
|
||||
@ -310,4 +297,4 @@ class RemoteCdm(Cdm):
|
||||
]
|
||||
|
||||
|
||||
__ALL__ = (RemoteCdm,)
|
||||
__all__ = ("RemoteCdm",)
|
||||
|
@ -1,8 +1,9 @@
|
||||
import base64
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from aiohttp.typedefs import Handler
|
||||
from google.protobuf.message import DecodeError
|
||||
|
||||
from pywidevine.pssh import PSSH
|
||||
@ -20,14 +21,14 @@ except ImportError:
|
||||
from pywidevine import __version__
|
||||
from pywidevine.cdm import Cdm
|
||||
from pywidevine.device import Device
|
||||
from pywidevine.exceptions import TooManySessions, InvalidSession, SignatureMismatch, InvalidInitData, \
|
||||
InvalidLicenseType, InvalidLicenseMessage, InvalidContext
|
||||
from pywidevine.exceptions import (InvalidContext, InvalidInitData, InvalidLicenseMessage, InvalidLicenseType,
|
||||
InvalidSession, SignatureMismatch, TooManySessions)
|
||||
|
||||
routes = web.RouteTableDef()
|
||||
|
||||
|
||||
async def _startup(app: web.Application):
|
||||
app["cdms"]: dict[tuple[str, str], Cdm] = {}
|
||||
async def _startup(app: web.Application) -> None:
|
||||
app["cdms"] = {}
|
||||
app["config"]["devices"] = {
|
||||
path.stem: path
|
||||
for x in app["config"]["devices"]
|
||||
@ -38,7 +39,7 @@ async def _startup(app: web.Application):
|
||||
raise FileNotFoundError(f"Device file does not exist: {device}")
|
||||
|
||||
|
||||
async def _cleanup(app: web.Application):
|
||||
async def _cleanup(app: web.Application) -> None:
|
||||
app["cdms"].clear()
|
||||
del app["cdms"]
|
||||
app["config"].clear()
|
||||
@ -46,7 +47,7 @@ async def _cleanup(app: web.Application):
|
||||
|
||||
|
||||
@routes.get("/")
|
||||
async def ping(_) -> web.Response:
|
||||
async def ping(_: Any) -> web.Response:
|
||||
return web.json_response({
|
||||
"status": 200,
|
||||
"message": "Pong!"
|
||||
@ -211,13 +212,15 @@ async def get_service_certificate(request: web.Request) -> web.Response:
|
||||
}, status=400)
|
||||
|
||||
if service_certificate:
|
||||
service_certificate = base64.b64encode(service_certificate.SerializeToString()).decode()
|
||||
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
|
||||
"service_certificate": service_certificate_b64
|
||||
}
|
||||
})
|
||||
|
||||
@ -267,7 +270,7 @@ async def get_license_challenge(request: web.Request) -> web.Response:
|
||||
license_request = cdm.get_license_challenge(
|
||||
session_id=session_id,
|
||||
pssh=init_data,
|
||||
type_=license_type,
|
||||
license_type=license_type,
|
||||
privacy_mode=privacy_mode
|
||||
)
|
||||
except InvalidSession:
|
||||
@ -366,7 +369,7 @@ async def get_keys(request: web.Request) -> web.Response:
|
||||
session_id = bytes.fromhex(body["session_id"])
|
||||
|
||||
# get key type
|
||||
key_type = request.match_info["key_type"]
|
||||
key_type: Optional[str] = request.match_info["key_type"]
|
||||
if key_type == "ALL":
|
||||
key_type = None
|
||||
|
||||
@ -414,26 +417,24 @@ async def get_keys(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def authentication(request: web.Request, handler) -> web.Response:
|
||||
response = None
|
||||
if request.path != "/":
|
||||
secret_key = request.headers.get("X-Secret-Key")
|
||||
if 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 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)
|
||||
async def authentication(request: web.Request, handler: Handler) -> web.Response:
|
||||
secret_key = request.headers.get("X-Secret-Key")
|
||||
|
||||
if response is None:
|
||||
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)
|
||||
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({
|
||||
@ -442,13 +443,13 @@ async def authentication(request: web.Request, handler) -> web.Response:
|
||||
}, status=500)
|
||||
|
||||
response.headers.update({
|
||||
"Server": f"https://github.com/rlaphoenix/pywidevine serve v{__version__}"
|
||||
"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):
|
||||
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)
|
||||
|
@ -3,13 +3,16 @@ from typing import Optional
|
||||
from Crypto.Random import get_random_bytes
|
||||
|
||||
from pywidevine.key import Key
|
||||
from pywidevine.license_protocol_pb2 import SignedMessage
|
||||
from pywidevine.license_protocol_pb2 import SignedDrmCertificate
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, number: int):
|
||||
self.number = number
|
||||
self.id = get_random_bytes(16)
|
||||
self.service_certificate: Optional[SignedMessage] = None
|
||||
self.service_certificate: Optional[SignedDrmCertificate] = None
|
||||
self.context: dict[bytes, tuple[bytes, bytes]] = {}
|
||||
self.keys: list[Key] = []
|
||||
|
||||
|
||||
__all__ = ("Session",)
|
||||
|
@ -4,7 +4,7 @@
|
||||
# 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\rlaphoenix\Documents\WVDs\test_device_001.wvd'
|
||||
- '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:
|
||||
|
Loading…
x
Reference in New Issue
Block a user