Compare commits

...

62 Commits

Author SHA1 Message Date
rlaphoenix
7ea2a72a8c Update Changelog for v1.8.0 2023-12-22 11:12:09 +00:00
rlaphoenix
84d30a69a9 Bump to v1.8.0 2023-12-22 11:08:57 +00:00
sr0lle
c39dd6df5d Create py.typed to silence mypy (PEP561) (#43) 2023-12-22 10:58:12 +00:00
rlaphoenix
94f8eba960 Remove PyYAML from the "serve" extras group
Fixes #44
2023-12-22 10:43:35 +00:00
rlaphoenix
25e03529f6 Simplify verification of parsing in Cdm.set_service_certificate 2023-12-06 16:00:52 +00:00
rlaphoenix
a04e751aa1 Support duplicated SignedMessages in Cdm.set_service_certificate
Fixes #41

Seems some services like TF1 (France) returns a SignedMessage twice in one response body by mistake, resulting in a partial parse decoding error as pywidevine doesn't expect the parsed-then-serialized data to differ from the received data.

This workaround checks if the parsed-then-serialized data is in the received data multiple times without any leftover data. If there's no leftover data it considers it safe to continue.
2023-12-06 15:36:27 +00:00
rlaphoenix
17cefbf1d8 Recompile protobuffers for v4.25 2023-12-06 15:31:53 +00:00
rlaphoenix
bcb2185f75 Add Python 3.12 to CI/CD workflows 2023-12-06 15:29:59 +00:00
rlaphoenix
532e68aba9 Drop Support for Python 3.7, update Dependencies 2023-12-06 15:29:06 +00:00
rlaphoenix
e348fc5df2 Update Changelog for v1.7.0 2023-11-21 10:14:56 +00:00
rlaphoenix
4fc8216c4a Bump to v1.7.0 2023-11-21 10:14:39 +00:00
rlaphoenix
81fd2649a4 Update Project URLs to devine-dl 2023-11-21 10:13:55 +00:00
rlaphoenix
00532979b6 Improve old Changelog entries 2023-11-21 09:56:12 +00:00
rlaphoenix
9479c069b5 Add common staging privacy cert, add docs to common certs 2023-11-09 12:23:31 +00:00
rlaphoenix
ba83e29147 Overhaul tooling, linting, editor configs, and README 2023-11-09 00:29:29 +00:00
rlaphoenix
49315eceb8 Fix usage of __all__, add missing __all__ assignments 2023-11-08 22:56:37 +00:00
rlaphoenix
5087da31a0 Fix test CLI function's PSSH type 2023-11-08 22:42:14 +00:00
rlaphoenix
79cdbc007c Remove Types shortcut from Device, rename to DeviceTypes
This is because a static linter cannot recognize a class variable as a type. If we instead directly reference the enum, it can.
2023-11-08 22:42:14 +00:00
rlaphoenix
c362192c11 Improve and simplify creation of protobuffer objects 2023-11-08 22:27:33 +00:00
rlaphoenix
0e6aa1d5e8 Various typing/linting fixes and improvements 2023-11-08 22:18:12 +00:00
rlaphoenix
97ec2e1c60 Have Device Flags be an empty dict if none set 2023-11-08 21:24:44 +00:00
rlaphoenix
0c31f88d23 Return subprocess returncode in decrypt() 2023-11-08 21:23:05 +00:00
rlaphoenix
2d8163f76d Fix typing and casting of type_ in get_license_challenge 2023-11-08 21:20:54 +00:00
rlaphoenix
797799a5aa Slight correction to typing and doc-string of set_service_certificate 2023-11-08 20:52:03 +00:00
rlaphoenix
dfdba71caf Remove system_id class variable from Cdm
The variable name `system_id` conflicts with the `system_id` of the class *instance* variable.

There's no need to have this variable there anyway, when it's easily accessible as bytes via `Cdm.uuid.bytes`.
2023-11-08 20:38:38 +00:00
rlaphoenix
65d8135e2a Ignore empty KID values in v4.0.0.0 PlayReadyHeaders 2023-11-08 19:47:37 +00:00
rlaphoenix
2fb3b21e4a Raise an exception if PlayReadyHeader KID VALUE doesn't exist 2023-11-08 19:47:37 +00:00
rlaphoenix
cd990e0f4e Have set_key_ids method call parse_key_ids directly
This improves user-experience by allowing set_key_ids to accept more types of Key ID formats directly. This also reduces code duplication because the parse function also checks the validity of the Key IDs list for set_key_ids.
2023-11-08 19:47:37 +00:00
rlaphoenix
52fd5e74ba Extract Key ID to UUID parsing to parse_key_ids method 2023-11-08 19:25:30 +00:00
rlaphoenix
2656a795c3 Remove unused f-strings and unused import 2023-11-08 19:01:23 +00:00
rlaphoenix
bbbaeafbb6 Lessen restriction on Python version and update deps 2023-11-08 17:20:20 +00:00
mediaminister
c71f867a72
Use std-lib xml instead of lxml (#35)
Allows for support on ARM devices and reduces dependencies.

---------

Co-authored-by: rlaphoenix <rlaphoenix@pm.me>
2023-10-17 20:40:47 +01:00
rlaphoenix
dad32e728b Add isort config, run isort across project 2023-09-19 12:05:41 +01:00
rlaphoenix
db7bf977a1 Update dependencies and GitHub Workflows 2023-09-19 11:57:00 +01:00
rlaphoenix
bfaae20e81 Prevent overwriting files when using create-device 2023-07-07 20:10:08 +01:00
rlaphoenix
728a3e7575 Add ability to specify output filename when using create-device 2023-07-07 20:09:34 +01:00
rlaphoenix
29693bedf6 Ensure output directory exists when using create-device 2023-07-07 19:48:11 +01:00
rlaphoenix
db6eaef450
Merge pull request #27 from rlaphoenix/dependabot/pip/requests-2.31.0
Bump requests from 2.28.1 to 2.31.0
2023-05-27 20:12:50 +01:00
dependabot[bot]
6a7f8b9a39
Bump requests from 2.28.1 to 2.31.0
Bumps [requests](https://github.com/psf/requests) from 2.28.1 to 2.31.0.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.28.1...v2.31.0)

---
updated-dependencies:
- dependency-name: requests
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-05-23 04:32:36 +00:00
rlaphoenix
e4a8316227 Use Python 3.11 in GitHub Workflows 2023-02-03 07:04:22 +00:00
rlaphoenix
9568d7fdb9 Update Poetry Version used in GitHub Workflows 2023-02-03 07:03:36 +00:00
rlaphoenix
ece0914920 Update Changelog for v1.6.0 2023-02-03 07:00:56 +00:00
rlaphoenix
2ab659eab6 Bump to v1.6.0 2023-02-03 06:58:00 +00:00
rlaphoenix
99aef63354 Add export-device command to export WVDs back as files
In reality you wouldn't need this for use with pywidevine, but a lot have asked me for this feature so they can use WVDs in other ways or with other software that does not support WVDs.
2023-02-03 06:53:55 +00:00
rlaphoenix
fd3df13e9c Add Support Python 3.11 2023-02-03 06:26:50 +00:00
rlaphoenix
2e9c09d5f1 Update Changelog for v1.5.3 2022-12-27 20:07:52 +00:00
rlaphoenix
2e25f9c7bd Bump to v1.5.3 2022-12-27 20:07:37 +00:00
rlaphoenix
ddc66f0a2b PSSH: Simplify the PSSH Data conversion function names 2022-12-27 00:26:05 +00:00
rlaphoenix
c9f55c6e6b PSSH: Implement Widevine to PlayReady conversion
The XML creation is a bit dodgy because I despise XML. If you like lxml, feel free to make a pull request.
2022-12-27 00:24:15 +00:00
rlaphoenix
2648d1c669 PSSH: Return Base64 representation with __str__ 2022-12-26 23:47:43 +00:00
rlaphoenix
bc2b5beef4 PSSH: Update class doc-string
It's no longer as Widevine-biased as it once was.
2022-12-26 23:46:40 +00:00
rlaphoenix
11284eddfb PSSH: Allow specifying the System ID to use 2022-12-26 23:44:58 +00:00
rlaphoenix
61097ce6de PSSH: Parse PlayReadyObjects efficiently, parse multiple records
The previous method was overall fine, but assumed only one PlayReadyHeader was in the PlayReadyObject. It also incorrectly assumed the start data to be garbage data when it's actually the header for the PlayReadyObject.
2022-12-26 23:35:29 +00:00
rlaphoenix
3a910bd03a PSSH: Fix loading of PlayReadyHeaders
Previously it would load PlayReadyHeader data under Widevine's SystemId breaking all PlayReady checks.

The actual PlayReadyHeader init_data still needs code to parse it into an object.
2022-12-26 23:27:51 +00:00
rlaphoenix
e31ba61302 PSSH: Create a string representation 2022-12-26 22:39:34 +00:00
rlaphoenix
0e4275bd1e Create and use utility to strip namespaces from XML data
Namespaces cause problems with the xpath calls when dealing with PlayReadyHeader's on some versions.
2022-12-26 22:38:02 +00:00
rlaphoenix
e0365ff2bb
Merge pull request #21 from rlaphoenix/dependabot/pip/certifi-2022.12.7
Bump certifi from 2022.6.15 to 2022.12.7
2022-12-09 20:22:23 +00:00
dependabot[bot]
ae95aeec96
Bump certifi from 2022.6.15 to 2022.12.7
Bumps [certifi](https://github.com/certifi/python-certifi) from 2022.6.15 to 2022.12.7.
- [Release notes](https://github.com/certifi/python-certifi/releases)
- [Commits](https://github.com/certifi/python-certifi/compare/2022.06.15...2022.12.07)

---
updated-dependencies:
- dependency-name: certifi
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-12-09 07:17:48 +00:00
rlaphoenix
1b40c2b369 PSSH: Set Key IDs more effectively via set_key_ids()
This reduces reading complexity of why and when pssh.set_key_ids() was being run. Generally less code repetition effectively.
2022-11-18 09:40:55 +00:00
rlaphoenix
05b30b3a89 PSSH: Only craft PSSH with key_IDs set if version is 1 2022-11-18 09:18:52 +00:00
rlaphoenix
7a993206a1 PSSH: Ensure key IDs are UUIDs instead of Bytes
This reduces code duplication when actually using those key_ids.
2022-11-18 09:09:01 +00:00
rlaphoenix
2d2359f9a2 PSSH: Fix key_IDs field when creating a new PSSH box 2022-11-18 08:49:33 +00:00
26 changed files with 2228 additions and 1375 deletions

15
.editorconfig Normal file
View 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

View File

@ -1,3 +0,0 @@
[flake8]
exclude = *_pb2.py
max-line-length = 120

1
.gitattributes vendored Normal file
View File

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

View File

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

View File

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

40
.gitignore vendored
View File

@ -23,7 +23,6 @@ parts/
sdist/ sdist/
var/ var/
wheels/ wheels/
pip-wheel-metadata/
share/python-wheels/ share/python-wheels/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
@ -53,6 +52,7 @@ coverage.xml
*.py,cover *.py,cover
.hypothesis/ .hypothesis/
.pytest_cache/ .pytest_cache/
cover/
# Translations # Translations
*.mo *.mo
@ -75,6 +75,7 @@ instance/
docs/_build/ docs/_build/
# PyBuilder # PyBuilder
.pybuilder/
target/ target/
# Jupyter Notebook # Jupyter Notebook
@ -85,7 +86,9 @@ profile_default/
ipython_config.py ipython_config.py
# pyenv # 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 # pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # 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. # install all needed dependencies.
#Pipfile.lock #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__/ __pypackages__/
# Celery stuff # Celery stuff
@ -120,9 +138,6 @@ venv.bak/
# Rope project settings # Rope project settings
.ropeproject .ropeproject
# Jetbrains project settings
.idea
# mkdocs documentation # mkdocs documentation
/site /site
@ -133,3 +148,16 @@ dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .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
View 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
View 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"
]
}

View File

@ -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/), 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). 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 ## [1.5.2] - 2022-10-11
- Supported Serve API: `v1.4.3` or newer
### Fixed ### Fixed
- Fixed license signature calculation for newer Widevine Server licenses on OEM Crypto v16.0.0 or newer. - 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 ## [1.5.1] - 2022-10-23
- Supported Serve API: `v1.4.3` or newer
### Added ### Added
- Added import path shortcuts in the `__init__.py` package constructor to all the user classes. Now you can do e.g., - Support for big-int Key IDs in `PSSH`. All integer values are converted to a UUID and are loaded big-endian.
`from pywidevine import PSSH` instead of `from pywidevine.pssh import PSSH`. You can still do it both ways. - Import path shortcuts in the `__init__.py` package constructor to all the user classes.
- Improved error handling and sanitization checks when parsing some Service Certificates in `set_service_certificate()`. - 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 ### 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, - Service Certificates are now stored in the session as a `SignedDrmCertificate`.
including Android's OEMCrypto Library. This also helps encourage people to close their sessions when they are no - This is to keep the signature with the Certificate, without wrapping it in a SignedMessage unnecessarily.
longer required. - Reduced the maximum concurrent Cdm sessions from 50 to 16 as it seems to be a more common limit on more up-to-date
- Service Certificates are now stored in the session as a `SignedDrmCertificate`. This is to keep the signature with devices and versions of OEMCrypto. This also helps encourage people to close their sessions when they are no longer
the stored Certificate for use by the user if necessary. It also reduces code repetition relating to the usage of the required.
signature.
### Fixed ### Fixed
- Improved reliability of computing License Signatures. Some license messages when parsed would be slightly different - Acquisition of the Certificate's provider_id in `Cdm.set_service_certificate()` in some edge cases, but also when you
when re-serialized with `SerializeToString()`, therefore the computed signature would have always mismatched. try to remove the certificate by setting it to `None`.
- Added support for Key IDs that are integer values. Effectively all values are now considered to be a UUID as 16 bytes - When exporting a PSSH object it will now do so in the same version it was initially loaded or created in. Previously
(in hex or bytes) or an integer value with support for up to 16 bytes. All integer values are converted to a UUID and it would always dump as a v1 PSSH box due to a cascading check in pymp4. It now also honors the currently set version
are loaded big-endian. in the case it gets overridden.
- Fixed acquisition of the Certificate's provider_id within `set_service_certificate()` in some edge cases, but also - Improved reliability of computing License Signatures by verifying the signature against the original raw License
when you try to remove the certificate by setting it to `None`. message instead of the re-serialized version of the message.
- PSSH now dumps in the same version the PSSH was loaded or created in. Previously it would always dump as a v1 PSSH - Some license messages when parsed would be slightly different when re-serialized against my protobuf, therefore the
box due to a cascading check in pymp4. It now also honors the currently set version in the case it gets overridden. computed signature would have always mismatched.
## [1.5.0] - 2022-09-24 ## [1.5.0] - 2022-09-24
With just one change this brings along a reduced dependency tree, smoother experience across different platforms, and - Supported Serve API: `v1.4.3` or newer
speed improvements (especially on larger input messages).
### Changed ### 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 ## [1.4.4] - 2022-09-24
- Supported Serve API: `v1.4.3` or newer
### Security ### 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> [GHSA-8gq9-2x98-w8hf]: <https://github.com/protocolbuffers/protobuf/security/advisories/GHSA-8gq9-2x98-w8hf>
## [1.4.3] - 2022-09-10 ## [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 ### Added
- Cdm now has a `get_service_certificate()` endpoint to get the currently set service certificate of a Session. - Serve's `/get_license_challenge` endpoint can now disable privacy mode per-request, even if a service certificate is
RemoteCdm and Serve also has support for these endpoints. 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 ### Changed
- Added installation instructions, troubleshooting steps, a minimal example, and a list of features to the README. - All f-string formatting in log statements have been replaced with logging formatting to save performance when that
- The minimum version for lxml has been upped to >=4.9.1. This is due to some vulnerabilities present in all older log wouldn't have been printed.
versions. - The Serve APIs `/open` endpoint's function has been renamed from `open()` to `open_()` to prevent shadowing the
- All f-string formatting in log statements have been replaced with logging formatting to improve performance when built-in `open`.
logging is disabled.
### 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 ### Removed
- The Protocol image has been removed from the README as it is too broad to Browser scenarios and some stuff on it - 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. 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 ## [1.4.2] - 2022-09-05
- Supported Serve API: `v1.4.0` to `v1.4.2`
### Changed ### Changed
- Device's constructor no longer throws `ValueError` exceptions if it fails to parse the provided Client ID or it's - Sessions in `Cdm.open()` are now initialized with a unique session number.
VMP data if any. It will now raise a `DecodeError`. - 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 ### Fixed
- Android Cdm Devices now use a Request ID formula similar to OEMCrypto library when generating a Challenge. - Parsed Proto Messages now go through an elaborate yet efficient verification, it must parse and serialize back to it's
This formula has yet to be fully confirmed and ironed out, but it is better than the Chrome Cdm formula. received form, byte-for-byte, or it will be rejected.
- Various Proto Message Parsing now has full verification and expects the parsed response to be the same length - This prevents protobuf from parsing a message that could be a different message depending on the starting bytes.
as the serialized input, or it will throw an error. For example, this prevents vague errors to happen when you - It was possible to bypass some minor checks by providing specially crafted messages that parsed as other messages.
provide a bad License to `Cdm.parse_license`. It also prevents possibilities of it going past various other checks However, I haven't noticed any way where this would lead to a vulnerability or anything bad. It mostly just lead to
depending on the first few bytes provided. 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 ## [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 ### Changed
- `PSSH.overwrite_key_ids` static method is now an instance method named `set_key_ids` and works on the current - Rework `PSSH.overwrite_key_ids()` as an instance method now named `PSSH.set_key_ids()`.
instance instead of making and returning a new one. - Rework `PSSH.get_key_ids()` as a property method named `PSSH.key_ids`. This allows swift access to all the Key IDs of
- `PSSH.get_key_ids` static method is now a property method named `key_ids`. This allows swift access to all the the current PSSH object data.
Key IDs of the current access. - Rework `PSSH.from_playready_pssh()` as an instance method now named `PSSH.playready_to_widevine()` that now converts
- `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 after wards
the current instances values directly. This allows you to more easily instance as any PSSH, then convert afterwards. and only if wanted and when needed.
## [1.4.0] - 2022-08-06 ## [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. - Supported Serve API: `v1.4.0` to `v1.4.2`
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.
### Added ### Added
- The PSSH class now has a `new()` method to craft a new PSSH box. The box can be crafted from arbitrary init_data - New PSSH boxes can now be manually crafted with `PSSH.new()`.
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 - The box can be crafted from arbitrary init_data and/or key_ids.
into it. This allows you to make compliant v0 or v1 boxes with as little data as just a Key ID. - If only key_ids is supplied a new Widevine CENC Header will be created and the key IDs will be put into it.
- The PSSH class now has `dump()` and `dumps()` methods to serialize the data as binary or base64 respectively. It will - This allows you to make compliant v0 or v1 boxes with as little data as just a Key ID.
be serialized as a pymp4 PSSH box, ready to be used in an MP4 file. - PSSH boxes can now be exported as MP4 Box objects using pymp4 with `PSSH.dump()`.
- Cdm now has a method `get_keys()` to get the keys of the loaded license. This is the alternative to manually - PSSH boxes can now also be exported as Base64 strings with `PSSH.dumps()`.
accessing the keys by navigating the `_sessions` class instance variable. - License Keys can now be obtained from a Cdm session with a parsed license using `Cdm.get_keys()`.
- Serve API now also has a `/get_keys` endpoint to call the `get_keys()` method of the underlying Cdm session. - 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 ### Changed
- Cdm and RemoteCdm now expect a PSSH object as the `init_data` param for `get_license_challenge`. You can no longer - `PSSH.get_as_box()` has been merged into the PSSH constructor, simplifying usage of the PSSH class.
provide it anything else, that includes base64 or bytes form. It must be a PSSH object. - `PSSH.from_playready_pssh()` is now a class method and returns as a PSSH object.
- Serve no longer returns license keys in the response of the `/keys` endpoint. - Only PSSH objects are now accepted by `Cdm.get_license_challenge()`.
- Serve has changed the endpoint `/challenge` to `/get_license_challenge` and `/keys` to `/parse_license`. This is to - You can no longer provide it anything else, that includes base64 or bytes form.
be consistent with the method names of the underlying Cdm class. - You should first parse or make a new PSSH with the PSSH class, and then pass that object.
- The PSSH class has been reworked from being a static helper class to a proper PSSH class. - This is to simplify typing and repetition across the codebase.
- PSSH.from_playready_pssh is now a class method and returns as a PSSH object. - 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 ### 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.
- 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
- All uses of a local Session() object has been removed from RemoteCdm. The session is now fully controlled by the 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. remote API and de-synchronization by external alteration or unexpected exceptions is no longer a possibility.
### Fixed ### Fixed
- Various uses of the `key_ids` field of WidevinePsshData proto has been fixed in the PSSH class. - Correct the WidevinePsshData proto field name from `key_id` to `key_ids` in the PSSH class.
- Fixed a few Serve API crashes in edge cases with improved error handling on Cdm method calls. - 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 ## [1.3.1] - 2022-08-04
- Supported Serve API: `v1.3.0` to `v1.3.1`
### Added ### Added
- Cdm and RemoteCdm can now be supplied a string value for `device_type` for scenarios where providing it as a string - String value support to the `device_type` parameter in `Cdm`s constructor.
is more convenient (e.g., from Config files).
### 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 ### Fixed
- The `force_privacy_mode` key no longer needs to be defined at all in the configuration file. This was previously - `RemoteCdm`s Server version check now ignores other Server/Proxy names prepended or appended to the Server header.
crashing serve APIs if it wasn't set before starting. - For example, if reverse-proxied through Caddy it may have prepended "Caddy" to the Server header.
- 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.
## [1.3.0] - 2022-08-04 ## [1.3.0] - 2022-08-04
- Supported Serve API: `v1.3.0` to `v1.3.1`
### Added ### Added
- New RemoteCdm class to be used as Client code for the `serve` Remote CDM API server. The RemoteCdm should be used - New Client for using the Serve API; `RemoteCdm` class. It has an identical interface as the original `Cdm` class.
entirely separately from the normal Cdm class. All serve APIs must update to v1.3.0 to be compatible. The RemoteCdm - However, the constructor is different. Instead of passing a Widevine device object, you need to pass information
verifies the server version to ensure compatibility. Changes to the serve API schema will be immediately reflected in about the API like its host (including port if not on a reverse-proxy), and info about the device like its name and
the RemoteCdm code in the future. security level.
- Implemented `/set_service_certificate` endpoint in serve schema as an improved way of setting the service certificate - Other than that, once the RemoteCdm object is created, you use it exactly the same. Magic!
than passing it to `/challenge`. - Any time there's a change or fix to `Cdm` in this update or any in the future, will also be done to RemoteCdm.
- You can now unset the service certificate by providing an empty service certificate value (or None or null). This - New Serve endpoint `/set_service_certificate` as an improved way of setting (or unsetting) the service certificate.
includes support for doing so even in serve API and the new RemoteCdm.
### Changed ### Changed
- The Construction of the Cdm object has changed. You can now initialize it with more direct values if you don't want - `Cdm`s constructor now uses more direct values, so you don't have to use the Device class or `.wvd` files.
to use the Device class or don't want to use `.wvd` files. To use Device classes, you must now use the - To continue using `.wvd` files you must now use `Cdm.from_device()` instead.
`Cdm.from_device()` class method. - You can now unset the Service certificate by providing `None` to `Cdm.set_service_certificate().
- 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 ### Removed
now want to use a different certificate.
- 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 ## [1.2.1] - 2022-08-02
This release is primarily a maintenance release for `serve` functionality but some Cdm fixes are also present.
### Added ### Added
- You can now return all License Keys from Serve's `/keys` endpoint by supplying `ALL` as the key type. - Support `SignedDrmCertificate` and `SignedMessages` messages in `Cdm.encrypt_client_id()`. This is mainly as a
This adds support for Exchange Systems like Netflix's WidevineExchange MSL scheme. I recommend using `ALL` unless convenience for any scripts wanting to encrypt their Client ID with a service certificate manually.
you only want `CONTENT` keys and will not be using any other type of keys including `SIGNING` and `OPERATOR_SESSION`. - All License Keys from Serve's `/keys` endpoint can now be received by providing `ALL` as the key type.
- Serve now has a `/close` endpoint to close a session. The Cdm has a limit of 50 sessions per user. - This adds support for systems needing more than two types of keys from the license, e.g., Netflix MSL.
- Serve now responds with a `Server` header denoting that pywidevine serve is being used, also specifying the version. - For faster response times it is best to still ask for only `CONTENT` keys if that's all you need.
This allows Clients to selectively support APIs based on version, and also verify the API as being supported at all. - Serve now has a `/close` endpoint to close a session. All clients should close the session once they are finished
- Serve now verifies that all Devices in config actually exist before letting you start serving. 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 ### 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 - 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 `/device_name/challenge/STREAMING`. This is to support the previous change.
Serve's Cdm objects.
### Fixed ### Fixed
- Fixed support for Raw PSSH values, e.g., Netflix's WidevineExchange MSL Scheme arbitrary init_data value. - Handle server crash when the session limit is reached in Serve's `/open` endpoint by returning a 400 error.
- The Service Certificate is now saved to the Session in full SignedMessage form instead of just the underlying - Serve now correctly updates (or rather now makes a new Cdm object) if a user switches from one Device to another.
DrmCertificate. This is so any class inheriting the Cdm (e.g., for Remote capabilities) can sufficiently use - Previously it would reuse an existing Cdm object, but would forget to switch device if they changed.
and supply the service certificate while being signed. - Note: It does still leave the previous Cdm with the older Device in memory.
- Serve's /open endpoint will now return a 400 error if there's too many sessions opened. - Handle IOError when parsing bytes as MP4 Box to allow arbitrary data to be made as new boxes in `PSSH.get_as_box()`.
- 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.
## [1.2.0] - 2022-07-30 ## [1.2.0] - 2022-07-30
### Added ### Added
- New CLI command `serve` to serve local WVD devices and CDM sessions remotely as a JSON API. - New CLI command `serve` that hosts a CDM API that can be externally accessed with authentication. This can be used to
- The CLI command `migrate` can now accept a folder path to batch migrate WVD files. access and/or share your CDM without exposing your Widevine device private key, or even it's identity by enforcing
- The Cdm now uses custom exceptions where the use case is justified. All custom exceptions are under a parent custom Privacy Mode.
exception to allow catching of any Pywidevine exception. - 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 ### Changed
- The Cdm has been reworked as a session-based Cdm. You now initialize the Cdm with just the device you wish to use, - Elevated the Development Status Classifier from 4 (Beta) to 5 (Production/Stable).
and now you open sessions with `Cdm.open()` to get a session ID. For usage example see `license` CLI command in - License messages passed to `Cdm.parse_license()` are now rejected if they are not of `LICENSE` type.
`main.py`. - Service Certificates passed to `Cdm.set_service_certificate()` are now verified. This patches a trivial "exploit"
- The Cdm no longer requires you to specify `raw` bool parameter. It now supports arbitrary and valid Widevine Cenc that allows an attacker to recover the plaintext Client ID from a license under Privacy Mode. See
Header Data without needing to explicitly specify which it is. <https://gist.github.com/rlaphoenix/74acabdd7269a21845e18b621c5860ef>.
- The Cdm `pssh` param has been renamed as `init_data`. Doc-strings have been changed to prioritize explanation of it - Data passed to `PSSH.get_as_box()` now supports arbitrary and box data automatically as it tries to detect if it is a
referring to Widevine Cenc Header rather than PSSH Boxes. This is to show that the Cdm more-so wants Init Data than valid box, otherwise makes a new box.
a PSSH box. The full PSSH is never kept nor ever used, only it's init data is. It still supports PSSH box data. - Renamed the `Cdm` constructor's parameter `pssh` to `init_data`, as that's what the Cdm actually wants and uses,
- Cdm `set_service_certificate()` now returns the provider ID string rather than the underlying (and now verified) whereas a `PSSH` is an `mp4` atom (aka box) containing `init_data` (a Widevine CENC Header). The full PSSH is never
DrmCertificate. This is because the DrmCertificate is not likely useful and would still be possible to obtain in full kept nor ever used. It still accepts PSSH box data.
but quick access to the Provider ID may be more useful. - Service Certificate's Provider ID is now returned by `Cdm.set_service_certificate()` instead of the passed
- License responses can now be only be parsed once by `Cdm.parse_license()`. Any further attempts will raise an certificate, of which they would already have.
InvalidContext exception. This is because context data is now cleared for it's respective License Request once it's - The Cdm class now works more closely to the official CDM model. Instead of using one Cdm object per-request having to
parsed to reduce data lingering in memory. provide device information each time,
- Trove Classifier for Development Status is now 5 (Production/Stable). - 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 ### Removed
- You can no longer provide a direct `DrmCertificate` to `Cdm.set_service_certificate()` for security reasons. - Direct `DrmCertificate`s are no longer supported by `Cdm.set_service_certificate()` as they have no signature.
You must provide either a `SignedDrmCertificate` or a `SignedMessage` containing a `SignedDrmCertificate`. See the 3rd Change above. Provide either a `SignedDrmCertificate` or a `SignedMessage` containing a
- PSSH `from_init_data()` has been removed. It was unused and is unnecessary with improvements to `get_as_box()`. `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 ### Fixed
- Cdm `set_service_certificate()` now verifies the signature of the provided Certificate. This patches a trivial - Detection of Widevine CENC Header data encoded as bytes in `PSSH.get_as_box()`.
exploit/workaround that allows an attacker to recover the plaintext Client ID from an encrypted Client ID. - Custom ValueError on missing contexts instead of the generic KeyError in `Cdm.parse_license()`.
- Cdm `parse_license()` now verifies the input message type as a `LICENSE` message. - Typing of `type_` parameter in `Cdm.get_license_challenge()`.
- Cdm `parse_license()` now clears context for the License Request once it's License Response message has been parsed. - Value of `type_` parameter if is a string in `Cdm.get_license_challenge()`.
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.
## [1.1.1] - 2022-07-22 ## [1.1.1] - 2022-07-22
### Fixed ### 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 ## [1.1.0] - 2022-07-21
### Added ### Added
- Added support for setting a Service Certificate in SignedDrmCertificate form as well as raw DrmCertificate form. - WVD (Widevine Device file) Version 2 bringing reduced file sizes by up to 30%~.
However, It's unlikely for the service to provide the certificate in raw DrmCertificate form without a signature. - New CLI command `create-device` to create `.wvd` files (Widevine Device files) from RSA PEM/DER Private Keys and
- Added a CLI command `create-device` to create Widevine Device (`.wvd`) 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. 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 - New CLI command `migrate` that uses `Device.migrate()` and `dump()` to migrate older v1 Widevine Device files to v2.
to v2. - New `Device` method `migrate()` to load an older Widevine Device file format. It is recommended to then use the
- Added the v1 Structure of Widevine Devices for migration use. `dumps()` method to save it as a new v2 Widevine Device file, which can then be loaded normally.
- Added `Device.migrate()` class method that effectively loads older format WVD data. You can then use `dumps()` to - Support `SignedDrmCertificate` and `DrmCertificate` messages in `Cdm.set_service_certificate()`. Services can provide
get back the WVD data in the latest supported format. the certificate as a `SignedMessage`, `SignedDrmCertificate`, or a `DrmCertificate`. Only `SignedMessage` and
- Added ability to use Privacy mode on the test command. `SignedDrmCertificate` are signed.
- Privacy Mode can now be used in the `test` CLI command with the `-p/--privacy` flag.
### Changed ### Changed
- Set Service Certificates are now stored as the raw underlying DrmCertificate as the signature data is unused by - Moved all `.wvd` Widevine Device file structures from `Device` to a `_Structures` class in `device.py`. The
the CDM. `_Structures` class can be imported and used directly, or via `Device.structures`.
- Moved all Widevine Device structures under a Structures class. - Moved the majority of Widevine Device file migration code from the CLI command `migrate` to `Device.migrate()`. The
- I removed the `send_key_control_nonce` flag from all Structures even though it was technically used. CLI command `migrate` now internally uses `Device.migrate()`.
This is because the flag was never used as of this project, and I do not want to take up the flag slot. - 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.
### 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.
### Removed ### 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 ### Fixed
- CDMs `set_service_certificate()` now correctly raises a DecodeError on Decode Error instead of a ValueError. - Correct the type argument name from `type` to `type_` in `Device.dump()`.
- 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 ### Security
would result in either a key decrypt error, or garbage key data.
- 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 ## [1.0.0] - 2022-07-20
Initial Release. Initial Release.
[1.5.2]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.5.2 ### Security
[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 - Service Certificate Signatures are unverified as the signing public key is Unknown.
[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.8.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.8.0
[1.4.2]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.4.2 [1.7.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.7.0
[1.4.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.4.1 [1.6.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.6.0
[1.4.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.4.0 [1.5.3]: https://github.com/devine-dl/pywidevine/releases/tag/v1.5.3
[1.3.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.3.1 [1.5.2]: https://github.com/devine-dl/pywidevine/releases/tag/v1.5.2
[1.3.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.3.0 [1.5.1]: https://github.com/devine-dl/pywidevine/releases/tag/v1.5.1
[1.2.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.2.1 [1.5.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.5.0
[1.2.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.2.0 [1.4.4]: https://github.com/devine-dl/pywidevine/releases/tag/v1.4.4
[1.1.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.1.1 [1.4.3]: https://github.com/devine-dl/pywidevine/releases/tag/v1.4.3
[1.1.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.1.0 [1.4.2]: https://github.com/devine-dl/pywidevine/releases/tag/v1.4.2
[1.0.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.0.1 [1.4.1]: https://github.com/devine-dl/pywidevine/releases/tag/v1.4.1
[1.0.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.0.0 [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
View 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
View File

@ -1,74 +1,58 @@
<p align="center"> <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/> <br/>
<sup><em>Python Widevine CDM implementation.</em></sup> <sup><em>Python Widevine CDM implementation</em></sup>
</p> </p>
<p align="center"> <p align="center">
<a href="https://github.com/rlaphoenix/pywidevine/actions/workflows/ci.yml"> <a href="https://github.com/devine-dl/pywidevine/actions/workflows/ci.yml">
<img src="https://github.com/rlaphoenix/pywidevine/actions/workflows/ci.yml/badge.svg" alt="Build status"> <img src="https://github.com/devine-dl/pywidevine/actions/workflows/ci.yml/badge.svg" alt="Build status">
</a> </a>
<a href="https://pypi.org/project/pywidevine"> <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>
<a href="https://deepsource.io/gh/rlaphoenix/pywidevine"> <a href="https://deepsource.io/gh/devine-dl/pywidevine">
<img src="https://deepsource.io/gh/rlaphoenix/pywidevine.svg/?label=active+issues" alt="DeepSource"> <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> </a>
</p> </p>
## Features ## Features
- 🛡️ Security-first approach; All user input has Signatures verified - 🚀 Seamless Installation via [pip](#installation)
- 👥 Remotely accessible Server/Client CDM code - 🛡️ Robust Security with message signature verification
- 📦 Supports parsing and serialization of WVD (v2) provisions - 🙈 Privacy Mode with Service Certificates
- 🛠️ Class for creation, parsing, and conversion of PSSH data - 🌐 Servable CDM API Server and Client with Authentication
- 🧩 Plug-and-play installation via PIP/PyPI - 📦 Custom provision serialization format (WVD v2)
- 🗃️ YAML configuration files - 🧰 Create, parse, or convert PSSH headers with ease
- 🗃️ User-friendly YAML configuration
- ❤️ Forever FOSS! - ❤️ Forever FOSS!
## Installation ## Installation
*Note: Requires [Python] 3.7.0 or newer with PIP installed.*
```shell ```shell
$ pip install pywidevine $ pip install pywidevine
``` ```
You now have the `pywidevine` package installed and a `pywidevine` executable is now available. > **Note**
Check it out with `pywidevine --help` - Voilà 🎉! 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 Voilà 🎉 — You now have the `pywidevine` package installed!
You can now import pywidevine in scripts ([see below](#usage)).
The following steps are instructions on download, preparing, and running the code under a Poetry environment. A command-line interface is also available, try `pywidevine --help`.
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>
## Usage ## Usage
The following is a minimal example of using pywidevine in a script. It gets a License for Bitmovin's The following is a minimal example of using pywidevine in a script to get a License for Bitmovin's
Art of Motion Demo. There's various stuff not shown in this specific example like: Art of Motion Demo.
- 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.
```py ```py
from pywidevine.cdm import Cdm from pywidevine.cdm import Cdm
@ -108,15 +92,19 @@ for key in cdm.get_keys(session_id):
cdm.close(session_id) cdm.close(session_id)
``` ```
## Troubleshooting > **Note**
> There are various features not shown in this specific example like:
### Executable `pywidevine` was not found >
> - Privacy Mode
Make sure the Python installation's Scripts directory is added to your Path Environment Variable. > - Setting Service Certificates
> - Remote CDMs and Serving
If this happened under a Poetry environment, make sure you use the appropriate Poetry-specific way of calling > - Choosing a License Type to request
the executable. You may make this executable available globally by adding the .venv's Scripts folder to your > - Creating WVD files
Path Environment Variable. > - 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 ## 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 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. 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 &copy; Google. - Widevine Icon &copy; 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

File diff suppressed because it is too large Load Diff

View File

@ -4,13 +4,13 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry] [tool.poetry]
name = "pywidevine" name = "pywidevine"
version = "1.5.2" version = "1.8.0"
description = "Widevine CDM (Content Decryption Module) implementation in Python." description = "Widevine CDM (Content Decryption Module) implementation in Python."
authors = ["rlaphoenix <rlaphoenix@pm.me>"]
license = "GPL-3.0-only" license = "GPL-3.0-only"
authors = ["rlaphoenix <rlaphoenix@pm.me>"]
readme = "README.md" readme = "README.md"
repository = "https://github.com/rlaphoenix/pywidevine" repository = "https://github.com/devine-dl/pywidevine"
keywords = ["widevine", "drm", "google"] keywords = ["python", "drm", "widevine", "google"]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers", "Intended Audience :: Developers",
@ -18,28 +18,70 @@ classifiers = [
"Natural Language :: English", "Natural Language :: English",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Topic :: Multimedia :: Video", "Topic :: Multimedia :: Video",
"Topic :: Security :: Cryptography" "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] [tool.poetry.urls]
"Bug Tracker" = "https://github.com/rlaphoenix/pywidevine/issues" "Issues" = "https://github.com/devine-dl/pywidevine/issues"
"Forums" = "https://github.com/rlaphoenix/pywidevine/discussions" "Discussions" = "https://github.com/devine-dl/pywidevine/discussions"
"Changelog" = "https://github.com/rlaphoenix/pywidevine/blob/master/CHANGELOG.md" "Changelog" = "https://github.com/devine-dl/pywidevine/blob/master/CHANGELOG.md"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.7,<3.11" python = ">=3.8,<4.0"
protobuf = "4.21.6" protobuf = "^4.25.1"
pymp4 = "^1.2.0" pymp4 = "^1.4.0"
pycryptodome = "^3.15.0" pycryptodome = "^3.19.0"
click = "^8.1.3" click = "^8.1.7"
requests = "^2.28.1" requests = "^2.31.0"
lxml = ">=4.9.1" Unidecode = "^1.3.7"
Unidecode = "^1.3.4" PyYAML = "^6.0.1"
aiohttp = {version = "^3.8.1", optional = true} aiohttp = {version = "^3.9.1", optional = true}
PyYAML = {version = "^6.0", 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] [tool.poetry.extras]
serve = ["aiohttp", "PyYAML"] serve = ["aiohttp"]
[tool.poetry.scripts] [tool.poetry.scripts]
pywidevine = "pywidevine.main:main" pywidevine = "pywidevine.main:main"
[tool.ruff]
extend-exclude = [
"*_pb2.py",
"*.pyi",
]
force-exclude = true
line-length = 120
select = ["E4", "E7", "E9", "F", "W"]
[tool.ruff.extend-per-file-ignores]
"pywidevine/__init__.py" = ["F403"]
[tool.isort]
line_length = 118
extend_skip_glob = ["*_pb2.py", "*.pyi"]
[tool.mypy]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_untyped_defs = true
exclude = [
'_pb2.pyi?$' # generated protobuffer files
]
follow_imports = "silent"
ignore_missing_imports = true
no_implicit_optional = true

View File

@ -5,5 +5,4 @@ from .pssh import *
from .remotecdm import * from .remotecdm import *
from .session import * from .session import *
__version__ = "1.8.0"
__version__ = "1.5.2"

View File

@ -7,45 +7,58 @@ import subprocess
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
from typing import Union, Optional from typing import Optional, Union
from uuid import UUID from uuid import UUID
from Crypto.Cipher import AES, PKCS1_OAEP 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.PublicKey import RSA
from Crypto.Random import get_random_bytes from Crypto.Random import get_random_bytes
from Crypto.Signature import pss from Crypto.Signature import pss
from Crypto.Util import Padding from Crypto.Util import Padding
from google.protobuf.message import DecodeError from google.protobuf.message import DecodeError
from pywidevine.device import Device from pywidevine.device import Device, DeviceTypes
from pywidevine.exceptions import TooManySessions, InvalidSession, InvalidLicenseType, SignatureMismatch, \ from pywidevine.exceptions import (InvalidContext, InvalidInitData, InvalidLicenseMessage, InvalidLicenseType,
InvalidInitData, InvalidLicenseMessage, NoKeysLoaded, InvalidContext InvalidSession, NoKeysLoaded, SignatureMismatch, TooManySessions)
from pywidevine.key import Key from pywidevine.key import Key
from pywidevine.license_protocol_pb2 import DrmCertificate, SignedMessage, SignedDrmCertificate, LicenseType, \ from pywidevine.license_protocol_pb2 import (ClientIdentification, DrmCertificate, EncryptedClientIdentification,
LicenseRequest, ProtocolVersion, ClientIdentification, EncryptedClientIdentification, License License, LicenseRequest, LicenseType, SignedDrmCertificate,
SignedMessage)
from pywidevine.pssh import PSSH from pywidevine.pssh import PSSH
from pywidevine.session import Session from pywidevine.session import Session
from pywidevine.utils import get_binary_path from pywidevine.utils import get_binary_path
class Cdm: class Cdm:
system_id = b"\xed\xef\x8b\xa9\x79\xd6\x4a\xce\xa3\xc8\x27\xdc\xd5\x1d\x21\xed" uuid = UUID(bytes=b"\xed\xef\x8b\xa9\x79\xd6\x4a\xce\xa3\xc8\x27\xdc\xd5\x1d\x21\xed")
uuid = UUID(bytes=system_id)
urn = f"urn:uuid:{uuid}" urn = f"urn:uuid:{uuid}"
key_format = urn key_format = urn
service_certificate_challenge = b"\x08\x04" service_certificate_challenge = b"\x08\x04"
common_privacy_cert = ("CAUSxwUKwQIIAxIQFwW5F8wSBIaLBjM6L3cqjBiCtIKSBSKOAjCCAQoCggEBAJntWzsyfateJO/DtiqVtZhSCtW8y" common_privacy_cert = (
"zdQPgZFuBTYdrjfQFEEQa2M462xG7iMTnJaXkqeB5UpHVhYQCOn4a8OOKkSeTkwCGELbxWMh4x+Ib/7/up34QGeHl" # Used by Google's production license server (license.google.com)
"eB6KRfRiY9FOYOgFioYHrc4E+shFexN6jWfM3rM3BdmDoh+07svUoQykdJDKR+ql1DghjduvHK3jOS8T1v+2RC/TH" # Not publicly accessible directly, but a lot of services have their own gateways to it
"hv0CwxgTRxLpMlSCkv5fuvWCSmvzu9Vu69WTi0Ods18Vcc6CCuZYSC4NZ7c4kcHCCaA1vZ8bYLErF8xNEkKdO7Dev" "CAUSxwUKwQIIAxIQFwW5F8wSBIaLBjM6L3cqjBiCtIKSBSKOAjCCAQoCggEBAJntWzsyfateJO/DtiqVtZhSCtW8yzdQPgZFuBTYdrjfQFEE"
"Sy8BDFnoKEPiWC8La59dsPxebt9k+9MItHEbzxJQAZyfWgkCAwEAAToUbGljZW5zZS53aWRldmluZS5jb20SgAOuN" "Qa2M462xG7iMTnJaXkqeB5UpHVhYQCOn4a8OOKkSeTkwCGELbxWMh4x+Ib/7/up34QGeHleB6KRfRiY9FOYOgFioYHrc4E+shFexN6jWfM3r"
"HMUtag1KX8nE4j7e7jLUnfSSYI83dHaMLkzOVEes8y96gS5RLknwSE0bv296snUE5F+bsF2oQQ4RgpQO8GVK5uk5M" "M3BdmDoh+07svUoQykdJDKR+ql1DghjduvHK3jOS8T1v+2RC/THhv0CwxgTRxLpMlSCkv5fuvWCSmvzu9Vu69WTi0Ods18Vcc6CCuZYSC4NZ"
"4PxL/CCpgIqq9L/NGcHc/N9XTMrCjRtBBBbPneiAQwHL2zNMr80NQJeEI6ZC5UYT3wr8+WykqSSdhV5Cs6cD7xdn9" "7c4kcHCCaA1vZ8bYLErF8xNEkKdO7DevSy8BDFnoKEPiWC8La59dsPxebt9k+9MItHEbzxJQAZyfWgkCAwEAAToUbGljZW5zZS53aWRldmlu"
"qm9Nta/gr52u/DLpP3lnSq8x2/rZCR7hcQx+8pSJmthn8NpeVQ/ypy727+voOGlXnVaPHvOZV+WRvWCq5z3CqCLl5" "ZS5jb20SgAOuNHMUtag1KX8nE4j7e7jLUnfSSYI83dHaMLkzOVEes8y96gS5RLknwSE0bv296snUE5F+bsF2oQQ4RgpQO8GVK5uk5M4PxL/C"
"+Gf2Ogsrf9s2LFvE7NVV2FvKqcWTw4PIV9Sdqrd+QLeFHd/SSZiAjjWyWOddeOrAyhb3BHMEwg2T7eTo/xxvF+YkP" "CpgIqq9L/NGcHc/N9XTMrCjRtBBBbPneiAQwHL2zNMr80NQJeEI6ZC5UYT3wr8+WykqSSdhV5Cs6cD7xdn9qm9Nta/gr52u/DLpP3lnSq8x2"
"j89qPwXCYcOxF+6gjomPwzvofcJOxkJkoMmMzcFBDopvab5tDQsyN9UPLGhGC98X/8z8QSQ+spbJTYLdgFenFoGq4" "/rZCR7hcQx+8pSJmthn8NpeVQ/ypy727+voOGlXnVaPHvOZV+WRvWCq5z3CqCLl5+Gf2Ogsrf9s2LFvE7NVV2FvKqcWTw4PIV9Sdqrd+QLeF"
"7gLwDS6NWYYQSqzE3Udf2W7pzk4ybyG4PHBYV3s4cyzdq8amvtE/sNSdOKReuHpfQ=") "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 = SignedDrmCertificate()
root_signed_cert.ParseFromString(base64.b64decode( root_signed_cert.ParseFromString(base64.b64decode(
"CpwDCAASAQAY3ZSIiwUijgMwggGKAoIBgQC0/jnDZZAD2zwRlwnoaM3yw16b8udNI7EQ24dl39z7nzWgVwNTTPZtNX2meNuzNtI/nECplSZy" "CpwDCAASAQAY3ZSIiwUijgMwggGKAoIBgQC0/jnDZZAD2zwRlwnoaM3yw16b8udNI7EQ24dl39z7nzWgVwNTTPZtNX2meNuzNtI/nECplSZy"
@ -66,7 +79,7 @@ class Cdm:
def __init__( def __init__(
self, self,
device_type: Union[Device.Types, str], device_type: Union[DeviceTypes, str],
system_id: int, system_id: int,
security_level: int, security_level: int,
client_id: ClientIdentification, client_id: ClientIdentification,
@ -76,9 +89,9 @@ class Cdm:
if not device_type: if not device_type:
raise ValueError("Device Type must be provided") raise ValueError("Device Type must be provided")
if isinstance(device_type, str): if isinstance(device_type, str):
device_type = Device.Types[device_type] device_type = DeviceTypes[device_type]
if not isinstance(device_type, Device.Types): if not isinstance(device_type, DeviceTypes):
raise TypeError(f"Expected device_type to be a {Device.Types!r} not {device_type!r}") raise TypeError(f"Expected device_type to be a {DeviceTypes!r} not {device_type!r}")
if not system_id: if not system_id:
raise ValueError("System ID must be provided") raise ValueError("System ID must be provided")
@ -151,7 +164,7 @@ class Cdm:
raise InvalidSession(f"Session identifier {session_id!r} is invalid.") raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
del self.__sessions[session_id] 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) Set a Service Privacy Certificate for Privacy Mode. (optional but recommended)
@ -178,7 +191,8 @@ class Cdm:
match the underlying DrmCertificate. match the underlying DrmCertificate.
Returns the Service Provider ID of the verified DrmCertificate if successful. 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) session = self.__sessions.get(session_id)
if not session: if not session:
@ -208,7 +222,11 @@ class Cdm:
try: try:
signed_message.ParseFromString(certificate) 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) signed_drm_certificate.ParseFromString(signed_message.msg)
else: else:
signed_drm_certificate.ParseFromString(certificate) signed_drm_certificate.ParseFromString(certificate)
@ -262,7 +280,7 @@ class Cdm:
self, self,
session_id: bytes, session_id: bytes,
pssh: PSSH, pssh: PSSH,
type_: Union[int, str] = LicenseType.STREAMING, license_type: str = "STREAMING",
privacy_mode: bool = True privacy_mode: bool = True
) -> bytes: ) -> bytes:
""" """
@ -271,8 +289,10 @@ class Cdm:
Parameters: Parameters:
session_id: Session identifier. session_id: Session identifier.
pssh: PSSH Object to get the init data from. pssh: PSSH Object to get the init data from.
type_: Type of License you wish to exchange, often `STREAMING`. The `OFFLINE` license_type: Type of License you wish to exchange, often `STREAMING`.
Licenses are for Offline licensing of Downloaded content. - "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_mode: Encrypt the Client ID using the Privacy Certificate. If the
privacy certificate is not set yet, this does nothing. privacy certificate is not set yet, this does nothing.
@ -295,17 +315,15 @@ class Cdm:
if not isinstance(pssh, PSSH): if not isinstance(pssh, PSSH):
raise InvalidInitData(f"Expected pssh to be a {PSSH}, not {pssh!r}") raise InvalidInitData(f"Expected pssh to be a {PSSH}, not {pssh!r}")
try: if not isinstance(license_type, str):
if isinstance(type_, int): raise InvalidLicenseType(f"Expected license_type to be a {str}, not {license_type!r}")
LicenseType.Name(int(type_)) if license_type not in LicenseType.keys():
elif isinstance(type_, str): raise InvalidLicenseType(
type_ = LicenseType.Value(type_) f"Invalid license_type value of '{license_type}'. "
elif not isinstance(type_, LicenseType): f"Available values: {LicenseType.keys()}"
raise InvalidLicenseType() )
except ValueError:
raise InvalidLicenseType(f"License Type {type_!r} is invalid")
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 # 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 # Bytes 5-8 does not seem random, in real tests they have been consecutive \x00 or \xFF
# Real example: A0DCE548000000000500000000000000 # Real example: A0DCE548000000000500000000000000
@ -317,35 +335,36 @@ class Cdm:
else: else:
request_id = get_random_bytes(16) request_id = get_random_bytes(16)
license_request = LicenseRequest() license_request = LicenseRequest(
license_request.type = LicenseRequest.RequestType.Value("NEW") client_id=(
license_request.request_time = int(time.time()) self.__client_id
license_request.protocol_version = ProtocolVersion.Value("VERSION_2_1") ) if not (session.service_certificate and privacy_mode) else None,
license_request.key_control_nonce = random.randrange(1, 2 ** 31) encrypted_client_id=self.encrypt_client_id(
# 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(
client_id=self.__client_id, client_id=self.__client_id,
service_certificate=session.service_certificate service_certificate=session.service_certificate
)) ) if session.service_certificate and privacy_mode else None,
else: content_id=LicenseRequest.ContentIdentification(
license_request.client_id.CopyFrom(self.__client_id) 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() signed_license_request = SignedMessage(
license_message.type = SignedMessage.MessageType.LICENSE_REQUEST type="LICENSE_REQUEST",
license_message.msg = license_request.SerializeToString() msg=license_request,
license_message.signature = self.__signer.sign(SHA1.new(license_message.msg)) 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: 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): if not isinstance(license_message, SignedMessage):
raise InvalidLicenseMessage(f"Expecting license_response to be a SignedMessage, got {license_message!r}") 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( raise InvalidLicenseMessage(
f"Expecting a LICENSE message, not a " f"Expecting a LICENSE message, not a "
f"'{SignedMessage.MessageType.Name(license_message.type)}' message." f"'{SignedMessage.MessageType.Name(license_message.type)}' message."
@ -474,7 +493,7 @@ class Cdm:
output_file: Union[Path, str], output_file: Union[Path, str],
temp_dir: Optional[Union[Path, str]] = None, temp_dir: Optional[Union[Path, str]] = None,
exists_ok: bool = False exists_ok: bool = False
): ) -> int:
""" """
Decrypt a Widevine-encrypted file using Shaka-packager. Decrypt a Widevine-encrypted file using Shaka-packager.
Shaka-packager is much more stable than mp4decrypt. Shaka-packager is much more stable than mp4decrypt.
@ -511,8 +530,7 @@ class Cdm:
input_file = Path(input_file) input_file = Path(input_file)
output_file = Path(output_file) output_file = Path(output_file)
if temp_dir: temp_dir_ = Path(temp_dir) if temp_dir else None
temp_dir = Path(temp_dir)
if not input_file.is_file(): if not input_file.is_file():
raise FileNotFoundError(f"Input file does not exist, {input_file}") raise FileNotFoundError(f"Input file does not exist, {input_file}")
@ -546,18 +564,18 @@ class Cdm:
]) ])
] ]
if temp_dir: if temp_dir_:
temp_dir.mkdir(parents=True, exist_ok=True) temp_dir_.mkdir(parents=True, exist_ok=True)
args.extend(["--temp_dir", temp_dir]) args.extend(["--temp_dir", str(temp_dir_)])
subprocess.check_call([executable, *args]) return subprocess.check_call([executable, *args])
@staticmethod @staticmethod
def encrypt_client_id( def encrypt_client_id(
client_id: ClientIdentification, client_id: ClientIdentification,
service_certificate: Union[SignedDrmCertificate, DrmCertificate], service_certificate: Union[SignedDrmCertificate, DrmCertificate],
key: bytes = None, key: Optional[bytes] = None,
iv: bytes = None iv: Optional[bytes] = None
) -> EncryptedClientIdentification: ) -> EncryptedClientIdentification:
"""Encrypt the Client ID with the Service's Privacy Certificate.""" """Encrypt the Client ID with the Service's Privacy Certificate."""
privacy_key = key or get_random_bytes(16) privacy_key = key or get_random_bytes(16)
@ -570,20 +588,19 @@ class Cdm:
if not isinstance(service_certificate, DrmCertificate): if not isinstance(service_certificate, DrmCertificate):
raise ValueError(f"Expecting Service Certificate to be a DrmCertificate, not {service_certificate!r}") raise ValueError(f"Expecting Service Certificate to be a DrmCertificate, not {service_certificate!r}")
enc_client_id = EncryptedClientIdentification() encrypted_client_id = EncryptedClientIdentification(
enc_client_id.provider_id = service_certificate.provider_id provider_id=service_certificate.provider_id,
enc_client_id.service_certificate_serial_number = service_certificate.serial_number service_certificate_serial_number=service_certificate.serial_number,
encrypted_client_id=AES.
enc_client_id.encrypted_client_id = AES. \ new(privacy_key, AES.MODE_CBC, privacy_iv).
new(privacy_key, AES.MODE_CBC, privacy_iv). \ encrypt(Padding.pad(client_id.SerializeToString(), 16)),
encrypt(Padding.pad(client_id.SerializeToString(), 16)) encrypted_client_id_iv=privacy_iv,
encrypted_privacy_key=PKCS1_OAEP.
enc_client_id.encrypted_privacy_key = PKCS1_OAEP. \ new(RSA.importKey(service_certificate.public_key)).
new(RSA.importKey(service_certificate.public_key)). \
encrypt(privacy_key) encrypt(privacy_key)
enc_client_id.encrypted_client_id_iv = privacy_iv )
return enc_client_id return encrypted_client_id
@staticmethod @staticmethod
def derive_context(message: bytes) -> tuple[bytes, bytes]: def derive_context(message: bytes) -> tuple[bytes, bytes]:
@ -638,4 +655,4 @@ class Cdm:
return enc_key, mac_key_server, mac_key_client return enc_key, mac_key_server, mac_key_client
__ALL__ = (Cdm,) __all__ = ("Cdm",)

View File

@ -14,10 +14,10 @@ from construct import Padded, Padding, Struct, this
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from google.protobuf.message import DecodeError 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 CHROME = 1
ANDROID = 2 ANDROID = 2
@ -36,7 +36,7 @@ class _Structures:
"version" / Const(Int8ub, 2), "version" / Const(Int8ub, 2),
"type_" / CEnum( "type_" / CEnum(
Int8ub, Int8ub,
**{t.name: t.value for t in _Types} **{t.name: t.value for t in DeviceTypes}
), ),
"security_level" / Int8ub, "security_level" / Int8ub,
"flags" / Padded(1, COptional(BitStruct( "flags" / Padded(1, COptional(BitStruct(
@ -55,7 +55,7 @@ class _Structures:
"version" / Const(Int8ub, 1), "version" / Const(Int8ub, 1),
"type_" / CEnum( "type_" / CEnum(
Int8ub, Int8ub,
**{t.name: t.value for t in _Types} **{t.name: t.value for t in DeviceTypes}
), ),
"security_level" / Int8ub, "security_level" / Int8ub,
"flags" / Padded(1, COptional(BitStruct( "flags" / Padded(1, COptional(BitStruct(
@ -72,14 +72,13 @@ class _Structures:
class Device: class Device:
Types = _Types
Structures = _Structures Structures = _Structures
supported_structure = Structures.v2 supported_structure = Structures.v2
def __init__( def __init__(
self, self,
*_: Any, *_: Any,
type_: Types, type_: DeviceTypes,
security_level: int, security_level: int,
flags: Optional[dict], flags: Optional[dict],
private_key: Optional[bytes], private_key: Optional[bytes],
@ -103,9 +102,9 @@ class Device:
if not private_key: if not private_key:
raise ValueError("Private Key is required, the WVD does not contain one or is malformed.") 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.security_level = security_level
self.flags = flags self.flags = flags or {}
self.private_key = RSA.importKey(private_key) self.private_key = RSA.importKey(private_key)
self.client_id = ClientIdentification() self.client_id = ClientIdentification()
try: try:
@ -199,36 +198,36 @@ class Device:
raise ValueError("Device Data does not seem to be a WVD file (v0).") raise ValueError("Device Data does not seem to be a WVD file (v0).")
if header.version == 1: # v1 to v2 if header.version == 1: # v1 to v2
data = _Structures.v1.parse(data) v1_struct = _Structures.v1.parse(data)
data.version = 2 # update version to 2 to allow loading v1_struct.version = 2 # update version to 2 to allow loading
data.flags = Container() # blank flags that may have been used in v1 v1_struct.flags = Container() # blank flags that may have been used in v1
vmp = FileHashes() vmp = FileHashes()
if data.vmp: if v1_struct.vmp:
try: try:
vmp.ParseFromString(data.vmp) vmp.ParseFromString(v1_struct.vmp)
if vmp.SerializeToString() != data.vmp: if vmp.SerializeToString() != v1_struct.vmp:
raise DecodeError("partial parse") raise DecodeError("partial parse")
except DecodeError as e: except DecodeError as e:
raise DecodeError(f"Failed to parse VMP data as FileHashes, {e}") raise DecodeError(f"Failed to parse VMP data as FileHashes, {e}")
data.vmp = vmp v1_struct.vmp = vmp
client_id = ClientIdentification() client_id = ClientIdentification()
try: try:
client_id.ParseFromString(data.client_id) client_id.ParseFromString(v1_struct.client_id)
if client_id.SerializeToString() != data.client_id: if client_id.SerializeToString() != v1_struct.client_id:
raise DecodeError("partial parse") raise DecodeError("partial parse")
except DecodeError as e: except DecodeError as e:
raise DecodeError(f"Failed to parse VMP data as FileHashes, {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: 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") logging.getLogger("migrate").warning("Client ID already has Verified Media Path data")
client_id.vmp_data = new_vmp_data client_id.vmp_data = new_vmp_data
data.client_id = client_id.SerializeToString() v1_struct.client_id = client_id.SerializeToString()
try: try:
data = _Structures.v2.build(data) data = _Structures.v2.build(v1_struct)
except ConstructError as e: except ConstructError as e:
raise ValueError(f"Migration failed, {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}") raise ValueError(f"Device Data seems to be corrupt or invalid, or migration failed, {e}")
__ALL__ = (Device,) __all__ = ("Device", "DeviceTypes")

View File

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

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,5 @@
# mypy: ignore-errors
from google.protobuf.internal import containers as _containers from google.protobuf.internal import containers as _containers
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor as _descriptor

View File

@ -6,13 +6,15 @@ from zlib import crc32
import click import click
import requests import requests
import yaml
from construct import ConstructError 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 import __version__
from pywidevine.cdm import Cdm from pywidevine.cdm import Cdm
from pywidevine.device import Device from pywidevine.device import Device, DeviceTypes
from pywidevine.license_protocol_pb2 import LicenseType, FileHashes from pywidevine.license_protocol_pb2 import FileHashes, LicenseType
from pywidevine.pssh import PSSH 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) logging.basicConfig(level=logging.DEBUG if debug else logging.INFO)
log = logging.getLogger() log = logging.getLogger()
copyright_years = 2022
current_year = datetime.now().year current_year = datetime.now().year
if copyright_years != current_year: copyright_years = f"2022-{current_year}"
copyright_years = f"{copyright_years}-{current_year}"
log.info("pywidevine version %s Copyright (c) %s rlaphoenix", __version__, copyright_years) 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: if version:
return return
@main.command(name="license") @main.command(name="license")
@click.argument("device", type=Path) @click.argument("device_path", type=Path)
@click.argument("pssh", type=str) @click.argument("pssh", type=PSSH)
@click.argument("server", type=str) @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", default="STREAMING",
help="License Type to Request.") help="License Type to Request.")
@click.option("-p", "--privacy", is_flag=True, default=False, @click.option("-p", "--privacy", is_flag=True, default=False,
help="Use Privacy Mode, off by default.") 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. Make a License Request for PSSH to SERVER using DEVICE.
It will return a list of all keys within the returned license. 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") log = logging.getLogger("license")
# prepare pssh
pssh = PSSH(pssh)
# load device # 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.info("[+] Loaded Device (%s L%s)", device.system_id, device.security_level)
log.debug(device) log.debug(device)
@ -82,37 +79,36 @@ def license_(device: Path, pssh: str, server: str, type_: str, privacy: bool):
if privacy: if privacy:
# get service cert for license server via cert challenge # get service cert for license server via cert challenge
service_cert = requests.post( service_cert_res = requests.post(
url=server, url=server,
data=cdm.service_certificate_challenge data=cdm.service_certificate_challenge
) )
if service_cert.status_code != 200: if service_cert_res.status_code != 200:
log.error( log.error(
"[-] Failed to get Service Privacy Certificate: [%s] %s", "[-] Failed to get Service Privacy Certificate: [%s] %s",
service_cert.status_code, service_cert_res.status_code,
service_cert.text service_cert_res.text
) )
return return
service_cert = service_cert.content service_cert = service_cert_res.content
provider_id = cdm.set_service_certificate(session_id, service_cert) provider_id = cdm.set_service_certificate(session_id, service_cert)
log.info("[+] Set Service Privacy Certificate: %s", provider_id) log.info("[+] Set Service Privacy Certificate: %s", provider_id)
log.debug(service_cert) log.debug(service_cert)
# get license challenge # get license challenge
license_type = LicenseType.Value(type_)
challenge = cdm.get_license_challenge(session_id, pssh, license_type, privacy_mode=True) challenge = cdm.get_license_challenge(session_id, pssh, license_type, privacy_mode=True)
log.info("[+] Created License Request Message (Challenge)") log.info("[+] Created License Request Message (Challenge)")
log.debug(challenge) log.debug(challenge)
# send license challenge # send license challenge
licence = requests.post( license_res = requests.post(
url=server, url=server,
data=challenge data=challenge
) )
if licence.status_code != 200: if license_res.status_code != 200:
log.error("[-] Failed to send challenge: [%s] %s", licence.status_code, licence.text) log.error("[-] Failed to send challenge: [%s] %s", license_res.status_code, license_res.text)
return return
licence = licence.content licence = license_res.content
log.info("[+] Got License Message") log.info("[+] Got License Message")
log.debug(licence) 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, @click.option("-p", "--privacy", is_flag=True, default=False,
help="Use Privacy Mode, off by default.") help="Use Privacy Mode, off by default.")
@click.pass_context @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. Test the CDM code by getting Content Keys for Bitmovin's Art of Motion example.
https://bitmovin.com/demos/drm 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. # The PSSH is the same for all tracks both video and audio.
# However, this might not be the case for all services/manifests. # However, this might not be the case for all services/manifests.
pssh = "AAAAW3Bzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAADsIARIQ62dqu8s0Xpa" \ pssh = PSSH("AAAAW3Bzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAADsIARIQ62dqu8s0Xpa"
"7z2FmMPGj2hoNd2lkZXZpbmVfdGVzdCIQZmtqM2xqYVNkZmFsa3IzaioCSEQyAA==" "7z2FmMPGj2hoNd2lkZXZpbmVfdGVzdCIQZmtqM2xqYVNkZmFsa3IzaioCSEQyAA==")
# This License Server requires no authorization at all, no cookies, no credentials # This License Server requires no authorization at all, no cookies, no credentials
# nothing. This is often not the case for real services. # 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 # 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. # 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 # this runs the `cdm license` CLI-command code with the data we set above
# it will print information as it goes to the terminal # it will print information as it goes to the terminal
ctx.invoke( ctx.invoke(
license_, license_,
device=device, device_path=device,
pssh=pssh, pssh=pssh,
server=license_server, server=license_server,
type_=LicenseType.Name(license_type), license_type=license_type,
privacy=privacy privacy=privacy
) )
@main.command() @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") required=True, help="Device Type")
@click.option("-l", "--level", type=click.IntRange(1, 3), required=True, help="Device Security Level") @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("-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("-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("-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 @click.pass_context
def create_device( def create_device(
ctx: click.Context, ctx: click.Context,
@ -199,7 +195,7 @@ def create_device(
log = logging.getLogger("create-device") log = logging.getLogger("create-device")
device = Device( device = Device(
type_=Device.Types[type_.upper()], type_=DeviceTypes[type_.upper()],
security_level=level, security_level=level,
flags=None, flags=None,
private_key=key.read_bytes(), private_key=key.read_bytes(),
@ -228,7 +224,19 @@ def create_device(
except UnidecodeError as e: except UnidecodeError as e:
raise click.ClickException(f"Failed to sanitize name, {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) out_path.write_bytes(wvd_bin)
log.info("Created Widevine Device (.wvd) file, %s", out_path.name) 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()) 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() @main.command()
@click.argument("path", type=Path) @click.argument("path", type=Path)
@click.pass_context @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.") @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("-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.") @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. 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. 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. Instead, use 0.0.0.0 and ensure the TCP port you choose is forwarded.
""" """
from pywidevine import serve from pywidevine import serve # isort:skip
import yaml 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) serve.run(config, host, port)

View File

@ -3,20 +3,24 @@ from __future__ import annotations
import base64 import base64
import binascii import binascii
import string import string
from typing import Union, Optional from io import BytesIO
from typing import Optional, Union
from uuid import UUID from uuid import UUID
from xml.etree.ElementTree import XML
import construct import construct
from construct import Container from construct import Container
from google.protobuf.message import DecodeError from google.protobuf.message import DecodeError
from lxml import etree
from pymp4.parser import Box from pymp4.parser import Box
from pywidevine.license_protocol_pb2 import WidevinePsshData from pywidevine.license_protocol_pb2 import WidevinePsshData
class PSSH: 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: class SystemId:
Widevine = UUID(hex="edef8ba979d64acea3c827dcd51d21ed") Widevine = UUID(hex="edef8ba979d64acea3c827dcd51d21ed")
@ -24,13 +28,19 @@ class PSSH:
def __init__(self, data: Union[Container, str, bytes], strict: bool = False): 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)] [Strict mode (strict=True)]
Supports the following forms of input data in either Base64 or Bytes form: Supports the following forms of input data in either Base64 or Bytes form:
- Full PSSH mp4 boxes (as defined by pymp4 Box). - Full PSSH mp4 boxes (as defined by pymp4 Box).
- Full Widevine Cenc Headers (as defined by WidevinePsshData proto). - Full Widevine Cenc Headers (as defined by WidevinePsshData proto).
- Full PlayReady Objects and Headers (as defined by Microsoft Docs).
[Lenient mode (strict=False, default)] [Lenient mode (strict=False, default)]
@ -72,25 +82,40 @@ class PSSH:
box = Box.parse(data) box = Box.parse(data)
except (IOError, construct.ConstructError): # not a box except (IOError, construct.ConstructError): # not a box
try: try:
cenc_header = WidevinePsshData() widevine_pssh_data = WidevinePsshData()
cenc_header.ParseFromString(data) widevine_pssh_data.ParseFromString(data)
cenc_header = cenc_header.SerializeToString() data_serialized = widevine_pssh_data.SerializeToString()
if cenc_header != data: # not actually a WidevinePsshData if data_serialized != data: # not actually a WidevinePsshData
raise DecodeError() raise DecodeError()
except DecodeError: # not a widevine cenc header
if 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( box = Box.parse(Box.build(dict(
type=b"pssh", type=b"pssh",
version=0, version=0,
flags=0, flags=0,
system_ID=PSSH.SystemId.Widevine, system_ID=PSSH.SystemId.Widevine,
init_data=cenc_header init_data=data_serialized
)))
except DecodeError: # not a widevine cenc header
if "</WRMHEADER>".encode("utf-16-le") in data:
# TODO: Actually parse `data` as a PlayReadyHeader object and store that instead
box = Box.parse(Box.build(dict(
type=b"pssh",
version=0,
flags=0,
system_ID=PSSH.SystemId.PlayReady,
init_data=data
)))
elif strict:
raise DecodeError(f"Could not parse data as a {Container} nor a {WidevinePsshData}.")
else:
# Data is not a WidevineCencHeader nor a PlayReadyHeader.
# The license server likely has something custom to parse it.
# See doc-string about Lenient mode for more information.
box = Box.parse(Box.build(dict(
type=b"pssh",
version=0,
flags=0,
system_ID=PSSH.SystemId.Widevine,
init_data=data
))) )))
self.version = box.version self.version = box.version
@ -99,15 +124,27 @@ class PSSH:
self.__key_ids = box.key_IDs self.__key_ids = box.key_IDs
self.init_data = box.init_data 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 @classmethod
def new( def new(
cls, cls,
system_id: UUID,
key_ids: Optional[list[Union[UUID, str, bytes]]] = None, key_ids: Optional[list[Union[UUID, str, bytes]]] = None,
init_data: Optional[Union[WidevinePsshData, str, bytes]] = None, init_data: Optional[Union[WidevinePsshData, str, bytes]] = None,
version: int = 0, version: int = 0,
flags: int = 0 flags: int = 0
) -> PSSH: ) -> PSSH:
"""Craft a new version 0 or 1 PSSH Box.""" """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): 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}") 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: 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") 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 init_data is not None:
if isinstance(init_data, WidevinePsshData): if isinstance(init_data, WidevinePsshData):
init_data = init_data.SerializeToString() 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}" 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", type=b"pssh",
version=version, version=version,
flags=flags, flags=flags,
system_ID=PSSH.SystemId.Widevine, system_ID=system_id,
key_ids=[key_ids, b""][key_ids is None],
init_data=[init_data, b""][init_data is None] init_data=[init_data, b""][init_data is None]
))) # key_IDs should not be set yet
))))
pssh = cls(box) if key_ids:
# We must reinforce the version because pymp4 forces v0 if key_IDs is not set.
if key_ids and version == 0: # The set_key_ids() func will set it efficiently in both init_data and the box where needed.
pssh.set_key_ids([UUID(bytes=x) for x in key_ids]) # 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 return pssh
@ -186,8 +208,8 @@ class PSSH:
Get all Key IDs from within the Box or Init Data, wherever possible. Get all Key IDs from within the Box or Init Data, wherever possible.
Supports: Supports:
- Version 1 Boxes - Version 1 PSSH Boxes
- Widevine Headers - WidevineCencHeaders
- PlayReadyHeaders (4.0.0.0->4.3.0.0) - PlayReadyHeaders (4.0.0.0->4.3.0.0)
""" """
if self.version == 1 and self.__key_ids: if self.version == 1 and self.__key_ids:
@ -208,24 +230,55 @@ class PSSH:
] ]
if self.system_id == PSSH.SystemId.PlayReady: if self.system_id == PSSH.SystemId.PlayReady:
xml_string = self.init_data.decode("utf-16-le") # Assuming init data is a PRO (PlayReadyObject)
# some of these init data has garbage(?) in front of it # https://learn.microsoft.com/en-us/playready/specifications/playready-header-specification
xml_string = xml_string[xml_string.index("<"):] pro_data = BytesIO(self.init_data)
xml = etree.fromstring(xml_string) pro_length = int.from_bytes(pro_data.read(4), "little")
header_version = xml.attrib["version"] if pro_length != len(self.init_data):
if header_version == "4.0.0.0": raise ValueError("The PlayReadyObject seems to be corrupt (too big or small, or missing data).")
key_ids = xml.xpath("DATA/KID/text()") pro_record_count = int.from_bytes(pro_data.read(2), "little")
elif header_version == "4.1.0.0":
key_ids = xml.xpath("DATA/PROTECTINFO/KID/@VALUE") for _ in range(pro_record_count):
elif header_version in ("4.2.0.0", "4.3.0.0"): prr_type = int.from_bytes(pro_data.read(2), "little")
key_ids = xml.xpath("DATA/PROTECTINFO/KIDS/KID/@VALUE") 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: else:
raise ValueError(f"Unsupported PlayReady header version {header_version}") raise ValueError(f"Unsupported PlayReadyHeader version {prr_header_version}")
return [ return [
UUID(bytes=base64.b64decode(key_id)) UUID(bytes=base64.b64decode(key_id))
for key_id in key_ids 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()}") raise ValueError(f"This PSSH is not supported by key_ids() property, {self.dumps()}")
def dump(self) -> bytes: def dump(self) -> bytes:
@ -243,7 +296,7 @@ class PSSH:
"""Export the PSSH object as a full PSSH box in base64 form.""" """Export the PSSH object as a full PSSH box in base64 form."""
return base64.b64encode(self.dump()).decode() 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. 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 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. in an accepted PSSH. It depends on what the License Server is expecting.
""" """
if self.system_id != PSSH.SystemId.PlayReady: if self.system_id == PSSH.SystemId.Widevine:
raise ValueError(f"This is not a PlayReady PSSH, {self.system_id}") raise ValueError("This is already a Widevine PSSH")
cenc_header = WidevinePsshData() widevine_pssh_data = WidevinePsshData(
cenc_header.algorithm = 1 # 0=Clear, 1=AES-CTR key_ids=[x.bytes for x in self.key_ids],
cenc_header.key_ids[:] = [x.bytes for x in self.key_ids] algorithm="AESCTR"
)
if self.version == 1: if self.version == 1:
# ensure both cenc header and box has same Key IDs # ensure both cenc header and box has same Key IDs
# v1 uses both this and within init data for basically no reason # v1 uses both this and within init data for basically no reason
self.__key_ids = self.key_ids 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 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.""" """Overwrite all Key IDs with the specified Key IDs."""
if self.system_id != PSSH.SystemId.Widevine: if self.system_id != PSSH.SystemId.Widevine:
# TODO: Add support for setting the Key IDs in a PlayReady Header # 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}.") raise ValueError(f"Only Widevine PSSH Boxes are supported, not {self.system_id}.")
if not isinstance(key_ids, list): key_id_uuids = self.parse_key_ids(key_ids)
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}")
if self.version == 1 or self.__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 # 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 # 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 = WidevinePsshData()
cenc_header.ParseFromString(self.init_data) cenc_header.ParseFromString(self.init_data)
cenc_header.key_ids[:] = [ cenc_header.key_ids[:] = [
key_id.bytes key_id.bytes
for key_id in key_ids for key_id in key_id_uuids
] ]
self.init_data = cenc_header.SerializeToString() 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
View File

View File

@ -3,21 +3,21 @@ from __future__ import annotations
import base64 import base64
import binascii import binascii
import re import re
from typing import Union, Optional from typing import Optional, Union
import requests import requests
from Crypto.Hash import SHA1 from Crypto.Hash import SHA1
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from Crypto.Signature import pss from Crypto.Signature import pss
from google.protobuf.message import DecodeError 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, \ from pywidevine.cdm import Cdm
SignedDrmCertificate 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 from pywidevine.pssh import PSSH
@ -26,7 +26,7 @@ class RemoteCdm(Cdm):
def __init__( def __init__(
self, self,
device_type: Union[Device.Types, str], device_type: Union[DeviceTypes, str],
system_id: int, system_id: int,
security_level: int, security_level: int,
host: str, host: str,
@ -37,9 +37,9 @@ class RemoteCdm(Cdm):
if not device_type: if not device_type:
raise ValueError("Device Type must be provided") raise ValueError("Device Type must be provided")
if isinstance(device_type, str): if isinstance(device_type, str):
device_type = Device.Types[device_type] device_type = DeviceTypes[device_type]
if not isinstance(device_type, Device.Types): if not isinstance(device_type, DeviceTypes):
raise TypeError(f"Expected device_type to be a {Device.Types!r} not {device_type!r}") raise TypeError(f"Expected device_type to be a {DeviceTypes!r} not {device_type!r}")
if not system_id: if not system_id:
raise ValueError("System ID must be provided") raise ValueError("System ID must be provided")
@ -86,10 +86,10 @@ class RemoteCdm(Cdm):
server = r.headers.get("Server") server = r.headers.get("Server")
if not server or "pywidevine serve" not in server.lower(): 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}).") 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) server_version_re = re.search(r"pywidevine serve v([\d.]+)", server, re.IGNORECASE)
if not server_version: if not server_version_re:
raise ValueError("The pywidevine server API is not stating the version correctly, cannot continue.") 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": if server_version < "1.4.3":
raise ValueError(f"This pywidevine serve API version ({server_version}) is not supported.") raise ValueError(f"This pywidevine serve API version ({server_version}) is not supported.")
@ -185,7 +185,7 @@ class RemoteCdm(Cdm):
self, self,
session_id: bytes, session_id: bytes,
pssh: PSSH, pssh: PSSH,
type_: Union[int, str] = LicenseType.STREAMING, license_type: str = "STREAMING",
privacy_mode: bool = True privacy_mode: bool = True
) -> bytes: ) -> bytes:
if not pssh: if not pssh:
@ -193,20 +193,16 @@ class RemoteCdm(Cdm):
if not isinstance(pssh, PSSH): if not isinstance(pssh, PSSH):
raise InvalidInitData(f"Expected pssh to be a {PSSH}, not {pssh!r}") raise InvalidInitData(f"Expected pssh to be a {PSSH}, not {pssh!r}")
try: if not isinstance(license_type, str):
if isinstance(type_, int): raise InvalidLicenseType(f"Expected license_type to be a {str}, not {license_type!r}")
type_ = LicenseType.Name(int(type_)) if license_type not in LicenseType.keys():
elif isinstance(type_, str): raise InvalidLicenseType(
type_ = LicenseType.Name(LicenseType.Value(type_)) f"Invalid license_type value of '{license_type}'. "
elif isinstance(type_, LicenseType): f"Available values: {LicenseType.keys()}"
type_ = LicenseType.Name(type_) )
else:
raise InvalidLicenseType()
except ValueError:
raise InvalidLicenseType(f"License Type {type_!r} is invalid")
r = self.__session.post( 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={ json={
"session_id": session_id.hex(), "session_id": session_id.hex(),
"init_data": pssh.dumps(), "init_data": pssh.dumps(),
@ -251,7 +247,7 @@ class RemoteCdm(Cdm):
if not isinstance(license_message, SignedMessage): if not isinstance(license_message, SignedMessage):
raise InvalidLicenseMessage(f"Expecting license_response to be a SignedMessage, got {license_message!r}") 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( raise InvalidLicenseMessage(
f"Expecting a LICENSE message, not a " f"Expecting a LICENSE message, not a "
f"'{SignedMessage.MessageType.Name(license_message.type)}' message." f"'{SignedMessage.MessageType.Name(license_message.type)}' message."
@ -301,4 +297,4 @@ class RemoteCdm(Cdm):
] ]
__ALL__ = (RemoteCdm,) __all__ = ("RemoteCdm",)

View File

@ -1,8 +1,9 @@
import base64 import base64
import sys import sys
from pathlib import Path 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 google.protobuf.message import DecodeError
from pywidevine.pssh import PSSH from pywidevine.pssh import PSSH
@ -20,14 +21,14 @@ except ImportError:
from pywidevine import __version__ from pywidevine import __version__
from pywidevine.cdm import Cdm from pywidevine.cdm import Cdm
from pywidevine.device import Device from pywidevine.device import Device
from pywidevine.exceptions import TooManySessions, InvalidSession, SignatureMismatch, InvalidInitData, \ from pywidevine.exceptions import (InvalidContext, InvalidInitData, InvalidLicenseMessage, InvalidLicenseType,
InvalidLicenseType, InvalidLicenseMessage, InvalidContext InvalidSession, SignatureMismatch, TooManySessions)
routes = web.RouteTableDef() routes = web.RouteTableDef()
async def _startup(app: web.Application): async def _startup(app: web.Application) -> None:
app["cdms"]: dict[tuple[str, str], Cdm] = {} app["cdms"] = {}
app["config"]["devices"] = { app["config"]["devices"] = {
path.stem: path path.stem: path
for x in app["config"]["devices"] 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}") 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() app["cdms"].clear()
del app["cdms"] del app["cdms"]
app["config"].clear() app["config"].clear()
@ -46,7 +47,7 @@ async def _cleanup(app: web.Application):
@routes.get("/") @routes.get("/")
async def ping(_) -> web.Response: async def ping(_: Any) -> web.Response:
return web.json_response({ return web.json_response({
"status": 200, "status": 200,
"message": "Pong!" "message": "Pong!"
@ -211,13 +212,15 @@ async def get_service_certificate(request: web.Request) -> web.Response:
}, status=400) }, status=400)
if service_certificate: 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({ return web.json_response({
"status": 200, "status": 200,
"message": "Successfully got the Service Certificate.", "message": "Successfully got the Service Certificate.",
"data": { "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( license_request = cdm.get_license_challenge(
session_id=session_id, session_id=session_id,
pssh=init_data, pssh=init_data,
type_=license_type, license_type=license_type,
privacy_mode=privacy_mode privacy_mode=privacy_mode
) )
except InvalidSession: except InvalidSession:
@ -366,7 +369,7 @@ async def get_keys(request: web.Request) -> web.Response:
session_id = bytes.fromhex(body["session_id"]) session_id = bytes.fromhex(body["session_id"])
# get key type # get key type
key_type = request.match_info["key_type"] key_type: Optional[str] = request.match_info["key_type"]
if key_type == "ALL": if key_type == "ALL":
key_type = None key_type = None
@ -414,26 +417,24 @@ async def get_keys(request: web.Request) -> web.Response:
@web.middleware @web.middleware
async def authentication(request: web.Request, handler) -> web.Response: async def authentication(request: web.Request, handler: Handler) -> web.Response:
response = None
if request.path != "/":
secret_key = request.headers.get("X-Secret-Key") secret_key = request.headers.get("X-Secret-Key")
if not secret_key:
if request.path != "/" and not secret_key:
request.app.logger.debug(f"{request.remote} did not provide authorization.") request.app.logger.debug(f"{request.remote} did not provide authorization.")
response = web.json_response({ response = web.json_response({
"status": "401", "status": "401",
"message": "Secret Key is Empty." "message": "Secret Key is Empty."
}, status=401) }, status=401)
elif secret_key not in request.app["config"]["users"]: elif request.path != "/" and secret_key not in request.app["config"]["users"]:
request.app.logger.debug(f"{request.remote} failed authentication with '{secret_key}'.") request.app.logger.debug(f"{request.remote} failed authentication with '{secret_key}'.")
response = web.json_response({ response = web.json_response({
"status": "401", "status": "401",
"message": "Secret Key is Invalid, the Key is case-sensitive." "message": "Secret Key is Invalid, the Key is case-sensitive."
}, status=401) }, status=401)
else:
if response is None:
try: try:
response = await handler(request) response = await handler(request) # type: ignore[assignment]
except web.HTTPException as e: except web.HTTPException as e:
request.app.logger.error(f"An unexpected error has occurred, {e}") request.app.logger.error(f"An unexpected error has occurred, {e}")
response = web.json_response({ response = web.json_response({
@ -442,13 +443,13 @@ async def authentication(request: web.Request, handler) -> web.Response:
}, status=500) }, status=500)
response.headers.update({ 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 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 = web.Application(middlewares=[authentication])
app.on_startup.append(_startup) app.on_startup.append(_startup)
app.on_cleanup.append(_cleanup) app.on_cleanup.append(_cleanup)

View File

@ -13,3 +13,6 @@ class Session:
self.service_certificate: Optional[SignedDrmCertificate] = None self.service_certificate: Optional[SignedDrmCertificate] = None
self.context: dict[bytes, tuple[bytes, bytes]] = {} self.context: dict[bytes, tuple[bytes, bytes]] = {}
self.keys: list[Key] = [] self.keys: list[Key] = []
__all__ = ("Session",)

View File

@ -4,7 +4,7 @@
# List of Widevine Device (.wvd) file paths to use with serve. # List of Widevine Device (.wvd) file paths to use with serve.
# Note: Each individual user needs explicit permission to use a device listed. # Note: Each individual user needs explicit permission to use a device listed.
devices: 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. # List of User's by Secret Key. The Secret Key must be supplied by the User to use the API.
users: users: