mirror of
https://github.com/devine-dl/pywidevine.git
synced 2025-04-29 22:24:36 +02:00
Compare commits
62 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 |
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"
|
||||
]
|
||||
}
|
570
CHANGELOG.md
570
CHANGELOG.md
@ -5,8 +5,114 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.8.0] - 2023-12-22
|
||||
|
||||
### Added
|
||||
|
||||
- Added `py.typed` file to support PEP561 and silence Mypy.
|
||||
|
||||
### Changed
|
||||
|
||||
- Dropped support for Python 3.7.
|
||||
- Recompiled protobuffers for version 4.25.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Missing `yaml` dependency as it was only installed alongside the `serve` extras group.
|
||||
- Duplicate Concatenated SignedMessages no longer throw a verification failure in `Cdm.set_service_certificate()`.
|
||||
To ensure security of the messages, verification will still fail if any of the SignedMessages do not match each other.
|
||||
|
||||
### New Contributors
|
||||
|
||||
- [sr0lle](https://github.com/sr0lle)
|
||||
|
||||
## [1.7.0] - 2023-11-21
|
||||
|
||||
- Supported Serve API: `v1.4.3` or newer
|
||||
|
||||
### Added
|
||||
|
||||
- Ability to specify output filename by specifying a full path or a relative file name in CLI command `create-device`.
|
||||
- Add the staging privacy certificate (`staging.google.com`) to `Cdm.staging_privacy_cert`.
|
||||
- Similar to `common_privacy_cert` which would be used on Google's production license server,
|
||||
- Though this one is used on Google's staging license server (a production-ready testing server).
|
||||
|
||||
### Changed
|
||||
|
||||
- Raise an error if a file already exists at the output path in CLI command `create-device`.
|
||||
- Use std-lib xml instead of lxml to reduce dependencies and support ARM (#35).
|
||||
- Lessen restriction on Python version to any Python version `>=3.7`, but `<4.0`.
|
||||
- I was hoping to do `^3.7`, but some dependencies also require `<4.0` therefore I cannot, for now.
|
||||
- Move Key ID parsing to static `PSSH.parse_key_ids()` method.
|
||||
- The `shaka-packager` subprocess call's return code is now returned from `Cdm.decrypt()`.
|
||||
- The flags variable of a `Device` now defaults to a dict, even if not set.
|
||||
- Heavily improve initializing of protobuf objects, improving readability, typing, and linting quite a bit.
|
||||
- Renamed Device's `_Types` enum class to `DeviceTypes`.
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed `Device.Types` class variable alias to `_Types` enum class as a static linter cannot recognize a class
|
||||
variable as a type. Instead, the actual `_Types` (now named `DeviceTypes`) enum should be imported and used instead.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Ensure output directory exists before creating new `.wvd` files in CLI command `create-device`.
|
||||
- Ignore empty Key ID values in v4.0.0.0 PlayReadyHeaders.
|
||||
- Remove `Cdm.system_id` class variable as it conflicted with the `cdm.system_id` class instance variable of the same
|
||||
name. It's also generally not needed. The same data can be gotten via `Cdm.uuid.bytes`.
|
||||
- Casting of `type_` when passed a non-int value in `Cdm.get_license_challenge()`.
|
||||
- Pass a PSSH object in `test` CLI command instead of a string.
|
||||
- Lower-case and setup `__all__` correctly, add missing `__all__` in some of the modules.
|
||||
- For the longest time I thought it was `__ALL__` and an iterable of objects/variables.
|
||||
- However, its actually `__all__` and explicitly a list of Strings...
|
||||
|
||||
### New Contributors
|
||||
|
||||
- [mediaminister](https://github.com/mediaminister)
|
||||
|
||||
## [1.6.0] - 2023-02-03
|
||||
|
||||
- Supported Serve API: `v1.4.3` or newer
|
||||
|
||||
### Added
|
||||
|
||||
- Support Python 3.11.
|
||||
- New CLI command `export-device` to export WVD files back as files. I.e., a private key and client ID blob file.
|
||||
|
||||
## [1.5.3] - 2022-12-27
|
||||
|
||||
- Supported Serve API: `v1.4.3` or newer
|
||||
|
||||
### Added
|
||||
|
||||
- New utility `load_xml()` to parse XML data with lxml ignoring Namespaces.
|
||||
- PSSH class now have `__str__` and `__repr__` methods to print the object in more Human-friendly ways.
|
||||
- `str(pssh)` is now identical to `pssh.dumps()`.
|
||||
- `repr(pssh)` or just `pssh` in some cases will result in a nice overview of the PSSHs contents.
|
||||
- New `to_playready()` method to convert Widevine PSSH Data to PlayReady PSSH Data. Please note that the
|
||||
Checksums for AES-CTR and COCKTAIL KIDs cannot be calculated as the Content Encryption Key would be needed.
|
||||
|
||||
### Changed
|
||||
|
||||
- The System ID must now be explicitly specified when creating a new PSSH box in `PSSH.new()`.
|
||||
- This allows you to now create PlayReady PSSH boxes.
|
||||
- The `playready_to_widevine()` method has been renamed to just `to_widevine()`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Correct capitalization of the `key_IDs` field when making the new box in `PSSH.new()`.
|
||||
- Correct the value type of `key_IDs` value when creating a new box in `PSSH.new()`.
|
||||
- Ensure Key IDs are list of UUIDs instead of bytes in `PSSH.new()`.
|
||||
- Create v0 PSSH boxes by only setting the `key_IDs` field when the version is set to `1` in `PSSH.new()`.
|
||||
- Fix loading of PlayReadyHeaders (and PlayReadyObjects) as PSSH boxes. It would previously load it under the
|
||||
Widevine SystemID breaking all PlayReady-specific code after construction.
|
||||
- Parse Key IDs within PlayReadyHeaders by using the new `load_xml()` utility to ignore namespaces so that `xpath` can
|
||||
correctly locate any and all KID tags.
|
||||
- Support parsing PlayReadyObjects with more than one PlayReadyHeader (more than one record).
|
||||
## [1.5.2] - 2022-10-11
|
||||
|
||||
- Supported Serve API: `v1.4.3` or newer
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed license signature calculation for newer Widevine Server licenses on OEM Crypto v16.0.0 or newer.
|
||||
@ -14,337 +120,397 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [1.5.1] - 2022-10-23
|
||||
|
||||
- Supported Serve API: `v1.4.3` or newer
|
||||
|
||||
### Added
|
||||
|
||||
- Added import path shortcuts in the `__init__.py` package constructor to all the user classes. Now you can do e.g.,
|
||||
`from pywidevine import PSSH` instead of `from pywidevine.pssh import PSSH`. You can still do it both ways.
|
||||
- Improved error handling and sanitization checks when parsing some Service Certificates in `set_service_certificate()`.
|
||||
- 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
|
||||
|
||||
- Maximum concurrent Cdm sessions are now set to 16 as it seems tto be a more common limit on more up-to-date CDMs,
|
||||
including Android's OEMCrypto Library. This also helps encourage people to close their sessions when they are no
|
||||
longer required.
|
||||
- Service Certificates are now stored in the session as a `SignedDrmCertificate`. This is to keep the signature with
|
||||
the stored Certificate for use by the user if necessary. It also reduces code repetition relating to the usage of the
|
||||
signature.
|
||||
- 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
|
||||
|
||||
- Improved reliability of computing License Signatures. Some license messages when parsed would be slightly different
|
||||
when re-serialized with `SerializeToString()`, therefore the computed signature would have always mismatched.
|
||||
- Added support for Key IDs that are integer values. Effectively all values are now considered to be a UUID as 16 bytes
|
||||
(in hex or bytes) or an integer value with support for up to 16 bytes. All integer values are converted to a UUID and
|
||||
are loaded big-endian.
|
||||
- Fixed acquisition of the Certificate's provider_id within `set_service_certificate()` in some edge cases, but also
|
||||
when you try to remove the certificate by setting it to `None`.
|
||||
- PSSH now dumps in the same version the PSSH was loaded or created in. Previously it would always dump as a v1 PSSH
|
||||
box due to a cascading check in pymp4. It now also honors the currently set version in the case it gets overridden.
|
||||
- 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
|
||||
|
||||
With just one change this brings along a reduced dependency tree, smoother experience across different platforms, and
|
||||
speed improvements (especially on larger input messages).
|
||||
- Supported Serve API: `v1.4.3` or newer
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated protobuf dependency to v4.x branch with recompiled proto-buffers. They now also have python stub files.
|
||||
- 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 v3.19.5 due to the Security Advisory [GHSA-8gq9-2x98-w8hf].
|
||||
- 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
|
||||
|
||||
RemoteCdm minimum supported Serve API version is now v1.4.3.
|
||||
- Supported Serve API: `v1.4.3` or newer
|
||||
|
||||
### 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.
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
- 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.5.2]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.5.2
|
||||
[1.5.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.5.1
|
||||
[1.5.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.5.0
|
||||
[1.4.4]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.4.4
|
||||
[1.4.3]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.4.3
|
||||
[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
|
||||
|
1522
poetry.lock
generated
1522
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.5.2"
|
||||
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 = "4.21.6"
|
||||
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
|
||||
|
@ -5,5 +5,4 @@ from .pssh import *
|
||||
from .remotecdm import *
|
||||
from .session import *
|
||||
|
||||
|
||||
__version__ = "1.5.2"
|
||||
__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"
|
||||
@ -66,7 +79,7 @@ class Cdm:
|
||||
|
||||
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,7 +191,8 @@ 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:
|
||||
@ -208,7 +222,11 @@ class Cdm:
|
||||
|
||||
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)
|
||||
@ -262,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:
|
||||
"""
|
||||
@ -271,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.
|
||||
|
||||
@ -295,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
|
||||
@ -317,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:
|
||||
"""
|
||||
@ -395,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."
|
||||
@ -474,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.
|
||||
@ -511,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}")
|
||||
@ -546,18 +564,18 @@ 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[SignedDrmCertificate, DrmCertificate],
|
||||
key: bytes = None,
|
||||
iv: bytes = None
|
||||
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)
|
||||
@ -570,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]:
|
||||
@ -638,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",)
|
||||
|
File diff suppressed because one or more lines are too long
@ -1,3 +1,5 @@
|
||||
# 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
|
||||
|
@ -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,20 +3,24 @@ 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(hex="edef8ba979d64acea3c827dcd51d21ed")
|
||||
@ -24,13 +28,19 @@ class PSSH:
|
||||
|
||||
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
|
||||
@ -208,23 +230,54 @@ class PSSH:
|
||||
]
|
||||
|
||||
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()}")
|
||||
|
||||
@ -243,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.
|
||||
|
||||
@ -251,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.")
|
||||
|
||||
@ -185,7 +185,7 @@ class RemoteCdm(Cdm):
|
||||
self,
|
||||
session_id: bytes,
|
||||
pssh: PSSH,
|
||||
type_: Union[int, str] = LicenseType.STREAMING,
|
||||
license_type: str = "STREAMING",
|
||||
privacy_mode: bool = True
|
||||
) -> bytes:
|
||||
if not pssh:
|
||||
@ -193,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(),
|
||||
@ -251,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."
|
||||
@ -301,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)
|
||||
|
@ -13,3 +13,6 @@ class Session:
|
||||
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