Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9191e0258f | ||
|
|
cabcc1c2c2 | ||
|
|
077a3aa6be | ||
|
|
0d13d4184b | ||
|
|
1064c7953c | ||
|
|
fc77f064ca | ||
|
|
f30ca45550 | ||
|
|
576d7212d5 | ||
|
|
4f32b4b790 | ||
|
|
2e2b5d528a | ||
|
|
2179987986 | ||
|
|
665b77bd24 | ||
|
|
3499c0cf4d | ||
|
|
e4e109b9f3 | ||
|
|
1d606a9e54 | ||
|
|
f36977ef19 | ||
|
|
dd1a355691 | ||
|
|
6eceaaf410 | ||
|
|
bd62b8d131 | ||
|
|
11a2358002 | ||
|
|
f2ed83205b | ||
|
|
796cf7ffb0 | ||
|
|
2c33af79df | ||
|
|
93d9561fac | ||
|
|
c73078b7a9 | ||
|
|
2445297ae8 | ||
|
|
01416f6513 | ||
|
|
60e3ef0201 | ||
|
|
a1844fb195 | ||
|
|
26d81a7bef | ||
|
|
27a701aaea | ||
|
|
2a87d55e20 | ||
|
|
76c7a402eb | ||
|
|
10fb954097 | ||
|
|
9d7eaf4949 | ||
|
|
0537c9666c | ||
|
|
fc47bbb436 | ||
|
|
1ea57865ad | ||
|
|
f09a06857a | ||
|
|
e4f6a23725 | ||
|
|
f21a21712b | ||
|
|
a1494a3742 | ||
|
|
5b13e1a689 | ||
|
|
c9288dc391 | ||
|
|
7640d6fcab | ||
|
|
3d794ad659 | ||
|
|
5788dde7b1 | ||
|
|
ddf755f82f | ||
|
|
e8785fcd84 | ||
|
|
c969d80931 | ||
|
|
f1a38d1966 | ||
|
|
3fe87f2917 | ||
|
|
dc48c11e1a | ||
|
|
97126391c4 | ||
|
|
6a286a4c23 | ||
|
|
4bc0edcca9 | ||
|
|
4f96ee402b | ||
|
|
2ba13f5e07 | ||
|
|
a4d8be683b | ||
|
|
9501c34f60 | ||
|
|
290da707ea | ||
|
|
64ae5709d3 | ||
|
|
5c1b0e89ef | ||
|
|
0c85abb2d4 | ||
|
|
a0fa559255 | ||
|
|
3e1ccaf5ba | ||
|
|
17384a8908 | ||
|
|
7bb9ebf8f7 | ||
|
|
e36411cfaf | ||
|
|
d744ed4c90 | ||
|
|
c7ec596031 | ||
|
|
3536caf5f9 | ||
|
|
58186de464 | ||
|
|
999900278f | ||
|
|
82d99d50d0 | ||
|
|
3afcf9c01c | ||
|
|
3a15c1050a | ||
|
|
71a43a069d | ||
|
|
0bfbbdccc3 | ||
|
|
d1974ad1fb | ||
|
|
7078759cdf | ||
|
|
1cedba7e49 | ||
|
|
b5ac0f45a2 | ||
|
|
8f7cacb10a | ||
|
|
676110c01e | ||
|
|
a3102ded18 | ||
|
|
d9d8074f73 | ||
|
|
fc9a290482 | ||
|
|
f63b94c31d | ||
|
|
ac469383b8 | ||
|
|
b081d66ca2 | ||
|
|
aaf2362634 | ||
|
|
683c3360a5 | ||
|
|
93cdc7f44e | ||
|
|
943968f2c7 | ||
|
|
657f9357f2 | ||
|
|
7cc40e802f | ||
|
|
d62b718f6d | ||
|
|
442a5c9fd6 | ||
|
|
d72607b080 | ||
|
|
60bb779c59 | ||
|
|
e1532b1451 | ||
|
|
e1951d20d0 | ||
|
|
35abd2962f | ||
|
|
b262e115d3 | ||
|
|
95982725c3 | ||
|
|
70e79825b3 | ||
|
|
f2174dfa72 | ||
|
|
fe21bfe88c | ||
|
|
93f70f73c2 | ||
|
|
1442c945cc | ||
|
|
a729648a34 | ||
|
|
3d6ddb8dcd | ||
|
|
b41f09bee4 | ||
|
|
db80776ac0 | ||
|
|
02ca1b00c9 | ||
|
|
7b06a3c053 | ||
|
|
14126c67b1 | ||
|
|
5e93d6321d | ||
|
|
1f389dbab9 | ||
|
|
ac4c8affb0 |
17
.deepsource.toml
Normal file
17
.deepsource.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
version = 1
|
||||
|
||||
exclude_patterns = [
|
||||
"pywidevine/license_protocol_pb2.py"
|
||||
]
|
||||
|
||||
[[analyzers]]
|
||||
name = "python"
|
||||
enabled = true
|
||||
|
||||
[analyzers.meta]
|
||||
runtime_version = "3.x.x"
|
||||
max_line_length = 120
|
||||
|
||||
[[analyzers]]
|
||||
name = "secrets"
|
||||
enabled = false
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
# pywidevine
|
||||
*.wvd
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
217
CHANGELOG.md
217
CHANGELOG.md
@@ -5,7 +5,210 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.1] - 2021-07-21
|
||||
## [1.4.1] - 2022-08-17
|
||||
|
||||
Small patch release for some fixes to the PSSH classes recent face-lift.
|
||||
|
||||
### Changed
|
||||
|
||||
- `PSSH.overwrite_key_ids` static method is now an instance method named `set_key_ids` and works on the current
|
||||
instance instead of making and returning a new one.
|
||||
- `PSSH.get_key_ids` static method is now a property method named `key_ids`. This allows swift access to all the
|
||||
Key IDs of the current access.
|
||||
- `PSSH.from_playready_pssh` class method is now an instance method named `playready_to_widevine` and now converts
|
||||
the current instances values directly. This allows you to more easily instance as any PSSH, then convert afterwards.
|
||||
|
||||
## [1.4.0] - 2022-08-06
|
||||
|
||||
This release is a face-lift for the PSSH class with a moderate amount of Cdm and Serve interface changes.
|
||||
You will likely need to make a moderate amount of changes in your client code, please study the changelog.
|
||||
|
||||
Please note that while it was always privatized as `_sessions`, accessing the Session directly for any purpose was
|
||||
never recommended or supported. With v1.4.0, there will be drastic problems if you continue to do so. One of the
|
||||
few reasons to do that was to get the license keys which is no longer required with CDMs new `get_keys()` method.
|
||||
|
||||
RemoteCdm minimum supported Serve API version is now v1.4.0.
|
||||
|
||||
### Added
|
||||
|
||||
- The PSSH class now has a `new()` method to craft a new PSSH box. The box can be crafted from arbitrary init_data
|
||||
and/or key_ids. If only key_ids is supplied a new Widevine Cenc Header will be created and the key IDs will be put
|
||||
into it. This allows you to make compliant v0 or v1 boxes with as little data as just a Key ID.
|
||||
- The PSSH class now has `dump()` and `dumps()` methods to serialize the data as binary or base64 respectively. It will
|
||||
be serialized as a pymp4 PSSH box, ready to be used in an MP4 file.
|
||||
- Cdm now has a method `get_keys()` to get the keys of the loaded license. This is the alternative to manually
|
||||
accessing the keys by navigating the `_sessions` class instance variable.
|
||||
- Serve API now also has a `/get_keys` endpoint to call the `get_keys()` method of the underlying Cdm session.
|
||||
|
||||
### Changed
|
||||
|
||||
- Cdm and RemoteCdm now expect a PSSH object as the `init_data` param for `get_license_challenge`. You can no longer
|
||||
provide it anything else, that includes base64 or bytes form. It must be a PSSH object.
|
||||
- Serve no longer returns license keys in the response of the `/keys` endpoint.
|
||||
- Serve has changed the endpoint `/challenge` to `/get_license_challenge` and `/keys` to `/parse_license`. This is to
|
||||
be consistent with the method names of the underlying Cdm class.
|
||||
- The PSSH class has been reworked from being a static helper class to a proper PSSH class.
|
||||
- PSSH.from_playready_pssh is now a class method and returns as a PSSH object.
|
||||
|
||||
### Removed
|
||||
|
||||
- PSSH.get_as_box has been removed and merged into the PSSH constructor.
|
||||
- PSSH.from_key_ids has been removed entirely, you should now use `PSSH.new(key_ids=...)` instead.
|
||||
- All uses of a local Session() object has been removed from RemoteCdm. The session is now fully controlled by the
|
||||
remote API and de-synchronization by external alteration or unexpected exceptions is no longer a possibility.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Various uses of the `key_ids` field of WidevinePsshData proto has been fixed in the PSSH class.
|
||||
- Fixed a few Serve API crashes in edge cases with improved error handling on Cdm method calls.
|
||||
|
||||
## [1.3.1] - 2022-08-04
|
||||
|
||||
### Added
|
||||
|
||||
- Cdm and RemoteCdm can now be supplied a string value for `device_type` for scenarios where providing it as a string
|
||||
is more convenient (e.g., from Config files).
|
||||
|
||||
### Fixed
|
||||
|
||||
- The `force_privacy_mode` key no longer needs to be defined at all in the configuration file. This was previously
|
||||
crashing serve APIs if it wasn't set before starting.
|
||||
- RemoteCdm's Server version check will no longer fail under certain serving conditions e.g., Caddy prepending `Caddy`
|
||||
to the Server header value. It also fixes case sensitivity and removed the full url from the header.
|
||||
|
||||
## [1.3.0] - 2022-08-04
|
||||
|
||||
### Added
|
||||
|
||||
- New RemoteCdm class to be used as Client code for the `serve` Remote CDM API server. The RemoteCdm should be used
|
||||
entirely separately from the normal Cdm class. All serve APIs must update to v1.3.0 to be compatible. The RemoteCdm
|
||||
verifies the server version to ensure compatibility. Changes to the serve API schema will be immediately reflected in
|
||||
the RemoteCdm code in the future.
|
||||
- Implemented `/set_service_certificate` endpoint in serve schema as an improved way of setting the service certificate
|
||||
than passing it to `/challenge`.
|
||||
- You can now unset the service certificate by providing an empty service certificate value (or None or null). This
|
||||
includes support for doing so even in serve API and the new RemoteCdm.
|
||||
|
||||
### Changed
|
||||
|
||||
- The Construction of the Cdm object has changed. You can now initialize it with more direct values if you don't want
|
||||
to use the Device class or don't want to use `.wvd` files. To use Device classes, you must now use the
|
||||
`Cdm.from_device()` class method.
|
||||
- The ability to pass the certificate to `/challenge` has been removed. Please use the new `/set_service_certificate`
|
||||
endpoint before calling `/challenge`. You do not need to set it every time. Once per session is enough unless you
|
||||
now want to use a different certificate.
|
||||
|
||||
## [1.2.1] - 2022-08-02
|
||||
|
||||
This release is primarily a maintenance release for `serve` functionality but some Cdm fixes are also present.
|
||||
|
||||
### Added
|
||||
|
||||
- You can now return all License Keys from Serve's `/keys` endpoint by supplying `ALL` as the key type.
|
||||
This adds support for Exchange Systems like Netflix's WidevineExchange MSL scheme. I recommend using `ALL` unless
|
||||
you only want `CONTENT` keys and will not be using any other type of keys including `SIGNING` and `OPERATOR_SESSION`.
|
||||
- Serve now has a `/close` endpoint to close a session. The Cdm has a limit of 50 sessions per user.
|
||||
- Serve now responds with a `Server` header denoting that pywidevine serve is being used, also specifying the version.
|
||||
This allows Clients to selectively support APIs based on version, and also verify the API as being supported at all.
|
||||
- Serve now verifies that all Devices in config actually exist before letting you start serving.
|
||||
|
||||
### Changed
|
||||
|
||||
- Downgraded lxml to >=4.8.0 to support projects using pycaption, which is likely considering the project's topic.
|
||||
- All of Serve's endpoints now have a `/{device}` prefix. E.g., instead of `/challenge/STREAMING`, it's now
|
||||
`/device_name/challenge/STREAMING`. This is to support a multi-device per-user Cdm setup, see Fixed below regarding
|
||||
Serve's Cdm objects.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed support for Raw PSSH values, e.g., Netflix's WidevineExchange MSL Scheme arbitrary init_data value.
|
||||
- The Service Certificate is now saved to the Session in full SignedMessage form instead of just the underlying
|
||||
DrmCertificate. This is so any class inheriting the Cdm (e.g., for Remote capabilities) can sufficiently use
|
||||
and supply the service certificate while being signed.
|
||||
- Serve's /open endpoint will now return a 400 error if there's too many sessions opened.
|
||||
- Serve's Cdm objects with Device initialized are now stored per-user and device name. This fixes the issue where the
|
||||
entire user base has only 50 sessions available to be used. Effectively rate limiting to only 50 users at a time.
|
||||
Since /close endpoint was not implemented yet, there was no way to even close effectively meaning only 50 uses could
|
||||
be done.
|
||||
|
||||
## [1.2.0] - 2022-07-30
|
||||
|
||||
### Added
|
||||
|
||||
- New CLI command `serve` to serve local WVD devices and CDM sessions remotely as a JSON API.
|
||||
- The CLI command `migrate` can now accept a folder path to batch migrate WVD files.
|
||||
- The Cdm now uses custom exceptions where the use case is justified. All custom exceptions are under a parent custom
|
||||
exception to allow catching of any Pywidevine exception.
|
||||
|
||||
### Changed
|
||||
|
||||
- The Cdm has been reworked as a session-based Cdm. You now initialize the Cdm with just the device you wish to use,
|
||||
and now you open sessions with `Cdm.open()` to get a session ID. For usage example see `license` CLI command in
|
||||
`main.py`.
|
||||
- The Cdm no longer requires you to specify `raw` bool parameter. It now supports arbitrary and valid Widevine Cenc
|
||||
Header Data without needing to explicitly specify which it is.
|
||||
- The Cdm `pssh` param has been renamed as `init_data`. Doc-strings have been changed to prioritize explanation of it
|
||||
referring to Widevine Cenc Header rather than PSSH Boxes. This is to show that the Cdm more-so wants Init Data than
|
||||
a PSSH box. The full PSSH is never kept nor ever used, only it's init data is. It still supports PSSH box data.
|
||||
- Cdm `set_service_certificate()` now returns the provider ID string rather than the underlying (and now verified)
|
||||
DrmCertificate. This is because the DrmCertificate is not likely useful and would still be possible to obtain in full
|
||||
but quick access to the Provider ID may be more useful.
|
||||
- License responses can now be only be parsed once by `Cdm.parse_license()`. Any further attempts will raise an
|
||||
InvalidContext exception. This is because context data is now cleared for it's respective License Request once it's
|
||||
parsed to reduce data lingering in memory.
|
||||
- Trove Classifier for Development Status is now 5 (Production/Stable).
|
||||
|
||||
### Removed
|
||||
|
||||
- You can no longer provide a direct `DrmCertificate` to `Cdm.set_service_certificate()` for security reasons.
|
||||
You must provide either a `SignedDrmCertificate` or a `SignedMessage` containing a `SignedDrmCertificate`.
|
||||
- PSSH `from_init_data()` has been removed. It was unused and is unnecessary with improvements to `get_as_box()`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Cdm `set_service_certificate()` now verifies the signature of the provided Certificate. This patches a trivial
|
||||
exploit/workaround that allows an attacker to recover the plaintext Client ID from an encrypted Client ID.
|
||||
- Cdm `parse_license()` now verifies the input message type as a `LICENSE` message.
|
||||
- Cdm `parse_license()` now clears context for the License Request once it's License Response message has been parsed.
|
||||
This reduces data lingering in the `context` dictionary when it may only be needed once.
|
||||
- The Context Availability error handler in Cdm `parse_license()` has been fixed.
|
||||
- Typing of `type_` param of `Cdm.get_license_challenge()` has been fixed.
|
||||
|
||||
## [1.1.1] - 2022-07-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- The --vmp argument of the create-device command is now optional.
|
||||
|
||||
## [1.1.0] - 2022-07-21
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for setting a Service Certificate in SignedDrmCertificate form as well as raw DrmCertificate form.
|
||||
However, It's unlikely for the service to provide the certificate in raw DrmCertificate form without a signature.
|
||||
- Added a CLI command `create-device` to create Widevine Device (`.wvd`) files from RSA PEM/DER Private Keys and
|
||||
Client ID blobs. You can also provide VMP (FileHashes) data which will be merged into the Client ID blob.
|
||||
- Added a CLI command `migrate` that uses `Device.migrate()` and `dump()` to migrate older v1 Widevine Device files
|
||||
to v2.
|
||||
- Added the v1 Structure of Widevine Devices for migration use.
|
||||
- Added `Device.migrate()` class method that effectively loads older format WVD data. You can then use `dumps()` to
|
||||
get back the WVD data in the latest supported format.
|
||||
- Added ability to use Privacy mode on the test command.
|
||||
|
||||
### Changed
|
||||
|
||||
- Set Service Certificates are now stored as the raw underlying DrmCertificate as the signature data is unused by
|
||||
the CDM.
|
||||
- Moved all Widevine Device structures under a Structures class.
|
||||
- I removed the `send_key_control_nonce` flag from all Structures even though it was technically used.
|
||||
This is because the flag was never used as of this project, and I do not want to take up the flag slot.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Devices `dump()` function now uses the correct `type_` parameter when building the struct.
|
||||
- Fixed release date year of v1.0.0 and v1.0.1 in the changelog.
|
||||
|
||||
## [1.0.1] - 2022-07-21
|
||||
|
||||
### Added
|
||||
|
||||
@@ -24,14 +227,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Cdm's `set_service_certificate()` now correctly raises a DecodeError on Decode Error instead of a ValueError.
|
||||
- CDMs `set_service_certificate()` now correctly raises a DecodeError on Decode Error instead of a ValueError.
|
||||
- Context Data will now always match to their corresponding License Responses. This fixes an issue where creating
|
||||
a second challenge would overwrite the context data of the first challenge. Parsing the first challenge after
|
||||
would result in either a key decrypt error, or garbage key data.
|
||||
|
||||
## [1.0.0] - 2021-07-20
|
||||
## [1.0.0] - 2022-07-20
|
||||
|
||||
Initial Release.
|
||||
|
||||
[1.4.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.4.1
|
||||
[1.4.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.4.0
|
||||
[1.3.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.3.1
|
||||
[1.3.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.3.0
|
||||
[1.2.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.2.1
|
||||
[1.2.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.2.0
|
||||
[1.1.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.1.1
|
||||
[1.1.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.1.0
|
||||
[1.0.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.0.1
|
||||
[1.0.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.0.0
|
||||
|
||||
42
README.md
42
README.md
@@ -1,21 +1,35 @@
|
||||
# pywidevine
|
||||
<p align="center">
|
||||
<img src="docs/images/widevine_icon_24.png"> <a href="https://github.com/rlaphoenix/pywidevine">pywidevine</a>
|
||||
<br/>
|
||||
<sup><em>Python Widevine CDM implementation.</em></sup>
|
||||
</p>
|
||||
|
||||
Widevine CDM (Content Decryption Module) implementation in Python.
|
||||
<p align="center">
|
||||
<a href="https://github.com/rlaphoenix/pywidevine/actions/workflows/ci.yml">
|
||||
<img src="https://github.com/rlaphoenix/pywidevine/actions/workflows/ci.yml/badge.svg" alt="Build status">
|
||||
</a>
|
||||
<a href="https://pypi.org/project/pywidevine">
|
||||
<img src="https://img.shields.io/badge/python-3.7%2B-informational" alt="Python version">
|
||||
</a>
|
||||
<a href="https://deepsource.io/gh/rlaphoenix/pywidevine">
|
||||
<img src="https://deepsource.io/gh/rlaphoenix/pywidevine.svg/?label=active+issues" alt="DeepSource">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Disclaimer
|
||||
|
||||
1. This project requires the use of a Google provisioned private key and Client Identification blob.
|
||||
2. Neither of them are provided by this project.
|
||||
3. Public test provisions are available to use for testing this project.
|
||||
4. License Servers have the ability to block requests from test provisions.
|
||||
5. This project does not condone piracy or any action against the terms of the DRM systems.
|
||||
6. All efforts in this project have been the result of Reverse Engineering and Trial & Error.
|
||||
1. This project requires a valid Google-provisioned Private Key and Client Identification blob which are not
|
||||
provided by this project.
|
||||
2. Public test provisions are available and provided by Google to use for testing projects such as this one.
|
||||
3. License Servers have the ability to block requests from any provision, and are likely already blocking test
|
||||
provisions on production endpoints.
|
||||
4. This project does not condone piracy or any action against the terms of the DRM systems.
|
||||
5. All efforts in this project have been the result of Reverse-Engineering, Publicly available research, and Trial
|
||||
& Error.
|
||||
|
||||
## Protocol
|
||||
|
||||

|
||||
|
||||
*Credit*: w3.org
|
||||

|
||||
|
||||
### Web Server
|
||||
|
||||
@@ -60,6 +74,12 @@ been improving its security using math and obscurity for years. It's getting har
|
||||
versions only being beaten by Brute-force style methods. However, they have a huge team of very skilled workers, and
|
||||
making a CDM in C++ has immediate security benefits and a lot of methods to obscure and obfuscate the code.
|
||||
|
||||
## Credit
|
||||
|
||||
- Widevine Icons © Google.
|
||||
- Protocol Overview © https://www.w3.org/TR/encrypted-media -- slightly modified to fit the page better.
|
||||
- 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)
|
||||
|
||||
BIN
docs/images/widevine_icon_24.png
Normal file
BIN
docs/images/widevine_icon_24.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 885 B |
438
poetry.lock
generated
438
poetry.lock
generated
@@ -1,3 +1,69 @@
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.8.1"
|
||||
description = "Async http client/server framework (asyncio)"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
aiosignal = ">=1.1.2"
|
||||
async-timeout = ">=4.0.0a3,<5.0"
|
||||
asynctest = {version = "0.13.0", markers = "python_version < \"3.8\""}
|
||||
attrs = ">=17.3.0"
|
||||
charset-normalizer = ">=2.0,<3.0"
|
||||
frozenlist = ">=1.1.1"
|
||||
multidict = ">=4.5,<7.0"
|
||||
typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""}
|
||||
yarl = ">=1.0,<2.0"
|
||||
|
||||
[package.extras]
|
||||
speedups = ["aiodns", "brotli", "cchardet"]
|
||||
|
||||
[[package]]
|
||||
name = "aiosignal"
|
||||
version = "1.2.0"
|
||||
description = "aiosignal: a list of registered asynchronous callbacks"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
frozenlist = ">=1.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "async-timeout"
|
||||
version = "4.0.2"
|
||||
description = "Timeout context manager for asyncio programs"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = {version = ">=3.6.5", markers = "python_version < \"3.8\""}
|
||||
|
||||
[[package]]
|
||||
name = "asynctest"
|
||||
version = "0.13.0"
|
||||
description = "Enhance the standard unittest package with features for testing asyncio libraries"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.5"
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "21.4.0"
|
||||
description = "Classes Without Boilerplate"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[package.extras]
|
||||
dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
|
||||
docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
|
||||
tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
|
||||
tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2022.6.15"
|
||||
@@ -45,6 +111,14 @@ category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "frozenlist"
|
||||
version = "1.3.0"
|
||||
description = "A list-like structure which implements collections.abc.MutableSequence"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.3"
|
||||
@@ -84,6 +158,14 @@ html5 = ["html5lib"]
|
||||
htmlsoup = ["beautifulsoup4"]
|
||||
source = ["Cython (>=0.29.7)"]
|
||||
|
||||
[[package]]
|
||||
name = "multidict"
|
||||
version = "6.0.2"
|
||||
description = "multidict implementation"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "3.19.3"
|
||||
@@ -111,6 +193,14 @@ python-versions = "*"
|
||||
[package.dependencies]
|
||||
construct = "2.8.8"
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0"
|
||||
description = "YAML parser and emitter for Python"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.28.1"
|
||||
@@ -137,6 +227,14 @@ category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "unidecode"
|
||||
version = "1.3.4"
|
||||
description = "ASCII transliterations of Unicode text"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "1.26.10"
|
||||
@@ -150,6 +248,19 @@ brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"]
|
||||
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "yarl"
|
||||
version = "1.7.2"
|
||||
description = "Yet another URL library"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
idna = ">=2.0"
|
||||
multidict = ">=4.0"
|
||||
typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""}
|
||||
|
||||
[[package]]
|
||||
name = "zipp"
|
||||
version = "3.8.1"
|
||||
@@ -162,12 +273,102 @@ python-versions = ">=3.7"
|
||||
docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"]
|
||||
testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"]
|
||||
|
||||
[extras]
|
||||
serve = ["aiohttp", "PyYAML"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = ">=3.7,<3.11"
|
||||
content-hash = "9c6a76629e0f0a4e98b6c47707899519f930debd70312d2e909eb42f94cd212f"
|
||||
content-hash = "5180330dabbacf34bd4d6faf8f3d4bdcaae26dc7b2951df04010ce54400eb520"
|
||||
|
||||
[metadata.files]
|
||||
aiohttp = [
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1ed0b6477896559f17b9eaeb6d38e07f7f9ffe40b9f0f9627ae8b9926ae260a8"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7dadf3c307b31e0e61689cbf9e06be7a867c563d5a63ce9dca578f956609abf8"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a79004bb58748f31ae1cbe9fa891054baaa46fb106c2dc7af9f8e3304dc30316"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12de6add4038df8f72fac606dff775791a60f113a725c960f2bab01d8b8e6b15"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f0d5f33feb5f69ddd57a4a4bd3d56c719a141080b445cbf18f238973c5c9923"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eaba923151d9deea315be1f3e2b31cc39a6d1d2f682f942905951f4e40200922"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:099ebd2c37ac74cce10a3527d2b49af80243e2a4fa39e7bce41617fbc35fa3c1"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2e5d962cf7e1d426aa0e528a7e198658cdc8aa4fe87f781d039ad75dcd52c516"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fa0ffcace9b3aa34d205d8130f7873fcfefcb6a4dd3dd705b0dab69af6712642"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:61bfc23df345d8c9716d03717c2ed5e27374e0fe6f659ea64edcd27b4b044cf7"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:31560d268ff62143e92423ef183680b9829b1b482c011713ae941997921eebc8"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:01d7bdb774a9acc838e6b8f1d114f45303841b89b95984cbb7d80ea41172a9e3"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:97ef77eb6b044134c0b3a96e16abcb05ecce892965a2124c566af0fd60f717e2"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-win32.whl", hash = "sha256:c2aef4703f1f2ddc6df17519885dbfa3514929149d3ff900b73f45998f2532fa"},
|
||||
{file = "aiohttp-3.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:713ac174a629d39b7c6a3aa757b337599798da4c1157114a314e4e391cd28e32"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:473d93d4450880fe278696549f2e7aed8cd23708c3c1997981464475f32137db"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b5eeae8e019e7aad8af8bb314fb908dd2e028b3cdaad87ec05095394cce632"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3af642b43ce56c24d063325dd2cf20ee012d2b9ba4c3c008755a301aaea720ad"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3630c3ef435c0a7c549ba170a0633a56e92629aeed0e707fec832dee313fb7a"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4a4a4e30bf1edcad13fb0804300557aedd07a92cabc74382fdd0ba6ca2661091"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6f8b01295e26c68b3a1b90efb7a89029110d3a4139270b24fda961893216c440"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:a25fa703a527158aaf10dafd956f7d42ac6d30ec80e9a70846253dd13e2f067b"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5bfde62d1d2641a1f5173b8c8c2d96ceb4854f54a44c23102e2ccc7e02f003ec"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:51467000f3647d519272392f484126aa716f747859794ac9924a7aafa86cd411"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:03a6d5349c9ee8f79ab3ff3694d6ce1cfc3ced1c9d36200cb8f08ba06bd3b782"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:102e487eeb82afac440581e5d7f8f44560b36cf0bdd11abc51a46c1cd88914d4"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-win32.whl", hash = "sha256:4aed991a28ea3ce320dc8ce655875e1e00a11bdd29fe9444dd4f88c30d558602"},
|
||||
{file = "aiohttp-3.8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b0e20cddbd676ab8a64c774fefa0ad787cc506afd844de95da56060348021e96"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:37951ad2f4a6df6506750a23f7cbabad24c73c65f23f72e95897bb2cecbae676"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c23b1ad869653bc818e972b7a3a79852d0e494e9ab7e1a701a3decc49c20d51"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15b09b06dae900777833fe7fc4b4aa426556ce95847a3e8d7548e2d19e34edb8"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:477c3ea0ba410b2b56b7efb072c36fa91b1e6fc331761798fa3f28bb224830dd"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2f2f69dca064926e79997f45b2f34e202b320fd3782f17a91941f7eb85502ee2"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ef9612483cb35171d51d9173647eed5d0069eaa2ee812793a75373447d487aa4"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6d69f36d445c45cda7b3b26afef2fc34ef5ac0cdc75584a87ef307ee3c8c6d00"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:55c3d1072704d27401c92339144d199d9de7b52627f724a949fc7d5fc56d8b93"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:b9d00268fcb9f66fbcc7cd9fe423741d90c75ee029a1d15c09b22d23253c0a44"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:07b05cd3305e8a73112103c834e91cd27ce5b4bd07850c4b4dbd1877d3f45be7"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c34dc4958b232ef6188c4318cb7b2c2d80521c9a56c52449f8f93ab7bc2a8a1c"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-win32.whl", hash = "sha256:d2f9b69293c33aaa53d923032fe227feac867f81682f002ce33ffae978f0a9a9"},
|
||||
{file = "aiohttp-3.8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:6ae828d3a003f03ae31915c31fa684b9890ea44c9c989056fea96e3d12a9fa17"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0c7ebbbde809ff4e970824b2b6cb7e4222be6b95a296e46c03cf050878fc1785"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b7ef7cbd4fec9a1e811a5de813311ed4f7ac7d93e0fda233c9b3e1428f7dd7b"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c3d6a4d0619e09dcd61021debf7059955c2004fa29f48788a3dfaf9c9901a7cd"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:718626a174e7e467f0558954f94af117b7d4695d48eb980146016afa4b580b2e"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:589c72667a5febd36f1315aa6e5f56dd4aa4862df295cb51c769d16142ddd7cd"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ed076098b171573161eb146afcb9129b5ff63308960aeca4b676d9d3c35e700"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:086f92daf51a032d062ec5f58af5ca6a44d082c35299c96376a41cbb33034675"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:11691cf4dc5b94236ccc609b70fec991234e7ef8d4c02dd0c9668d1e486f5abf"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:31d1e1c0dbf19ebccbfd62eff461518dcb1e307b195e93bba60c965a4dcf1ba0"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:11a67c0d562e07067c4e86bffc1553f2cf5b664d6111c894671b2b8712f3aba5"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:bb01ba6b0d3f6c68b89fce7305080145d4877ad3acaed424bae4d4ee75faa950"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:44db35a9e15d6fe5c40d74952e803b1d96e964f683b5a78c3cc64eb177878155"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:844a9b460871ee0a0b0b68a64890dae9c415e513db0f4a7e3cab41a0f2fedf33"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-win32.whl", hash = "sha256:7d08744e9bae2ca9c382581f7dce1273fe3c9bae94ff572c3626e8da5b193c6a"},
|
||||
{file = "aiohttp-3.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:04d48b8ce6ab3cf2097b1855e1505181bdd05586ca275f2505514a6e274e8e75"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f5315a2eb0239185af1bddb1abf472d877fede3cc8d143c6cddad37678293237"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a996d01ca39b8dfe77440f3cd600825d05841088fd6bc0144cc6c2ec14cc5f74"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:13487abd2f761d4be7c8ff9080de2671e53fff69711d46de703c310c4c9317ca"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea302f34477fda3f85560a06d9ebdc7fa41e82420e892fc50b577e35fc6a50b2"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2f635ce61a89c5732537a7896b6319a8fcfa23ba09bec36e1b1ac0ab31270d2"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e999f2d0e12eea01caeecb17b653f3713d758f6dcc770417cf29ef08d3931421"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0770e2806a30e744b4e21c9d73b7bee18a1cfa3c47991ee2e5a65b887c49d5cf"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d15367ce87c8e9e09b0f989bfd72dc641bcd04ba091c68cd305312d00962addd"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6c7cefb4b0640703eb1069835c02486669312bf2f12b48a748e0a7756d0de33d"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:71927042ed6365a09a98a6377501af5c9f0a4d38083652bcd2281a06a5976724"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:28d490af82bc6b7ce53ff31337a18a10498303fe66f701ab65ef27e143c3b0ef"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:b6613280ccedf24354406caf785db748bebbddcf31408b20c0b48cb86af76866"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81e3d8c34c623ca4e36c46524a3530e99c0bc95ed068fd6e9b55cb721d408fb2"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-win32.whl", hash = "sha256:7187a76598bdb895af0adbd2fb7474d7f6025d170bc0a1130242da817ce9e7d1"},
|
||||
{file = "aiohttp-3.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:1c182cb873bc91b411e184dab7a2b664d4fea2743df0e4d57402f7f3fa644bac"},
|
||||
{file = "aiohttp-3.8.1.tar.gz", hash = "sha256:fc5471e1a54de15ef71c1bc6ebe80d4dc681ea600e68bfd1cbce40427f0b7578"},
|
||||
]
|
||||
aiosignal = [
|
||||
{file = "aiosignal-1.2.0-py3-none-any.whl", hash = "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a"},
|
||||
{file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"},
|
||||
]
|
||||
async-timeout = [
|
||||
{file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"},
|
||||
{file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"},
|
||||
]
|
||||
asynctest = []
|
||||
attrs = [
|
||||
{file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"},
|
||||
{file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
|
||||
]
|
||||
certifi = []
|
||||
charset-normalizer = []
|
||||
click = [
|
||||
@@ -181,6 +382,67 @@ colorama = [
|
||||
construct = [
|
||||
{file = "construct-2.8.8.tar.gz", hash = "sha256:1b84b8147f6fd15bcf64b737c3e8ac5100811ad80c830cb4b2545140511c4157"},
|
||||
]
|
||||
frozenlist = [
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2257aaba9660f78c7b1d8fea963b68f3feffb1a9d5d05a18401ca9eb3e8d0a3"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4a44ebbf601d7bac77976d429e9bdb5a4614f9f4027777f9e54fd765196e9d3b"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:45334234ec30fc4ea677f43171b18a27505bfb2dba9aca4398a62692c0ea8868"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47be22dc27ed933d55ee55845d34a3e4e9f6fee93039e7f8ebadb0c2f60d403f"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03a7dd1bfce30216a3f51a84e6dd0e4a573d23ca50f0346634916ff105ba6e6b"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:691ddf6dc50480ce49f68441f1d16a4c3325887453837036e0fb94736eae1e58"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bde99812f237f79eaf3f04ebffd74f6718bbd216101b35ac7955c2d47c17da02"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a202458d1298ced3768f5a7d44301e7c86defac162ace0ab7434c2e961166e8"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9e3e9e365991f8cc5f5edc1fd65b58b41d0514a6a7ad95ef5c7f34eb49b3d3e"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:04cb491c4b1c051734d41ea2552fde292f5f3a9c911363f74f39c23659c4af78"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:436496321dad302b8b27ca955364a439ed1f0999311c393dccb243e451ff66aa"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:754728d65f1acc61e0f4df784456106e35afb7bf39cfe37227ab00436fb38676"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6eb275c6385dd72594758cbe96c07cdb9bd6becf84235f4a594bdf21e3596c9d"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-win32.whl", hash = "sha256:e30b2f9683812eb30cf3f0a8e9f79f8d590a7999f731cf39f9105a7c4a39489d"},
|
||||
{file = "frozenlist-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:f7353ba3367473d1d616ee727945f439e027f0bb16ac1a750219a8344d1d5d3c"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88aafd445a233dbbf8a65a62bc3249a0acd0d81ab18f6feb461cc5a938610d24"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4406cfabef8f07b3b3af0f50f70938ec06d9f0fc26cbdeaab431cbc3ca3caeaa"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8cf829bd2e2956066dd4de43fd8ec881d87842a06708c035b37ef632930505a2"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:603b9091bd70fae7be28bdb8aa5c9990f4241aa33abb673390a7f7329296695f"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25af28b560e0c76fa41f550eacb389905633e7ac02d6eb3c09017fa1c8cdfde1"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94c7a8a9fc9383b52c410a2ec952521906d355d18fccc927fca52ab575ee8b93"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:65bc6e2fece04e2145ab6e3c47428d1bbc05aede61ae365b2c1bddd94906e478"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:3f7c935c7b58b0d78c0beea0c7358e165f95f1fd8a7e98baa40d22a05b4a8141"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd89acd1b8bb4f31b47072615d72e7f53a948d302b7c1d1455e42622de180eae"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:6983a31698490825171be44ffbafeaa930ddf590d3f051e397143a5045513b01"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:adac9700675cf99e3615eb6a0eb5e9f5a4143c7d42c05cea2e7f71c27a3d0846"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-win32.whl", hash = "sha256:0c36e78b9509e97042ef869c0e1e6ef6429e55817c12d78245eb915e1cca7468"},
|
||||
{file = "frozenlist-1.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:57f4d3f03a18facacb2a6bcd21bccd011e3b75d463dc49f838fd699d074fabd1"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8c905a5186d77111f02144fab5b849ab524f1e876a1e75205cd1386a9be4b00a"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b5009062d78a8c6890d50b4e53b0ddda31841b3935c1937e2ed8c1bda1c7fb9d"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2fdc3cd845e5a1f71a0c3518528bfdbfe2efaf9886d6f49eacc5ee4fd9a10953"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92e650bd09b5dda929523b9f8e7f99b24deac61240ecc1a32aeba487afcd970f"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:40dff8962b8eba91fd3848d857203f0bd704b5f1fa2b3fc9af64901a190bba08"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:768efd082074bb203c934e83a61654ed4931ef02412c2fbdecea0cff7ecd0274"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:006d3595e7d4108a12025ddf415ae0f6c9e736e726a5db0183326fd191b14c5e"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:871d42623ae15eb0b0e9df65baeee6976b2e161d0ba93155411d58ff27483ad8"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aff388be97ef2677ae185e72dc500d19ecaf31b698986800d3fc4f399a5e30a5"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9f892d6a94ec5c7b785e548e42722e6f3a52f5f32a8461e82ac3e67a3bd073f1"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:e982878792c971cbd60ee510c4ee5bf089a8246226dea1f2138aa0bb67aff148"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:c6c321dd013e8fc20735b92cb4892c115f5cdb82c817b1e5b07f6b95d952b2f0"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:30530930410855c451bea83f7b272fb1c495ed9d5cc72895ac29e91279401db3"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-win32.whl", hash = "sha256:40ec383bc194accba825fbb7d0ef3dda5736ceab2375462f1d8672d9f6b68d07"},
|
||||
{file = "frozenlist-1.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:f20baa05eaa2bcd5404c445ec51aed1c268d62600362dc6cfe04fae34a424bd9"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0437fe763fb5d4adad1756050cbf855bbb2bf0d9385c7bb13d7a10b0dd550486"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b684c68077b84522b5c7eafc1dc735bfa5b341fb011d5552ebe0968e22ed641c"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:93641a51f89473837333b2f8100f3f89795295b858cd4c7d4a1f18e299dc0a4f"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6d32ff213aef0fd0bcf803bffe15cfa2d4fde237d1d4838e62aec242a8362fa"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31977f84828b5bb856ca1eb07bf7e3a34f33a5cddce981d880240ba06639b94d"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c62964192a1c0c30b49f403495911298810bada64e4f03249ca35a33ca0417a"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4eda49bea3602812518765810af732229b4291d2695ed24a0a20e098c45a707b"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acb267b09a509c1df5a4ca04140da96016f40d2ed183cdc356d237286c971b51"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e1e26ac0a253a2907d654a37e390904426d5ae5483150ce3adedb35c8c06614a"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f96293d6f982c58ebebb428c50163d010c2f05de0cde99fd681bfdc18d4b2dc2"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:e84cb61b0ac40a0c3e0e8b79c575161c5300d1d89e13c0e02f76193982f066ed"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:ff9310f05b9d9c5c4dd472983dc956901ee6cb2c3ec1ab116ecdde25f3ce4951"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d26b650b71fdc88065b7a21f8ace70175bcf3b5bdba5ea22df4bfd893e795a3b"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-win32.whl", hash = "sha256:01a73627448b1f2145bddb6e6c2259988bb8aee0fb361776ff8604b99616cd08"},
|
||||
{file = "frozenlist-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:772965f773757a6026dea111a15e6e2678fbd6216180f82a48a40b27de1ee2ab"},
|
||||
{file = "frozenlist-1.3.0.tar.gz", hash = "sha256:ce6f2ba0edb7b0c1d8976565298ad2deba6f8064d2bebb6ffce2ca896eb35b0b"},
|
||||
]
|
||||
idna = [
|
||||
{file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
|
||||
{file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
|
||||
@@ -190,10 +452,184 @@ importlib-metadata = [
|
||||
{file = "importlib_metadata-4.12.0.tar.gz", hash = "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670"},
|
||||
]
|
||||
lxml = []
|
||||
multidict = [
|
||||
{file = "multidict-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b9e95a740109c6047602f4db4da9949e6c5945cefbad34a1299775ddc9a62e2"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac0e27844758d7177989ce406acc6a83c16ed4524ebc363c1f748cba184d89d3"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:041b81a5f6b38244b34dc18c7b6aba91f9cdaf854d9a39e5ff0b58e2b5773b9c"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fdda29a3c7e76a064f2477c9aab1ba96fd94e02e386f1e665bca1807fc5386f"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3368bf2398b0e0fcbf46d85795adc4c259299fec50c1416d0f77c0a843a3eed9"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4f052ee022928d34fe1f4d2bc743f32609fb79ed9c49a1710a5ad6b2198db20"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:225383a6603c086e6cef0f2f05564acb4f4d5f019a4e3e983f572b8530f70c88"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50bd442726e288e884f7be9071016c15a8742eb689a593a0cac49ea093eef0a7"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:47e6a7e923e9cada7c139531feac59448f1f47727a79076c0b1ee80274cd8eee"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0556a1d4ea2d949efe5fd76a09b4a82e3a4a30700553a6725535098d8d9fb672"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:626fe10ac87851f4cffecee161fc6f8f9853f0f6f1035b59337a51d29ff3b4f9"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:8064b7c6f0af936a741ea1efd18690bacfbae4078c0c385d7c3f611d11f0cf87"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2d36e929d7f6a16d4eb11b250719c39560dd70545356365b494249e2186bc389"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-win32.whl", hash = "sha256:fcb91630817aa8b9bc4a74023e4198480587269c272c58b3279875ed7235c293"},
|
||||
{file = "multidict-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:8cbf0132f3de7cc6c6ce00147cc78e6439ea736cee6bca4f068bcf892b0fd658"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:05f6949d6169878a03e607a21e3b862eaf8e356590e8bdae4227eedadacf6e51"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2c2e459f7050aeb7c1b1276763364884595d47000c1cddb51764c0d8976e608"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0509e469d48940147e1235d994cd849a8f8195e0bca65f8f5439c56e17872a3"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:514fe2b8d750d6cdb4712346a2c5084a80220821a3e91f3f71eec11cf8d28fd4"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19adcfc2a7197cdc3987044e3f415168fc5dc1f720c932eb1ef4f71a2067e08b"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9d153e7f1f9ba0b23ad1568b3b9e17301e23b042c23870f9ee0522dc5cc79e8"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:aef9cc3d9c7d63d924adac329c33835e0243b5052a6dfcbf7732a921c6e918ba"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4571f1beddff25f3e925eea34268422622963cd8dc395bb8778eb28418248e43"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:d48b8ee1d4068561ce8033d2c344cf5232cb29ee1a0206a7b828c79cbc5982b8"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:45183c96ddf61bf96d2684d9fbaf6f3564d86b34cb125761f9a0ef9e36c1d55b"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:75bdf08716edde767b09e76829db8c1e5ca9d8bb0a8d4bd94ae1eafe3dac5e15"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-win32.whl", hash = "sha256:a45e1135cb07086833ce969555df39149680e5471c04dfd6a915abd2fc3f6dbc"},
|
||||
{file = "multidict-6.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6f3cdef8a247d1eafa649085812f8a310e728bdf3900ff6c434eafb2d443b23a"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0327292e745a880459ef71be14e709aaea2f783f3537588fb4ed09b6c01bca60"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e875b6086e325bab7e680e4316d667fc0e5e174bb5611eb16b3ea121c8951b86"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:feea820722e69451743a3d56ad74948b68bf456984d63c1a92e8347b7b88452d"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc57c68cb9139c7cd6fc39f211b02198e69fb90ce4bc4a094cf5fe0d20fd8b0"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:497988d6b6ec6ed6f87030ec03280b696ca47dbf0648045e4e1d28b80346560d"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89171b2c769e03a953d5969b2f272efa931426355b6c0cb508022976a17fd376"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:684133b1e1fe91eda8fa7447f137c9490a064c6b7f392aa857bba83a28cfb693"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd9fc9c4849a07f3635ccffa895d57abce554b467d611a5009ba4f39b78a8849"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e07c8e79d6e6fd37b42f3250dba122053fddb319e84b55dd3a8d6446e1a7ee49"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4070613ea2227da2bfb2c35a6041e4371b0af6b0be57f424fe2318b42a748516"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:47fbeedbf94bed6547d3aa632075d804867a352d86688c04e606971595460227"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:5774d9218d77befa7b70d836004a768fb9aa4fdb53c97498f4d8d3f67bb9cfa9"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2957489cba47c2539a8eb7ab32ff49101439ccf78eab724c828c1a54ff3ff98d"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-win32.whl", hash = "sha256:e5b20e9599ba74391ca0cfbd7b328fcc20976823ba19bc573983a25b32e92b57"},
|
||||
{file = "multidict-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:8004dca28e15b86d1b1372515f32eb6f814bdf6f00952699bdeb541691091f96"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2e4a0785b84fb59e43c18a015ffc575ba93f7d1dbd272b4cdad9f5134b8a006c"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6701bf8a5d03a43375909ac91b6980aea74b0f5402fbe9428fc3f6edf5d9677e"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a007b1638e148c3cfb6bf0bdc4f82776cef0ac487191d093cdc316905e504071"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07a017cfa00c9890011628eab2503bee5872f27144936a52eaab449be5eaf032"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c207fff63adcdf5a485969131dc70e4b194327666b7e8a87a97fbc4fd80a53b2"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:373ba9d1d061c76462d74e7de1c0c8e267e9791ee8cfefcf6b0b2495762c370c"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfba7c6d5d7c9099ba21f84662b037a0ffd4a5e6b26ac07d19e423e6fdf965a9"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19d9bad105dfb34eb539c97b132057a4e709919ec4dd883ece5838bcbf262b80"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:de989b195c3d636ba000ee4281cd03bb1234635b124bf4cd89eeee9ca8fcb09d"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7c40b7bbece294ae3a87c1bc2abff0ff9beef41d14188cda94ada7bcea99b0fb"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:d16cce709ebfadc91278a1c005e3c17dd5f71f5098bfae1035149785ea6e9c68"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:a2c34a93e1d2aa35fbf1485e5010337c72c6791407d03aa5f4eed920343dd360"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:feba80698173761cddd814fa22e88b0661e98cb810f9f986c54aa34d281e4937"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-win32.whl", hash = "sha256:23b616fdc3c74c9fe01d76ce0d1ce872d2d396d8fa8e4899398ad64fb5aa214a"},
|
||||
{file = "multidict-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:4bae31803d708f6f15fd98be6a6ac0b6958fcf68fda3c77a048a4f9073704aae"},
|
||||
{file = "multidict-6.0.2.tar.gz", hash = "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013"},
|
||||
]
|
||||
protobuf = []
|
||||
pycryptodome = []
|
||||
pymp4 = []
|
||||
pyyaml = [
|
||||
{file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
|
||||
{file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
|
||||
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"},
|
||||
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"},
|
||||
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"},
|
||||
{file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"},
|
||||
{file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"},
|
||||
{file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"},
|
||||
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"},
|
||||
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"},
|
||||
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"},
|
||||
{file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"},
|
||||
{file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"},
|
||||
{file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"},
|
||||
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"},
|
||||
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"},
|
||||
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"},
|
||||
{file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"},
|
||||
{file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"},
|
||||
{file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"},
|
||||
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"},
|
||||
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"},
|
||||
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"},
|
||||
{file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"},
|
||||
{file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"},
|
||||
{file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"},
|
||||
{file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"},
|
||||
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"},
|
||||
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"},
|
||||
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"},
|
||||
{file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"},
|
||||
{file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
|
||||
{file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
|
||||
]
|
||||
requests = []
|
||||
typing-extensions = []
|
||||
unidecode = [
|
||||
{file = "Unidecode-1.3.4-py3-none-any.whl", hash = "sha256:afa04efcdd818a93237574791be9b2817d7077c25a068b00f8cff7baa4e59257"},
|
||||
{file = "Unidecode-1.3.4.tar.gz", hash = "sha256:8e4352fb93d5a735c788110d2e7ac8e8031eb06ccbfe8d324ab71735015f9342"},
|
||||
]
|
||||
urllib3 = []
|
||||
yarl = [
|
||||
{file = "yarl-1.7.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2a8508f7350512434e41065684076f640ecce176d262a7d54f0da41d99c5a95"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da6df107b9ccfe52d3a48165e48d72db0eca3e3029b5b8cb4fe6ee3cb870ba8b"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1d0894f238763717bdcfea74558c94e3bc34aeacd3351d769460c1a586a8b05"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfe4b95b7e00c6635a72e2d00b478e8a28bfb122dc76349a06e20792eb53a523"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c145ab54702334c42237a6c6c4cc08703b6aa9b94e2f227ceb3d477d20c36c63"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ca56f002eaf7998b5fcf73b2421790da9d2586331805f38acd9997743114e98"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1d3d5ad8ea96bd6d643d80c7b8d5977b4e2fb1bab6c9da7322616fd26203d125"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:167ab7f64e409e9bdd99333fe8c67b5574a1f0495dcfd905bc7454e766729b9e"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:95a1873b6c0dd1c437fb3bb4a4aaa699a48c218ac7ca1e74b0bee0ab16c7d60d"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6152224d0a1eb254f97df3997d79dadd8bb2c1a02ef283dbb34b97d4f8492d23"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:5bb7d54b8f61ba6eee541fba4b83d22b8a046b4ef4d8eb7f15a7e35db2e1e245"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:9c1f083e7e71b2dd01f7cd7434a5f88c15213194df38bc29b388ccdf1492b739"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f44477ae29025d8ea87ec308539f95963ffdc31a82f42ca9deecf2d505242e72"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-win32.whl", hash = "sha256:cff3ba513db55cc6a35076f32c4cdc27032bd075c9faef31fec749e64b45d26c"},
|
||||
{file = "yarl-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:c9c6d927e098c2d360695f2e9d38870b2e92e0919be07dbe339aefa32a090265"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9b4c77d92d56a4c5027572752aa35082e40c561eec776048330d2907aead891d"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c01a89a44bb672c38f42b49cdb0ad667b116d731b3f4c896f72302ff77d71656"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c19324a1c5399b602f3b6e7db9478e5b1adf5cf58901996fc973fe4fccd73eed"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3abddf0b8e41445426d29f955b24aeecc83fa1072be1be4e0d194134a7d9baee"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6a1a9fe17621af43e9b9fcea8bd088ba682c8192d744b386ee3c47b56eaabb2c"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8b0915ee85150963a9504c10de4e4729ae700af11df0dc5550e6587ed7891e92"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:29e0656d5497733dcddc21797da5a2ab990c0cb9719f1f969e58a4abac66234d"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:bf19725fec28452474d9887a128e98dd67eee7b7d52e932e6949c532d820dc3b"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:d6f3d62e16c10e88d2168ba2d065aa374e3c538998ed04996cd373ff2036d64c"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:ac10bbac36cd89eac19f4e51c032ba6b412b3892b685076f4acd2de18ca990aa"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:aa32aaa97d8b2ed4e54dc65d241a0da1c627454950f7d7b1f95b13985afd6c5d"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-win32.whl", hash = "sha256:87f6e082bce21464857ba58b569370e7b547d239ca22248be68ea5d6b51464a1"},
|
||||
{file = "yarl-1.7.2-cp36-cp36m-win_amd64.whl", hash = "sha256:ac35ccde589ab6a1870a484ed136d49a26bcd06b6a1c6397b1967ca13ceb3913"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a467a431a0817a292121c13cbe637348b546e6ef47ca14a790aa2fa8cc93df63"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ab0c3274d0a846840bf6c27d2c60ba771a12e4d7586bf550eefc2df0b56b3b4"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d260d4dc495c05d6600264a197d9d6f7fc9347f21d2594926202fd08cf89a8ba"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fc4dd8b01a8112809e6b636b00f487846956402834a7fd59d46d4f4267181c41"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c1164a2eac148d85bbdd23e07dfcc930f2e633220f3eb3c3e2a25f6148c2819e"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:67e94028817defe5e705079b10a8438b8cb56e7115fa01640e9c0bb3edf67332"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:89ccbf58e6a0ab89d487c92a490cb5660d06c3a47ca08872859672f9c511fc52"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8cce6f9fa3df25f55521fbb5c7e4a736683148bcc0c75b21863789e5185f9185"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:211fcd65c58bf250fb994b53bc45a442ddc9f441f6fec53e65de8cba48ded986"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c10ea1e80a697cf7d80d1ed414b5cb8f1eec07d618f54637067ae3c0334133c4"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:52690eb521d690ab041c3919666bea13ab9fbff80d615ec16fa81a297131276b"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-win32.whl", hash = "sha256:695ba021a9e04418507fa930d5f0704edbce47076bdcfeeaba1c83683e5649d1"},
|
||||
{file = "yarl-1.7.2-cp37-cp37m-win_amd64.whl", hash = "sha256:c17965ff3706beedafd458c452bf15bac693ecd146a60a06a214614dc097a271"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fce78593346c014d0d986b7ebc80d782b7f5e19843ca798ed62f8e3ba8728576"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c2a1ac41a6aa980db03d098a5531f13985edcb451bcd9d00670b03129922cd0d"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:39d5493c5ecd75c8093fa7700a2fb5c94fe28c839c8e40144b7ab7ccba6938c8"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1eb6480ef366d75b54c68164094a6a560c247370a68c02dddb11f20c4c6d3c9d"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ba63585a89c9885f18331a55d25fe81dc2d82b71311ff8bd378fc8004202ff6"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e39378894ee6ae9f555ae2de332d513a5763276a9265f8e7cbaeb1b1ee74623a"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c0910c6b6c31359d2f6184828888c983d54d09d581a4a23547a35f1d0b9484b1"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6feca8b6bfb9eef6ee057628e71e1734caf520a907b6ec0d62839e8293e945c0"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8300401dc88cad23f5b4e4c1226f44a5aa696436a4026e456fe0e5d2f7f486e6"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:788713c2896f426a4e166b11f4ec538b5736294ebf7d5f654ae445fd44270832"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:fd547ec596d90c8676e369dd8a581a21227fe9b4ad37d0dc7feb4ccf544c2d59"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:737e401cd0c493f7e3dd4db72aca11cfe069531c9761b8ea474926936b3c57c8"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:baf81561f2972fb895e7844882898bda1eef4b07b5b385bcd308d2098f1a767b"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-win32.whl", hash = "sha256:ede3b46cdb719c794427dcce9d8beb4abe8b9aa1e97526cc20de9bd6583ad1ef"},
|
||||
{file = "yarl-1.7.2-cp38-cp38-win_amd64.whl", hash = "sha256:cc8b7a7254c0fc3187d43d6cb54b5032d2365efd1df0cd1749c0c4df5f0ad45f"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:580c1f15500e137a8c37053e4cbf6058944d4c114701fa59944607505c2fe3a0"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ec1d9a0d7780416e657f1e405ba35ec1ba453a4f1511eb8b9fbab81cb8b3ce1"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3bf8cfe8856708ede6a73907bf0501f2dc4e104085e070a41f5d88e7faf237f3"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1be4bbb3d27a4e9aa5f3df2ab61e3701ce8fcbd3e9846dbce7c033a7e8136746"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:534b047277a9a19d858cde163aba93f3e1677d5acd92f7d10ace419d478540de"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6ddcd80d79c96eb19c354d9dca95291589c5954099836b7c8d29278a7ec0bda"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9bfcd43c65fbb339dc7086b5315750efa42a34eefad0256ba114cd8ad3896f4b"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f64394bd7ceef1237cc604b5a89bf748c95982a84bcd3c4bbeb40f685c810794"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044daf3012e43d4b3538562da94a88fb12a6490652dbc29fb19adfa02cf72eac"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:368bcf400247318382cc150aaa632582d0780b28ee6053cd80268c7e72796dec"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:bab827163113177aee910adb1f48ff7af31ee0289f434f7e22d10baf624a6dfe"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0cba38120db72123db7c58322fa69e3c0efa933040ffb586c3a87c063ec7cae8"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:59218fef177296451b23214c91ea3aba7858b4ae3306dde120224cfe0f7a6ee8"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-win32.whl", hash = "sha256:1edc172dcca3f11b38a9d5c7505c83c1913c0addc99cd28e993efeaafdfaa18d"},
|
||||
{file = "yarl-1.7.2-cp39-cp39-win_amd64.whl", hash = "sha256:797c2c412b04403d2da075fb93c123df35239cd7b4cc4e0cd9e5839b73f52c58"},
|
||||
{file = "yarl-1.7.2.tar.gz", hash = "sha256:45399b46d60c253327a460e99856752009fcee5f5d3c80b2f7c0cae1c38d56dd"},
|
||||
]
|
||||
zipp = []
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry]
|
||||
name = "pywidevine"
|
||||
version = "1.0.1"
|
||||
version = "1.4.1"
|
||||
description = "Widevine CDM (Content Decryption Module) implementation in Python."
|
||||
authors = ["rlaphoenix <rlaphoenix@pm.me>"]
|
||||
license = "GPL-3.0-only"
|
||||
@@ -12,7 +12,7 @@ readme = "README.md"
|
||||
repository = "https://github.com/rlaphoenix/pywidevine"
|
||||
keywords = ["widevine", "drm", "google"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Natural Language :: English",
|
||||
@@ -24,6 +24,7 @@ classifiers = [
|
||||
[tool.poetry.urls]
|
||||
"Bug Tracker" = "https://github.com/rlaphoenix/pywidevine/issues"
|
||||
"Forums" = "https://github.com/rlaphoenix/pywidevine/discussions"
|
||||
"Changelog" = "https://github.com/rlaphoenix/pywidevine/blob/master/CHANGELOG.md"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.7,<3.11"
|
||||
@@ -32,7 +33,13 @@ pymp4 = "^1.2.0"
|
||||
pycryptodome = "^3.15.0"
|
||||
click = "^8.1.3"
|
||||
requests = "^2.28.1"
|
||||
lxml = "^4.9.1"
|
||||
lxml = ">=4.8.0"
|
||||
Unidecode = "^1.3.4"
|
||||
aiohttp = {version = "^3.8.1", optional = true}
|
||||
PyYAML = {version = "^6.0", optional = true}
|
||||
|
||||
[tool.poetry.extras]
|
||||
serve = ["aiohttp", "PyYAML"]
|
||||
|
||||
[tool.poetry.scripts]
|
||||
pywidevine = "pywidevine.main:main"
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.0.1"
|
||||
__version__ = "1.4.1"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -13,15 +16,17 @@ from Crypto.PublicKey import RSA
|
||||
from Crypto.Random import get_random_bytes
|
||||
from Crypto.Signature import pss
|
||||
from Crypto.Util import Padding
|
||||
from construct import Container
|
||||
from google.protobuf.message import DecodeError
|
||||
|
||||
from pywidevine.utils import get_binary_path
|
||||
from pywidevine.license_protocol_pb2 import LicenseType, SignedMessage, LicenseRequest, ProtocolVersion, \
|
||||
SignedDrmCertificate, DrmCertificate, EncryptedClientIdentification, ClientIdentification, License
|
||||
from pywidevine.device import Device
|
||||
from pywidevine.exceptions import TooManySessions, InvalidSession, InvalidLicenseType, SignatureMismatch, \
|
||||
InvalidInitData, InvalidLicenseMessage, NoKeysLoaded, InvalidContext
|
||||
from pywidevine.key import Key
|
||||
from pywidevine.license_protocol_pb2 import DrmCertificate, SignedMessage, SignedDrmCertificate, LicenseType, \
|
||||
LicenseRequest, ProtocolVersion, ClientIdentification, EncryptedClientIdentification, License
|
||||
from pywidevine.pssh import PSSH
|
||||
from pywidevine.session import Session
|
||||
from pywidevine.utils import get_binary_path
|
||||
|
||||
|
||||
class Cdm:
|
||||
@@ -41,92 +46,239 @@ class Cdm:
|
||||
"+Gf2Ogsrf9s2LFvE7NVV2FvKqcWTw4PIV9Sdqrd+QLeFHd/SSZiAjjWyWOddeOrAyhb3BHMEwg2T7eTo/xxvF+YkP"
|
||||
"j89qPwXCYcOxF+6gjomPwzvofcJOxkJkoMmMzcFBDopvab5tDQsyN9UPLGhGC98X/8z8QSQ+spbJTYLdgFenFoGq4"
|
||||
"7gLwDS6NWYYQSqzE3Udf2W7pzk4ybyG4PHBYV3s4cyzdq8amvtE/sNSdOKReuHpfQ=")
|
||||
root_signed_cert = SignedDrmCertificate()
|
||||
root_signed_cert.ParseFromString(base64.b64decode(
|
||||
"CpwDCAASAQAY3ZSIiwUijgMwggGKAoIBgQC0/jnDZZAD2zwRlwnoaM3yw16b8udNI7EQ24dl39z7nzWgVwNTTPZtNX2meNuzNtI/nECplSZy"
|
||||
"f7i+Zt/FIZh4FRZoXS9GDkPLioQ5q/uwNYAivjQji6tTW3LsS7VIaVM+R1/9Cf2ndhOPD5LWTN+udqm62SIQqZ1xRdbX4RklhZxTmpfrhNfM"
|
||||
"qIiCIHAmIP1+QFAn4iWTb7w+cqD6wb0ptE2CXMG0y5xyfrDpihc+GWP8/YJIK7eyM7l97Eu6iR8nuJuISISqGJIOZfXIbBH/azbkdDTKjDOx"
|
||||
"+biOtOYS4AKYeVJeRTP/Edzrw1O6fGAaET0A+9K3qjD6T15Id1sX3HXvb9IZbdy+f7B4j9yCYEy/5CkGXmmMOROtFCXtGbLynwGCDVZEiMg1"
|
||||
"7B8RsyTgWQ035Ec86kt/lzEcgXyUikx9aBWE/6UI/Rjn5yvkRycSEbgj7FiTPKwS0ohtQT3F/hzcufjUUT4H5QNvpxLoEve1zqaWVT94tGSC"
|
||||
"UNIzX5ECAwEAARKAA1jx1k0ECXvf1+9dOwI5F/oUNnVKOGeFVxKnFO41FtU9v0KG9mkAds2T9Hyy355EzUzUrgkYU0Qy7OBhG+XaE9NVxd0a"
|
||||
"y5AeflvG6Q8in76FAv6QMcxrA4S9IsRV+vXyCM1lQVjofSnaBFiC9TdpvPNaV4QXezKHcLKwdpyywxXRESYqI3WZPrl3IjINvBoZwdVlkHZV"
|
||||
"dA8OaU1fTY8Zr9/WFjGUqJJfT7x6Mfiujq0zt+kw0IwKimyDNfiKgbL+HIisKmbF/73mF9BiC9yKRfewPlrIHkokL2yl4xyIFIPVxe9enz2F"
|
||||
"RXPia1BSV0z7kmxmdYrWDRuu8+yvUSIDXQouY5OcCwEgqKmELhfKrnPsIht5rvagcizfB0fbiIYwFHghESKIrNdUdPnzJsKlVshWTwApHQh7"
|
||||
"evuVicPumFSePGuUBRMS9nG5qxPDDJtGCHs9Mmpoyh6ckGLF7RC5HxclzpC5bc3ERvWjYhN0AqdipPpV2d7PouaAdFUGSdUCDA=="
|
||||
))
|
||||
root_cert = DrmCertificate()
|
||||
root_cert.ParseFromString(root_signed_cert.drm_certificate)
|
||||
|
||||
NUM_OF_SESSIONS = 0
|
||||
MAX_NUM_OF_SESSIONS = 50 # most common limit
|
||||
|
||||
def __init__(self, device: Device, pssh: Union[Container, bytes, str], raw: bool = False):
|
||||
def __init__(
|
||||
self,
|
||||
device_type: Union[Device.Types, str],
|
||||
system_id: int,
|
||||
security_level: int,
|
||||
client_id: ClientIdentification,
|
||||
rsa_key: RSA.RsaKey
|
||||
):
|
||||
"""Initialize a Widevine Content Decryption Module (CDM)."""
|
||||
if not device_type:
|
||||
raise ValueError("Device Type must be provided")
|
||||
if isinstance(device_type, str):
|
||||
device_type = Device.Types[device_type]
|
||||
if not isinstance(device_type, Device.Types):
|
||||
raise TypeError(f"Expected device_type to be a {Device.Types!r} not {device_type!r}")
|
||||
|
||||
if not system_id:
|
||||
raise ValueError("System ID must be provided")
|
||||
if not isinstance(system_id, int):
|
||||
raise TypeError(f"Expected system_id to be a {int} not {system_id!r}")
|
||||
|
||||
if not security_level:
|
||||
raise ValueError("Security Level must be provided")
|
||||
if not isinstance(security_level, int):
|
||||
raise TypeError(f"Expected security_level to be a {int} not {security_level!r}")
|
||||
|
||||
if not client_id:
|
||||
raise ValueError("Client ID must be provided")
|
||||
if not isinstance(client_id, ClientIdentification):
|
||||
raise TypeError(f"Expected client_id to be a {ClientIdentification} not {client_id!r}")
|
||||
|
||||
if not rsa_key:
|
||||
raise ValueError("RSA Key must be provided")
|
||||
if not isinstance(rsa_key, RSA.RsaKey):
|
||||
raise TypeError(f"Expected rsa_key to be a {RSA.RsaKey} not {rsa_key!r}")
|
||||
|
||||
self.device_type = device_type
|
||||
self.system_id = system_id
|
||||
self.security_level = security_level
|
||||
self.__client_id = client_id
|
||||
|
||||
self.__signer = pss.new(rsa_key)
|
||||
self.__decrypter = PKCS1_OAEP.new(rsa_key)
|
||||
|
||||
self.__sessions: dict[bytes, Session] = {}
|
||||
|
||||
@classmethod
|
||||
def from_device(cls, device: Device) -> Cdm:
|
||||
"""Initialize a Widevine CDM from a Widevine Device (.wvd) file."""
|
||||
return cls(
|
||||
device_type=device.type,
|
||||
system_id=device.system_id,
|
||||
security_level=device.security_level,
|
||||
client_id=device.client_id,
|
||||
rsa_key=device.private_key
|
||||
)
|
||||
|
||||
def open(self) -> bytes:
|
||||
"""
|
||||
Open a Widevine Content Decryption Module (CDM) session.
|
||||
|
||||
Parameters:
|
||||
device: Widevine Device containing the Client ID, Device Private Key, and
|
||||
more device-specific information.
|
||||
pssh: Protection System Specific Header Box or Init Data. This should be a
|
||||
compliant mp4 pssh box, or just the init data (Widevine Cenc Header).
|
||||
raw: This should be set to True if the PSSH data provided is arbitrary data.
|
||||
E.g., a PSSH Box where the init data is not a Widevine Cenc Header, or
|
||||
is simply arbitrary data.
|
||||
|
||||
Devices have a limit on how many sessions can be open and active concurrently.
|
||||
The limit is different for each device and security level, most commonly 50.
|
||||
This limit is handled by the OEM Crypto API. Multiple sessions can be open at
|
||||
a time and sessions should be closed when no longer needed.
|
||||
Raises:
|
||||
TooManySessions: If the session cannot be opened as limit has been reached.
|
||||
"""
|
||||
if not device:
|
||||
raise ValueError("A Widevine Device must be provided.")
|
||||
if not pssh:
|
||||
raise ValueError("A PSSH Box must be provided.")
|
||||
if len(self.__sessions) > self.MAX_NUM_OF_SESSIONS:
|
||||
raise TooManySessions(f"Too many Sessions open ({self.MAX_NUM_OF_SESSIONS}).")
|
||||
|
||||
if self.NUM_OF_SESSIONS >= self.MAX_NUM_OF_SESSIONS:
|
||||
raise ValueError(
|
||||
f"Too many Sessions open {self.NUM_OF_SESSIONS}/{self.MAX_NUM_OF_SESSIONS}. "
|
||||
f"Close some Sessions to be able to open more."
|
||||
)
|
||||
session = Session()
|
||||
self.__sessions[session.id] = session
|
||||
|
||||
self.NUM_OF_SESSIONS += 1
|
||||
return session.id
|
||||
|
||||
self.device = device
|
||||
self.init_data = pssh
|
||||
def close(self, session_id: bytes) -> None:
|
||||
"""
|
||||
Close a Widevine Content Decryption Module (CDM) session.
|
||||
|
||||
if not raw:
|
||||
# we only want the init_data of the pssh box
|
||||
self.init_data = PSSH.get_as_box(pssh).init_data
|
||||
Parameters:
|
||||
session_id: Session identifier.
|
||||
|
||||
self.session_id = get_random_bytes(16)
|
||||
self.service_certificate: Optional[SignedMessage] = None
|
||||
self.context: dict[bytes, tuple[bytes, bytes]] = {}
|
||||
Raises:
|
||||
InvalidSession: If the Session identifier is invalid.
|
||||
"""
|
||||
session = self.__sessions.get(session_id)
|
||||
if not session:
|
||||
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||
del self.__sessions[session_id]
|
||||
|
||||
def set_service_certificate(self, certificate: Union[bytes, str]) -> SignedMessage:
|
||||
def set_service_certificate(self, session_id: bytes, certificate: Optional[Union[bytes, str]]) -> str:
|
||||
"""
|
||||
Set a Service Privacy Certificate for Privacy Mode. (optional but recommended)
|
||||
|
||||
Parameters:
|
||||
certificate: Signed Message in Base64 or Bytes form obtained from the Service.
|
||||
Some services have their own, but most use the common privacy cert,
|
||||
(common_privacy_cert).
|
||||
|
||||
Returns the parsed Signed Message if successful, otherwise raises a DecodeError.
|
||||
|
||||
The Service Certificate is used to encrypt Client IDs in Licenses. This is also
|
||||
known as Privacy Mode and may be required for some services or for some devices.
|
||||
Chrome CDM requires it as of the enforcement of VMP (Verified Media Path).
|
||||
"""
|
||||
if isinstance(certificate, str):
|
||||
certificate = base64.b64decode(certificate) # assuming base64
|
||||
|
||||
signed_message = SignedMessage()
|
||||
try:
|
||||
signed_message.ParseFromString(certificate)
|
||||
except DecodeError as e:
|
||||
raise DecodeError(f"Could not parse certificate as a Signed Message: {e}")
|
||||
|
||||
self.service_certificate = signed_message
|
||||
return signed_message
|
||||
|
||||
def get_license_challenge(self, type_: LicenseType = LicenseType.STREAMING, privacy_mode: bool = True) -> bytes:
|
||||
"""
|
||||
Get a License Challenge to send to a License Server.
|
||||
We reject direct DrmCertificates as they do not have signature verification and
|
||||
cannot be verified. You must provide a SignedDrmCertificate or a SignedMessage
|
||||
containing a SignedDrmCertificate.
|
||||
|
||||
Parameters:
|
||||
type_: Type of License you wish to exchange, often `STREAMING`.
|
||||
The `OFFLINE` Licenses are for Offline licensing of Downloaded content.
|
||||
session_id: Session identifier.
|
||||
certificate: SignedDrmCertificate (or SignedMessage containing one) in Base64
|
||||
or Bytes form obtained from the Service. Some services have their own,
|
||||
but most use the common privacy cert, (common_privacy_cert). If None, it
|
||||
will remove the current certificate.
|
||||
|
||||
Raises:
|
||||
InvalidSession: If the Session identifier is invalid.
|
||||
DecodeError: If the certificate could not be parsed as a SignedDrmCertificate
|
||||
nor a SignedMessage containing a SignedDrmCertificate.
|
||||
SignatureMismatch: If the Signature of the SignedDrmCertificate does not
|
||||
match the underlying DrmCertificate.
|
||||
|
||||
Returns the Service Provider ID of the verified DrmCertificate if successful.
|
||||
If certificate is None, it will return the now unset certificate's Provider ID.
|
||||
"""
|
||||
session = self.__sessions.get(session_id)
|
||||
if not session:
|
||||
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||
|
||||
if certificate is None:
|
||||
drm_certificate = DrmCertificate()
|
||||
drm_certificate.ParseFromString(session.service_certificate.drm_certificate)
|
||||
session.service_certificate = None
|
||||
return drm_certificate.provider_id
|
||||
|
||||
if isinstance(certificate, str):
|
||||
try:
|
||||
certificate = base64.b64decode(certificate) # assuming base64
|
||||
except binascii.Error:
|
||||
raise DecodeError("Could not decode certificate string as Base64, expected bytes.")
|
||||
elif not isinstance(certificate, bytes):
|
||||
raise DecodeError(f"Expecting Certificate to be bytes, not {certificate!r}")
|
||||
|
||||
signed_message = SignedMessage()
|
||||
signed_drm_certificate = SignedDrmCertificate()
|
||||
|
||||
try:
|
||||
signed_message.ParseFromString(certificate)
|
||||
if signed_message.SerializeToString() == certificate:
|
||||
signed_drm_certificate.ParseFromString(signed_message.msg)
|
||||
else:
|
||||
signed_drm_certificate.ParseFromString(certificate)
|
||||
if signed_drm_certificate.SerializeToString() != certificate:
|
||||
raise DecodeError()
|
||||
# Craft a SignedMessage as it's stored as a SignedMessage
|
||||
signed_message.Clear()
|
||||
signed_message.msg = signed_drm_certificate.SerializeToString()
|
||||
# we don't need to sign this message, this is normal
|
||||
except DecodeError:
|
||||
# could be a direct unsigned DrmCertificate, but reject those anyway
|
||||
raise DecodeError("Could not parse certificate as a SignedDrmCertificate")
|
||||
|
||||
try:
|
||||
pss. \
|
||||
new(RSA.import_key(self.root_cert.public_key)). \
|
||||
verify(
|
||||
msg_hash=SHA1.new(signed_drm_certificate.drm_certificate),
|
||||
signature=signed_drm_certificate.signature
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
raise SignatureMismatch("Signature Mismatch on SignedDrmCertificate, rejecting certificate")
|
||||
else:
|
||||
session.service_certificate = signed_message
|
||||
drm_certificate = DrmCertificate()
|
||||
drm_certificate.ParseFromString(signed_drm_certificate.drm_certificate)
|
||||
return drm_certificate.provider_id
|
||||
|
||||
def get_license_challenge(
|
||||
self,
|
||||
session_id: bytes,
|
||||
pssh: PSSH,
|
||||
type_: Union[int, str] = LicenseType.STREAMING,
|
||||
privacy_mode: bool = True
|
||||
) -> bytes:
|
||||
"""
|
||||
Get a License Request (Challenge) to send to a License Server.
|
||||
|
||||
Parameters:
|
||||
session_id: Session identifier.
|
||||
pssh: PSSH Object to get the init data from.
|
||||
type_: Type of License you wish to exchange, often `STREAMING`. The `OFFLINE`
|
||||
Licenses are for Offline licensing of Downloaded content.
|
||||
privacy_mode: Encrypt the Client ID using the Privacy Certificate. If the
|
||||
privacy certificate is not set yet, this does nothing.
|
||||
|
||||
Raises:
|
||||
InvalidSession: If the Session identifier is invalid.
|
||||
InvalidInitData: If the Init Data (or PSSH box) provided is invalid.
|
||||
InvalidLicenseType: If the type_ parameter value is not a License Type. It
|
||||
must be a LicenseType enum, or a string/int representing the enum's keys
|
||||
or values.
|
||||
|
||||
Returns a SignedMessage containing a LicenseRequest message. It's signed with
|
||||
the Private Key of the device provision.
|
||||
"""
|
||||
session = self.__sessions.get(session_id)
|
||||
if not session:
|
||||
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||
|
||||
if not pssh:
|
||||
raise InvalidInitData("A pssh must be provided.")
|
||||
if not isinstance(pssh, PSSH):
|
||||
raise InvalidInitData(f"Expected pssh to be a {PSSH}, not {pssh!r}")
|
||||
|
||||
try:
|
||||
if isinstance(type_, int):
|
||||
LicenseType.Name(int(type_))
|
||||
elif isinstance(type_, str):
|
||||
type_ = LicenseType.Value(type_)
|
||||
elif not isinstance(type_, LicenseType):
|
||||
raise InvalidLicenseType()
|
||||
except ValueError:
|
||||
raise InvalidLicenseType(f"License Type {type_!r} is invalid")
|
||||
|
||||
request_id = get_random_bytes(16)
|
||||
|
||||
license_request = LicenseRequest()
|
||||
@@ -135,120 +287,226 @@ class Cdm:
|
||||
license_request.protocol_version = ProtocolVersion.Value("VERSION_2_1")
|
||||
license_request.key_control_nonce = random.randrange(1, 2 ** 31)
|
||||
|
||||
license_request.content_id.widevine_pssh_data.pssh_data.append(self.init_data)
|
||||
# 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 self.service_certificate and privacy_mode:
|
||||
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.device.client_id,
|
||||
service_certificate=self.service_certificate
|
||||
client_id=self.__client_id,
|
||||
service_certificate=session.service_certificate
|
||||
))
|
||||
else:
|
||||
license_request.client_id.CopyFrom(self.device.client_id)
|
||||
license_request.client_id.CopyFrom(self.__client_id)
|
||||
|
||||
license_message = SignedMessage()
|
||||
license_message.type = SignedMessage.MessageType.Value("LICENSE_REQUEST")
|
||||
license_message.type = SignedMessage.MessageType.LICENSE_REQUEST
|
||||
license_message.msg = license_request.SerializeToString()
|
||||
license_message.signature = self.__signer.sign(SHA1.new(license_message.msg))
|
||||
|
||||
license_message.signature = pss. \
|
||||
new(self.device.private_key). \
|
||||
sign(SHA1.new(license_message.msg))
|
||||
|
||||
self.context[request_id] = self.derive_context(license_message.msg)
|
||||
session.context[request_id] = self.derive_context(license_message.msg)
|
||||
|
||||
return license_message.SerializeToString()
|
||||
|
||||
def parse_license(self, license_message: Union[bytes, str]) -> list[Key]:
|
||||
def parse_license(self, session_id: bytes, license_message: Union[SignedMessage, bytes, str]) -> None:
|
||||
"""
|
||||
Load Keys from a License Message from a License Server Response.
|
||||
|
||||
License Messages can only be loaded a single time. An InvalidContext error will
|
||||
be raised if you attempt to parse a License Message more than once.
|
||||
|
||||
Parameters:
|
||||
session_id: Session identifier.
|
||||
license_message: A SignedMessage containing a License message.
|
||||
|
||||
Raises:
|
||||
InvalidSession: If the Session identifier is invalid.
|
||||
InvalidLicenseMessage: The License message could not be decoded as a Signed
|
||||
Message or License message.
|
||||
InvalidContext: If the Session has no Context Data. This is likely to happen
|
||||
if the License Challenge was not made by this CDM instance, or was not
|
||||
by this CDM at all. It could also happen if the Session is closed after
|
||||
calling parse_license but not before it got the context data.
|
||||
SignatureMismatch: If the Signature of the License SignedMessage does not
|
||||
match the underlying License.
|
||||
"""
|
||||
session = self.__sessions.get(session_id)
|
||||
if not session:
|
||||
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||
|
||||
if not license_message:
|
||||
raise ValueError("Cannot parse an empty license_message as a SignedMessage")
|
||||
raise InvalidLicenseMessage("Cannot parse an empty license_message")
|
||||
|
||||
if isinstance(license_message, str):
|
||||
license_message = base64.b64decode(license_message)
|
||||
try:
|
||||
license_message = base64.b64decode(license_message)
|
||||
except (binascii.Error, binascii.Incomplete) as e:
|
||||
raise InvalidLicenseMessage(f"Could not decode license_message as Base64, {e}")
|
||||
|
||||
if isinstance(license_message, bytes):
|
||||
signed_message = SignedMessage()
|
||||
try:
|
||||
signed_message.ParseFromString(license_message)
|
||||
except DecodeError:
|
||||
raise ValueError("Failed to parse license_message as a SignedMessage")
|
||||
except DecodeError as e:
|
||||
raise InvalidLicenseMessage(f"Could not parse license_message as a SignedMessage, {e}")
|
||||
license_message = signed_message
|
||||
|
||||
if not isinstance(license_message, SignedMessage):
|
||||
raise ValueError(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:
|
||||
raise InvalidLicenseMessage(
|
||||
f"Expecting a LICENSE message, not a "
|
||||
f"'{SignedMessage.MessageType.Name(license_message.type)}' message."
|
||||
)
|
||||
|
||||
licence = License()
|
||||
licence.ParseFromString(license_message.msg)
|
||||
|
||||
context = self.context[licence.id.request_id]
|
||||
context = session.context.get(licence.id.request_id)
|
||||
if not context:
|
||||
raise ValueError("Cannot parse a license message without first making a license request")
|
||||
raise InvalidContext("Cannot parse a license message without first making a license request")
|
||||
|
||||
session_key = PKCS1_OAEP. \
|
||||
new(self.device.private_key). \
|
||||
decrypt(license_message.session_key)
|
||||
enc_key, mac_key_server, _ = self.derive_keys(
|
||||
*context,
|
||||
key=self.__decrypter.decrypt(license_message.session_key)
|
||||
)
|
||||
|
||||
enc_key, mac_key_server, mac_key_client = self.derive_keys(*context, session_key)
|
||||
|
||||
license_signature = HMAC. \
|
||||
computed_signature = HMAC. \
|
||||
new(mac_key_server, digestmod=SHA256). \
|
||||
update(licence.SerializeToString()). \
|
||||
digest()
|
||||
|
||||
if license_message.signature != license_signature:
|
||||
raise ValueError("The License Signature doesn't match the Signature listed in the Message")
|
||||
if license_message.signature != computed_signature:
|
||||
raise SignatureMismatch("Signature Mismatch on License Message, rejecting license")
|
||||
|
||||
return [
|
||||
session.keys = [
|
||||
Key.from_key_container(key, enc_key)
|
||||
for key in licence.key
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def decrypt(content_keys: dict[UUID, str], input_: Path, output: Path, temp: Optional[Path] = None):
|
||||
del session.context[licence.id.request_id]
|
||||
|
||||
def get_keys(self, session_id: bytes, type_: Optional[Union[int, str]] = None) -> list[Key]:
|
||||
"""
|
||||
Get Keys from the loaded License message.
|
||||
|
||||
Parameters:
|
||||
session_id: Session identifier.
|
||||
type_: (optional) Key Type to filter by and return.
|
||||
|
||||
Raises:
|
||||
InvalidSession: If the Session identifier is invalid.
|
||||
TypeError: If the provided type_ is an unexpected value type.
|
||||
ValueError: If the provided type_ is not a valid Key Type.
|
||||
"""
|
||||
session = self.__sessions.get(session_id)
|
||||
if not session:
|
||||
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||
|
||||
try:
|
||||
if isinstance(type_, str):
|
||||
type_ = License.KeyContainer.KeyType.Value(type_)
|
||||
elif isinstance(type_, int):
|
||||
License.KeyContainer.KeyType.Name(type_) # only test
|
||||
elif type_ is not None:
|
||||
raise TypeError(f"Expected type_ to be a {License.KeyContainer.KeyType} or int, not {type_!r}")
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Could not parse type_ as a {License.KeyContainer.KeyType}, {e}")
|
||||
|
||||
return [
|
||||
key
|
||||
for key in session.keys
|
||||
if not type_ or key.type == License.KeyContainer.KeyType.Name(type_)
|
||||
]
|
||||
|
||||
def decrypt(
|
||||
self,
|
||||
session_id: bytes,
|
||||
input_file: Union[Path, str],
|
||||
output_file: Union[Path, str],
|
||||
temp_dir: Optional[Union[Path, str]] = None,
|
||||
exists_ok: bool = False
|
||||
):
|
||||
"""
|
||||
Decrypt a Widevine-encrypted file using Shaka-packager.
|
||||
Shaka-packager is much more stable than mp4decrypt.
|
||||
|
||||
Parameters:
|
||||
session_id: Session identifier.
|
||||
input_file: File to be decrypted with Session's currently loaded keys.
|
||||
output_file: Location to save decrypted file.
|
||||
temp_dir: Directory to store temporary data while decrypting.
|
||||
exists_ok: Allow overwriting the output_file if it exists.
|
||||
|
||||
Raises:
|
||||
EnvironmentError if the Shaka Packager executable could not be found.
|
||||
ValueError if the track has not yet been downloaded.
|
||||
SubprocessError if Shaka Packager returned a non-zero exit code.
|
||||
ValueError: If the input or output paths have not been supplied or are
|
||||
invalid.
|
||||
FileNotFoundError: If the input file path does not exist.
|
||||
FileExistsError: If the output file path already exists. Ignored if exists_ok
|
||||
is set to True.
|
||||
NoKeysLoaded: No License was parsed for this Session, No Keys available.
|
||||
EnvironmentError: If the shaka-packager executable could not be found.
|
||||
subprocess.CalledProcessError: If the shaka-packager call returned a non-zero
|
||||
exit code.
|
||||
"""
|
||||
if not content_keys:
|
||||
raise ValueError("Cannot decrypt without any Content Keys")
|
||||
if not input_:
|
||||
if not input_file:
|
||||
raise ValueError("Cannot decrypt nothing, specify an input path")
|
||||
if not output:
|
||||
if not output_file:
|
||||
raise ValueError("Cannot decrypt nowhere, specify an output path")
|
||||
|
||||
if not isinstance(input_file, (Path, str)):
|
||||
raise ValueError(f"Expecting input_file to be a Path or str, got {input_file!r}")
|
||||
if not isinstance(output_file, (Path, str)):
|
||||
raise ValueError(f"Expecting output_file to be a Path or str, got {output_file!r}")
|
||||
if not isinstance(temp_dir, (Path, str)) and temp_dir is not None:
|
||||
raise ValueError(f"Expecting temp_dir to be a Path or str, got {temp_dir!r}")
|
||||
|
||||
input_file = Path(input_file)
|
||||
output_file = Path(output_file)
|
||||
if temp_dir:
|
||||
temp_dir = Path(temp_dir)
|
||||
|
||||
if not input_file.is_file():
|
||||
raise FileNotFoundError(f"Input file does not exist, {input_file}")
|
||||
if output_file.is_file() and not exists_ok:
|
||||
raise FileExistsError(f"Output file already exists, {output_file}")
|
||||
|
||||
session = self.__sessions.get(session_id)
|
||||
if not session:
|
||||
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||
|
||||
if not session.keys:
|
||||
raise NoKeysLoaded("No Keys are loaded yet, cannot decrypt")
|
||||
|
||||
platform = {"win32": "win", "darwin": "osx"}.get(sys.platform, sys.platform)
|
||||
executable = get_binary_path("shaka-packager", f"packager-{platform}", f"packager-{platform}-x64")
|
||||
if not executable:
|
||||
raise EnvironmentError("Shaka Packager executable not found but is required")
|
||||
|
||||
args = [
|
||||
f"input={input_},stream=0,output={output}",
|
||||
"--enable_raw_key_decryption", "--keys",
|
||||
",".join([
|
||||
*[
|
||||
"label={}:key_id={}:key={}".format(i, kid.hex, key.lower())
|
||||
for i, (kid, key) in enumerate(content_keys.items())
|
||||
],
|
||||
*[
|
||||
# Apple TV+ needs this as their files do not use the KID supplied in the manifest
|
||||
"label={}:key_id={}:key={}".format(i, "00" * 16, key.lower())
|
||||
for i, (kid, key) in enumerate(content_keys.items(), len(content_keys))
|
||||
f"input={input_file},stream=0,output={output_file}",
|
||||
"--enable_raw_key_decryption",
|
||||
"--keys", ",".join([
|
||||
label
|
||||
for i, key in enumerate(session.keys)
|
||||
for label in [
|
||||
f"label=1_{i}:key_id={key.kid.hex}:key={key.key.hex()}",
|
||||
# some services need the KID blanked, e.g., Apple TV+
|
||||
f"label=2_{i}:key_id={'0' * 32}:key={key.key.hex()}"
|
||||
]
|
||||
]),
|
||||
if key.type == "CONTENT"
|
||||
])
|
||||
]
|
||||
|
||||
if temp:
|
||||
temp.mkdir(parents=True, exist_ok=True)
|
||||
args.extend(["--temp_dir", temp])
|
||||
if temp_dir:
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
args.extend(["--temp_dir", temp_dir])
|
||||
|
||||
try:
|
||||
subprocess.check_call([executable, *args])
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise subprocess.SubprocessError(f"Failed to Decrypt! Shaka Packager Error: {e}")
|
||||
subprocess.check_call([executable, *args])
|
||||
|
||||
@staticmethod
|
||||
def encrypt_client_id(
|
||||
@@ -262,17 +520,15 @@ class Cdm:
|
||||
privacy_iv = iv or get_random_bytes(16)
|
||||
|
||||
if isinstance(service_certificate, SignedMessage):
|
||||
signed_service_certificate = SignedDrmCertificate()
|
||||
signed_service_certificate.ParseFromString(service_certificate.msg)
|
||||
service_certificate = signed_service_certificate
|
||||
|
||||
signed_drm_certificate = SignedDrmCertificate()
|
||||
signed_drm_certificate.ParseFromString(service_certificate.msg)
|
||||
service_certificate = signed_drm_certificate
|
||||
if isinstance(service_certificate, SignedDrmCertificate):
|
||||
service_service_drm_certificate = DrmCertificate()
|
||||
service_service_drm_certificate.ParseFromString(service_certificate.drm_certificate)
|
||||
service_certificate = service_service_drm_certificate
|
||||
|
||||
drm_certificate = DrmCertificate()
|
||||
drm_certificate.ParseFromString(service_certificate.drm_certificate)
|
||||
service_certificate = drm_certificate
|
||||
if not isinstance(service_certificate, DrmCertificate):
|
||||
raise ValueError(f"Service Certificate is in an unexpected type {service_certificate!r}")
|
||||
raise ValueError(f"Expecting Service Certificate to be a DrmCertificate, not {service_certificate!r}")
|
||||
|
||||
enc_client_id = EncryptedClientIdentification()
|
||||
enc_client_id.provider_id = service_certificate.provider_id
|
||||
@@ -328,7 +584,8 @@ class Cdm:
|
||||
"""
|
||||
|
||||
def _derive(session_key: bytes, context: bytes, counter: int) -> bytes:
|
||||
return CMAC.new(session_key, ciphermod=AES). \
|
||||
return CMAC. \
|
||||
new(session_key, ciphermod=AES). \
|
||||
update(counter.to_bytes(1, "big") + context). \
|
||||
digest()
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from construct import BitStruct, Bytes, Const
|
||||
from construct import BitStruct, Bytes, Const, ConstructError, Container
|
||||
from construct import Enum as CEnum
|
||||
from construct import Flag, Int8ub, Int16ub
|
||||
from construct import Int8ub, Int16ub
|
||||
from construct import Optional as COptional
|
||||
from construct import Padded, Padding, Struct, this
|
||||
from Crypto.PublicKey import RSA
|
||||
@@ -21,12 +22,17 @@ class _Types(Enum):
|
||||
ANDROID = 2
|
||||
|
||||
|
||||
class Device:
|
||||
# needed so bin_format can enumerate the types
|
||||
Types = _Types
|
||||
class _Structures:
|
||||
magic = Const(b"WVD")
|
||||
|
||||
bin_format = Struct(
|
||||
"signature" / Const(b"WVD"),
|
||||
header = Struct(
|
||||
"signature" / magic,
|
||||
"version" / Int8ub
|
||||
)
|
||||
|
||||
# - Removed vmp and vmp_len as it should already be within the Client ID
|
||||
v2 = Struct(
|
||||
"signature" / magic,
|
||||
"version" / Const(Int8ub, 2),
|
||||
"type_" / CEnum(
|
||||
Int8ub,
|
||||
@@ -34,8 +40,8 @@ class Device:
|
||||
),
|
||||
"security_level" / Int8ub,
|
||||
"flags" / Padded(1, COptional(BitStruct(
|
||||
Padding(7),
|
||||
"send_key_control_nonce" / Flag # deprecated, do not use
|
||||
# no per-device flags yet
|
||||
Padding(8)
|
||||
))),
|
||||
"private_key_len" / Int16ub,
|
||||
"private_key" / Bytes(this.private_key_len),
|
||||
@@ -43,9 +49,32 @@ class Device:
|
||||
"client_id" / Bytes(this.client_id_len)
|
||||
)
|
||||
|
||||
# == Bin Format Revisions == #
|
||||
# Version 2: Removed vmp and vmp_len as it should already be within the Client ID
|
||||
# Version 1: Removed system_id as it can be retrieved from the Client ID's DRM Certificate
|
||||
# - Removed system_id as it can be retrieved from the Client ID's DRM Certificate
|
||||
v1 = Struct(
|
||||
"signature" / magic,
|
||||
"version" / Const(Int8ub, 1),
|
||||
"type_" / CEnum(
|
||||
Int8ub,
|
||||
**{t.name: t.value for t in _Types}
|
||||
),
|
||||
"security_level" / Int8ub,
|
||||
"flags" / Padded(1, COptional(BitStruct(
|
||||
# no per-device flags yet
|
||||
Padding(8)
|
||||
))),
|
||||
"private_key_len" / Int16ub,
|
||||
"private_key" / Bytes(this.private_key_len),
|
||||
"client_id_len" / Int16ub,
|
||||
"client_id" / Bytes(this.client_id_len),
|
||||
"vmp_len" / Int16ub,
|
||||
"vmp" / Bytes(this.vmp_len)
|
||||
)
|
||||
|
||||
|
||||
class Device:
|
||||
Types = _Types
|
||||
Structures = _Structures
|
||||
supported_structure = Structures.v2
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -109,20 +138,20 @@ class Device:
|
||||
data = base64.b64decode(data)
|
||||
if not isinstance(data, bytes):
|
||||
raise ValueError(f"Expecting Bytes or Base64 input, got {data!r}")
|
||||
return cls(**cls.bin_format.parse(data))
|
||||
return cls(**cls.supported_structure.parse(data))
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Union[Path, str]) -> Device:
|
||||
if not isinstance(path, (Path, str)):
|
||||
raise ValueError(f"Expecting Path object or path string, got {path!r}")
|
||||
with Path(path).open(mode="rb") as f:
|
||||
return cls(**cls.bin_format.parse_stream(f))
|
||||
return cls(**cls.supported_structure.parse_stream(f))
|
||||
|
||||
def dumps(self) -> bytes:
|
||||
private_key = self.private_key.export_key("DER") if self.private_key else None
|
||||
return self.bin_format.build(dict(
|
||||
return self.supported_structure.build(dict(
|
||||
version=2,
|
||||
type=self.type.value,
|
||||
type_=self.type.value,
|
||||
security_level=self.security_level,
|
||||
flags=self.flags,
|
||||
private_key_len=len(private_key) if private_key else 0,
|
||||
@@ -138,5 +167,54 @@ class Device:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(self.dumps())
|
||||
|
||||
@classmethod
|
||||
def migrate(cls, data: Union[bytes, str]) -> Device:
|
||||
if isinstance(data, str):
|
||||
data = base64.b64decode(data)
|
||||
if not isinstance(data, bytes):
|
||||
raise ValueError(f"Expecting Bytes or Base64 input, got {data!r}")
|
||||
|
||||
header = _Structures.header.parse(data)
|
||||
if header.version == 2:
|
||||
raise ValueError("Device Data is already migrated to the latest version.")
|
||||
if header.version == 0 or header.version > 2:
|
||||
# we have never used version 0, likely data that just so happened to use the WVD magic
|
||||
raise ValueError("Device Data does not seem to be a WVD file (v0).")
|
||||
|
||||
if header.version == 1: # v1 to v2
|
||||
data = _Structures.v1.parse(data)
|
||||
data.version = 2 # update version to 2 to allow loading
|
||||
data.flags = Container() # blank flags that may have been used in v1
|
||||
|
||||
vmp = FileHashes()
|
||||
if data.vmp:
|
||||
try:
|
||||
vmp.ParseFromString(data.vmp)
|
||||
except DecodeError as e:
|
||||
raise DecodeError(f"Failed to parse VMP data as FileHashes, {e}")
|
||||
data.vmp = vmp
|
||||
|
||||
client_id = ClientIdentification()
|
||||
try:
|
||||
client_id.ParseFromString(data.client_id)
|
||||
except DecodeError as e:
|
||||
raise DecodeError(f"Failed to parse VMP data as FileHashes, {e}")
|
||||
|
||||
new_vmp_data = data.vmp.SerializeToString()
|
||||
if client_id.vmp_data and client_id.vmp_data != new_vmp_data:
|
||||
logging.getLogger("migrate").warning("Client ID already has Verified Media Path data")
|
||||
client_id.vmp_data = new_vmp_data
|
||||
data.client_id = client_id.SerializeToString()
|
||||
|
||||
try:
|
||||
data = _Structures.v2.build(data)
|
||||
except ConstructError as e:
|
||||
raise ValueError(f"Migration failed, {e}")
|
||||
|
||||
try:
|
||||
return cls.loads(data)
|
||||
except ConstructError as e:
|
||||
raise ValueError(f"Device Data seems to be corrupt or invalid, or migration failed, {e}")
|
||||
|
||||
|
||||
__ALL__ = (Device,)
|
||||
|
||||
38
pywidevine/exceptions.py
Normal file
38
pywidevine/exceptions.py
Normal file
@@ -0,0 +1,38 @@
|
||||
class PyWidevineException(Exception):
|
||||
"""Exceptions used by pywidevine."""
|
||||
|
||||
|
||||
class TooManySessions(PyWidevineException):
|
||||
"""Too many Sessions are open."""
|
||||
|
||||
|
||||
class InvalidSession(PyWidevineException):
|
||||
"""No Session is open with the specified identifier."""
|
||||
|
||||
|
||||
class InvalidInitData(PyWidevineException):
|
||||
"""The Widevine Cenc Header Data is invalid or empty."""
|
||||
|
||||
|
||||
class InvalidLicenseType(PyWidevineException):
|
||||
"""The License Type is an Invalid Value."""
|
||||
|
||||
|
||||
class InvalidLicenseMessage(PyWidevineException):
|
||||
"""The License Message is Invalid or Missing."""
|
||||
|
||||
|
||||
class InvalidContext(PyWidevineException):
|
||||
"""The Context is Invalid or Missing."""
|
||||
|
||||
|
||||
class SignatureMismatch(PyWidevineException):
|
||||
"""The Signature did not match."""
|
||||
|
||||
|
||||
class NoKeysLoaded(PyWidevineException):
|
||||
"""No License was parsed for this Session, No Keys available."""
|
||||
|
||||
|
||||
class DeviceMismatch(PyWidevineException):
|
||||
"""The Remote CDMs Device information and the APIs Device information did not match."""
|
||||
@@ -1,14 +1,19 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from zlib import crc32
|
||||
|
||||
import click
|
||||
import requests
|
||||
from construct import ConstructError
|
||||
from unidecode import unidecode, UnidecodeError
|
||||
|
||||
from pywidevine import __version__
|
||||
from pywidevine.cdm import Cdm
|
||||
from pywidevine.device import Device
|
||||
from pywidevine.license_protocol_pb2 import LicenseType
|
||||
from pywidevine.license_protocol_pb2 import LicenseType, FileHashes
|
||||
from pywidevine.pssh import PSSH
|
||||
|
||||
|
||||
@click.group(invoke_without_command=True)
|
||||
@@ -37,11 +42,9 @@ def main(version: bool, debug: bool) -> None:
|
||||
@click.option("-t", "--type", "type_", type=click.Choice(LicenseType.keys(), case_sensitive=False),
|
||||
default="STREAMING",
|
||||
help="License Type to Request.")
|
||||
@click.option("-r", "--raw", is_flag=True, default=False,
|
||||
help="PSSH is Raw.")
|
||||
@click.option("-p", "--privacy", is_flag=True, default=False,
|
||||
help="Use Privacy Mode, off by default.")
|
||||
def license_(device: Path, pssh: str, server: str, type_: str, raw: bool, privacy: bool):
|
||||
def license_(device: Path, pssh: str, server: str, type_: str, privacy: bool):
|
||||
"""
|
||||
Make a License Request for PSSH to SERVER using DEVICE.
|
||||
It will return a list of all keys within the returned license.
|
||||
@@ -60,16 +63,23 @@ def license_(device: Path, pssh: str, server: str, type_: str, raw: bool, privac
|
||||
"""
|
||||
log = logging.getLogger("license")
|
||||
|
||||
# prepare pssh
|
||||
pssh = PSSH(pssh)
|
||||
|
||||
# load device
|
||||
device = Device.load(device)
|
||||
log.info(f"[+] Loaded Device ({device.system_id} L{device.security_level})")
|
||||
log.debug(device)
|
||||
|
||||
# load cdm
|
||||
cdm = Cdm(device, pssh, raw)
|
||||
log.info(f"[+] Loaded CDM with PSSH: {pssh}")
|
||||
cdm = Cdm.from_device(device)
|
||||
log.info(f"[+] Loaded CDM")
|
||||
log.debug(cdm)
|
||||
|
||||
# open cdm session
|
||||
session_id = cdm.open()
|
||||
log.info(f"[+] Opened CDM Session: {session_id.hex()}")
|
||||
|
||||
if privacy:
|
||||
# get service cert for license server via cert challenge
|
||||
service_cert = requests.post(
|
||||
@@ -80,13 +90,13 @@ def license_(device: Path, pssh: str, server: str, type_: str, raw: bool, privac
|
||||
log.error(f"[-] Failed to get Service Privacy Certificate: [{service_cert.status_code}] {service_cert.text}")
|
||||
return
|
||||
service_cert = service_cert.content
|
||||
cdm.set_service_certificate(service_cert)
|
||||
log.info("[+] Set Service Privacy Certificate")
|
||||
provider_id = cdm.set_service_certificate(session_id, service_cert)
|
||||
log.info(f"[+] Set Service Privacy Certificate: {provider_id}")
|
||||
log.debug(service_cert)
|
||||
|
||||
# get license challenge
|
||||
license_type = LicenseType.Value(type_)
|
||||
challenge = cdm.get_license_challenge(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.debug(challenge)
|
||||
|
||||
@@ -103,18 +113,23 @@ def license_(device: Path, pssh: str, server: str, type_: str, raw: bool, privac
|
||||
log.debug(licence)
|
||||
|
||||
# parse license challenge
|
||||
keys = cdm.parse_license(licence)
|
||||
cdm.parse_license(session_id, licence)
|
||||
log.info("[+] License Parsed Successfully")
|
||||
|
||||
# print keys
|
||||
for key in keys:
|
||||
for key in cdm.get_keys(session_id):
|
||||
log.info(f"[{key.type}] {key.kid.hex}:{key.key.hex()}")
|
||||
|
||||
# close session, disposes of session data
|
||||
cdm.close(session_id)
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("device", type=Path)
|
||||
@click.option("-p", "--privacy", is_flag=True, default=False,
|
||||
help="Use Privacy Mode, off by default.")
|
||||
@click.pass_context
|
||||
def test(ctx: click.Context, device: Path):
|
||||
def test(ctx: click.Context, device: Path, privacy: bool):
|
||||
"""
|
||||
Test the CDM code by getting Content Keys for Bitmovin's Art of Motion example.
|
||||
https://bitmovin.com/demos/drm
|
||||
@@ -136,10 +151,6 @@ def test(ctx: click.Context, device: Path):
|
||||
# Download feature on Netflix Apps. Otherwise, use STREAMING or AUTOMATIC.
|
||||
license_type = LicenseType.STREAMING
|
||||
|
||||
# If the PSSH is not a valid mp4 pssh box, nor a valid CENC Header (init data) then
|
||||
# set this to True, otherwise leave it False.
|
||||
raw = False
|
||||
|
||||
# this runs the `cdm license` CLI-command code with the data we set above
|
||||
# it will print information as it goes to the terminal
|
||||
ctx.invoke(
|
||||
@@ -148,5 +159,149 @@ def test(ctx: click.Context, device: Path):
|
||||
pssh=pssh,
|
||||
server=license_server,
|
||||
type_=LicenseType.Name(license_type),
|
||||
raw=raw
|
||||
privacy=privacy
|
||||
)
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.option("-t", "--type", "type_", type=click.Choice([x.name for x in Device.Types], case_sensitive=False),
|
||||
required=True, help="Device Type")
|
||||
@click.option("-l", "--level", type=click.IntRange(1, 3), required=True, help="Device Security Level")
|
||||
@click.option("-k", "--key", type=Path, required=True, help="Device RSA Private Key in PEM or DER format")
|
||||
@click.option("-c", "--client_id", type=Path, required=True, help="Widevine ClientIdentification Blob file")
|
||||
@click.option("-v", "--vmp", type=Path, default=None, help="Widevine FileHashes Blob file")
|
||||
@click.option("-o", "--output", type=Path, default=None, help="Output Directory")
|
||||
@click.pass_context
|
||||
def create_device(
|
||||
ctx: click.Context,
|
||||
type_: str,
|
||||
level: int,
|
||||
key: Path,
|
||||
client_id: Path,
|
||||
vmp: Optional[Path] = None,
|
||||
output: Optional[Path] = None
|
||||
) -> None:
|
||||
"""
|
||||
Create a Widevine Device (.wvd) file from an RSA Private Key (PEM or DER) and Client ID Blob.
|
||||
Optionally also a VMP (Verified Media Path) Blob, which will be stored in the Client ID.
|
||||
"""
|
||||
if not key.is_file():
|
||||
raise click.UsageError("key: Not a path to a file, or it doesn't exist.", ctx)
|
||||
if not client_id.is_file():
|
||||
raise click.UsageError("client_id: Not a path to a file, or it doesn't exist.", ctx)
|
||||
if vmp and not vmp.is_file():
|
||||
raise click.UsageError("vmp: Not a path to a file, or it doesn't exist.", ctx)
|
||||
|
||||
log = logging.getLogger("create-device")
|
||||
|
||||
device = Device(
|
||||
type_=Device.Types[type_.upper()],
|
||||
security_level=level,
|
||||
flags=None,
|
||||
private_key=key.read_bytes(),
|
||||
client_id=client_id.read_bytes()
|
||||
)
|
||||
|
||||
if vmp:
|
||||
new_vmp_data = vmp.read_bytes()
|
||||
if device.client_id.vmp_data and device.client_id.vmp_data != new_vmp_data:
|
||||
log.warning("Client ID already has Verified Media Path data")
|
||||
device.client_id.vmp_data = new_vmp_data
|
||||
|
||||
client_info = {}
|
||||
for entry in device.client_id.client_info:
|
||||
client_info[entry.name] = entry.value
|
||||
|
||||
wvd_bin = device.dumps()
|
||||
|
||||
name = f"{client_info['company_name']} {client_info['model_name']}"
|
||||
if client_info.get("widevine_cdm_version"):
|
||||
name += f" {client_info['widevine_cdm_version']}"
|
||||
name += f" {crc32(wvd_bin).to_bytes(4, 'big').hex()}"
|
||||
|
||||
try:
|
||||
name = unidecode(name.strip().lower().replace(" ", "_"))
|
||||
except UnidecodeError as e:
|
||||
raise click.ClickException(f"Failed to sanitize name, {e}")
|
||||
|
||||
out_path = (output or Path.cwd()) / f"{name}_{device.system_id}_l{device.security_level}.wvd"
|
||||
out_path.write_bytes(wvd_bin)
|
||||
|
||||
log.info(f"Created Widevine Device (.wvd) file, {out_path.name}")
|
||||
log.info(f" + Type: {device.type.name}")
|
||||
log.info(f" + System ID: {device.system_id}")
|
||||
log.info(f" + Security Level: {device.security_level}")
|
||||
log.info(f" + Flags: {device.flags}")
|
||||
log.info(f" + Private Key: {bool(device.private_key)} ({device.private_key.size_in_bits()} bit)")
|
||||
log.info(f" + Client ID: {bool(device.client_id)} ({len(device.client_id.SerializeToString())} bytes)")
|
||||
if device.client_id.vmp_data:
|
||||
file_hashes_ = FileHashes()
|
||||
file_hashes_.ParseFromString(device.client_id.vmp_data)
|
||||
log.info(f" + VMP: True ({len(file_hashes_.signatures)} signatures)")
|
||||
else:
|
||||
log.info(" + VMP: False")
|
||||
log.info(f" + Saved to: {out_path.absolute()}")
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.argument("path", type=Path)
|
||||
@click.pass_context
|
||||
def migrate(ctx: click.Context, path: Path) -> None:
|
||||
"""
|
||||
Upgrade from earlier versions of the Widevine Device (.wvd) format.
|
||||
|
||||
The path argument can be a direct path to a Widevine Device (.wvd) file, or a path
|
||||
to a folder of Widevine Devices files.
|
||||
|
||||
The migrated devices are saved to its original location, overwriting the old version.
|
||||
"""
|
||||
if not path.exists():
|
||||
raise click.UsageError(f"path: The path '{path}' does not exist.", ctx)
|
||||
|
||||
log = logging.getLogger("migrate")
|
||||
|
||||
if path.is_dir():
|
||||
devices = list(path.glob("*.wvd"))
|
||||
else:
|
||||
devices = [path]
|
||||
|
||||
migrated = 0
|
||||
for device in devices:
|
||||
log.info(f"Migrating {device.name}...")
|
||||
|
||||
try:
|
||||
new_device = Device.migrate(device.read_bytes())
|
||||
except (ConstructError, ValueError) as e:
|
||||
log.error(f" - {e}")
|
||||
continue
|
||||
|
||||
log.debug(new_device)
|
||||
new_device.dump(device)
|
||||
|
||||
log.info(" + Success")
|
||||
migrated += 1
|
||||
|
||||
log.info(f"Migrated {migrated}/{len(devices)} devices!")
|
||||
|
||||
|
||||
@main.command("serve", short_help="Serve your local CDM and Widevine Devices Remotely.")
|
||||
@click.argument("config", type=Path)
|
||||
@click.option("-h", "--host", type=str, default="127.0.0.1", help="Host to serve from.")
|
||||
@click.option("-p", "--port", type=int, default=8786, help="Port to serve from.")
|
||||
def serve_(config: Path, host: str, port: int):
|
||||
"""
|
||||
Serve your local CDM and Widevine Devices Remotely.
|
||||
|
||||
\b
|
||||
[CONFIG] is a path to a serve config file.
|
||||
See `serve.example.yml` for an example config file.
|
||||
|
||||
\b
|
||||
Host as 127.0.0.1 may block remote access even if port-forwarded.
|
||||
Instead, use 0.0.0.0 and ensure the TCP port you choose is forwarded.
|
||||
"""
|
||||
from pywidevine import serve
|
||||
import yaml
|
||||
|
||||
config = yaml.safe_load(config.read_text(encoding="utf8"))
|
||||
serve.run(config, host, port)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Union
|
||||
import binascii
|
||||
import string
|
||||
from typing import Union, Optional
|
||||
from uuid import UUID
|
||||
|
||||
import construct
|
||||
from construct import Container
|
||||
from google.protobuf.message import DecodeError
|
||||
from lxml import etree
|
||||
@@ -19,142 +22,191 @@ class PSSH:
|
||||
Widevine = UUID(bytes=b"\xed\xef\x8b\xa9\x79\xd6\x4a\xce\xa3\xc8\x27\xdc\xd5\x1d\x21\xed")
|
||||
PlayReady = UUID(bytes=b"\x9a\x04\xf0\x79\x98\x40\x42\x86\xab\x92\xe6\x5b\xe0\x88\x5f\x95")
|
||||
|
||||
def __init__(self, box: Container):
|
||||
self._box = box
|
||||
|
||||
@staticmethod
|
||||
def from_init_data(init_data: Union[str, bytes, WidevinePsshData]) -> Container:
|
||||
"""Craft a new PSSH Box from just Widevine PSSH Data (init data)."""
|
||||
if isinstance(init_data, str):
|
||||
init_data = base64.b64decode(init_data)
|
||||
if isinstance(init_data, bytes):
|
||||
cenc_header = WidevinePsshData()
|
||||
cenc_header.ParseFromString(init_data)
|
||||
init_data = cenc_header
|
||||
if not isinstance(init_data, WidevinePsshData):
|
||||
raise ValueError(f"Unexpected value for init_data, {init_data!r}")
|
||||
|
||||
box = Box.parse(Box.build(dict(
|
||||
type=b"pssh",
|
||||
version=0,
|
||||
flags=0,
|
||||
system_ID=PSSH.SystemId.Widevine,
|
||||
init_data=init_data.SerializeToString()
|
||||
)))
|
||||
|
||||
return box
|
||||
|
||||
@staticmethod
|
||||
def from_playready_pssh(box: Container) -> Container:
|
||||
def __init__(self, data: Union[Container, str, bytes], strict: bool = False):
|
||||
"""
|
||||
Convert a PlayReady PSSH to a Widevine PSSH.
|
||||
Load a PSSH box or Widevine Cenc Header data as a new v0 PSSH box.
|
||||
|
||||
Note: The resulting Widevine PSSH will likely not be usable for Licensing. This
|
||||
is because there is some data for a Widevine CENC Header that is not going to be
|
||||
listed in a PlayReady PSSH.
|
||||
[Strict mode (strict=True)]
|
||||
|
||||
This converted PSSH will only be useful for it's Key IDs, so realistically only
|
||||
for matching Key IDs with a Track. As a fallback.
|
||||
Supports the following forms of input data in either Base64 or Bytes form:
|
||||
- Full PSSH mp4 boxes (as defined by pymp4 Box).
|
||||
- Full Widevine Cenc Headers (as defined by WidevinePsshData proto).
|
||||
|
||||
[Lenient mode (strict=False, default)]
|
||||
|
||||
If the data is not supported in Strict mode, and is assumed not to be corrupt or
|
||||
parsed incorrectly, the License Server likely accepts a custom init_data value
|
||||
during a License Request call. This is uncommon behavior but not out of realm of
|
||||
possibilities. For example, Netflix does this with it's MSL WidevineExchange
|
||||
scheme.
|
||||
|
||||
Lenient mode will craft a new v0 PSSH box with the init_data field set to
|
||||
the provided data as-is. The data will first be base64 decoded. This behavior
|
||||
may not work in your scenario and if that's the case please manually craft
|
||||
your own PSSH box with the init_data field to be used in License Requests.
|
||||
|
||||
Raises:
|
||||
ValueError: If the data is empty.
|
||||
TypeError: If the data is an unexpected type.
|
||||
binascii.Error: If the data could not be decoded as Base64 if provided as a
|
||||
string.
|
||||
DecodeError: If the data could not be parsed as a PSSH mp4 box nor a Widevine
|
||||
Cenc Header and strict mode is enabled.
|
||||
"""
|
||||
if box.type != b"pssh":
|
||||
raise ValueError(f"Box must be a PSSH box, not {box.type}")
|
||||
if box.system_ID != PSSH.SystemId.PlayReady:
|
||||
raise ValueError(f"This is not a PlayReady PSSH Box, {box.system_ID}")
|
||||
if not data:
|
||||
raise ValueError("Data must not be empty.")
|
||||
|
||||
key_ids = PSSH.get_key_ids(box)
|
||||
if isinstance(data, Container):
|
||||
box = data
|
||||
else:
|
||||
if isinstance(data, str):
|
||||
try:
|
||||
data = base64.b64decode(data)
|
||||
except (binascii.Error, binascii.Incomplete) as e:
|
||||
raise binascii.Error(f"Could not decode data as Base64, {e}")
|
||||
|
||||
cenc_header = WidevinePsshData()
|
||||
cenc_header.algorithm = 1 # 0=Clear, 1=AES-CTR
|
||||
if not isinstance(data, bytes):
|
||||
raise TypeError(f"Expected data to be a {Container}, bytes, or base64, not {data!r}")
|
||||
|
||||
for key_id in key_ids:
|
||||
cenc_header.key_id.append(key_id.bytes)
|
||||
if box.version == 1:
|
||||
# ensure both cenc header and box has same Key IDs
|
||||
# v1 uses both this and within init data for basically no reason
|
||||
box.key_IDs = key_ids
|
||||
|
||||
box.init_data = cenc_header.SerializeToString()
|
||||
box.system_ID = PSSH.SystemId.Widevine
|
||||
|
||||
return box
|
||||
|
||||
@staticmethod
|
||||
def from_key_ids(key_ids: list[UUID]) -> Container:
|
||||
"""
|
||||
Craft a new PSSH Box from just Key IDs.
|
||||
This should only be used as a very last measure.
|
||||
"""
|
||||
cenc_header = WidevinePsshData()
|
||||
for key_id in key_ids:
|
||||
cenc_header.key_id.append(key_id.bytes)
|
||||
cenc_header.algorithm = 1 # 0=Clear, 1=AES-CTR
|
||||
|
||||
box = Box.parse(Box.build(dict(
|
||||
type=b"pssh",
|
||||
version=0,
|
||||
flags=0,
|
||||
system_ID=PSSH.SystemId.Widevine,
|
||||
init_data=cenc_header.SerializeToString()
|
||||
)))
|
||||
|
||||
return box
|
||||
|
||||
@staticmethod
|
||||
def get_as_box(data: Union[Container, bytes, str]) -> Container:
|
||||
"""
|
||||
Get the possibly arbitrary data as a parsed PSSH mp4 box.
|
||||
If the data is just Widevine PSSH Data (init data) then it will be crafted
|
||||
into a new PSSH mp4 box.
|
||||
If the data could not be recognized as a PSSH box of some form of encoding
|
||||
it will raise a ValueError.
|
||||
"""
|
||||
if isinstance(data, str):
|
||||
data = base64.b64decode(data)
|
||||
if isinstance(data, bytes):
|
||||
if base64.b64encode(data) == b"CAES": # likely widevine pssh data
|
||||
try:
|
||||
box = Box.parse(data)
|
||||
except (IOError, construct.ConstructError): # not a box
|
||||
try:
|
||||
cenc_header = WidevinePsshData()
|
||||
cenc_header.ParseFromString(data)
|
||||
except DecodeError:
|
||||
# not actually init data after all
|
||||
pass
|
||||
else:
|
||||
data = Box.parse(Box.build(dict(
|
||||
type=b"pssh",
|
||||
version=0,
|
||||
flags=0,
|
||||
system_ID=PSSH.SystemId.Widevine,
|
||||
init_data=cenc_header.SerializeToString()
|
||||
)))
|
||||
data = Box.parse(data)
|
||||
if isinstance(data, Container):
|
||||
return data
|
||||
raise ValueError(f"Unrecognized PSSH data: {data!r}")
|
||||
cenc_header = cenc_header.SerializeToString()
|
||||
if cenc_header != data: # not actually a WidevinePsshData
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def get_key_ids(box: Container) -> list[UUID]:
|
||||
box = Box.parse(Box.build(dict(
|
||||
type=b"pssh",
|
||||
version=0,
|
||||
flags=0,
|
||||
system_ID=PSSH.SystemId.Widevine,
|
||||
init_data=cenc_header
|
||||
)))
|
||||
|
||||
self.version = box.version
|
||||
self.flags = box.flags
|
||||
self.system_id = box.system_ID
|
||||
self.__key_ids = box.key_IDs
|
||||
self.init_data = box.init_data
|
||||
|
||||
@classmethod
|
||||
def new(
|
||||
cls,
|
||||
key_ids: Optional[list[Union[UUID, str, bytes]]] = None,
|
||||
init_data: Optional[Union[WidevinePsshData, str, bytes]] = None,
|
||||
version: int = 0,
|
||||
flags: int = 0
|
||||
) -> PSSH:
|
||||
"""Craft a new version 0 or 1 PSSH Box."""
|
||||
if key_ids is not None:
|
||||
if not isinstance(key_ids, list):
|
||||
raise TypeError(f"Expected key_ids to be a list not {key_ids!r}")
|
||||
|
||||
if init_data is not None:
|
||||
if not isinstance(init_data, (WidevinePsshData, str, bytes)):
|
||||
raise TypeError(f"Expected init_data to be a {WidevinePsshData}, base64, or bytes, not {init_data!r}")
|
||||
|
||||
if not isinstance(version, int):
|
||||
raise TypeError(f"Expected version to be an int not {version!r}")
|
||||
if version not in (0, 1):
|
||||
raise ValueError(f"Invalid version, must be either 0 or 1, not {version}.")
|
||||
|
||||
if not isinstance(flags, int):
|
||||
raise TypeError(f"Expected flags to be an int not {flags!r}")
|
||||
if flags < 0:
|
||||
raise ValueError(f"Invalid flags, cannot be less than 0.")
|
||||
|
||||
if version == 0 and key_ids is not None and init_data is not None:
|
||||
# v0 boxes use only init_data in the pssh field, but we can use the key_ids within the init_data
|
||||
raise ValueError("Version 0 PSSH boxes must use only init_data, not init_data and key_ids.")
|
||||
elif version == 1:
|
||||
# TODO: I cannot tell if they need either init_data or key_ids exclusively, or both is fine
|
||||
# So for now I will just make sure at least one is supplied
|
||||
if init_data is None and key_ids is None:
|
||||
raise ValueError("Version 1 PSSH boxes must use either init_data or key_ids but neither were provided")
|
||||
|
||||
if key_ids is not None:
|
||||
# ensure key_ids are bytes, supports hex, base64, and bytes
|
||||
key_ids = [
|
||||
(
|
||||
x.bytes if isinstance(x, UUID) else
|
||||
bytes.fromhex(x) if all(c in string.hexdigits for c in x) else
|
||||
base64.b64decode(x) if isinstance(x, str) else
|
||||
x
|
||||
)
|
||||
for x in key_ids
|
||||
]
|
||||
if not all(isinstance(x, bytes) for x in key_ids):
|
||||
not_bytes = [x for x in key_ids if not isinstance(x, bytes)]
|
||||
raise TypeError(
|
||||
"Expected all of key_ids to be a UUID, hex, base64, or bytes, but one or more are not, "
|
||||
f"{not_bytes!r}"
|
||||
)
|
||||
|
||||
if init_data is not None:
|
||||
if isinstance(init_data, WidevinePsshData):
|
||||
init_data = init_data.SerializeToString()
|
||||
elif isinstance(init_data, str):
|
||||
if all(c in string.hexdigits for c in init_data):
|
||||
init_data = bytes.fromhex(init_data)
|
||||
else:
|
||||
init_data = base64.b64decode(init_data)
|
||||
elif not isinstance(init_data, bytes):
|
||||
raise TypeError(
|
||||
f"Expecting init_data to be {WidevinePsshData}, hex, base64, or bytes, not {init_data!r}"
|
||||
)
|
||||
|
||||
box = Box.parse(Box.build(dict(
|
||||
type=b"pssh",
|
||||
version=version,
|
||||
flags=flags,
|
||||
system_ID=PSSH.SystemId.Widevine,
|
||||
key_ids=[key_ids, b""][key_ids is None],
|
||||
init_data=[init_data, b""][init_data is None]
|
||||
)))
|
||||
|
||||
pssh = cls(box)
|
||||
|
||||
if key_ids and version == 0:
|
||||
pssh.set_key_ids([UUID(bytes=x) for x in key_ids])
|
||||
|
||||
return pssh
|
||||
|
||||
@property
|
||||
def key_ids(self) -> list[UUID]:
|
||||
"""
|
||||
Get Key IDs from a PSSH Box from within the Box or Init Data where possible.
|
||||
Get all Key IDs from within the Box or Init Data, wherever possible.
|
||||
|
||||
Supports:
|
||||
- Version 1 Boxes
|
||||
- Widevine Headers
|
||||
- PlayReady Headers (4.0.0.0->4.3.0.0)
|
||||
"""
|
||||
if box.version == 1 and box.key_IDs:
|
||||
return box.key_IDs
|
||||
if self.version == 1 and self.__key_ids:
|
||||
return self.__key_ids
|
||||
|
||||
if box.system_ID == PSSH.SystemId.Widevine:
|
||||
init = WidevinePsshData()
|
||||
init.ParseFromString(box.init_data)
|
||||
if self.system_id == PSSH.SystemId.Widevine:
|
||||
# TODO: What if its not a Widevine Cenc Header but the System ID is set as Widevine?
|
||||
cenc_header = WidevinePsshData()
|
||||
cenc_header.ParseFromString(self.init_data)
|
||||
return [
|
||||
# the key_ids value may or may not be hex underlying
|
||||
UUID(bytes=key_id) if len(key_id) == 16 else UUID(hex=key_id.decode())
|
||||
for key_id in init.key_id
|
||||
for key_id in cenc_header.key_ids
|
||||
]
|
||||
|
||||
if box.system_ID == PSSH.SystemId.PlayReady:
|
||||
xml_string = box.init_data.decode("utf-16-le")
|
||||
if self.system_id == PSSH.SystemId.PlayReady:
|
||||
xml_string = self.init_data.decode("utf-16-le")
|
||||
# some of these init data has garbage(?) in front of it
|
||||
xml_string = xml_string[xml_string.index("<"):]
|
||||
xml = etree.fromstring(xml_string)
|
||||
@@ -172,30 +224,70 @@ class PSSH:
|
||||
for key_id in key_ids
|
||||
]
|
||||
|
||||
raise ValueError(f"Unsupported Box {box!r}")
|
||||
raise ValueError(f"This PSSH is not supported by key_ids() property, {self.dumps()}")
|
||||
|
||||
@staticmethod
|
||||
def overwrite_key_ids(box: Container, key_ids: list[UUID]) -> Container:
|
||||
"""Overwrite all Key IDs in PSSH box with the specified Key IDs."""
|
||||
if box.system_ID != PSSH.SystemId.Widevine:
|
||||
raise ValueError(f"Only Widevine PSSH Boxes are supported, not {box.system_ID}.")
|
||||
def dump(self) -> bytes:
|
||||
"""Export the PSSH object as a full PSSH box in bytes form."""
|
||||
return Box.build(dict(
|
||||
type=b"pssh",
|
||||
version=self.version,
|
||||
flags=self.flags,
|
||||
system_ID=self.system_id,
|
||||
key_IDs=self.key_ids,
|
||||
init_data=self.init_data
|
||||
))
|
||||
|
||||
if box.version == 1 or box.key_IDs:
|
||||
# only use key_IDs if version is 1, or it's already being used
|
||||
def dumps(self) -> str:
|
||||
"""Export the PSSH object as a full PSSH box in base64 form."""
|
||||
return base64.b64encode(self.dump()).decode()
|
||||
|
||||
def playready_to_widevine(self) -> None:
|
||||
"""
|
||||
Convert PlayReady PSSH data to Widevine PSSH data.
|
||||
|
||||
There's only a limited amount of information within a PlayReady PSSH header that
|
||||
can be used in a Widevine PSSH Header. The converted data may or may not result
|
||||
in an accepted PSSH. It depends on what the License Server is expecting.
|
||||
"""
|
||||
if self.system_id != PSSH.SystemId.PlayReady:
|
||||
raise ValueError(f"This is not a PlayReady PSSH, {self.system_id}")
|
||||
|
||||
cenc_header = WidevinePsshData()
|
||||
cenc_header.algorithm = 1 # 0=Clear, 1=AES-CTR
|
||||
cenc_header.key_ids[:] = [x.bytes for x in self.key_ids]
|
||||
|
||||
if self.version == 1:
|
||||
# ensure both cenc header and box has same Key IDs
|
||||
# v1 uses both this and within init data for basically no reason
|
||||
self.__key_ids = self.key_ids
|
||||
|
||||
self.init_data = cenc_header.SerializeToString()
|
||||
self.system_id = PSSH.SystemId.Widevine
|
||||
|
||||
def set_key_ids(self, key_ids: list[UUID]) -> None:
|
||||
"""Overwrite all Key IDs with the specified Key IDs."""
|
||||
if self.system_id != PSSH.SystemId.Widevine:
|
||||
# TODO: Add support for setting the Key IDs in a PlayReady Header
|
||||
raise ValueError(f"Only Widevine PSSH Boxes are supported, not {self.system_id}.")
|
||||
|
||||
if not isinstance(key_ids, list):
|
||||
raise TypeError(f"Expecting key_ids to be a list, not {key_ids!r}")
|
||||
|
||||
if not all(isinstance(x, UUID) for x in key_ids):
|
||||
not_uuid = [x for x in key_ids if not isinstance(x, UUID)]
|
||||
raise TypeError(f"All Key IDs in key_ids must be a {UUID}, not {not_uuid}")
|
||||
|
||||
if self.version == 1 or self.__key_ids:
|
||||
# only use v1 box key_ids if version is 1, or it's already being used
|
||||
# this is in case the service stupidly expects it for version 0
|
||||
box.key_IDs = key_ids
|
||||
self.__key_ids = key_ids
|
||||
|
||||
init = WidevinePsshData()
|
||||
init.ParseFromString(box.init_data)
|
||||
cenc_header = WidevinePsshData()
|
||||
cenc_header.ParseFromString(self.init_data)
|
||||
|
||||
# TODO: Is there a better way to clear the Key IDs?
|
||||
for _ in range(len(init.key_id or [])):
|
||||
init.key_id.pop(0)
|
||||
cenc_header.key_ids[:] = [
|
||||
key_id.bytes
|
||||
for key_id in key_ids
|
||||
]
|
||||
|
||||
# TODO: Is there a .extend or a way to add all without a loop?
|
||||
for key_id in key_ids:
|
||||
init.key_id.append(key_id.bytes)
|
||||
|
||||
box.init_data = init.SerializeToString()
|
||||
|
||||
return box
|
||||
self.init_data = cenc_header.SerializeToString()
|
||||
|
||||
256
pywidevine/remotecdm.py
Normal file
256
pywidevine/remotecdm.py
Normal file
@@ -0,0 +1,256 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import re
|
||||
from typing import Union, Optional
|
||||
|
||||
import requests
|
||||
from Crypto.PublicKey import RSA
|
||||
from google.protobuf.message import DecodeError
|
||||
from pywidevine.cdm import Cdm
|
||||
from pywidevine.device import Device
|
||||
from pywidevine.exceptions import InvalidInitData, InvalidLicenseType, InvalidLicenseMessage, DeviceMismatch
|
||||
from pywidevine.key import Key
|
||||
|
||||
from pywidevine.license_protocol_pb2 import LicenseType, SignedMessage, License, ClientIdentification
|
||||
from pywidevine.pssh import PSSH
|
||||
|
||||
|
||||
class RemoteCdm(Cdm):
|
||||
"""Remote Accessible CDM using pywidevine's serve schema."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_type: Union[Device.Types, str],
|
||||
system_id: int,
|
||||
security_level: int,
|
||||
host: str,
|
||||
secret: str,
|
||||
device_name: str
|
||||
):
|
||||
"""Initialize a Widevine Content Decryption Module (CDM)."""
|
||||
if not device_type:
|
||||
raise ValueError("Device Type must be provided")
|
||||
if isinstance(device_type, str):
|
||||
device_type = Device.Types[device_type]
|
||||
if not isinstance(device_type, Device.Types):
|
||||
raise TypeError(f"Expected device_type to be a {Device.Types!r} not {device_type!r}")
|
||||
|
||||
if not system_id:
|
||||
raise ValueError("System ID must be provided")
|
||||
if not isinstance(system_id, int):
|
||||
raise TypeError(f"Expected system_id to be a {int} not {system_id!r}")
|
||||
|
||||
if not security_level:
|
||||
raise ValueError("Security Level must be provided")
|
||||
if not isinstance(security_level, int):
|
||||
raise TypeError(f"Expected security_level to be a {int} not {security_level!r}")
|
||||
|
||||
if not host:
|
||||
raise ValueError("API Host must be provided")
|
||||
if not isinstance(host, str):
|
||||
raise TypeError(f"Expected host to be a {str} not {host!r}")
|
||||
|
||||
if not secret:
|
||||
raise ValueError("API Secret must be provided")
|
||||
if not isinstance(secret, str):
|
||||
raise TypeError(f"Expected secret to be a {str} not {secret!r}")
|
||||
|
||||
if not device_name:
|
||||
raise ValueError("API Device name must be provided")
|
||||
if not isinstance(device_name, str):
|
||||
raise TypeError(f"Expected device_name to be a {str} not {device_name!r}")
|
||||
|
||||
self.device_type = device_type
|
||||
self.system_id = system_id
|
||||
self.security_level = security_level
|
||||
self.host = host
|
||||
self.device_name = device_name
|
||||
|
||||
# spoof client_id and rsa_key just so we can construct via super call
|
||||
super().__init__(device_type, system_id, security_level, ClientIdentification(), RSA.generate(2048))
|
||||
|
||||
self.__session = requests.Session()
|
||||
self.__session.headers.update({
|
||||
"X-Secret-Key": secret
|
||||
})
|
||||
|
||||
r = requests.head(self.host)
|
||||
if r.status_code != 200:
|
||||
raise ValueError(f"Could not test Remote API version [{r.status_code}]")
|
||||
server = r.headers.get("Server")
|
||||
if not server or "pywidevine serve" not in server.lower():
|
||||
raise ValueError(f"This Remote CDM API does not seem to be a pywidevine serve API ({server}).")
|
||||
server_version = re.search(r"pywidevine serve v([\d.]+)", server, re.IGNORECASE)
|
||||
if not server_version:
|
||||
raise ValueError(f"The pywidevine server API is not stating the version correctly, cannot continue.")
|
||||
server_version = server_version.group(1)
|
||||
if server_version < "1.4.0":
|
||||
raise ValueError(f"This pywidevine serve API version ({server_version}) is not supported.")
|
||||
|
||||
@classmethod
|
||||
def from_device(cls, device: Device) -> RemoteCdm:
|
||||
raise NotImplementedError("You cannot load a RemoteCdm from a local Device file.")
|
||||
|
||||
def open(self) -> bytes:
|
||||
r = self.__session.get(
|
||||
url=f"{self.host}/{self.device_name}/open"
|
||||
).json()
|
||||
if r['status'] != 200:
|
||||
raise ValueError(f"Cannot Open CDM Session, {r['message']} [{r['status']}]")
|
||||
r = r["data"]
|
||||
|
||||
if int(r["device"]["system_id"]) != self.system_id:
|
||||
raise DeviceMismatch("The System ID specified does not match the one specified in the API response.")
|
||||
|
||||
if int(r["device"]["security_level"]) != self.security_level:
|
||||
raise DeviceMismatch("The Security Level specified does not match the one specified in the API response.")
|
||||
|
||||
return bytes.fromhex(r["session_id"])
|
||||
|
||||
def close(self, session_id: bytes) -> None:
|
||||
r = self.__session.get(
|
||||
url=f"{self.host}/{self.device_name}/close/{session_id.hex()}"
|
||||
).json()
|
||||
if r["status"] != 200:
|
||||
raise ValueError(f"Cannot Close CDM Session, {r['message']} [{r['status']}]")
|
||||
|
||||
def set_service_certificate(self, session_id: bytes, certificate: Optional[Union[bytes, str]]) -> str:
|
||||
if certificate is None:
|
||||
certificate_b64 = None
|
||||
elif isinstance(certificate, str):
|
||||
certificate_b64 = certificate # assuming base64
|
||||
elif isinstance(certificate, bytes):
|
||||
certificate_b64 = base64.b64encode(certificate).decode()
|
||||
else:
|
||||
raise DecodeError(f"Expecting Certificate to be base64 or bytes, not {certificate!r}")
|
||||
|
||||
r = self.__session.post(
|
||||
url=f"{self.host}/{self.device_name}/set_service_certificate",
|
||||
json={
|
||||
"session_id": session_id.hex(),
|
||||
"certificate": certificate_b64
|
||||
}
|
||||
).json()
|
||||
if r["status"] != 200:
|
||||
raise ValueError(f"Cannot Set CDMs Service Certificate, {r['message']} [{r['status']}]")
|
||||
r = r["data"]
|
||||
|
||||
return r["provider_id"]
|
||||
|
||||
def get_license_challenge(
|
||||
self,
|
||||
session_id: bytes,
|
||||
pssh: PSSH,
|
||||
type_: Union[int, str] = LicenseType.STREAMING,
|
||||
privacy_mode: bool = True
|
||||
) -> bytes:
|
||||
if not pssh:
|
||||
raise InvalidInitData("A pssh must be provided.")
|
||||
if not isinstance(pssh, PSSH):
|
||||
raise InvalidInitData(f"Expected pssh to be a {PSSH}, not {pssh!r}")
|
||||
|
||||
try:
|
||||
if isinstance(type_, int):
|
||||
type_ = LicenseType.Name(int(type_))
|
||||
elif isinstance(type_, str):
|
||||
type_ = LicenseType.Name(LicenseType.Value(type_))
|
||||
elif isinstance(type_, LicenseType):
|
||||
type_ = LicenseType.Name(type_)
|
||||
else:
|
||||
raise InvalidLicenseType()
|
||||
except ValueError:
|
||||
raise InvalidLicenseType(f"License Type {type_!r} is invalid")
|
||||
|
||||
r = self.__session.post(
|
||||
url=f"{self.host}/{self.device_name}/get_license_challenge/{type_}",
|
||||
json={
|
||||
"session_id": session_id.hex(),
|
||||
"init_data": pssh.dumps()
|
||||
}
|
||||
).json()
|
||||
if r["status"] != 200:
|
||||
raise ValueError(f"Cannot get Challenge, {r['message']} [{r['status']}]")
|
||||
r = r["data"]
|
||||
|
||||
try:
|
||||
license_message = SignedMessage()
|
||||
license_message.ParseFromString(base64.b64decode(r["challenge_b64"]))
|
||||
except DecodeError as e:
|
||||
raise InvalidLicenseMessage(f"Failed to parse license request, {e}")
|
||||
|
||||
return license_message.SerializeToString()
|
||||
|
||||
def parse_license(self, session_id: bytes, license_message: Union[SignedMessage, bytes, str]) -> None:
|
||||
if not license_message:
|
||||
raise InvalidLicenseMessage("Cannot parse an empty license_message")
|
||||
|
||||
if isinstance(license_message, str):
|
||||
try:
|
||||
license_message = base64.b64decode(license_message)
|
||||
except (binascii.Error, binascii.Incomplete) as e:
|
||||
raise InvalidLicenseMessage(f"Could not decode license_message as Base64, {e}")
|
||||
|
||||
if isinstance(license_message, bytes):
|
||||
signed_message = SignedMessage()
|
||||
try:
|
||||
signed_message.ParseFromString(license_message)
|
||||
except DecodeError as e:
|
||||
raise InvalidLicenseMessage(f"Could not parse license_message as a SignedMessage, {e}")
|
||||
license_message = signed_message
|
||||
|
||||
if not isinstance(license_message, SignedMessage):
|
||||
raise InvalidLicenseMessage(f"Expecting license_response to be a SignedMessage, got {license_message!r}")
|
||||
|
||||
if license_message.type != SignedMessage.MessageType.LICENSE:
|
||||
raise InvalidLicenseMessage(
|
||||
f"Expecting a LICENSE message, not a "
|
||||
f"'{SignedMessage.MessageType.Name(license_message.type)}' message."
|
||||
)
|
||||
|
||||
r = self.__session.post(
|
||||
url=f"{self.host}/{self.device_name}/parse_license",
|
||||
json={
|
||||
"session_id": session_id.hex(),
|
||||
"license_message": base64.b64encode(license_message.SerializeToString()).decode()
|
||||
}
|
||||
).json()
|
||||
if r["status"] != 200:
|
||||
raise ValueError(f"Cannot parse License, {r['message']} [{r['status']}]")
|
||||
|
||||
def get_keys(self, session_id: bytes, type_: Optional[Union[int, str]] = None) -> list[Key]:
|
||||
try:
|
||||
if isinstance(type_, str):
|
||||
License.KeyContainer.KeyType.Value(type_) # only test
|
||||
elif isinstance(type_, int):
|
||||
type_ = License.KeyContainer.KeyType.Name(type_)
|
||||
elif type_ is None:
|
||||
type_ = "ALL"
|
||||
else:
|
||||
raise TypeError(f"Expected type_ to be a {License.KeyContainer.KeyType} or int, not {type_!r}")
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Could not parse type_ as a {License.KeyContainer.KeyType}, {e}")
|
||||
|
||||
r = self.__session.post(
|
||||
url=f"{self.host}/{self.device_name}/get_keys/{type_}",
|
||||
json={
|
||||
"session_id": session_id.hex()
|
||||
}
|
||||
).json()
|
||||
if r["status"] != 200:
|
||||
raise ValueError(f"Could not get {type_} Keys, {r['message']} [{r['status']}]")
|
||||
r = r["data"]
|
||||
|
||||
return [
|
||||
Key(
|
||||
type_=key["type"],
|
||||
kid=Key.kid_to_uuid(bytes.fromhex(key["key_id"])),
|
||||
key=bytes.fromhex(key["key"]),
|
||||
permissions=key["permissions"]
|
||||
)
|
||||
for key in r["keys"]
|
||||
]
|
||||
|
||||
|
||||
__ALL__ = (RemoteCdm,)
|
||||
408
pywidevine/serve.py
Normal file
408
pywidevine/serve.py
Normal file
@@ -0,0 +1,408 @@
|
||||
import base64
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from google.protobuf.message import DecodeError
|
||||
|
||||
from pywidevine.pssh import PSSH
|
||||
|
||||
try:
|
||||
from aiohttp import web
|
||||
except ImportError:
|
||||
print(
|
||||
"Missing the extra dependencies for serve functionality. "
|
||||
"You may install them under poetry with `poetry install -E serve`, "
|
||||
"or under pip with `pip install pywidevine[serve]`."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
from pywidevine import __version__
|
||||
from pywidevine.cdm import Cdm
|
||||
from pywidevine.device import Device
|
||||
from pywidevine.exceptions import TooManySessions, InvalidSession, SignatureMismatch, InvalidInitData, \
|
||||
InvalidLicenseType, InvalidLicenseMessage, InvalidContext
|
||||
|
||||
routes = web.RouteTableDef()
|
||||
|
||||
|
||||
async def _startup(app: web.Application):
|
||||
app["cdms"]: dict[tuple[str, str], Cdm] = {}
|
||||
app["config"]["devices"] = {
|
||||
path.stem: path
|
||||
for x in app["config"]["devices"]
|
||||
for path in [Path(x)]
|
||||
}
|
||||
for device in app["config"]["devices"].values():
|
||||
if not device.is_file():
|
||||
raise FileNotFoundError(f"Device file does not exist: {device}")
|
||||
|
||||
|
||||
async def _cleanup(app: web.Application):
|
||||
app["cdms"].clear()
|
||||
del app["cdms"]
|
||||
app["config"].clear()
|
||||
del app["config"]
|
||||
|
||||
|
||||
@routes.get("/")
|
||||
async def ping(_) -> web.Response:
|
||||
return web.json_response({
|
||||
"status": 200,
|
||||
"message": "Pong!"
|
||||
})
|
||||
|
||||
|
||||
@routes.get("/{device}/open")
|
||||
async def open(request: web.Request) -> web.Response:
|
||||
secret_key = request.headers["X-Secret-Key"]
|
||||
device_name = request.match_info["device"]
|
||||
user = request.app["config"]["users"][secret_key]
|
||||
|
||||
if device_name not in user["devices"] or device_name not in request.app["config"]["devices"]:
|
||||
# we don't want to be verbose with the error as to not reveal device names
|
||||
# by trial and error to users that are not authorized to use them
|
||||
return web.json_response({
|
||||
"status": 403,
|
||||
"message": f"Device '{device_name}' is not found or you are not authorized to use it."
|
||||
}, status=403)
|
||||
|
||||
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
|
||||
if not cdm:
|
||||
device = Device.load(request.app["config"]["devices"][device_name])
|
||||
cdm = request.app["cdms"][(secret_key, device_name)] = Cdm.from_device(device)
|
||||
|
||||
try:
|
||||
session_id = cdm.open()
|
||||
except TooManySessions as e:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": str(e)
|
||||
}, status=400)
|
||||
|
||||
return web.json_response({
|
||||
"status": 200,
|
||||
"message": "Success",
|
||||
"data": {
|
||||
"session_id": session_id.hex(),
|
||||
"device": {
|
||||
"system_id": cdm.system_id,
|
||||
"security_level": cdm.security_level
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@routes.get("/{device}/close/{session_id}")
|
||||
async def close(request: web.Request) -> web.Response:
|
||||
secret_key = request.headers["X-Secret-Key"]
|
||||
device_name = request.match_info["device"]
|
||||
session_id = bytes.fromhex(request.match_info["session_id"])
|
||||
|
||||
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
|
||||
if not cdm:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"No Cdm session for {device_name} has been opened yet. No session to close."
|
||||
}, status=400)
|
||||
|
||||
try:
|
||||
cdm.close(session_id)
|
||||
except InvalidSession:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
|
||||
}, status=400)
|
||||
|
||||
return web.json_response({
|
||||
"status": 200,
|
||||
"message": f"Successfully closed Session '{session_id.hex()}'."
|
||||
})
|
||||
|
||||
|
||||
@routes.post("/{device}/set_service_certificate")
|
||||
async def set_service_certificate(request: web.Request) -> web.Response:
|
||||
secret_key = request.headers["X-Secret-Key"]
|
||||
device_name = request.match_info["device"]
|
||||
|
||||
body = await request.json()
|
||||
for required_field in ("session_id", "certificate"):
|
||||
if required_field == "certificate":
|
||||
has_field = required_field in body # it needs the key, but can be empty/null
|
||||
else:
|
||||
has_field = body.get(required_field)
|
||||
if not has_field:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"Missing required field '{required_field}' in JSON body."
|
||||
}, status=400)
|
||||
|
||||
# get session id
|
||||
session_id = bytes.fromhex(body["session_id"])
|
||||
|
||||
# get cdm
|
||||
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
|
||||
if not cdm:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"No Cdm session for {device_name} has been opened yet. No session to use."
|
||||
}, status=400)
|
||||
|
||||
# set service certificate
|
||||
certificate = body.get("certificate")
|
||||
try:
|
||||
provider_id = cdm.set_service_certificate(session_id, certificate)
|
||||
except InvalidSession:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
|
||||
}, status=400)
|
||||
except DecodeError as e:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"Invalid Service Certificate, {e}"
|
||||
}, status=400)
|
||||
except SignatureMismatch:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": "Signature Validation failed on the Service Certificate, rejecting."
|
||||
}, status=400)
|
||||
|
||||
return web.json_response({
|
||||
"status": 200,
|
||||
"message": f"Successfully {['set', 'unset'][not certificate]} the Service Certificate.",
|
||||
"data": {
|
||||
"provider_id": provider_id
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@routes.post("/{device}/get_license_challenge/{license_type}")
|
||||
async def get_license_challenge(request: web.Request) -> web.Response:
|
||||
secret_key = request.headers["X-Secret-Key"]
|
||||
device_name = request.match_info["device"]
|
||||
license_type = request.match_info["license_type"]
|
||||
|
||||
body = await request.json()
|
||||
for required_field in ("session_id", "init_data"):
|
||||
if not body.get(required_field):
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"Missing required field '{required_field}' in JSON body."
|
||||
}, status=400)
|
||||
|
||||
# get session id
|
||||
session_id = bytes.fromhex(body["session_id"])
|
||||
|
||||
# get cdm
|
||||
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
|
||||
if not cdm:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"No Cdm session for {device_name} has been opened yet. No session to use."
|
||||
}, status=400)
|
||||
|
||||
# enforce service certificate (opt-in)
|
||||
# TODO: Add a way to check if there's a service certificate set properly
|
||||
if request.app["config"].get("force_privacy_mode") and not cdm._Cdm__sessions[session_id].service_certificate:
|
||||
return web.json_response({
|
||||
"status": 403,
|
||||
"message": "No Service Certificate set but Privacy Mode is Enforced."
|
||||
}, status=403)
|
||||
|
||||
# get init data
|
||||
init_data = PSSH(body["init_data"])
|
||||
|
||||
# get challenge
|
||||
try:
|
||||
license_request = cdm.get_license_challenge(
|
||||
session_id=session_id,
|
||||
pssh=init_data,
|
||||
type_=license_type,
|
||||
privacy_mode=True
|
||||
)
|
||||
except InvalidSession:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
|
||||
}, status=400)
|
||||
except InvalidInitData as e:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"Invalid Init Data, {e}"
|
||||
}, status=400)
|
||||
except InvalidLicenseType:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"Invalid License Type '{license_type}'"
|
||||
}, status=400)
|
||||
|
||||
return web.json_response({
|
||||
"status": 200,
|
||||
"message": "Success",
|
||||
"data": {
|
||||
"challenge_b64": base64.b64encode(license_request).decode()
|
||||
}
|
||||
}, status=200)
|
||||
|
||||
|
||||
@routes.post("/{device}/parse_license")
|
||||
async def parse_license(request: web.Request) -> web.Response:
|
||||
secret_key = request.headers["X-Secret-Key"]
|
||||
device_name = request.match_info["device"]
|
||||
|
||||
body = await request.json()
|
||||
for required_field in ("session_id", "license_message"):
|
||||
if not body.get(required_field):
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"Missing required field '{required_field}' in JSON body."
|
||||
}, status=400)
|
||||
|
||||
# get session id
|
||||
session_id = bytes.fromhex(body["session_id"])
|
||||
|
||||
# get cdm
|
||||
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
|
||||
if not cdm:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"No Cdm session for {device_name} has been opened yet. No session to use."
|
||||
}, status=400)
|
||||
|
||||
# parse the license message
|
||||
try:
|
||||
cdm.parse_license(session_id, body["license_message"])
|
||||
except InvalidSession:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
|
||||
}, status=400)
|
||||
except InvalidLicenseMessage as e:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"Invalid License Message, {e}"
|
||||
}, status=400)
|
||||
except InvalidContext as e:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"Invalid Context, {e}"
|
||||
}, status=400)
|
||||
except SignatureMismatch:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": "Signature Validation failed on the License Message, rejecting."
|
||||
}, status=400)
|
||||
|
||||
return web.json_response({
|
||||
"status": 200,
|
||||
"message": "Successfully parsed and loaded the Keys from the License message."
|
||||
})
|
||||
|
||||
|
||||
@routes.post("/{device}/get_keys/{key_type}")
|
||||
async def get_keys(request: web.Request) -> web.Response:
|
||||
secret_key = request.headers["X-Secret-Key"]
|
||||
device_name = request.match_info["device"]
|
||||
|
||||
body = await request.json()
|
||||
for required_field in ("session_id",):
|
||||
if not body.get(required_field):
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"Missing required field '{required_field}' in JSON body."
|
||||
}, status=400)
|
||||
|
||||
# get session id
|
||||
session_id = bytes.fromhex(body["session_id"])
|
||||
|
||||
# get key type
|
||||
key_type = request.match_info["key_type"]
|
||||
if key_type == "ALL":
|
||||
key_type = None
|
||||
|
||||
# get cdm
|
||||
cdm = request.app["cdms"].get((secret_key, device_name))
|
||||
if not cdm:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"No Cdm session for {device_name} has been opened yet. No session to use."
|
||||
}, status=400)
|
||||
|
||||
# get keys
|
||||
try:
|
||||
keys = cdm.get_keys(session_id, key_type)
|
||||
except InvalidSession:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
|
||||
}, status=400)
|
||||
except ValueError as e:
|
||||
return web.json_response({
|
||||
"status": 400,
|
||||
"message": f"The Key Type value '{key_type}' is invalid, {e}"
|
||||
}, status=400)
|
||||
|
||||
# get the keys in json form
|
||||
keys_json = [
|
||||
{
|
||||
"key_id": key.kid.hex,
|
||||
"key": key.key.hex(),
|
||||
"type": key.type,
|
||||
"permissions": key.permissions,
|
||||
}
|
||||
for key in keys
|
||||
if not key_type or key.type == key_type
|
||||
]
|
||||
|
||||
return web.json_response({
|
||||
"status": 200,
|
||||
"message": "Success",
|
||||
"data": {
|
||||
"keys": keys_json
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@web.middleware
|
||||
async def authentication(request: web.Request, handler) -> web.Response:
|
||||
response = None
|
||||
if request.path != "/":
|
||||
secret_key = request.headers.get("X-Secret-Key")
|
||||
if not secret_key:
|
||||
request.app.logger.debug(f"{request.remote} did not provide authorization.")
|
||||
response = web.json_response({
|
||||
"status": "401",
|
||||
"message": "Secret Key is Empty."
|
||||
}, status=401)
|
||||
elif secret_key not in request.app["config"]["users"]:
|
||||
request.app.logger.debug(f"{request.remote} failed authentication with '{secret_key}'.")
|
||||
response = web.json_response({
|
||||
"status": "401",
|
||||
"message": "Secret Key is Invalid, the Key is case-sensitive."
|
||||
}, status=401)
|
||||
|
||||
if response is None:
|
||||
try:
|
||||
response = await handler(request)
|
||||
except web.HTTPException as e:
|
||||
request.app.logger.error(f"An unexpected error has occurred, {e}")
|
||||
response = web.json_response({
|
||||
"status": 500,
|
||||
"message": e.reason
|
||||
}, status=500)
|
||||
|
||||
response.headers.update({
|
||||
"Server": f"https://github.com/rlaphoenix/pywidevine serve v{__version__}"
|
||||
})
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def run(config: dict, host: Optional[Union[str, web.HostSequence]] = None, port: Optional[int] = None):
|
||||
app = web.Application(middlewares=[authentication])
|
||||
app.on_startup.append(_startup)
|
||||
app.on_cleanup.append(_cleanup)
|
||||
app.add_routes(routes)
|
||||
app["config"] = config
|
||||
web.run_app(app, host=host, port=port)
|
||||
14
pywidevine/session.py
Normal file
14
pywidevine/session.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from typing import Optional
|
||||
|
||||
from Crypto.Random import get_random_bytes
|
||||
|
||||
from pywidevine.key import Key
|
||||
from pywidevine.license_protocol_pb2 import SignedMessage
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self):
|
||||
self.id = get_random_bytes(16)
|
||||
self.service_certificate: Optional[SignedMessage] = None
|
||||
self.context: dict[bytes, tuple[bytes, bytes]] = {}
|
||||
self.keys: list[Key] = []
|
||||
20
serve.example.yml
Normal file
20
serve.example.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
# This data serves as an example configuration file for the `serve` command.
|
||||
# None of the sensitive data should be re-used.
|
||||
|
||||
# List of Widevine Device (.wvd) file paths to use with serve.
|
||||
# Note: Each individual user needs explicit permission to use a device listed.
|
||||
devices:
|
||||
- 'C:\Users\rlaphoenix\Documents\WVDs\test_device_001.wvd'
|
||||
|
||||
# List of User's by Secret Key. The Secret Key must be supplied by the User to use the API.
|
||||
users:
|
||||
fvYBh0C3fRAxlvyJcynD1see3GmNbIiC: # secret key, a-zA-Z-09{32} is recommended, case-sensitive
|
||||
username: jane # only for internal logging, user will not see this name
|
||||
devices: # list of allowed devices by filename
|
||||
- test_key_001
|
||||
# ...
|
||||
|
||||
# All clients must provide a service certificate for privacy mode.
|
||||
# If the client does not provide a certificate, privacy mode may or may not be used.
|
||||
# Enforcing Privacy Mode helps protect the identity of the device and is recommended.
|
||||
force_privacy_mode: true
|
||||
Reference in New Issue
Block a user