Initial Commit
This commit is contained in:
commit
97b8a79be8
674
LICENSE
Normal file
674
LICENSE
Normal file
@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
142
README.md
Normal file
142
README.md
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
# pywidevine
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🚀 Seamless Installation via [pip](#installation)
|
||||||
|
- 🛡️ Robust Security with message signature verification
|
||||||
|
- 🙈 Privacy Mode with Service Certificates
|
||||||
|
- 🌐 Servable CDM API Server and Client with Authentication
|
||||||
|
- 📦 Custom provision serialization format (WVD v2)
|
||||||
|
- 🧰 Create, parse, or convert PSSH headers with ease
|
||||||
|
- 🗃️ User-friendly YAML configuration
|
||||||
|
- ❤️ Forever FOSS!
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ pip install pywidevine
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**
|
||||||
|
If pip gives you a warning about a path not being in your PATH environment variable then promptly add that path then
|
||||||
|
close all open command prompt/terminal windows, or `pywidevine` CLI won't work as it will not be found.
|
||||||
|
|
||||||
|
Voilà 🎉 — You now have the `pywidevine` package installed!
|
||||||
|
You can now import pywidevine in scripts ([see below](#usage)).
|
||||||
|
A command-line interface is also available, try `pywidevine --help`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
The following is a minimal example of using pywidevine in a script to get a License for Bitmovin's
|
||||||
|
Art of Motion Demo.
|
||||||
|
|
||||||
|
```py
|
||||||
|
from pywidevine.cdm import Cdm
|
||||||
|
from pywidevine.device import Device
|
||||||
|
from pywidevine.pssh import PSSH
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# prepare pssh
|
||||||
|
pssh = PSSH("AAAAW3Bzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAADsIARIQ62dqu8s0Xpa"
|
||||||
|
"7z2FmMPGj2hoNd2lkZXZpbmVfdGVzdCIQZmtqM2xqYVNkZmFsa3IzaioCSEQyAA==")
|
||||||
|
|
||||||
|
# load device
|
||||||
|
device = Device.load("C:/Path/To/A/Provision.wvd")
|
||||||
|
|
||||||
|
# load cdm
|
||||||
|
cdm = Cdm.from_device(device)
|
||||||
|
|
||||||
|
# open cdm session
|
||||||
|
session_id = cdm.open()
|
||||||
|
|
||||||
|
# get license challenge
|
||||||
|
challenge = cdm.get_license_challenge(session_id, pssh)
|
||||||
|
|
||||||
|
# send license challenge (assuming a generic license server SDK with no API front)
|
||||||
|
licence = requests.post("https://...", data=challenge)
|
||||||
|
licence.raise_for_status()
|
||||||
|
|
||||||
|
# parse license challenge
|
||||||
|
cdm.parse_license(session_id, licence.content)
|
||||||
|
|
||||||
|
# print keys
|
||||||
|
for key in cdm.get_keys(session_id):
|
||||||
|
print(f"[{key.type}] {key.kid.hex}:{key.key.hex()}")
|
||||||
|
|
||||||
|
# close session, disposes of session data
|
||||||
|
cdm.close(session_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**
|
||||||
|
> There are various features not shown in this specific example like:
|
||||||
|
>
|
||||||
|
> - Privacy Mode
|
||||||
|
> - Setting Service Certificates
|
||||||
|
> - Remote CDMs and Serving
|
||||||
|
> - Choosing a License Type to request
|
||||||
|
> - Creating WVD files
|
||||||
|
> - and much more!
|
||||||
|
>
|
||||||
|
> Take a look at the methods available in the [Cdm class](/pywidevine/cdm.py) and their doc-strings for
|
||||||
|
> further information. For more examples see the [CLI functions](/pywidevine/main.py) which uses a lot
|
||||||
|
> of previously mentioned features.
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
1. This project requires a valid Google-provisioned Private Key and Client Identification blob which are not
|
||||||
|
provided by this project.
|
||||||
|
2. Public test provisions are available and provided by Google to use for testing projects such as this one.
|
||||||
|
3. License Servers have the ability to block requests from any provision, and are likely already blocking test
|
||||||
|
provisions on production endpoints.
|
||||||
|
4. This project does not condone piracy or any action against the terms of the DRM systems.
|
||||||
|
5. All efforts in this project have been the result of Reverse-Engineering, Publicly available research, and Trial
|
||||||
|
& Error.
|
||||||
|
|
||||||
|
## Key and Output Security
|
||||||
|
|
||||||
|
*Licenses, Content Keys, and Decrypted Data is not secure in this CDM implementation.*
|
||||||
|
|
||||||
|
The Content Decryption Module is meant to do all downloading, decrypting, and decoding of content, not just license
|
||||||
|
acquisition. This Python implementation only does License Acquisition within the CDM.
|
||||||
|
|
||||||
|
The section of which a 'Decrypt Frame' call is made would be more of a 'Decrypt File' in this implementation. Just
|
||||||
|
returning the original file in plain text defeats the point of the DRM. Even if 'Decrypt File' was somehow secure, the
|
||||||
|
Content Keys used to decrypt the files are already exposed to the caller anyway, allowing them to manually decrypt.
|
||||||
|
|
||||||
|
An attack on a 'Decrypt Frame' system would be analogous to doing an HDMI capture or similar attack. This is because it
|
||||||
|
would require re-encoding the video by splicing each individual frame with the right frame-rate, syncing to audio, and
|
||||||
|
more.
|
||||||
|
|
||||||
|
While a 'Decrypt Video' system would be analogous to downloading a Video and passing it through a script. Not much of
|
||||||
|
an attack if at all. The only protection against a system like this would be monitoring the provision and acquisitions
|
||||||
|
of licenses and prevent them. This can be done by revoking the device provision, or the user or their authorization to
|
||||||
|
the service.
|
||||||
|
|
||||||
|
There isn't any immediate way to secure either Key or Decrypted information within a Python environment that is not
|
||||||
|
Hardware backed. Even if obfuscation or some other form of Security by Obscurity was used, this is a Software-based
|
||||||
|
Content Protection Module (in Python no less) with no hardware backed security. It would be incredibly trivial to break
|
||||||
|
any sort of protection against retrieving the original video data.
|
||||||
|
|
||||||
|
Though, it's not impossible. Google's Chrome Browser CDM is a simple library extension file programmed in C++ that has
|
||||||
|
been improving its security using math and obscurity for years. It's getting harder and harder to break with its latest
|
||||||
|
versions only being beaten by Brute-force style methods. However, they have a huge team of very skilled workers, and
|
||||||
|
making a CDM in C++ has immediate security benefits and a lot of methods to obscure and obfuscate the code.
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
<a href="https://github.com/rlaphoenix"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17136956?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt=""/></a>
|
||||||
|
<a href="https://github.com/mediaminister"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45148099?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt=""/></a>
|
||||||
|
<a href="https://github.com/sr0lle"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/111277375?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt=""/></a>
|
||||||
|
|
||||||
|
## Licensing
|
||||||
|
|
||||||
|
This software is licensed under the terms of [GNU General Public License, Version 3.0](LICENSE).
|
||||||
|
You can find a copy of the license in the LICENSE file in the root folder.
|
||||||
|
|
||||||
|
- Widevine Icon © Google.
|
||||||
|
- Props to the awesome community for their shared research and insight into the Widevine Protocol and Key Derivation.
|
||||||
|
|
||||||
|
* * *
|
||||||
|
|
||||||
|
© rlaphoenix 2022-2023
|
||||||
48
pyproject.toml
Normal file
48
pyproject.toml
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
|
[tool.poetry]
|
||||||
|
name = "pywidevine"
|
||||||
|
version = "1.8.0"
|
||||||
|
description = "Widevine CDM (Content Decryption Module) implementation in Python."
|
||||||
|
license = "GPL-3.0-only"
|
||||||
|
authors = ["rlaphoenix <rlaphoenix@pm.me>", "DevLARLEY"]
|
||||||
|
readme = "README.md"
|
||||||
|
repository = "https://git.gay/ready-dl/pywidevine/pywidevine"
|
||||||
|
keywords = ["python", "drm", "widevine", "google"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"Intended Audience :: End Users/Desktop",
|
||||||
|
"Natural Language :: English",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"Topic :: Multimedia :: Video",
|
||||||
|
"Topic :: Security :: Cryptography",
|
||||||
|
"Topic :: Software Development :: Libraries :: Python Modules"
|
||||||
|
]
|
||||||
|
include = [
|
||||||
|
{ path = "CHANGELOG.md", format = "sdist" },
|
||||||
|
{ path = "README.md", format = "sdist" },
|
||||||
|
{ path = "LICENSE", format = "sdist" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.poetry.urls]
|
||||||
|
"Issues" = "https://git.gay/ready-dl/pywidevine/pywidevine/issues"
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = ">=3.8"
|
||||||
|
protobuf = "^4.25.1"
|
||||||
|
pymp4 = "^1.4.0"
|
||||||
|
pycryptodome = "^3.19.0"
|
||||||
|
click = "^8.1.7"
|
||||||
|
requests = "^2.31.0"
|
||||||
|
Unidecode = "^1.3.7"
|
||||||
|
PyYAML = "^6.0.1"
|
||||||
|
aiohttp = {version = "^3.9.1", optional = true}
|
||||||
|
|
||||||
|
[tool.poetry.extras]
|
||||||
|
serve = ["aiohttp"]
|
||||||
|
|
||||||
|
[tool.poetry.scripts]
|
||||||
|
pywidevine = "pywidevine.main:main"
|
||||||
8
pywidevine/__init__.py
Normal file
8
pywidevine/__init__.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from .cdm import *
|
||||||
|
from .device import *
|
||||||
|
from .key import *
|
||||||
|
from .pssh import *
|
||||||
|
from .remotecdm import *
|
||||||
|
from .session import *
|
||||||
|
|
||||||
|
__version__ = "1.8.0"
|
||||||
658
pywidevine/cdm.py
Normal file
658
pywidevine/cdm.py
Normal file
@ -0,0 +1,658 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import random
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Union
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from Crypto.Cipher import AES, PKCS1_OAEP
|
||||||
|
from Crypto.Hash import CMAC, HMAC, SHA1, SHA256
|
||||||
|
from Crypto.PublicKey import RSA
|
||||||
|
from Crypto.Random import get_random_bytes
|
||||||
|
from Crypto.Signature import pss
|
||||||
|
from Crypto.Util import Padding
|
||||||
|
from google.protobuf.message import DecodeError
|
||||||
|
|
||||||
|
from pywidevine.device import Device, DeviceTypes
|
||||||
|
from pywidevine.exceptions import (InvalidContext, InvalidInitData, InvalidLicenseMessage, InvalidLicenseType,
|
||||||
|
InvalidSession, NoKeysLoaded, SignatureMismatch, TooManySessions)
|
||||||
|
from pywidevine.key import Key
|
||||||
|
from pywidevine.license_protocol_pb2 import (ClientIdentification, DrmCertificate, EncryptedClientIdentification,
|
||||||
|
License, LicenseRequest, LicenseType, SignedDrmCertificate,
|
||||||
|
SignedMessage)
|
||||||
|
from pywidevine.pssh import PSSH
|
||||||
|
from pywidevine.session import Session
|
||||||
|
from pywidevine.utils import get_binary_path
|
||||||
|
|
||||||
|
|
||||||
|
class Cdm:
|
||||||
|
uuid = UUID(bytes=b"\xed\xef\x8b\xa9\x79\xd6\x4a\xce\xa3\xc8\x27\xdc\xd5\x1d\x21\xed")
|
||||||
|
urn = f"urn:uuid:{uuid}"
|
||||||
|
key_format = urn
|
||||||
|
service_certificate_challenge = b"\x08\x04"
|
||||||
|
common_privacy_cert = (
|
||||||
|
# Used by Google's production license server (license.google.com)
|
||||||
|
# Not publicly accessible directly, but a lot of services have their own gateways to it
|
||||||
|
"CAUSxwUKwQIIAxIQFwW5F8wSBIaLBjM6L3cqjBiCtIKSBSKOAjCCAQoCggEBAJntWzsyfateJO/DtiqVtZhSCtW8yzdQPgZFuBTYdrjfQFEE"
|
||||||
|
"Qa2M462xG7iMTnJaXkqeB5UpHVhYQCOn4a8OOKkSeTkwCGELbxWMh4x+Ib/7/up34QGeHleB6KRfRiY9FOYOgFioYHrc4E+shFexN6jWfM3r"
|
||||||
|
"M3BdmDoh+07svUoQykdJDKR+ql1DghjduvHK3jOS8T1v+2RC/THhv0CwxgTRxLpMlSCkv5fuvWCSmvzu9Vu69WTi0Ods18Vcc6CCuZYSC4NZ"
|
||||||
|
"7c4kcHCCaA1vZ8bYLErF8xNEkKdO7DevSy8BDFnoKEPiWC8La59dsPxebt9k+9MItHEbzxJQAZyfWgkCAwEAAToUbGljZW5zZS53aWRldmlu"
|
||||||
|
"ZS5jb20SgAOuNHMUtag1KX8nE4j7e7jLUnfSSYI83dHaMLkzOVEes8y96gS5RLknwSE0bv296snUE5F+bsF2oQQ4RgpQO8GVK5uk5M4PxL/C"
|
||||||
|
"CpgIqq9L/NGcHc/N9XTMrCjRtBBBbPneiAQwHL2zNMr80NQJeEI6ZC5UYT3wr8+WykqSSdhV5Cs6cD7xdn9qm9Nta/gr52u/DLpP3lnSq8x2"
|
||||||
|
"/rZCR7hcQx+8pSJmthn8NpeVQ/ypy727+voOGlXnVaPHvOZV+WRvWCq5z3CqCLl5+Gf2Ogsrf9s2LFvE7NVV2FvKqcWTw4PIV9Sdqrd+QLeF"
|
||||||
|
"Hd/SSZiAjjWyWOddeOrAyhb3BHMEwg2T7eTo/xxvF+YkPj89qPwXCYcOxF+6gjomPwzvofcJOxkJkoMmMzcFBDopvab5tDQsyN9UPLGhGC98"
|
||||||
|
"X/8z8QSQ+spbJTYLdgFenFoGq47gLwDS6NWYYQSqzE3Udf2W7pzk4ybyG4PHBYV3s4cyzdq8amvtE/sNSdOKReuHpfQ=")
|
||||||
|
staging_privacy_cert = (
|
||||||
|
# Used by Google's staging license server (staging.google.com)
|
||||||
|
# This can be publicly accessed without authentication using https://cwip-shaka-proxy.appspot.com/no_auth
|
||||||
|
"CAUSxQUKvwIIAxIQKHA0VMAI9jYYredEPbbEyBiL5/mQBSKOAjCCAQoCggEBALUhErjQXQI/zF2V4sJRwcZJtBd82NK+7zVbsGdD3mYePSq8"
|
||||||
|
"MYK3mUbVX9wI3+lUB4FemmJ0syKix/XgZ7tfCsB6idRa6pSyUW8HW2bvgR0NJuG5priU8rmFeWKqFxxPZmMNPkxgJxiJf14e+baq9a1Nuip+"
|
||||||
|
"FBdt8TSh0xhbWiGKwFpMQfCB7/+Ao6BAxQsJu8dA7tzY8U1nWpGYD5LKfdxkagatrVEB90oOSYzAHwBTK6wheFC9kF6QkjZWt9/v70JIZ2fz"
|
||||||
|
"PvYoPU9CVKtyWJOQvuVYCPHWaAgNRdiTwryi901goMDQoJk87wFgRwMzTDY4E5SGvJ2vJP1noH+a2UMCAwEAAToSc3RhZ2luZy5nb29nbGUu"
|
||||||
|
"Y29tEoADmD4wNSZ19AunFfwkm9rl1KxySaJmZSHkNlVzlSlyH/iA4KrvxeJ7yYDa6tq/P8OG0ISgLIJTeEjMdT/0l7ARp9qXeIoA4qprhM19"
|
||||||
|
"ccB6SOv2FgLMpaPzIDCnKVww2pFbkdwYubyVk7jei7UPDe3BKTi46eA5zd4Y+oLoG7AyYw/pVdhaVmzhVDAL9tTBvRJpZjVrKH1lexjOY9Dv"
|
||||||
|
"1F/FJp6X6rEctWPlVkOyb/SfEJwhAa/K81uDLyiPDZ1Flg4lnoX7XSTb0s+Cdkxd2b9yfvvpyGH4aTIfat4YkF9Nkvmm2mU224R1hx0WjocL"
|
||||||
|
"sjA89wxul4TJPS3oRa2CYr5+DU4uSgdZzvgtEJ0lksckKfjAF0K64rPeytvDPD5fS69eFuy3Tq26/LfGcF96njtvOUA4P5xRFtICogySKe6W"
|
||||||
|
"nCUZcYMDtQ0BMMM1LgawFNg4VA+KDCJ8ABHg9bOOTimO0sswHrRWSWX1XF15dXolCk65yEqz5lOfa2/fVomeopkU")
|
||||||
|
root_signed_cert = SignedDrmCertificate()
|
||||||
|
root_signed_cert.ParseFromString(base64.b64decode(
|
||||||
|
"CpwDCAASAQAY3ZSIiwUijgMwggGKAoIBgQC0/jnDZZAD2zwRlwnoaM3yw16b8udNI7EQ24dl39z7nzWgVwNTTPZtNX2meNuzNtI/nECplSZy"
|
||||||
|
"f7i+Zt/FIZh4FRZoXS9GDkPLioQ5q/uwNYAivjQji6tTW3LsS7VIaVM+R1/9Cf2ndhOPD5LWTN+udqm62SIQqZ1xRdbX4RklhZxTmpfrhNfM"
|
||||||
|
"qIiCIHAmIP1+QFAn4iWTb7w+cqD6wb0ptE2CXMG0y5xyfrDpihc+GWP8/YJIK7eyM7l97Eu6iR8nuJuISISqGJIOZfXIbBH/azbkdDTKjDOx"
|
||||||
|
"+biOtOYS4AKYeVJeRTP/Edzrw1O6fGAaET0A+9K3qjD6T15Id1sX3HXvb9IZbdy+f7B4j9yCYEy/5CkGXmmMOROtFCXtGbLynwGCDVZEiMg1"
|
||||||
|
"7B8RsyTgWQ035Ec86kt/lzEcgXyUikx9aBWE/6UI/Rjn5yvkRycSEbgj7FiTPKwS0ohtQT3F/hzcufjUUT4H5QNvpxLoEve1zqaWVT94tGSC"
|
||||||
|
"UNIzX5ECAwEAARKAA1jx1k0ECXvf1+9dOwI5F/oUNnVKOGeFVxKnFO41FtU9v0KG9mkAds2T9Hyy355EzUzUrgkYU0Qy7OBhG+XaE9NVxd0a"
|
||||||
|
"y5AeflvG6Q8in76FAv6QMcxrA4S9IsRV+vXyCM1lQVjofSnaBFiC9TdpvPNaV4QXezKHcLKwdpyywxXRESYqI3WZPrl3IjINvBoZwdVlkHZV"
|
||||||
|
"dA8OaU1fTY8Zr9/WFjGUqJJfT7x6Mfiujq0zt+kw0IwKimyDNfiKgbL+HIisKmbF/73mF9BiC9yKRfewPlrIHkokL2yl4xyIFIPVxe9enz2F"
|
||||||
|
"RXPia1BSV0z7kmxmdYrWDRuu8+yvUSIDXQouY5OcCwEgqKmELhfKrnPsIht5rvagcizfB0fbiIYwFHghESKIrNdUdPnzJsKlVshWTwApHQh7"
|
||||||
|
"evuVicPumFSePGuUBRMS9nG5qxPDDJtGCHs9Mmpoyh6ckGLF7RC5HxclzpC5bc3ERvWjYhN0AqdipPpV2d7PouaAdFUGSdUCDA=="
|
||||||
|
))
|
||||||
|
root_cert = DrmCertificate()
|
||||||
|
root_cert.ParseFromString(root_signed_cert.drm_certificate)
|
||||||
|
|
||||||
|
MAX_NUM_OF_SESSIONS = 16
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device_type: Union[DeviceTypes, 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 = DeviceTypes[device_type]
|
||||||
|
if not isinstance(device_type, DeviceTypes):
|
||||||
|
raise TypeError(f"Expected device_type to be a {DeviceTypes!r} not {device_type!r}")
|
||||||
|
|
||||||
|
if not system_id:
|
||||||
|
raise ValueError("System ID must be provided")
|
||||||
|
if not isinstance(system_id, int):
|
||||||
|
raise TypeError(f"Expected system_id to be a {int} not {system_id!r}")
|
||||||
|
|
||||||
|
if not security_level:
|
||||||
|
raise ValueError("Security Level must be provided")
|
||||||
|
if not isinstance(security_level, int):
|
||||||
|
raise TypeError(f"Expected security_level to be a {int} not {security_level!r}")
|
||||||
|
|
||||||
|
if not client_id:
|
||||||
|
raise ValueError("Client ID must be provided")
|
||||||
|
if not isinstance(client_id, ClientIdentification):
|
||||||
|
raise TypeError(f"Expected client_id to be a {ClientIdentification} not {client_id!r}")
|
||||||
|
|
||||||
|
if not rsa_key:
|
||||||
|
raise ValueError("RSA Key must be provided")
|
||||||
|
if not isinstance(rsa_key, RSA.RsaKey):
|
||||||
|
raise TypeError(f"Expected rsa_key to be a {RSA.RsaKey} not {rsa_key!r}")
|
||||||
|
|
||||||
|
self.device_type = device_type
|
||||||
|
self.system_id = system_id
|
||||||
|
self.security_level = security_level
|
||||||
|
self.__client_id = client_id
|
||||||
|
|
||||||
|
self.__signer = pss.new(rsa_key)
|
||||||
|
self.__decrypter = PKCS1_OAEP.new(rsa_key)
|
||||||
|
|
||||||
|
self.__sessions: dict[bytes, Session] = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_device(cls, device: Device) -> Cdm:
|
||||||
|
"""Initialize a Widevine CDM from a Widevine Device (.wvd) file."""
|
||||||
|
return cls(
|
||||||
|
device_type=device.type,
|
||||||
|
system_id=device.system_id,
|
||||||
|
security_level=device.security_level,
|
||||||
|
client_id=device.client_id,
|
||||||
|
rsa_key=device.private_key
|
||||||
|
)
|
||||||
|
|
||||||
|
def open(self) -> bytes:
|
||||||
|
"""
|
||||||
|
Open a Widevine Content Decryption Module (CDM) session.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TooManySessions: If the session cannot be opened as limit has been reached.
|
||||||
|
"""
|
||||||
|
if len(self.__sessions) > self.MAX_NUM_OF_SESSIONS:
|
||||||
|
raise TooManySessions(f"Too many Sessions open ({self.MAX_NUM_OF_SESSIONS}).")
|
||||||
|
|
||||||
|
session = Session(len(self.__sessions) + 1)
|
||||||
|
self.__sessions[session.id] = session
|
||||||
|
|
||||||
|
return session.id
|
||||||
|
|
||||||
|
def close(self, session_id: bytes) -> None:
|
||||||
|
"""
|
||||||
|
Close a Widevine Content Decryption Module (CDM) session.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
session_id: Session identifier.
|
||||||
|
|
||||||
|
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, session_id: bytes, certificate: Optional[Union[bytes, str]]) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Set a Service Privacy Certificate for Privacy Mode. (optional but recommended)
|
||||||
|
|
||||||
|
The Service Certificate is used to encrypt Client IDs in Licenses. This is also
|
||||||
|
known as Privacy Mode and may be required for some services or for some devices.
|
||||||
|
Chrome CDM requires it as of the enforcement of VMP (Verified Media Path).
|
||||||
|
|
||||||
|
We reject direct DrmCertificates as they do not have signature verification and
|
||||||
|
cannot be verified. You must provide a SignedDrmCertificate or a SignedMessage
|
||||||
|
containing a SignedDrmCertificate.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
session_id: Session identifier.
|
||||||
|
certificate: SignedDrmCertificate (or SignedMessage containing one) in Base64
|
||||||
|
or Bytes form obtained from the Service. Some services have their own,
|
||||||
|
but most use the common privacy cert, (common_privacy_cert). If None, it
|
||||||
|
will remove the current certificate.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InvalidSession: If the Session identifier is invalid.
|
||||||
|
DecodeError: If the certificate could not be parsed as a SignedDrmCertificate
|
||||||
|
nor a SignedMessage containing a SignedDrmCertificate.
|
||||||
|
SignatureMismatch: If the Signature of the SignedDrmCertificate does not
|
||||||
|
match the underlying DrmCertificate.
|
||||||
|
|
||||||
|
Returns the Service Provider ID of the verified DrmCertificate if successful.
|
||||||
|
If certificate is None, it will return the now-unset certificate's Provider ID,
|
||||||
|
or None if no certificate was set yet.
|
||||||
|
"""
|
||||||
|
session = self.__sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||||
|
|
||||||
|
if certificate is None:
|
||||||
|
if session.service_certificate:
|
||||||
|
drm_certificate = DrmCertificate()
|
||||||
|
drm_certificate.ParseFromString(session.service_certificate.drm_certificate)
|
||||||
|
provider_id = drm_certificate.provider_id
|
||||||
|
else:
|
||||||
|
provider_id = None
|
||||||
|
session.service_certificate = None
|
||||||
|
return provider_id
|
||||||
|
|
||||||
|
if isinstance(certificate, str):
|
||||||
|
try:
|
||||||
|
certificate = base64.b64decode(certificate) # assuming base64
|
||||||
|
except binascii.Error:
|
||||||
|
raise DecodeError("Could not decode certificate string as Base64, expected bytes.")
|
||||||
|
elif not isinstance(certificate, bytes):
|
||||||
|
raise DecodeError(f"Expecting Certificate to be bytes, not {certificate!r}")
|
||||||
|
|
||||||
|
signed_message = SignedMessage()
|
||||||
|
signed_drm_certificate = SignedDrmCertificate()
|
||||||
|
drm_certificate = DrmCertificate()
|
||||||
|
|
||||||
|
try:
|
||||||
|
signed_message.ParseFromString(certificate)
|
||||||
|
if all(
|
||||||
|
# See https://github.com/devine-dl/pywidevine/issues/41
|
||||||
|
bytes(chunk) == signed_message.SerializeToString()
|
||||||
|
for chunk in zip(*[iter(certificate)] * len(signed_message.SerializeToString()))
|
||||||
|
):
|
||||||
|
signed_drm_certificate.ParseFromString(signed_message.msg)
|
||||||
|
else:
|
||||||
|
signed_drm_certificate.ParseFromString(certificate)
|
||||||
|
if signed_drm_certificate.SerializeToString() != certificate:
|
||||||
|
raise DecodeError("partial parse")
|
||||||
|
except DecodeError as e:
|
||||||
|
# could be a direct unsigned DrmCertificate, but reject those anyway
|
||||||
|
raise DecodeError(f"Could not parse certificate as a SignedDrmCertificate, {e}")
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
try:
|
||||||
|
drm_certificate.ParseFromString(signed_drm_certificate.drm_certificate)
|
||||||
|
if drm_certificate.SerializeToString() != signed_drm_certificate.drm_certificate:
|
||||||
|
raise DecodeError("partial parse")
|
||||||
|
except DecodeError as e:
|
||||||
|
raise DecodeError(f"Could not parse signed certificate's message as a DrmCertificate, {e}")
|
||||||
|
|
||||||
|
# must be stored as a SignedDrmCertificate as the signature needs to be kept for RemoteCdm
|
||||||
|
# if we store as DrmCertificate (no signature) then RemoteCdm cannot verify the Certificate
|
||||||
|
session.service_certificate = signed_drm_certificate
|
||||||
|
return drm_certificate.provider_id
|
||||||
|
|
||||||
|
def get_service_certificate(self, session_id: bytes) -> Optional[SignedDrmCertificate]:
|
||||||
|
"""
|
||||||
|
Get the currently set Service Privacy Certificate of the Session.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
session_id: Session identifier.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InvalidSession: If the Session identifier is invalid.
|
||||||
|
|
||||||
|
Returns the Service Certificate if one is set, otherwise None.
|
||||||
|
"""
|
||||||
|
session = self.__sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||||
|
|
||||||
|
return session.service_certificate
|
||||||
|
|
||||||
|
def get_license_challenge(
|
||||||
|
self,
|
||||||
|
session_id: bytes,
|
||||||
|
pssh: PSSH,
|
||||||
|
license_type: str = "STREAMING",
|
||||||
|
privacy_mode: bool = True
|
||||||
|
) -> bytes:
|
||||||
|
"""
|
||||||
|
Get a License Request (Challenge) to send to a License Server.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
session_id: Session identifier.
|
||||||
|
pssh: PSSH Object to get the init data from.
|
||||||
|
license_type: Type of License you wish to exchange, often `STREAMING`.
|
||||||
|
- "STREAMING": Normal one-time-use license.
|
||||||
|
- "OFFLINE": Offline-use licence, usually for Downloaded content.
|
||||||
|
- "AUTOMATIC": License type decision is left to provider.
|
||||||
|
privacy_mode: Encrypt the Client ID using the Privacy Certificate. If the
|
||||||
|
privacy certificate is not set yet, this does nothing.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InvalidSession: If the Session identifier is invalid.
|
||||||
|
InvalidInitData: If the Init Data (or PSSH box) provided is invalid.
|
||||||
|
InvalidLicenseType: If the type_ parameter value is not a License Type. It
|
||||||
|
must be a LicenseType enum, or a string/int representing the enum's keys
|
||||||
|
or values.
|
||||||
|
|
||||||
|
Returns a SignedMessage containing a LicenseRequest message. It's signed with
|
||||||
|
the Private Key of the device provision.
|
||||||
|
"""
|
||||||
|
session = self.__sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||||
|
|
||||||
|
if not pssh:
|
||||||
|
raise InvalidInitData("A pssh must be provided.")
|
||||||
|
if not isinstance(pssh, PSSH):
|
||||||
|
raise InvalidInitData(f"Expected pssh to be a {PSSH}, not {pssh!r}")
|
||||||
|
|
||||||
|
if not isinstance(license_type, str):
|
||||||
|
raise InvalidLicenseType(f"Expected license_type to be a {str}, not {license_type!r}")
|
||||||
|
if license_type not in LicenseType.keys():
|
||||||
|
raise InvalidLicenseType(
|
||||||
|
f"Invalid license_type value of '{license_type}'. "
|
||||||
|
f"Available values: {LicenseType.keys()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.device_type == DeviceTypes.ANDROID:
|
||||||
|
# OEMCrypto's request_id seems to be in AES CTR Counter block form with no suffix
|
||||||
|
# Bytes 5-8 does not seem random, in real tests they have been consecutive \x00 or \xFF
|
||||||
|
# Real example: A0DCE548000000000500000000000000
|
||||||
|
request_id = (get_random_bytes(4) + (b"\x00" * 4)) # (?)
|
||||||
|
request_id += session.number.to_bytes(8, "little") # counter
|
||||||
|
# as you can see in the real example, it is stored as uppercase hex and re-encoded
|
||||||
|
# it's really 16 bytes of data, but it's stored as a 32-char HEX string (32 bytes)
|
||||||
|
request_id = request_id.hex().upper().encode()
|
||||||
|
else:
|
||||||
|
request_id = get_random_bytes(16)
|
||||||
|
|
||||||
|
license_request = LicenseRequest(
|
||||||
|
client_id=(
|
||||||
|
self.__client_id
|
||||||
|
) if not (session.service_certificate and privacy_mode) else None,
|
||||||
|
encrypted_client_id=self.encrypt_client_id(
|
||||||
|
client_id=self.__client_id,
|
||||||
|
service_certificate=session.service_certificate
|
||||||
|
) if session.service_certificate and privacy_mode else None,
|
||||||
|
content_id=LicenseRequest.ContentIdentification(
|
||||||
|
widevine_pssh_data=LicenseRequest.ContentIdentification.WidevinePsshData(
|
||||||
|
pssh_data=[pssh.init_data], # either a WidevineCencHeader or custom data
|
||||||
|
license_type=license_type,
|
||||||
|
request_id=request_id
|
||||||
|
)
|
||||||
|
),
|
||||||
|
type="NEW",
|
||||||
|
request_time=int(time.time()),
|
||||||
|
protocol_version="VERSION_2_1",
|
||||||
|
key_control_nonce=random.randrange(1, 2 ** 31),
|
||||||
|
).SerializeToString()
|
||||||
|
|
||||||
|
signed_license_request = SignedMessage(
|
||||||
|
type="LICENSE_REQUEST",
|
||||||
|
msg=license_request,
|
||||||
|
signature=self.__signer.sign(SHA1.new(license_request))
|
||||||
|
).SerializeToString()
|
||||||
|
|
||||||
|
session.context[request_id] = self.derive_context(license_request)
|
||||||
|
|
||||||
|
return signed_license_request
|
||||||
|
|
||||||
|
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 InvalidLicenseMessage("Cannot parse an empty license_message")
|
||||||
|
|
||||||
|
if isinstance(license_message, str):
|
||||||
|
try:
|
||||||
|
license_message = base64.b64decode(license_message)
|
||||||
|
except (binascii.Error, binascii.Incomplete) as e:
|
||||||
|
raise InvalidLicenseMessage(f"Could not decode license_message as Base64, {e}")
|
||||||
|
|
||||||
|
if isinstance(license_message, bytes):
|
||||||
|
signed_message = SignedMessage()
|
||||||
|
try:
|
||||||
|
signed_message.ParseFromString(license_message)
|
||||||
|
if signed_message.SerializeToString() != license_message:
|
||||||
|
raise DecodeError(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.Value("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 = session.context.get(licence.id.request_id)
|
||||||
|
if not context:
|
||||||
|
raise InvalidContext("Cannot parse a license message without first making a license request")
|
||||||
|
|
||||||
|
enc_key, mac_key_server, _ = self.derive_keys(
|
||||||
|
*context,
|
||||||
|
key=self.__decrypter.decrypt(license_message.session_key)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. Explicitly use the original `license_message.msg` instead of a re-serializing from `licence`
|
||||||
|
# as some differences may end up in the output due to differences in the proto schema
|
||||||
|
# 2. The oemcrypto_core_message (unknown purpose) is part of the signature algorithm starting with
|
||||||
|
# OEM Crypto API v16 and if available, must be prefixed when HMAC'ing a signature.
|
||||||
|
|
||||||
|
computed_signature = HMAC. \
|
||||||
|
new(mac_key_server, digestmod=SHA256). \
|
||||||
|
update(license_message.oemcrypto_core_message or b""). \
|
||||||
|
update(license_message.msg). \
|
||||||
|
digest()
|
||||||
|
|
||||||
|
if license_message.signature != computed_signature:
|
||||||
|
raise SignatureMismatch("Signature Mismatch on License Message, rejecting license")
|
||||||
|
|
||||||
|
session.keys = [
|
||||||
|
Key.from_key_container(key, enc_key)
|
||||||
|
for key in licence.key
|
||||||
|
]
|
||||||
|
|
||||||
|
del session.context[licence.id.request_id]
|
||||||
|
|
||||||
|
def get_keys(self, session_id: bytes, type_: Optional[Union[int, str]] = None) -> list[Key]:
|
||||||
|
"""
|
||||||
|
Get Keys from the loaded License message.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
session_id: Session identifier.
|
||||||
|
type_: (optional) Key Type to filter by and return.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InvalidSession: If the Session identifier is invalid.
|
||||||
|
TypeError: If the provided type_ is an unexpected value type.
|
||||||
|
ValueError: If the provided type_ is not a valid Key Type.
|
||||||
|
"""
|
||||||
|
session = self.__sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(type_, str):
|
||||||
|
type_ = License.KeyContainer.KeyType.Value(type_)
|
||||||
|
elif isinstance(type_, int):
|
||||||
|
License.KeyContainer.KeyType.Name(type_) # only test
|
||||||
|
elif type_ is not None:
|
||||||
|
raise TypeError(f"Expected type_ to be a {License.KeyContainer.KeyType} or int, not {type_!r}")
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"Could not parse type_ as a {License.KeyContainer.KeyType}, {e}")
|
||||||
|
|
||||||
|
return [
|
||||||
|
key
|
||||||
|
for key in session.keys
|
||||||
|
if not type_ or key.type == License.KeyContainer.KeyType.Name(type_)
|
||||||
|
]
|
||||||
|
|
||||||
|
def decrypt(
|
||||||
|
self,
|
||||||
|
session_id: bytes,
|
||||||
|
input_file: Union[Path, str],
|
||||||
|
output_file: Union[Path, str],
|
||||||
|
temp_dir: Optional[Union[Path, str]] = None,
|
||||||
|
exists_ok: bool = False
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Decrypt a Widevine-encrypted file using Shaka-packager.
|
||||||
|
Shaka-packager is much more stable than mp4decrypt.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
session_id: Session identifier.
|
||||||
|
input_file: File to be decrypted with Session's currently loaded keys.
|
||||||
|
output_file: Location to save decrypted file.
|
||||||
|
temp_dir: Directory to store temporary data while decrypting.
|
||||||
|
exists_ok: Allow overwriting the output_file if it exists.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the input or output paths have not been supplied or are
|
||||||
|
invalid.
|
||||||
|
FileNotFoundError: If the input file path does not exist.
|
||||||
|
FileExistsError: If the output file path already exists. Ignored if exists_ok
|
||||||
|
is set to True.
|
||||||
|
NoKeysLoaded: No License was parsed for this Session, No Keys available.
|
||||||
|
EnvironmentError: If the shaka-packager executable could not be found.
|
||||||
|
subprocess.CalledProcessError: If the shaka-packager call returned a non-zero
|
||||||
|
exit code.
|
||||||
|
"""
|
||||||
|
if not input_file:
|
||||||
|
raise ValueError("Cannot decrypt nothing, specify an input path")
|
||||||
|
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)
|
||||||
|
temp_dir_ = Path(temp_dir) if temp_dir else None
|
||||||
|
|
||||||
|
if not input_file.is_file():
|
||||||
|
raise FileNotFoundError(f"Input file does not exist, {input_file}")
|
||||||
|
if output_file.is_file() and not exists_ok:
|
||||||
|
raise FileExistsError(f"Output file already exists, {output_file}")
|
||||||
|
|
||||||
|
session = self.__sessions.get(session_id)
|
||||||
|
if not session:
|
||||||
|
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||||
|
|
||||||
|
if not session.keys:
|
||||||
|
raise NoKeysLoaded("No Keys are loaded yet, cannot decrypt")
|
||||||
|
|
||||||
|
platform = {"win32": "win", "darwin": "osx"}.get(sys.platform, sys.platform)
|
||||||
|
executable = get_binary_path("shaka-packager", f"packager-{platform}", f"packager-{platform}-x64")
|
||||||
|
if not executable:
|
||||||
|
raise EnvironmentError("Shaka Packager executable not found but is required")
|
||||||
|
|
||||||
|
args = [
|
||||||
|
f"input={input_file},stream=0,output={output_file}",
|
||||||
|
"--enable_raw_key_decryption",
|
||||||
|
"--keys", ",".join([
|
||||||
|
label
|
||||||
|
for i, key in enumerate(session.keys)
|
||||||
|
for label in [
|
||||||
|
f"label=1_{i}:key_id={key.kid.hex}:key={key.key.hex()}",
|
||||||
|
# some services need the KID blanked, e.g., Apple TV+
|
||||||
|
f"label=2_{i}:key_id={'0' * 32}:key={key.key.hex()}"
|
||||||
|
]
|
||||||
|
if key.type == "CONTENT"
|
||||||
|
])
|
||||||
|
]
|
||||||
|
|
||||||
|
if temp_dir_:
|
||||||
|
temp_dir_.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.extend(["--temp_dir", str(temp_dir_)])
|
||||||
|
|
||||||
|
return subprocess.check_call([executable, *args])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encrypt_client_id(
|
||||||
|
client_id: ClientIdentification,
|
||||||
|
service_certificate: Union[SignedDrmCertificate, DrmCertificate],
|
||||||
|
key: Optional[bytes] = None,
|
||||||
|
iv: Optional[bytes] = None
|
||||||
|
) -> EncryptedClientIdentification:
|
||||||
|
"""Encrypt the Client ID with the Service's Privacy Certificate."""
|
||||||
|
privacy_key = key or get_random_bytes(16)
|
||||||
|
privacy_iv = iv or get_random_bytes(16)
|
||||||
|
|
||||||
|
if isinstance(service_certificate, SignedDrmCertificate):
|
||||||
|
drm_certificate = DrmCertificate()
|
||||||
|
drm_certificate.ParseFromString(service_certificate.drm_certificate)
|
||||||
|
service_certificate = drm_certificate
|
||||||
|
if not isinstance(service_certificate, DrmCertificate):
|
||||||
|
raise ValueError(f"Expecting Service Certificate to be a DrmCertificate, not {service_certificate!r}")
|
||||||
|
|
||||||
|
encrypted_client_id = EncryptedClientIdentification(
|
||||||
|
provider_id=service_certificate.provider_id,
|
||||||
|
service_certificate_serial_number=service_certificate.serial_number,
|
||||||
|
encrypted_client_id=AES.
|
||||||
|
new(privacy_key, AES.MODE_CBC, privacy_iv).
|
||||||
|
encrypt(Padding.pad(client_id.SerializeToString(), 16)),
|
||||||
|
encrypted_client_id_iv=privacy_iv,
|
||||||
|
encrypted_privacy_key=PKCS1_OAEP.
|
||||||
|
new(RSA.importKey(service_certificate.public_key)).
|
||||||
|
encrypt(privacy_key)
|
||||||
|
)
|
||||||
|
|
||||||
|
return encrypted_client_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def derive_context(message: bytes) -> tuple[bytes, bytes]:
|
||||||
|
"""Returns 2 Context Data used for computing the AES Encryption and HMAC Keys."""
|
||||||
|
|
||||||
|
def _get_enc_context(msg: bytes) -> bytes:
|
||||||
|
label = b"ENCRYPTION"
|
||||||
|
key_size = 16 * 8 # 128-bit
|
||||||
|
return label + b"\x00" + msg + key_size.to_bytes(4, "big")
|
||||||
|
|
||||||
|
def _get_mac_context(msg: bytes) -> bytes:
|
||||||
|
label = b"AUTHENTICATION"
|
||||||
|
key_size = 32 * 8 * 2 # 512-bit
|
||||||
|
return label + b"\x00" + msg + key_size.to_bytes(4, "big")
|
||||||
|
|
||||||
|
return _get_enc_context(message), _get_mac_context(message)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def derive_keys(enc_context: bytes, mac_context: bytes, key: bytes) -> tuple[bytes, bytes, bytes]:
|
||||||
|
"""
|
||||||
|
Returns 3 keys derived from the input message.
|
||||||
|
Key can either be a pre-provision device aes key, provision key, or a session key.
|
||||||
|
|
||||||
|
For provisioning:
|
||||||
|
- enc: aes key used for unwrapping RSA key out of response
|
||||||
|
- mac_key_server: hmac-sha256 key used for verifying provisioning response
|
||||||
|
- mac_key_client: hmac-sha256 key used for signing provisioning request
|
||||||
|
|
||||||
|
When used with a session key:
|
||||||
|
- enc: decrypting content and other keys
|
||||||
|
- mac_key_server: verifying response
|
||||||
|
- mac_key_client: renewals
|
||||||
|
|
||||||
|
With key as pre-provision device key, it can be used to provision and get an
|
||||||
|
RSA device key and token/cert with key as session key (OAEP wrapped with the
|
||||||
|
post-provision RSA device key), it can be used to decrypt content and signing
|
||||||
|
keys and verify licenses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _derive(session_key: bytes, context: bytes, counter: int) -> bytes:
|
||||||
|
return CMAC. \
|
||||||
|
new(session_key, ciphermod=AES). \
|
||||||
|
update(counter.to_bytes(1, "big") + context). \
|
||||||
|
digest()
|
||||||
|
|
||||||
|
enc_key = _derive(key, enc_context, 1)
|
||||||
|
mac_key_server = _derive(key, mac_context, 1)
|
||||||
|
mac_key_server += _derive(key, mac_context, 2)
|
||||||
|
mac_key_client = _derive(key, mac_context, 3)
|
||||||
|
mac_key_client += _derive(key, mac_context, 4)
|
||||||
|
|
||||||
|
return enc_key, mac_key_server, mac_key_client
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("Cdm",)
|
||||||
240
pywidevine/device.py
Normal file
240
pywidevine/device.py
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
|
from construct import BitStruct, Bytes, Const, ConstructError, Container
|
||||||
|
from construct import Enum as CEnum
|
||||||
|
from construct import Int8ub, Int16ub
|
||||||
|
from construct import Optional as COptional
|
||||||
|
from construct import Padded, Padding, Struct, this
|
||||||
|
from Crypto.PublicKey import RSA
|
||||||
|
from google.protobuf.message import DecodeError
|
||||||
|
|
||||||
|
from pywidevine.license_protocol_pb2 import ClientIdentification, DrmCertificate, FileHashes, SignedDrmCertificate
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceTypes(Enum):
|
||||||
|
CHROME = 1
|
||||||
|
ANDROID = 2
|
||||||
|
|
||||||
|
|
||||||
|
class _Structures:
|
||||||
|
magic = 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,
|
||||||
|
**{t.name: t.value for t in DeviceTypes}
|
||||||
|
),
|
||||||
|
"security_level" / Int8ub,
|
||||||
|
"flags" / Padded(1, COptional(BitStruct(
|
||||||
|
# no per-device flags yet
|
||||||
|
Padding(8)
|
||||||
|
))),
|
||||||
|
"private_key_len" / Int16ub,
|
||||||
|
"private_key" / Bytes(this.private_key_len),
|
||||||
|
"client_id_len" / Int16ub,
|
||||||
|
"client_id" / Bytes(this.client_id_len)
|
||||||
|
)
|
||||||
|
|
||||||
|
# - Removed system_id as it can be retrieved from the Client ID's DRM Certificate
|
||||||
|
v1 = Struct(
|
||||||
|
"signature" / magic,
|
||||||
|
"version" / Const(Int8ub, 1),
|
||||||
|
"type_" / CEnum(
|
||||||
|
Int8ub,
|
||||||
|
**{t.name: t.value for t in DeviceTypes}
|
||||||
|
),
|
||||||
|
"security_level" / Int8ub,
|
||||||
|
"flags" / Padded(1, COptional(BitStruct(
|
||||||
|
# no per-device flags yet
|
||||||
|
Padding(8)
|
||||||
|
))),
|
||||||
|
"private_key_len" / Int16ub,
|
||||||
|
"private_key" / Bytes(this.private_key_len),
|
||||||
|
"client_id_len" / Int16ub,
|
||||||
|
"client_id" / Bytes(this.client_id_len),
|
||||||
|
"vmp_len" / Int16ub,
|
||||||
|
"vmp" / Bytes(this.vmp_len)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Device:
|
||||||
|
Structures = _Structures
|
||||||
|
supported_structure = Structures.v2
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*_: Any,
|
||||||
|
type_: DeviceTypes,
|
||||||
|
security_level: int,
|
||||||
|
flags: Optional[dict],
|
||||||
|
private_key: Optional[bytes],
|
||||||
|
client_id: Optional[bytes],
|
||||||
|
**__: Any
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
This is the device key data that is needed for the CDM (Content Decryption Module).
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
type_: Device Type
|
||||||
|
security_level: Security level from 1 (the highest ranking) to 3 (the lowest ranking)
|
||||||
|
flags: Extra flags
|
||||||
|
private_key: Device Private Key
|
||||||
|
client_id: Device Client Identification Blob
|
||||||
|
"""
|
||||||
|
# *_,*__ is to ignore unwanted args, like signature and version from the struct
|
||||||
|
|
||||||
|
if not client_id:
|
||||||
|
raise ValueError("Client ID is required, the WVD does not contain one or is malformed.")
|
||||||
|
if not private_key:
|
||||||
|
raise ValueError("Private Key is required, the WVD does not contain one or is malformed.")
|
||||||
|
|
||||||
|
self.type = DeviceTypes[type_] if isinstance(type_, str) else type_
|
||||||
|
self.security_level = security_level
|
||||||
|
self.flags = flags or {}
|
||||||
|
self.private_key = RSA.importKey(private_key)
|
||||||
|
self.client_id = ClientIdentification()
|
||||||
|
try:
|
||||||
|
self.client_id.ParseFromString(client_id)
|
||||||
|
if self.client_id.SerializeToString() != client_id:
|
||||||
|
raise DecodeError("partial parse")
|
||||||
|
except DecodeError as e:
|
||||||
|
raise DecodeError(f"Failed to parse client_id as a ClientIdentification, {e}")
|
||||||
|
|
||||||
|
self.vmp = FileHashes()
|
||||||
|
if self.client_id.vmp_data:
|
||||||
|
try:
|
||||||
|
self.vmp.ParseFromString(self.client_id.vmp_data)
|
||||||
|
if self.vmp.SerializeToString() != self.client_id.vmp_data:
|
||||||
|
raise DecodeError("partial parse")
|
||||||
|
except DecodeError as e:
|
||||||
|
raise DecodeError(f"Failed to parse Client ID's VMP data as a FileHashes, {e}")
|
||||||
|
|
||||||
|
signed_drm_certificate = SignedDrmCertificate()
|
||||||
|
drm_certificate = DrmCertificate()
|
||||||
|
|
||||||
|
try:
|
||||||
|
signed_drm_certificate.ParseFromString(self.client_id.token)
|
||||||
|
if signed_drm_certificate.SerializeToString() != self.client_id.token:
|
||||||
|
raise DecodeError("partial parse")
|
||||||
|
except DecodeError as e:
|
||||||
|
raise DecodeError(f"Failed to parse the Signed DRM Certificate of the Client ID, {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
drm_certificate.ParseFromString(signed_drm_certificate.drm_certificate)
|
||||||
|
if drm_certificate.SerializeToString() != signed_drm_certificate.drm_certificate:
|
||||||
|
raise DecodeError("partial parse")
|
||||||
|
except DecodeError as e:
|
||||||
|
raise DecodeError(f"Failed to parse the DRM Certificate of the Client ID, {e}")
|
||||||
|
|
||||||
|
self.system_id = drm_certificate.system_id
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return "{name}({items})".format(
|
||||||
|
name=self.__class__.__name__,
|
||||||
|
items=", ".join([f"{k}={repr(v)}" for k, v in self.__dict__.items()])
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def loads(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}")
|
||||||
|
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.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.supported_structure.build(dict(
|
||||||
|
version=2,
|
||||||
|
type_=self.type.value,
|
||||||
|
security_level=self.security_level,
|
||||||
|
flags=self.flags,
|
||||||
|
private_key_len=len(private_key) if private_key else 0,
|
||||||
|
private_key=private_key,
|
||||||
|
client_id_len=len(self.client_id.SerializeToString()) if self.client_id else 0,
|
||||||
|
client_id=self.client_id.SerializeToString() if self.client_id else None
|
||||||
|
))
|
||||||
|
|
||||||
|
def dump(self, path: Union[Path, str]) -> None:
|
||||||
|
if not isinstance(path, (Path, str)):
|
||||||
|
raise ValueError(f"Expecting Path object or path string, got {path!r}")
|
||||||
|
path = Path(path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(self.dumps())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def migrate(cls, data: Union[bytes, str]) -> Device:
|
||||||
|
if isinstance(data, str):
|
||||||
|
data = base64.b64decode(data)
|
||||||
|
if not isinstance(data, bytes):
|
||||||
|
raise ValueError(f"Expecting Bytes or Base64 input, got {data!r}")
|
||||||
|
|
||||||
|
header = _Structures.header.parse(data)
|
||||||
|
if header.version == 2:
|
||||||
|
raise ValueError("Device Data is already migrated to the latest version.")
|
||||||
|
if header.version == 0 or header.version > 2:
|
||||||
|
# we have never used version 0, likely data that just so happened to use the WVD magic
|
||||||
|
raise ValueError("Device Data does not seem to be a WVD file (v0).")
|
||||||
|
|
||||||
|
if header.version == 1: # v1 to v2
|
||||||
|
v1_struct = _Structures.v1.parse(data)
|
||||||
|
v1_struct.version = 2 # update version to 2 to allow loading
|
||||||
|
v1_struct.flags = Container() # blank flags that may have been used in v1
|
||||||
|
|
||||||
|
vmp = FileHashes()
|
||||||
|
if v1_struct.vmp:
|
||||||
|
try:
|
||||||
|
vmp.ParseFromString(v1_struct.vmp)
|
||||||
|
if vmp.SerializeToString() != v1_struct.vmp:
|
||||||
|
raise DecodeError("partial parse")
|
||||||
|
except DecodeError as e:
|
||||||
|
raise DecodeError(f"Failed to parse VMP data as FileHashes, {e}")
|
||||||
|
v1_struct.vmp = vmp
|
||||||
|
|
||||||
|
client_id = ClientIdentification()
|
||||||
|
try:
|
||||||
|
client_id.ParseFromString(v1_struct.client_id)
|
||||||
|
if client_id.SerializeToString() != v1_struct.client_id:
|
||||||
|
raise DecodeError("partial parse")
|
||||||
|
except DecodeError as e:
|
||||||
|
raise DecodeError(f"Failed to parse VMP data as FileHashes, {e}")
|
||||||
|
|
||||||
|
new_vmp_data = v1_struct.vmp.SerializeToString()
|
||||||
|
if client_id.vmp_data and client_id.vmp_data != new_vmp_data:
|
||||||
|
logging.getLogger("migrate").warning("Client ID already has Verified Media Path data")
|
||||||
|
client_id.vmp_data = new_vmp_data
|
||||||
|
v1_struct.client_id = client_id.SerializeToString()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = _Structures.v2.build(v1_struct)
|
||||||
|
except ConstructError as e:
|
||||||
|
raise ValueError(f"Migration failed, {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return cls.loads(data)
|
||||||
|
except ConstructError as e:
|
||||||
|
raise ValueError(f"Device Data seems to be corrupt or invalid, or migration failed, {e}")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("Device", "DeviceTypes")
|
||||||
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."""
|
||||||
66
pywidevine/key.py
Normal file
66
pywidevine/key.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from typing import Optional, Union
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
from Crypto.Util import Padding
|
||||||
|
|
||||||
|
from pywidevine.license_protocol_pb2 import License
|
||||||
|
|
||||||
|
|
||||||
|
class Key:
|
||||||
|
def __init__(self, type_: str, kid: UUID, key: bytes, permissions: Optional[list[str]] = None):
|
||||||
|
self.type = type_
|
||||||
|
self.kid = kid
|
||||||
|
self.key = key
|
||||||
|
self.permissions = permissions or []
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return "{name}({items})".format(
|
||||||
|
name=self.__class__.__name__,
|
||||||
|
items=", ".join([f"{k}={repr(v)}" for k, v in self.__dict__.items()])
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_key_container(cls, key: License.KeyContainer, enc_key: bytes) -> Key:
|
||||||
|
"""Load Key from a KeyContainer object."""
|
||||||
|
permissions = []
|
||||||
|
if key.type == License.KeyContainer.KeyType.Value("OPERATOR_SESSION"):
|
||||||
|
for descriptor, value in key.operator_session_key_permissions.ListFields():
|
||||||
|
if value == 1:
|
||||||
|
permissions.append(descriptor.name)
|
||||||
|
|
||||||
|
return Key(
|
||||||
|
type_=License.KeyContainer.KeyType.Name(key.type),
|
||||||
|
kid=cls.kid_to_uuid(key.id),
|
||||||
|
key=Padding.unpad(
|
||||||
|
AES.new(enc_key, AES.MODE_CBC, iv=key.iv).decrypt(key.key),
|
||||||
|
16
|
||||||
|
),
|
||||||
|
permissions=permissions
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def kid_to_uuid(kid: Union[str, bytes]) -> UUID:
|
||||||
|
"""
|
||||||
|
Convert a Key ID from a string or bytes to a UUID object.
|
||||||
|
At first this may seem very simple but some types of Key IDs
|
||||||
|
may not be 16 bytes and some may be decimal vs. hex.
|
||||||
|
"""
|
||||||
|
if isinstance(kid, str):
|
||||||
|
kid = base64.b64decode(kid)
|
||||||
|
if not kid:
|
||||||
|
kid = b"\x00" * 16
|
||||||
|
|
||||||
|
if kid.decode(errors="replace").isdigit():
|
||||||
|
return UUID(int=int(kid.decode()))
|
||||||
|
|
||||||
|
if len(kid) < 16:
|
||||||
|
kid += b"\x00" * (16 - len(kid))
|
||||||
|
|
||||||
|
return UUID(bytes=kid)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("Key",)
|
||||||
752
pywidevine/license_protocol.proto
Normal file
752
pywidevine/license_protocol.proto
Normal file
@ -0,0 +1,752 @@
|
|||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package pywidevine_license_protocol;
|
||||||
|
|
||||||
|
// need this if we are using libprotobuf-cpp-2.3.0-lite
|
||||||
|
option optimize_for = LITE_RUNTIME;
|
||||||
|
|
||||||
|
option java_package = "com.rlaphoenix.pywidevine.protos";
|
||||||
|
|
||||||
|
enum LicenseType {
|
||||||
|
STREAMING = 1;
|
||||||
|
OFFLINE = 2;
|
||||||
|
// License type decision is left to provider.
|
||||||
|
AUTOMATIC = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PlatformVerificationStatus {
|
||||||
|
// The platform is not verified.
|
||||||
|
PLATFORM_UNVERIFIED = 0;
|
||||||
|
// Tampering detected on the platform.
|
||||||
|
PLATFORM_TAMPERED = 1;
|
||||||
|
// The platform has been verified by means of software.
|
||||||
|
PLATFORM_SOFTWARE_VERIFIED = 2;
|
||||||
|
// The platform has been verified by means of hardware (e.g. secure boot).
|
||||||
|
PLATFORM_HARDWARE_VERIFIED = 3;
|
||||||
|
// Platform verification was not performed.
|
||||||
|
PLATFORM_NO_VERIFICATION = 4;
|
||||||
|
// Platform and secure storage capability have been verified by means of
|
||||||
|
// software.
|
||||||
|
PLATFORM_SECURE_STORAGE_SOFTWARE_VERIFIED = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LicenseIdentification is propagated from LicenseRequest to License,
|
||||||
|
// incrementing version with each iteration.
|
||||||
|
message LicenseIdentification {
|
||||||
|
optional bytes request_id = 1;
|
||||||
|
optional bytes session_id = 2;
|
||||||
|
optional bytes purchase_id = 3;
|
||||||
|
optional LicenseType type = 4;
|
||||||
|
optional int32 version = 5;
|
||||||
|
optional bytes provider_session_token = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message License {
|
||||||
|
message Policy {
|
||||||
|
// Indicates that playback of the content is allowed.
|
||||||
|
optional bool can_play = 1 [default = false];
|
||||||
|
|
||||||
|
// Indicates that the license may be persisted to non-volatile
|
||||||
|
// storage for offline use.
|
||||||
|
optional bool can_persist = 2 [default = false];
|
||||||
|
|
||||||
|
// Indicates that renewal of this license is allowed.
|
||||||
|
optional bool can_renew = 3 [default = false];
|
||||||
|
|
||||||
|
// For the |*duration*| fields, playback must halt when
|
||||||
|
// license_start_time (seconds since the epoch (UTC)) +
|
||||||
|
// license_duration_seconds is exceeded. A value of 0
|
||||||
|
// indicates that there is no limit to the duration.
|
||||||
|
|
||||||
|
// Indicates the rental window.
|
||||||
|
optional int64 rental_duration_seconds = 4 [default = 0];
|
||||||
|
|
||||||
|
// Indicates the viewing window, once playback has begun.
|
||||||
|
optional int64 playback_duration_seconds = 5 [default = 0];
|
||||||
|
|
||||||
|
// Indicates the time window for this specific license.
|
||||||
|
optional int64 license_duration_seconds = 6 [default = 0];
|
||||||
|
|
||||||
|
// The |renewal*| fields only apply if |can_renew| is true.
|
||||||
|
|
||||||
|
// The window of time, in which playback is allowed to continue while
|
||||||
|
// renewal is attempted, yet unsuccessful due to backend problems with
|
||||||
|
// the license server.
|
||||||
|
optional int64 renewal_recovery_duration_seconds = 7 [default = 0];
|
||||||
|
|
||||||
|
// All renewal requests for this license shall be directed to the
|
||||||
|
// specified URL.
|
||||||
|
optional string renewal_server_url = 8;
|
||||||
|
|
||||||
|
// How many seconds after license_start_time, before renewal is first
|
||||||
|
// attempted.
|
||||||
|
optional int64 renewal_delay_seconds = 9 [default = 0];
|
||||||
|
|
||||||
|
// Specifies the delay in seconds between subsequent license
|
||||||
|
// renewal requests, in case of failure.
|
||||||
|
optional int64 renewal_retry_interval_seconds = 10 [default = 0];
|
||||||
|
|
||||||
|
// Indicates that the license shall be sent for renewal when usage is
|
||||||
|
// started.
|
||||||
|
optional bool renew_with_usage = 11 [default = false];
|
||||||
|
|
||||||
|
// Indicates to client that license renewal and release requests ought to
|
||||||
|
// include ClientIdentification (client_id).
|
||||||
|
optional bool always_include_client_id = 12 [default = false];
|
||||||
|
|
||||||
|
// Duration of grace period before playback_duration_seconds (short window)
|
||||||
|
// goes into effect. Optional.
|
||||||
|
optional int64 play_start_grace_period_seconds = 13 [default = 0];
|
||||||
|
|
||||||
|
// Enables "soft enforcement" of playback_duration_seconds, letting the user
|
||||||
|
// finish playback even if short window expires. Optional.
|
||||||
|
optional bool soft_enforce_playback_duration = 14 [default = false];
|
||||||
|
|
||||||
|
// Enables "soft enforcement" of rental_duration_seconds. Initial playback
|
||||||
|
// must always start before rental duration expires. In order to allow
|
||||||
|
// subsequent playbacks to start after the rental duration expires,
|
||||||
|
// soft_enforce_playback_duration must be true. Otherwise, subsequent
|
||||||
|
// playbacks will not be allowed once rental duration expires. Optional.
|
||||||
|
optional bool soft_enforce_rental_duration = 15 [default = true];
|
||||||
|
}
|
||||||
|
|
||||||
|
message KeyContainer {
|
||||||
|
enum KeyType {
|
||||||
|
SIGNING = 1; // Exactly one key of this type must appear.
|
||||||
|
CONTENT = 2; // Content key.
|
||||||
|
KEY_CONTROL = 3; // Key control block for license renewals. No key.
|
||||||
|
OPERATOR_SESSION = 4; // wrapped keys for auxiliary crypto operations.
|
||||||
|
ENTITLEMENT = 5; // Entitlement keys.
|
||||||
|
OEM_CONTENT = 6; // Partner-specific content key.
|
||||||
|
}
|
||||||
|
|
||||||
|
// The SecurityLevel enumeration allows the server to communicate the level
|
||||||
|
// of robustness required by the client, in order to use the key.
|
||||||
|
enum SecurityLevel {
|
||||||
|
// Software-based whitebox crypto is required.
|
||||||
|
SW_SECURE_CRYPTO = 1;
|
||||||
|
|
||||||
|
// Software crypto and an obfuscated decoder is required.
|
||||||
|
SW_SECURE_DECODE = 2;
|
||||||
|
|
||||||
|
// The key material and crypto operations must be performed within a
|
||||||
|
// hardware backed trusted execution environment.
|
||||||
|
HW_SECURE_CRYPTO = 3;
|
||||||
|
|
||||||
|
// The crypto and decoding of content must be performed within a hardware
|
||||||
|
// backed trusted execution environment.
|
||||||
|
HW_SECURE_DECODE = 4;
|
||||||
|
|
||||||
|
// The crypto, decoding and all handling of the media (compressed and
|
||||||
|
// uncompressed) must be handled within a hardware backed trusted
|
||||||
|
// execution environment.
|
||||||
|
HW_SECURE_ALL = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message KeyControl {
|
||||||
|
// |key_control| is documented in:
|
||||||
|
// Widevine Modular DRM Security Integration Guide for CENC
|
||||||
|
// If present, the key control must be communicated to the secure
|
||||||
|
// environment prior to any usage. This message is automatically generated
|
||||||
|
// by the Widevine License Server SDK.
|
||||||
|
optional bytes key_control_block = 1;
|
||||||
|
optional bytes iv = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OutputProtection {
|
||||||
|
// Indicates whether HDCP is required on digital outputs, and which
|
||||||
|
// version should be used.
|
||||||
|
enum HDCP {
|
||||||
|
HDCP_NONE = 0;
|
||||||
|
HDCP_V1 = 1;
|
||||||
|
HDCP_V2 = 2;
|
||||||
|
HDCP_V2_1 = 3;
|
||||||
|
HDCP_V2_2 = 4;
|
||||||
|
HDCP_V2_3 = 5;
|
||||||
|
HDCP_NO_DIGITAL_OUTPUT = 0xff;
|
||||||
|
}
|
||||||
|
optional HDCP hdcp = 1 [default = HDCP_NONE];
|
||||||
|
|
||||||
|
// Indicate the CGMS setting to be inserted on analog output.
|
||||||
|
enum CGMS {
|
||||||
|
CGMS_NONE = 42;
|
||||||
|
COPY_FREE = 0;
|
||||||
|
COPY_ONCE = 2;
|
||||||
|
COPY_NEVER = 3;
|
||||||
|
}
|
||||||
|
optional CGMS cgms_flags = 2 [default = CGMS_NONE];
|
||||||
|
|
||||||
|
enum HdcpSrmRule {
|
||||||
|
HDCP_SRM_RULE_NONE = 0;
|
||||||
|
// In 'required_protection', this means most current SRM is required.
|
||||||
|
// Update the SRM on the device. If update cannot happen,
|
||||||
|
// do not allow the key.
|
||||||
|
// In 'requested_protection', this means most current SRM is requested.
|
||||||
|
// Update the SRM on the device. If update cannot happen,
|
||||||
|
// allow use of the key anyway.
|
||||||
|
CURRENT_SRM = 1;
|
||||||
|
}
|
||||||
|
optional HdcpSrmRule hdcp_srm_rule = 3 [default = HDCP_SRM_RULE_NONE];
|
||||||
|
// Optional requirement to indicate analog output is not allowed.
|
||||||
|
optional bool disable_analog_output = 4 [default = false];
|
||||||
|
// Optional requirement to indicate digital output is not allowed.
|
||||||
|
optional bool disable_digital_output = 5 [default = false];
|
||||||
|
}
|
||||||
|
|
||||||
|
message VideoResolutionConstraint {
|
||||||
|
// Minimum and maximum video resolutions in the range (height x width).
|
||||||
|
optional uint32 min_resolution_pixels = 1;
|
||||||
|
optional uint32 max_resolution_pixels = 2;
|
||||||
|
// Optional output protection requirements for this range. If not
|
||||||
|
// specified, the OutputProtection in the KeyContainer applies.
|
||||||
|
optional OutputProtection required_protection = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OperatorSessionKeyPermissions {
|
||||||
|
// Permissions/key usage flags for operator service keys
|
||||||
|
// (type = OPERATOR_SESSION).
|
||||||
|
optional bool allow_encrypt = 1 [default = false];
|
||||||
|
optional bool allow_decrypt = 2 [default = false];
|
||||||
|
optional bool allow_sign = 3 [default = false];
|
||||||
|
optional bool allow_signature_verify = 4 [default = false];
|
||||||
|
}
|
||||||
|
|
||||||
|
optional bytes id = 1;
|
||||||
|
optional bytes iv = 2;
|
||||||
|
optional bytes key = 3;
|
||||||
|
optional KeyType type = 4;
|
||||||
|
optional SecurityLevel level = 5 [default = SW_SECURE_CRYPTO];
|
||||||
|
optional OutputProtection required_protection = 6;
|
||||||
|
// NOTE: Use of requested_protection is not recommended as it is only
|
||||||
|
// supported on a small number of platforms.
|
||||||
|
optional OutputProtection requested_protection = 7;
|
||||||
|
optional KeyControl key_control = 8;
|
||||||
|
optional OperatorSessionKeyPermissions operator_session_key_permissions = 9;
|
||||||
|
// Optional video resolution constraints. If the video resolution of the
|
||||||
|
// content being decrypted/decoded falls within one of the specified ranges,
|
||||||
|
// the optional required_protections may be applied. Otherwise an error will
|
||||||
|
// be reported.
|
||||||
|
// NOTE: Use of this feature is not recommended, as it is only supported on
|
||||||
|
// a small number of platforms.
|
||||||
|
repeated VideoResolutionConstraint video_resolution_constraints = 10;
|
||||||
|
// Optional flag to indicate the key must only be used if the client
|
||||||
|
// supports anti rollback of the user table. Content provider can query the
|
||||||
|
// client capabilities to determine if the client support this feature.
|
||||||
|
optional bool anti_rollback_usage_table = 11 [default = false];
|
||||||
|
// Optional not limited to commonly known track types such as SD, HD.
|
||||||
|
// It can be some provider defined label to identify the track.
|
||||||
|
optional string track_label = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
optional LicenseIdentification id = 1;
|
||||||
|
optional Policy policy = 2;
|
||||||
|
repeated KeyContainer key = 3;
|
||||||
|
// Time of the request in seconds (UTC) as set in
|
||||||
|
// LicenseRequest.request_time. If this time is not set in the request,
|
||||||
|
// the local time at the license service is used in this field.
|
||||||
|
optional int64 license_start_time = 4;
|
||||||
|
optional bool remote_attestation_verified = 5 [default = false];
|
||||||
|
// Client token generated by the content provider. Optional.
|
||||||
|
optional bytes provider_client_token = 6;
|
||||||
|
// 4cc code specifying the CENC protection scheme as defined in the CENC 3.0
|
||||||
|
// specification. Propagated from Widevine PSSH box. Optional.
|
||||||
|
optional uint32 protection_scheme = 7;
|
||||||
|
// 8 byte verification field "HDCPDATA" followed by unsigned 32 bit minimum
|
||||||
|
// HDCP SRM version (whether the version is for HDCP1 SRM or HDCP2 SRM
|
||||||
|
// depends on client max_hdcp_version).
|
||||||
|
// Additional details can be found in Widevine Modular DRM Security
|
||||||
|
// Integration Guide for CENC.
|
||||||
|
optional bytes srm_requirement = 8;
|
||||||
|
// If present this contains a signed SRM file (either HDCP1 SRM or HDCP2 SRM
|
||||||
|
// depending on client max_hdcp_version) that should be installed on the
|
||||||
|
// client device.
|
||||||
|
optional bytes srm_update = 9;
|
||||||
|
// Indicates the status of any type of platform verification performed by the
|
||||||
|
// server.
|
||||||
|
optional PlatformVerificationStatus platform_verification_status = 10
|
||||||
|
[default = PLATFORM_NO_VERIFICATION];
|
||||||
|
// IDs of the groups for which keys are delivered in this license, if any.
|
||||||
|
repeated bytes group_ids = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProtocolVersion {
|
||||||
|
VERSION_2_0 = 20;
|
||||||
|
VERSION_2_1 = 21;
|
||||||
|
VERSION_2_2 = 22;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LicenseRequest {
|
||||||
|
message ContentIdentification {
|
||||||
|
message WidevinePsshData {
|
||||||
|
repeated bytes pssh_data = 1;
|
||||||
|
optional LicenseType license_type = 2;
|
||||||
|
optional bytes request_id = 3; // Opaque, client-specified.
|
||||||
|
}
|
||||||
|
|
||||||
|
message WebmKeyId {
|
||||||
|
optional bytes header = 1;
|
||||||
|
optional LicenseType license_type = 2;
|
||||||
|
optional bytes request_id = 3; // Opaque, client-specified.
|
||||||
|
}
|
||||||
|
|
||||||
|
message ExistingLicense {
|
||||||
|
optional LicenseIdentification license_id = 1;
|
||||||
|
optional int64 seconds_since_started = 2;
|
||||||
|
optional int64 seconds_since_last_played = 3;
|
||||||
|
optional bytes session_usage_table_entry = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message InitData {
|
||||||
|
enum InitDataType {
|
||||||
|
CENC = 1;
|
||||||
|
WEBM = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
optional InitDataType init_data_type = 1 [default = CENC];
|
||||||
|
optional bytes init_data = 2;
|
||||||
|
optional LicenseType license_type = 3;
|
||||||
|
optional bytes request_id = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
oneof content_id_variant {
|
||||||
|
// Exactly one of these must be present.
|
||||||
|
WidevinePsshData widevine_pssh_data = 1;
|
||||||
|
WebmKeyId webm_key_id = 2;
|
||||||
|
ExistingLicense existing_license = 3;
|
||||||
|
InitData init_data = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RequestType {
|
||||||
|
NEW = 1;
|
||||||
|
RENEWAL = 2;
|
||||||
|
RELEASE = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The client_id provides information authenticating the calling device. It
|
||||||
|
// contains the Widevine keybox token that was installed on the device at the
|
||||||
|
// factory. This field or encrypted_client_id below is required for a valid
|
||||||
|
// license request, but both should never be present in the same request.
|
||||||
|
optional ClientIdentification client_id = 1;
|
||||||
|
optional ContentIdentification content_id = 2;
|
||||||
|
optional RequestType type = 3;
|
||||||
|
// Time of the request in seconds (UTC) as set by the client.
|
||||||
|
optional int64 request_time = 4;
|
||||||
|
// Old-style decimal-encoded string key control nonce.
|
||||||
|
optional bytes key_control_nonce_deprecated = 5;
|
||||||
|
optional ProtocolVersion protocol_version = 6 [default = VERSION_2_0];
|
||||||
|
// New-style uint32 key control nonce, please use instead of
|
||||||
|
// key_control_nonce_deprecated.
|
||||||
|
optional uint32 key_control_nonce = 7;
|
||||||
|
// Encrypted ClientIdentification message, used for privacy purposes.
|
||||||
|
optional EncryptedClientIdentification encrypted_client_id = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MetricData {
|
||||||
|
enum MetricType {
|
||||||
|
// The time spent in the 'stage', specified in microseconds.
|
||||||
|
LATENCY = 1;
|
||||||
|
// The UNIX epoch timestamp at which the 'stage' was first accessed in
|
||||||
|
// microseconds.
|
||||||
|
TIMESTAMP = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message TypeValue {
|
||||||
|
optional MetricType type = 1;
|
||||||
|
// The value associated with 'type'. For example if type == LATENCY, the
|
||||||
|
// value would be the time in microseconds spent in this 'stage'.
|
||||||
|
optional int64 value = 2 [default = 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'stage' that is currently processing the SignedMessage. Required.
|
||||||
|
optional string stage_name = 1;
|
||||||
|
// metric and associated value.
|
||||||
|
repeated TypeValue metric_data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message VersionInfo {
|
||||||
|
// License SDK version reported by the Widevine License SDK. This field
|
||||||
|
// is populated automatically by the SDK.
|
||||||
|
optional string license_sdk_version = 1;
|
||||||
|
// Version of the service hosting the license SDK. This field is optional.
|
||||||
|
// It may be provided by the hosting service.
|
||||||
|
optional string license_service_version = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SignedMessage {
|
||||||
|
enum MessageType {
|
||||||
|
LICENSE_REQUEST = 1;
|
||||||
|
LICENSE = 2;
|
||||||
|
ERROR_RESPONSE = 3;
|
||||||
|
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||||
|
SERVICE_CERTIFICATE = 5;
|
||||||
|
SUB_LICENSE = 6;
|
||||||
|
CAS_LICENSE_REQUEST = 7;
|
||||||
|
CAS_LICENSE = 8;
|
||||||
|
EXTERNAL_LICENSE_REQUEST = 9;
|
||||||
|
EXTERNAL_LICENSE = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SessionKeyType {
|
||||||
|
UNDEFINED = 0;
|
||||||
|
WRAPPED_AES_KEY = 1;
|
||||||
|
EPHERMERAL_ECC_PUBLIC_KEY = 2;
|
||||||
|
}
|
||||||
|
optional MessageType type = 1;
|
||||||
|
optional bytes msg = 2;
|
||||||
|
// Required field that contains the signature of the bytes of msg.
|
||||||
|
// For license requests, the signing algorithm is determined by the
|
||||||
|
// certificate contained in the request.
|
||||||
|
// For license responses, the signing algorithm is HMAC with signing key based
|
||||||
|
// on |session_key|.
|
||||||
|
optional bytes signature = 3;
|
||||||
|
// If populated, the contents of this field will be signaled by the
|
||||||
|
// |session_key_type| type. If the |session_key_type| is WRAPPED_AES_KEY the
|
||||||
|
// key is the bytes of an encrypted AES key. If the |session_key_type| is
|
||||||
|
// EPHERMERAL_ECC_PUBLIC_KEY the field contains the bytes of an RFC5208 ASN1
|
||||||
|
// serialized ECC public key.
|
||||||
|
optional bytes session_key = 4;
|
||||||
|
// Remote attestation data which will be present in the initial license
|
||||||
|
// request for ChromeOS client devices operating in verified mode. Remote
|
||||||
|
// attestation challenge data is |msg| field above. Optional.
|
||||||
|
optional bytes remote_attestation = 5;
|
||||||
|
|
||||||
|
repeated MetricData metric_data = 6;
|
||||||
|
// Version information from the SDK and license service. This information is
|
||||||
|
// provided in the license response.
|
||||||
|
optional VersionInfo service_version_info = 7;
|
||||||
|
// Optional field that contains the algorithm type used to generate the
|
||||||
|
// session_key and signature in a LICENSE message.
|
||||||
|
optional SessionKeyType session_key_type = 8 [default = WRAPPED_AES_KEY];
|
||||||
|
// The core message is the simple serialization of fields used by OEMCrypto.
|
||||||
|
// This field was introduced in OEMCrypto API v16.
|
||||||
|
optional bytes oemcrypto_core_message = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HashAlgorithmProto {
|
||||||
|
// Unspecified hash algorithm: SHA_256 shall be used for ECC based algorithms
|
||||||
|
// and SHA_1 shall be used otherwise.
|
||||||
|
HASH_ALGORITHM_UNSPECIFIED = 0;
|
||||||
|
HASH_ALGORITHM_SHA_1 = 1;
|
||||||
|
HASH_ALGORITHM_SHA_256 = 2;
|
||||||
|
HASH_ALGORITHM_SHA_384 = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientIdentification message used to authenticate the client device.
|
||||||
|
message ClientIdentification {
|
||||||
|
enum TokenType {
|
||||||
|
KEYBOX = 0;
|
||||||
|
DRM_DEVICE_CERTIFICATE = 1;
|
||||||
|
REMOTE_ATTESTATION_CERTIFICATE = 2;
|
||||||
|
OEM_DEVICE_CERTIFICATE = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message NameValue {
|
||||||
|
optional string name = 1;
|
||||||
|
optional string value = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capabilities which not all clients may support. Used for the license
|
||||||
|
// exchange protocol only.
|
||||||
|
message ClientCapabilities {
|
||||||
|
enum HdcpVersion {
|
||||||
|
HDCP_NONE = 0;
|
||||||
|
HDCP_V1 = 1;
|
||||||
|
HDCP_V2 = 2;
|
||||||
|
HDCP_V2_1 = 3;
|
||||||
|
HDCP_V2_2 = 4;
|
||||||
|
HDCP_V2_3 = 5;
|
||||||
|
HDCP_NO_DIGITAL_OUTPUT = 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CertificateKeyType {
|
||||||
|
RSA_2048 = 0;
|
||||||
|
RSA_3072 = 1;
|
||||||
|
ECC_SECP256R1 = 2;
|
||||||
|
ECC_SECP384R1 = 3;
|
||||||
|
ECC_SECP521R1 = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AnalogOutputCapabilities {
|
||||||
|
ANALOG_OUTPUT_UNKNOWN = 0;
|
||||||
|
ANALOG_OUTPUT_NONE = 1;
|
||||||
|
ANALOG_OUTPUT_SUPPORTED = 2;
|
||||||
|
ANALOG_OUTPUT_SUPPORTS_CGMS_A = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
optional bool client_token = 1 [default = false];
|
||||||
|
optional bool session_token = 2 [default = false];
|
||||||
|
optional bool video_resolution_constraints = 3 [default = false];
|
||||||
|
optional HdcpVersion max_hdcp_version = 4 [default = HDCP_NONE];
|
||||||
|
optional uint32 oem_crypto_api_version = 5;
|
||||||
|
// Client has hardware support for protecting the usage table, such as
|
||||||
|
// storing the generation number in secure memory. For Details, see:
|
||||||
|
// Widevine Modular DRM Security Integration Guide for CENC
|
||||||
|
optional bool anti_rollback_usage_table = 6 [default = false];
|
||||||
|
// The client shall report |srm_version| if available.
|
||||||
|
optional uint32 srm_version = 7;
|
||||||
|
// A device may have SRM data, and report a version, but may not be capable
|
||||||
|
// of updating SRM data.
|
||||||
|
optional bool can_update_srm = 8 [default = false];
|
||||||
|
repeated CertificateKeyType supported_certificate_key_type = 9;
|
||||||
|
optional AnalogOutputCapabilities analog_output_capabilities = 10
|
||||||
|
[default = ANALOG_OUTPUT_UNKNOWN];
|
||||||
|
optional bool can_disable_analog_output = 11 [default = false];
|
||||||
|
// Clients can indicate a performance level supported by OEMCrypto.
|
||||||
|
// This will allow applications and providers to choose an appropriate
|
||||||
|
// quality of content to serve. Currently defined tiers are
|
||||||
|
// 1 (low), 2 (medium) and 3 (high). Any other value indicates that
|
||||||
|
// the resource rating is unavailable or reporting erroneous values
|
||||||
|
// for that device. For details see,
|
||||||
|
// Widevine Modular DRM Security Integration Guide for CENC
|
||||||
|
optional uint32 resource_rating_tier = 12 [default = 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClientCredentials {
|
||||||
|
optional TokenType type = 1 [default = KEYBOX];
|
||||||
|
optional bytes token = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type of factory-provisioned device root of trust. Optional.
|
||||||
|
optional TokenType type = 1 [default = KEYBOX];
|
||||||
|
// Factory-provisioned device root of trust. Required.
|
||||||
|
optional bytes token = 2;
|
||||||
|
// Optional client information name/value pairs.
|
||||||
|
repeated NameValue client_info = 3;
|
||||||
|
// Client token generated by the content provider. Optional.
|
||||||
|
optional bytes provider_client_token = 4;
|
||||||
|
// Number of licenses received by the client to which the token above belongs.
|
||||||
|
// Only present if client_token is specified.
|
||||||
|
optional uint32 license_counter = 5;
|
||||||
|
// List of non-baseline client capabilities.
|
||||||
|
optional ClientCapabilities client_capabilities = 6;
|
||||||
|
// Serialized VmpData message. Optional.
|
||||||
|
optional bytes vmp_data = 7;
|
||||||
|
// Optional field that may contain additional provisioning credentials.
|
||||||
|
repeated ClientCredentials device_credentials = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptedClientIdentification message used to hold ClientIdentification
|
||||||
|
// messages encrypted for privacy purposes.
|
||||||
|
message EncryptedClientIdentification {
|
||||||
|
// Provider ID for which the ClientIdentifcation is encrypted (owner of
|
||||||
|
// service certificate).
|
||||||
|
optional string provider_id = 1;
|
||||||
|
// Serial number for the service certificate for which ClientIdentification is
|
||||||
|
// encrypted.
|
||||||
|
optional bytes service_certificate_serial_number = 2;
|
||||||
|
// Serialized ClientIdentification message, encrypted with the privacy key
|
||||||
|
// using AES-128-CBC with PKCS#5 padding.
|
||||||
|
optional bytes encrypted_client_id = 3;
|
||||||
|
// Initialization vector needed to decrypt encrypted_client_id.
|
||||||
|
optional bytes encrypted_client_id_iv = 4;
|
||||||
|
// AES-128 privacy key, encrypted with the service public key using RSA-OAEP.
|
||||||
|
optional bytes encrypted_privacy_key = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DRM certificate definition for user devices, intermediate, service, and root
|
||||||
|
// certificates.
|
||||||
|
message DrmCertificate {
|
||||||
|
enum Type {
|
||||||
|
ROOT = 0; // ProtoBestPractices: ignore.
|
||||||
|
DEVICE_MODEL = 1;
|
||||||
|
DEVICE = 2;
|
||||||
|
SERVICE = 3;
|
||||||
|
PROVISIONER = 4;
|
||||||
|
}
|
||||||
|
enum ServiceType {
|
||||||
|
UNKNOWN_SERVICE_TYPE = 0;
|
||||||
|
LICENSE_SERVER_SDK = 1;
|
||||||
|
LICENSE_SERVER_PROXY_SDK = 2;
|
||||||
|
PROVISIONING_SDK = 3;
|
||||||
|
CAS_PROXY_SDK = 4;
|
||||||
|
}
|
||||||
|
enum Algorithm {
|
||||||
|
UNKNOWN_ALGORITHM = 0;
|
||||||
|
RSA = 1;
|
||||||
|
ECC_SECP256R1 = 2;
|
||||||
|
ECC_SECP384R1 = 3;
|
||||||
|
ECC_SECP521R1 = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EncryptionKey {
|
||||||
|
// Device public key. PKCS#1 ASN.1 DER-encoded. Required.
|
||||||
|
optional bytes public_key = 1;
|
||||||
|
// Required. The algorithm field contains the curve used to create the
|
||||||
|
// |public_key| if algorithm is one of the ECC types.
|
||||||
|
// The |algorithm| is used for both to determine the if the certificate is
|
||||||
|
// ECC or RSA. The |algorithm| also specifies the parameters that were used
|
||||||
|
// to create |public_key| and are used to create an ephemeral session key.
|
||||||
|
optional Algorithm algorithm = 2 [default = RSA];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type of certificate. Required.
|
||||||
|
optional Type type = 1;
|
||||||
|
// 128-bit globally unique serial number of certificate.
|
||||||
|
// Value is 0 for root certificate. Required.
|
||||||
|
optional bytes serial_number = 2;
|
||||||
|
// POSIX time, in seconds, when the certificate was created. Required.
|
||||||
|
optional uint32 creation_time_seconds = 3;
|
||||||
|
// POSIX time, in seconds, when the certificate should expire. Value of zero
|
||||||
|
// denotes indefinite expiry time. For more information on limited lifespan
|
||||||
|
// DRM certificates see (go/limited-lifespan-drm-certificates).
|
||||||
|
optional uint32 expiration_time_seconds = 12;
|
||||||
|
// Device public key. PKCS#1 ASN.1 DER-encoded. Required.
|
||||||
|
optional bytes public_key = 4;
|
||||||
|
// Widevine system ID for the device. Required for intermediate and
|
||||||
|
// user device certificates.
|
||||||
|
optional uint32 system_id = 5;
|
||||||
|
// Deprecated field, which used to indicate whether the device was a test
|
||||||
|
// (non-production) device. The test_device field in ProvisionedDeviceInfo
|
||||||
|
// below should be observed instead.
|
||||||
|
optional bool test_device_deprecated = 6 [deprecated = true];
|
||||||
|
// Service identifier (web origin) for the provider which owns the
|
||||||
|
// certificate. Required for service and provisioner certificates.
|
||||||
|
optional string provider_id = 7;
|
||||||
|
// This field is used only when type = SERVICE to specify which SDK uses
|
||||||
|
// service certificate. This repeated field is treated as a set. A certificate
|
||||||
|
// may be used for the specified service SDK if the appropriate ServiceType
|
||||||
|
// is specified in this field.
|
||||||
|
repeated ServiceType service_types = 8;
|
||||||
|
// Required. The algorithm field contains the curve used to create the
|
||||||
|
// |public_key| if algorithm is one of the ECC types.
|
||||||
|
// The |algorithm| is used for both to determine the if the certificate is ECC
|
||||||
|
// or RSA. The |algorithm| also specifies the parameters that were used to
|
||||||
|
// create |public_key| and are used to create an ephemeral session key.
|
||||||
|
optional Algorithm algorithm = 9 [default = RSA];
|
||||||
|
// Optional. May be present in DEVICE certificate types. This is the root
|
||||||
|
// of trust identifier that holds an encrypted value that identifies the
|
||||||
|
// keybox or other root of trust that was used to provision a DEVICE drm
|
||||||
|
// certificate.
|
||||||
|
optional bytes rot_id = 10;
|
||||||
|
// Optional. May be present in devices that explicitly support dual keys. When
|
||||||
|
// present the |public_key| is used for verification of received license
|
||||||
|
// request messages.
|
||||||
|
optional EncryptionKey encryption_key = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DrmCertificate signed by a higher (CA) DRM certificate.
|
||||||
|
message SignedDrmCertificate {
|
||||||
|
// Serialized certificate. Required.
|
||||||
|
optional bytes drm_certificate = 1;
|
||||||
|
// Signature of certificate. Signed with root or intermediate
|
||||||
|
// certificate specified below. Required.
|
||||||
|
optional bytes signature = 2;
|
||||||
|
// SignedDrmCertificate used to sign this certificate.
|
||||||
|
optional SignedDrmCertificate signer = 3;
|
||||||
|
// Optional field that indicates the hash algorithm used in signature scheme.
|
||||||
|
optional HashAlgorithmProto hash_algorithm = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message WidevinePsshData {
|
||||||
|
enum Type {
|
||||||
|
SINGLE = 0; // Single PSSH to be used to retrieve content keys.
|
||||||
|
ENTITLEMENT = 1; // Primary PSSH used to retrieve entitlement keys.
|
||||||
|
ENTITLED_KEY = 2; // Secondary PSSH containing entitled key(s).
|
||||||
|
}
|
||||||
|
|
||||||
|
message EntitledKey {
|
||||||
|
// ID of entitlement key used for wrapping |key|.
|
||||||
|
optional bytes entitlement_key_id = 1;
|
||||||
|
// ID of the entitled key.
|
||||||
|
optional bytes key_id = 2;
|
||||||
|
// Wrapped key. Required.
|
||||||
|
optional bytes key = 3;
|
||||||
|
// IV used for wrapping |key|. Required.
|
||||||
|
optional bytes iv = 4;
|
||||||
|
// Size of entitlement key used for wrapping |key|.
|
||||||
|
optional uint32 entitlement_key_size_bytes = 5 [default = 32];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entitlement or content key IDs. Can onnly present in SINGLE or ENTITLEMENT
|
||||||
|
// PSSHs. May be repeated to facilitate delivery of multiple keys in a
|
||||||
|
// single license. Cannot be used in conjunction with content_id or
|
||||||
|
// group_ids, which are the preferred mechanism.
|
||||||
|
repeated bytes key_ids = 2;
|
||||||
|
|
||||||
|
// Content identifier which may map to multiple entitlement or content key
|
||||||
|
// IDs to facilitate the delivery of multiple keys in a single license.
|
||||||
|
// Cannot be present in conjunction with key_ids, but if used must be in all
|
||||||
|
// PSSHs.
|
||||||
|
optional bytes content_id = 4;
|
||||||
|
|
||||||
|
// Crypto period index, for media using key rotation. Always corresponds to
|
||||||
|
// The content key period. This means that if using entitlement licensing
|
||||||
|
// the ENTITLED_KEY PSSHs will have sequential crypto_period_index's, whereas
|
||||||
|
// the ENTITELEMENT PSSHs will have gaps in the sequence. Required if doing
|
||||||
|
// key rotation.
|
||||||
|
optional uint32 crypto_period_index = 7;
|
||||||
|
|
||||||
|
// Protection scheme identifying the encryption algorithm. The protection
|
||||||
|
// scheme is represented as a uint32 value. The uint32 contains 4 bytes each
|
||||||
|
// representing a single ascii character in one of the 4CC protection scheme
|
||||||
|
// values. To be deprecated in favor of signaling from content.
|
||||||
|
// 'cenc' (AES-CTR) protection_scheme = 0x63656E63,
|
||||||
|
// 'cbc1' (AES-CBC) protection_scheme = 0x63626331,
|
||||||
|
// 'cens' (AES-CTR pattern encryption) protection_scheme = 0x63656E73,
|
||||||
|
// 'cbcs' (AES-CBC pattern encryption) protection_scheme = 0x63626373.
|
||||||
|
optional uint32 protection_scheme = 9;
|
||||||
|
|
||||||
|
// Optional. For media using key rotation, this represents the duration
|
||||||
|
// of each crypto period in seconds.
|
||||||
|
optional uint32 crypto_period_seconds = 10;
|
||||||
|
|
||||||
|
// Type of PSSH. Required if not SINGLE.
|
||||||
|
optional Type type = 11 [default = SINGLE];
|
||||||
|
|
||||||
|
// Key sequence for Widevine-managed keys. Optional.
|
||||||
|
optional uint32 key_sequence = 12;
|
||||||
|
|
||||||
|
// Group identifiers for all groups to which the content belongs. This can
|
||||||
|
// be used to deliver licenses to unlock multiple titles / channels.
|
||||||
|
// Optional, and may only be present in ENTITLEMENT and ENTITLED_KEY PSSHs, and
|
||||||
|
// not in conjunction with key_ids.
|
||||||
|
repeated bytes group_ids = 13;
|
||||||
|
|
||||||
|
// Copy/copies of the content key used to decrypt the media stream in which
|
||||||
|
// the PSSH box is embedded, each wrapped with a different entitlement key.
|
||||||
|
// May also contain sub-licenses to support devices with OEMCrypto 13 or
|
||||||
|
// older. May be repeated if using group entitlement keys. Present only in
|
||||||
|
// PSSHs of type ENTITLED_KEY.
|
||||||
|
repeated EntitledKey entitled_keys = 14;
|
||||||
|
|
||||||
|
// Video feature identifier, which is used in conjunction with |content_id|
|
||||||
|
// to determine the set of keys to be returned in the license. Cannot be
|
||||||
|
// present in conjunction with |key_ids|.
|
||||||
|
// Current values are "HDR".
|
||||||
|
optional string video_feature = 15;
|
||||||
|
|
||||||
|
//////////////////////////// Deprecated Fields ////////////////////////////
|
||||||
|
enum Algorithm {
|
||||||
|
UNENCRYPTED = 0;
|
||||||
|
AESCTR = 1;
|
||||||
|
};
|
||||||
|
optional Algorithm algorithm = 1 [deprecated = true];
|
||||||
|
|
||||||
|
// Content provider name.
|
||||||
|
optional string provider = 3 [deprecated = true];
|
||||||
|
|
||||||
|
// Track type. Acceptable values are SD, HD and AUDIO. Used to
|
||||||
|
// differentiate content keys used by an asset.
|
||||||
|
optional string track_type = 5 [deprecated = true];
|
||||||
|
|
||||||
|
// The name of a registered policy to be used for this asset.
|
||||||
|
optional string policy = 6 [deprecated = true];
|
||||||
|
|
||||||
|
// Optional protected context for group content. The grouped_license is a
|
||||||
|
// serialized SignedMessage.
|
||||||
|
optional bytes grouped_license = 8 [deprecated = true];
|
||||||
|
}
|
||||||
|
|
||||||
|
// File Hashes for Verified Media Path (VMP) support.
|
||||||
|
message FileHashes {
|
||||||
|
message Signature {
|
||||||
|
optional string filename = 1;
|
||||||
|
optional bool test_signing = 2; //0 - release, 1 - testing
|
||||||
|
optional bytes SHA512Hash = 3;
|
||||||
|
optional bool main_exe = 4; //0 for dlls, 1 for exe, this is field 3 in file
|
||||||
|
optional bytes signature = 5;
|
||||||
|
}
|
||||||
|
optional bytes signer = 1;
|
||||||
|
repeated Signature signatures = 2;
|
||||||
|
}
|
||||||
143
pywidevine/license_protocol_pb2.py
Normal file
143
pywidevine/license_protocol_pb2.py
Normal file
File diff suppressed because one or more lines are too long
607
pywidevine/license_protocol_pb2.pyi
Normal file
607
pywidevine/license_protocol_pb2.pyi
Normal file
@ -0,0 +1,607 @@
|
|||||||
|
# mypy: ignore-errors
|
||||||
|
|
||||||
|
from google.protobuf.internal import containers as _containers
|
||||||
|
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
|
||||||
|
from google.protobuf import descriptor as _descriptor
|
||||||
|
from google.protobuf import message as _message
|
||||||
|
from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
|
||||||
|
|
||||||
|
AUTOMATIC: LicenseType
|
||||||
|
DESCRIPTOR: _descriptor.FileDescriptor
|
||||||
|
HASH_ALGORITHM_SHA_1: HashAlgorithmProto
|
||||||
|
HASH_ALGORITHM_SHA_256: HashAlgorithmProto
|
||||||
|
HASH_ALGORITHM_SHA_384: HashAlgorithmProto
|
||||||
|
HASH_ALGORITHM_UNSPECIFIED: HashAlgorithmProto
|
||||||
|
OFFLINE: LicenseType
|
||||||
|
PLATFORM_HARDWARE_VERIFIED: PlatformVerificationStatus
|
||||||
|
PLATFORM_NO_VERIFICATION: PlatformVerificationStatus
|
||||||
|
PLATFORM_SECURE_STORAGE_SOFTWARE_VERIFIED: PlatformVerificationStatus
|
||||||
|
PLATFORM_SOFTWARE_VERIFIED: PlatformVerificationStatus
|
||||||
|
PLATFORM_TAMPERED: PlatformVerificationStatus
|
||||||
|
PLATFORM_UNVERIFIED: PlatformVerificationStatus
|
||||||
|
STREAMING: LicenseType
|
||||||
|
VERSION_2_0: ProtocolVersion
|
||||||
|
VERSION_2_1: ProtocolVersion
|
||||||
|
VERSION_2_2: ProtocolVersion
|
||||||
|
|
||||||
|
class ClientIdentification(_message.Message):
|
||||||
|
__slots__ = ["client_capabilities", "client_info", "device_credentials", "license_counter", "provider_client_token", "token", "type", "vmp_data"]
|
||||||
|
class TokenType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
class ClientCapabilities(_message.Message):
|
||||||
|
__slots__ = ["analog_output_capabilities", "anti_rollback_usage_table", "can_disable_analog_output", "can_update_srm", "client_token", "max_hdcp_version", "oem_crypto_api_version", "resource_rating_tier", "session_token", "srm_version", "supported_certificate_key_type", "video_resolution_constraints"]
|
||||||
|
class AnalogOutputCapabilities(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
class CertificateKeyType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
class HdcpVersion(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
ANALOG_OUTPUT_CAPABILITIES_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
ANALOG_OUTPUT_NONE: ClientIdentification.ClientCapabilities.AnalogOutputCapabilities
|
||||||
|
ANALOG_OUTPUT_SUPPORTED: ClientIdentification.ClientCapabilities.AnalogOutputCapabilities
|
||||||
|
ANALOG_OUTPUT_SUPPORTS_CGMS_A: ClientIdentification.ClientCapabilities.AnalogOutputCapabilities
|
||||||
|
ANALOG_OUTPUT_UNKNOWN: ClientIdentification.ClientCapabilities.AnalogOutputCapabilities
|
||||||
|
ANTI_ROLLBACK_USAGE_TABLE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
CAN_DISABLE_ANALOG_OUTPUT_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
CAN_UPDATE_SRM_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
CLIENT_TOKEN_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
ECC_SECP256R1: ClientIdentification.ClientCapabilities.CertificateKeyType
|
||||||
|
ECC_SECP384R1: ClientIdentification.ClientCapabilities.CertificateKeyType
|
||||||
|
ECC_SECP521R1: ClientIdentification.ClientCapabilities.CertificateKeyType
|
||||||
|
HDCP_NONE: ClientIdentification.ClientCapabilities.HdcpVersion
|
||||||
|
HDCP_NO_DIGITAL_OUTPUT: ClientIdentification.ClientCapabilities.HdcpVersion
|
||||||
|
HDCP_V1: ClientIdentification.ClientCapabilities.HdcpVersion
|
||||||
|
HDCP_V2: ClientIdentification.ClientCapabilities.HdcpVersion
|
||||||
|
HDCP_V2_1: ClientIdentification.ClientCapabilities.HdcpVersion
|
||||||
|
HDCP_V2_2: ClientIdentification.ClientCapabilities.HdcpVersion
|
||||||
|
HDCP_V2_3: ClientIdentification.ClientCapabilities.HdcpVersion
|
||||||
|
MAX_HDCP_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
OEM_CRYPTO_API_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
RESOURCE_RATING_TIER_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
RSA_2048: ClientIdentification.ClientCapabilities.CertificateKeyType
|
||||||
|
RSA_3072: ClientIdentification.ClientCapabilities.CertificateKeyType
|
||||||
|
SESSION_TOKEN_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SRM_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SUPPORTED_CERTIFICATE_KEY_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
VIDEO_RESOLUTION_CONSTRAINTS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
analog_output_capabilities: ClientIdentification.ClientCapabilities.AnalogOutputCapabilities
|
||||||
|
anti_rollback_usage_table: bool
|
||||||
|
can_disable_analog_output: bool
|
||||||
|
can_update_srm: bool
|
||||||
|
client_token: bool
|
||||||
|
max_hdcp_version: ClientIdentification.ClientCapabilities.HdcpVersion
|
||||||
|
oem_crypto_api_version: int
|
||||||
|
resource_rating_tier: int
|
||||||
|
session_token: bool
|
||||||
|
srm_version: int
|
||||||
|
supported_certificate_key_type: _containers.RepeatedScalarFieldContainer[ClientIdentification.ClientCapabilities.CertificateKeyType]
|
||||||
|
video_resolution_constraints: bool
|
||||||
|
def __init__(self, client_token: bool = ..., session_token: bool = ..., video_resolution_constraints: bool = ..., max_hdcp_version: _Optional[_Union[ClientIdentification.ClientCapabilities.HdcpVersion, str]] = ..., oem_crypto_api_version: _Optional[int] = ..., anti_rollback_usage_table: bool = ..., srm_version: _Optional[int] = ..., can_update_srm: bool = ..., supported_certificate_key_type: _Optional[_Iterable[_Union[ClientIdentification.ClientCapabilities.CertificateKeyType, str]]] = ..., analog_output_capabilities: _Optional[_Union[ClientIdentification.ClientCapabilities.AnalogOutputCapabilities, str]] = ..., can_disable_analog_output: bool = ..., resource_rating_tier: _Optional[int] = ...) -> None: ...
|
||||||
|
class ClientCredentials(_message.Message):
|
||||||
|
__slots__ = ["token", "type"]
|
||||||
|
TOKEN_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
token: bytes
|
||||||
|
type: ClientIdentification.TokenType
|
||||||
|
def __init__(self, type: _Optional[_Union[ClientIdentification.TokenType, str]] = ..., token: _Optional[bytes] = ...) -> None: ...
|
||||||
|
class NameValue(_message.Message):
|
||||||
|
__slots__ = ["name", "value"]
|
||||||
|
NAME_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
VALUE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
name: str
|
||||||
|
value: str
|
||||||
|
def __init__(self, name: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ...
|
||||||
|
CLIENT_CAPABILITIES_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
CLIENT_INFO_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
DEVICE_CREDENTIALS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
DRM_DEVICE_CERTIFICATE: ClientIdentification.TokenType
|
||||||
|
KEYBOX: ClientIdentification.TokenType
|
||||||
|
LICENSE_COUNTER_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
OEM_DEVICE_CERTIFICATE: ClientIdentification.TokenType
|
||||||
|
PROVIDER_CLIENT_TOKEN_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
REMOTE_ATTESTATION_CERTIFICATE: ClientIdentification.TokenType
|
||||||
|
TOKEN_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
VMP_DATA_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
client_capabilities: ClientIdentification.ClientCapabilities
|
||||||
|
client_info: _containers.RepeatedCompositeFieldContainer[ClientIdentification.NameValue]
|
||||||
|
device_credentials: _containers.RepeatedCompositeFieldContainer[ClientIdentification.ClientCredentials]
|
||||||
|
license_counter: int
|
||||||
|
provider_client_token: bytes
|
||||||
|
token: bytes
|
||||||
|
type: ClientIdentification.TokenType
|
||||||
|
vmp_data: bytes
|
||||||
|
def __init__(self, type: _Optional[_Union[ClientIdentification.TokenType, str]] = ..., token: _Optional[bytes] = ..., client_info: _Optional[_Iterable[_Union[ClientIdentification.NameValue, _Mapping]]] = ..., provider_client_token: _Optional[bytes] = ..., license_counter: _Optional[int] = ..., client_capabilities: _Optional[_Union[ClientIdentification.ClientCapabilities, _Mapping]] = ..., vmp_data: _Optional[bytes] = ..., device_credentials: _Optional[_Iterable[_Union[ClientIdentification.ClientCredentials, _Mapping]]] = ...) -> None: ...
|
||||||
|
|
||||||
|
class DrmCertificate(_message.Message):
|
||||||
|
__slots__ = ["algorithm", "creation_time_seconds", "encryption_key", "expiration_time_seconds", "provider_id", "public_key", "rot_id", "serial_number", "service_types", "system_id", "test_device_deprecated", "type"]
|
||||||
|
class Algorithm(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
class ServiceType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
class Type(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
class EncryptionKey(_message.Message):
|
||||||
|
__slots__ = ["algorithm", "public_key"]
|
||||||
|
ALGORITHM_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
PUBLIC_KEY_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
algorithm: DrmCertificate.Algorithm
|
||||||
|
public_key: bytes
|
||||||
|
def __init__(self, public_key: _Optional[bytes] = ..., algorithm: _Optional[_Union[DrmCertificate.Algorithm, str]] = ...) -> None: ...
|
||||||
|
ALGORITHM_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
CAS_PROXY_SDK: DrmCertificate.ServiceType
|
||||||
|
CREATION_TIME_SECONDS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
DEVICE: DrmCertificate.Type
|
||||||
|
DEVICE_MODEL: DrmCertificate.Type
|
||||||
|
ECC_SECP256R1: DrmCertificate.Algorithm
|
||||||
|
ECC_SECP384R1: DrmCertificate.Algorithm
|
||||||
|
ECC_SECP521R1: DrmCertificate.Algorithm
|
||||||
|
ENCRYPTION_KEY_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
EXPIRATION_TIME_SECONDS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
LICENSE_SERVER_PROXY_SDK: DrmCertificate.ServiceType
|
||||||
|
LICENSE_SERVER_SDK: DrmCertificate.ServiceType
|
||||||
|
PROVIDER_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
PROVISIONER: DrmCertificate.Type
|
||||||
|
PROVISIONING_SDK: DrmCertificate.ServiceType
|
||||||
|
PUBLIC_KEY_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
ROOT: DrmCertificate.Type
|
||||||
|
ROT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
RSA: DrmCertificate.Algorithm
|
||||||
|
SERIAL_NUMBER_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SERVICE: DrmCertificate.Type
|
||||||
|
SERVICE_TYPES_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SYSTEM_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
TEST_DEVICE_DEPRECATED_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
UNKNOWN_ALGORITHM: DrmCertificate.Algorithm
|
||||||
|
UNKNOWN_SERVICE_TYPE: DrmCertificate.ServiceType
|
||||||
|
algorithm: DrmCertificate.Algorithm
|
||||||
|
creation_time_seconds: int
|
||||||
|
encryption_key: DrmCertificate.EncryptionKey
|
||||||
|
expiration_time_seconds: int
|
||||||
|
provider_id: str
|
||||||
|
public_key: bytes
|
||||||
|
rot_id: bytes
|
||||||
|
serial_number: bytes
|
||||||
|
service_types: _containers.RepeatedScalarFieldContainer[DrmCertificate.ServiceType]
|
||||||
|
system_id: int
|
||||||
|
test_device_deprecated: bool
|
||||||
|
type: DrmCertificate.Type
|
||||||
|
def __init__(self, type: _Optional[_Union[DrmCertificate.Type, str]] = ..., serial_number: _Optional[bytes] = ..., creation_time_seconds: _Optional[int] = ..., expiration_time_seconds: _Optional[int] = ..., public_key: _Optional[bytes] = ..., system_id: _Optional[int] = ..., test_device_deprecated: bool = ..., provider_id: _Optional[str] = ..., service_types: _Optional[_Iterable[_Union[DrmCertificate.ServiceType, str]]] = ..., algorithm: _Optional[_Union[DrmCertificate.Algorithm, str]] = ..., rot_id: _Optional[bytes] = ..., encryption_key: _Optional[_Union[DrmCertificate.EncryptionKey, _Mapping]] = ...) -> None: ...
|
||||||
|
|
||||||
|
class EncryptedClientIdentification(_message.Message):
|
||||||
|
__slots__ = ["encrypted_client_id", "encrypted_client_id_iv", "encrypted_privacy_key", "provider_id", "service_certificate_serial_number"]
|
||||||
|
ENCRYPTED_CLIENT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
ENCRYPTED_CLIENT_ID_IV_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
ENCRYPTED_PRIVACY_KEY_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
PROVIDER_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SERVICE_CERTIFICATE_SERIAL_NUMBER_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
encrypted_client_id: bytes
|
||||||
|
encrypted_client_id_iv: bytes
|
||||||
|
encrypted_privacy_key: bytes
|
||||||
|
provider_id: str
|
||||||
|
service_certificate_serial_number: bytes
|
||||||
|
def __init__(self, provider_id: _Optional[str] = ..., service_certificate_serial_number: _Optional[bytes] = ..., encrypted_client_id: _Optional[bytes] = ..., encrypted_client_id_iv: _Optional[bytes] = ..., encrypted_privacy_key: _Optional[bytes] = ...) -> None: ...
|
||||||
|
|
||||||
|
class FileHashes(_message.Message):
|
||||||
|
__slots__ = ["signatures", "signer"]
|
||||||
|
class Signature(_message.Message):
|
||||||
|
__slots__ = ["SHA512Hash", "filename", "main_exe", "signature", "test_signing"]
|
||||||
|
FILENAME_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
MAIN_EXE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SHA512HASH_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SHA512Hash: bytes
|
||||||
|
SIGNATURE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
TEST_SIGNING_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
filename: str
|
||||||
|
main_exe: bool
|
||||||
|
signature: bytes
|
||||||
|
test_signing: bool
|
||||||
|
def __init__(self, filename: _Optional[str] = ..., test_signing: bool = ..., SHA512Hash: _Optional[bytes] = ..., main_exe: bool = ..., signature: _Optional[bytes] = ...) -> None: ...
|
||||||
|
SIGNATURES_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SIGNER_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
signatures: _containers.RepeatedCompositeFieldContainer[FileHashes.Signature]
|
||||||
|
signer: bytes
|
||||||
|
def __init__(self, signer: _Optional[bytes] = ..., signatures: _Optional[_Iterable[_Union[FileHashes.Signature, _Mapping]]] = ...) -> None: ...
|
||||||
|
|
||||||
|
class License(_message.Message):
|
||||||
|
__slots__ = ["group_ids", "id", "key", "license_start_time", "platform_verification_status", "policy", "protection_scheme", "provider_client_token", "remote_attestation_verified", "srm_requirement", "srm_update"]
|
||||||
|
class KeyContainer(_message.Message):
|
||||||
|
__slots__ = ["anti_rollback_usage_table", "id", "iv", "key", "key_control", "level", "operator_session_key_permissions", "requested_protection", "required_protection", "track_label", "type", "video_resolution_constraints"]
|
||||||
|
class KeyType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
class SecurityLevel(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
class KeyControl(_message.Message):
|
||||||
|
__slots__ = ["iv", "key_control_block"]
|
||||||
|
IV_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
KEY_CONTROL_BLOCK_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
iv: bytes
|
||||||
|
key_control_block: bytes
|
||||||
|
def __init__(self, key_control_block: _Optional[bytes] = ..., iv: _Optional[bytes] = ...) -> None: ...
|
||||||
|
class OperatorSessionKeyPermissions(_message.Message):
|
||||||
|
__slots__ = ["allow_decrypt", "allow_encrypt", "allow_sign", "allow_signature_verify"]
|
||||||
|
ALLOW_DECRYPT_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
ALLOW_ENCRYPT_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
ALLOW_SIGNATURE_VERIFY_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
ALLOW_SIGN_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
allow_decrypt: bool
|
||||||
|
allow_encrypt: bool
|
||||||
|
allow_sign: bool
|
||||||
|
allow_signature_verify: bool
|
||||||
|
def __init__(self, allow_encrypt: bool = ..., allow_decrypt: bool = ..., allow_sign: bool = ..., allow_signature_verify: bool = ...) -> None: ...
|
||||||
|
class OutputProtection(_message.Message):
|
||||||
|
__slots__ = ["cgms_flags", "disable_analog_output", "disable_digital_output", "hdcp", "hdcp_srm_rule"]
|
||||||
|
class CGMS(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
class HDCP(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
class HdcpSrmRule(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
CGMS_FLAGS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
CGMS_NONE: License.KeyContainer.OutputProtection.CGMS
|
||||||
|
COPY_FREE: License.KeyContainer.OutputProtection.CGMS
|
||||||
|
COPY_NEVER: License.KeyContainer.OutputProtection.CGMS
|
||||||
|
COPY_ONCE: License.KeyContainer.OutputProtection.CGMS
|
||||||
|
CURRENT_SRM: License.KeyContainer.OutputProtection.HdcpSrmRule
|
||||||
|
DISABLE_ANALOG_OUTPUT_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
DISABLE_DIGITAL_OUTPUT_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
HDCP_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
HDCP_NONE: License.KeyContainer.OutputProtection.HDCP
|
||||||
|
HDCP_NO_DIGITAL_OUTPUT: License.KeyContainer.OutputProtection.HDCP
|
||||||
|
HDCP_SRM_RULE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
HDCP_SRM_RULE_NONE: License.KeyContainer.OutputProtection.HdcpSrmRule
|
||||||
|
HDCP_V1: License.KeyContainer.OutputProtection.HDCP
|
||||||
|
HDCP_V2: License.KeyContainer.OutputProtection.HDCP
|
||||||
|
HDCP_V2_1: License.KeyContainer.OutputProtection.HDCP
|
||||||
|
HDCP_V2_2: License.KeyContainer.OutputProtection.HDCP
|
||||||
|
HDCP_V2_3: License.KeyContainer.OutputProtection.HDCP
|
||||||
|
cgms_flags: License.KeyContainer.OutputProtection.CGMS
|
||||||
|
disable_analog_output: bool
|
||||||
|
disable_digital_output: bool
|
||||||
|
hdcp: License.KeyContainer.OutputProtection.HDCP
|
||||||
|
hdcp_srm_rule: License.KeyContainer.OutputProtection.HdcpSrmRule
|
||||||
|
def __init__(self, hdcp: _Optional[_Union[License.KeyContainer.OutputProtection.HDCP, str]] = ..., cgms_flags: _Optional[_Union[License.KeyContainer.OutputProtection.CGMS, str]] = ..., hdcp_srm_rule: _Optional[_Union[License.KeyContainer.OutputProtection.HdcpSrmRule, str]] = ..., disable_analog_output: bool = ..., disable_digital_output: bool = ...) -> None: ...
|
||||||
|
class VideoResolutionConstraint(_message.Message):
|
||||||
|
__slots__ = ["max_resolution_pixels", "min_resolution_pixels", "required_protection"]
|
||||||
|
MAX_RESOLUTION_PIXELS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
MIN_RESOLUTION_PIXELS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
REQUIRED_PROTECTION_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
max_resolution_pixels: int
|
||||||
|
min_resolution_pixels: int
|
||||||
|
required_protection: License.KeyContainer.OutputProtection
|
||||||
|
def __init__(self, min_resolution_pixels: _Optional[int] = ..., max_resolution_pixels: _Optional[int] = ..., required_protection: _Optional[_Union[License.KeyContainer.OutputProtection, _Mapping]] = ...) -> None: ...
|
||||||
|
ANTI_ROLLBACK_USAGE_TABLE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
CONTENT: License.KeyContainer.KeyType
|
||||||
|
ENTITLEMENT: License.KeyContainer.KeyType
|
||||||
|
HW_SECURE_ALL: License.KeyContainer.SecurityLevel
|
||||||
|
HW_SECURE_CRYPTO: License.KeyContainer.SecurityLevel
|
||||||
|
HW_SECURE_DECODE: License.KeyContainer.SecurityLevel
|
||||||
|
ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
IV_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
KEY_CONTROL: License.KeyContainer.KeyType
|
||||||
|
KEY_CONTROL_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
KEY_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
LEVEL_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
OEM_CONTENT: License.KeyContainer.KeyType
|
||||||
|
OPERATOR_SESSION: License.KeyContainer.KeyType
|
||||||
|
OPERATOR_SESSION_KEY_PERMISSIONS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
REQUESTED_PROTECTION_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
REQUIRED_PROTECTION_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SIGNING: License.KeyContainer.KeyType
|
||||||
|
SW_SECURE_CRYPTO: License.KeyContainer.SecurityLevel
|
||||||
|
SW_SECURE_DECODE: License.KeyContainer.SecurityLevel
|
||||||
|
TRACK_LABEL_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
VIDEO_RESOLUTION_CONSTRAINTS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
anti_rollback_usage_table: bool
|
||||||
|
id: bytes
|
||||||
|
iv: bytes
|
||||||
|
key: bytes
|
||||||
|
key_control: License.KeyContainer.KeyControl
|
||||||
|
level: License.KeyContainer.SecurityLevel
|
||||||
|
operator_session_key_permissions: License.KeyContainer.OperatorSessionKeyPermissions
|
||||||
|
requested_protection: License.KeyContainer.OutputProtection
|
||||||
|
required_protection: License.KeyContainer.OutputProtection
|
||||||
|
track_label: str
|
||||||
|
type: License.KeyContainer.KeyType
|
||||||
|
video_resolution_constraints: _containers.RepeatedCompositeFieldContainer[License.KeyContainer.VideoResolutionConstraint]
|
||||||
|
def __init__(self, id: _Optional[bytes] = ..., iv: _Optional[bytes] = ..., key: _Optional[bytes] = ..., type: _Optional[_Union[License.KeyContainer.KeyType, str]] = ..., level: _Optional[_Union[License.KeyContainer.SecurityLevel, str]] = ..., required_protection: _Optional[_Union[License.KeyContainer.OutputProtection, _Mapping]] = ..., requested_protection: _Optional[_Union[License.KeyContainer.OutputProtection, _Mapping]] = ..., key_control: _Optional[_Union[License.KeyContainer.KeyControl, _Mapping]] = ..., operator_session_key_permissions: _Optional[_Union[License.KeyContainer.OperatorSessionKeyPermissions, _Mapping]] = ..., video_resolution_constraints: _Optional[_Iterable[_Union[License.KeyContainer.VideoResolutionConstraint, _Mapping]]] = ..., anti_rollback_usage_table: bool = ..., track_label: _Optional[str] = ...) -> None: ...
|
||||||
|
class Policy(_message.Message):
|
||||||
|
__slots__ = ["always_include_client_id", "can_persist", "can_play", "can_renew", "license_duration_seconds", "play_start_grace_period_seconds", "playback_duration_seconds", "renew_with_usage", "renewal_delay_seconds", "renewal_recovery_duration_seconds", "renewal_retry_interval_seconds", "renewal_server_url", "rental_duration_seconds", "soft_enforce_playback_duration", "soft_enforce_rental_duration"]
|
||||||
|
ALWAYS_INCLUDE_CLIENT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
CAN_PERSIST_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
CAN_PLAY_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
CAN_RENEW_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
LICENSE_DURATION_SECONDS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
PLAYBACK_DURATION_SECONDS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
PLAY_START_GRACE_PERIOD_SECONDS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
RENEWAL_DELAY_SECONDS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
RENEWAL_RECOVERY_DURATION_SECONDS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
RENEWAL_RETRY_INTERVAL_SECONDS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
RENEWAL_SERVER_URL_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
RENEW_WITH_USAGE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
RENTAL_DURATION_SECONDS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SOFT_ENFORCE_PLAYBACK_DURATION_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SOFT_ENFORCE_RENTAL_DURATION_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
always_include_client_id: bool
|
||||||
|
can_persist: bool
|
||||||
|
can_play: bool
|
||||||
|
can_renew: bool
|
||||||
|
license_duration_seconds: int
|
||||||
|
play_start_grace_period_seconds: int
|
||||||
|
playback_duration_seconds: int
|
||||||
|
renew_with_usage: bool
|
||||||
|
renewal_delay_seconds: int
|
||||||
|
renewal_recovery_duration_seconds: int
|
||||||
|
renewal_retry_interval_seconds: int
|
||||||
|
renewal_server_url: str
|
||||||
|
rental_duration_seconds: int
|
||||||
|
soft_enforce_playback_duration: bool
|
||||||
|
soft_enforce_rental_duration: bool
|
||||||
|
def __init__(self, can_play: bool = ..., can_persist: bool = ..., can_renew: bool = ..., rental_duration_seconds: _Optional[int] = ..., playback_duration_seconds: _Optional[int] = ..., license_duration_seconds: _Optional[int] = ..., renewal_recovery_duration_seconds: _Optional[int] = ..., renewal_server_url: _Optional[str] = ..., renewal_delay_seconds: _Optional[int] = ..., renewal_retry_interval_seconds: _Optional[int] = ..., renew_with_usage: bool = ..., always_include_client_id: bool = ..., play_start_grace_period_seconds: _Optional[int] = ..., soft_enforce_playback_duration: bool = ..., soft_enforce_rental_duration: bool = ...) -> None: ...
|
||||||
|
GROUP_IDS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
KEY_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
LICENSE_START_TIME_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
PLATFORM_VERIFICATION_STATUS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
POLICY_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
PROTECTION_SCHEME_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
PROVIDER_CLIENT_TOKEN_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
REMOTE_ATTESTATION_VERIFIED_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SRM_REQUIREMENT_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SRM_UPDATE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
group_ids: _containers.RepeatedScalarFieldContainer[bytes]
|
||||||
|
id: LicenseIdentification
|
||||||
|
key: _containers.RepeatedCompositeFieldContainer[License.KeyContainer]
|
||||||
|
license_start_time: int
|
||||||
|
platform_verification_status: PlatformVerificationStatus
|
||||||
|
policy: License.Policy
|
||||||
|
protection_scheme: int
|
||||||
|
provider_client_token: bytes
|
||||||
|
remote_attestation_verified: bool
|
||||||
|
srm_requirement: bytes
|
||||||
|
srm_update: bytes
|
||||||
|
def __init__(self, id: _Optional[_Union[LicenseIdentification, _Mapping]] = ..., policy: _Optional[_Union[License.Policy, _Mapping]] = ..., key: _Optional[_Iterable[_Union[License.KeyContainer, _Mapping]]] = ..., license_start_time: _Optional[int] = ..., remote_attestation_verified: bool = ..., provider_client_token: _Optional[bytes] = ..., protection_scheme: _Optional[int] = ..., srm_requirement: _Optional[bytes] = ..., srm_update: _Optional[bytes] = ..., platform_verification_status: _Optional[_Union[PlatformVerificationStatus, str]] = ..., group_ids: _Optional[_Iterable[bytes]] = ...) -> None: ...
|
||||||
|
|
||||||
|
class LicenseIdentification(_message.Message):
|
||||||
|
__slots__ = ["provider_session_token", "purchase_id", "request_id", "session_id", "type", "version"]
|
||||||
|
PROVIDER_SESSION_TOKEN_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
PURCHASE_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
REQUEST_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SESSION_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
provider_session_token: bytes
|
||||||
|
purchase_id: bytes
|
||||||
|
request_id: bytes
|
||||||
|
session_id: bytes
|
||||||
|
type: LicenseType
|
||||||
|
version: int
|
||||||
|
def __init__(self, request_id: _Optional[bytes] = ..., session_id: _Optional[bytes] = ..., purchase_id: _Optional[bytes] = ..., type: _Optional[_Union[LicenseType, str]] = ..., version: _Optional[int] = ..., provider_session_token: _Optional[bytes] = ...) -> None: ...
|
||||||
|
|
||||||
|
class LicenseRequest(_message.Message):
|
||||||
|
__slots__ = ["client_id", "content_id", "encrypted_client_id", "key_control_nonce", "key_control_nonce_deprecated", "protocol_version", "request_time", "type"]
|
||||||
|
class RequestType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
class ContentIdentification(_message.Message):
|
||||||
|
__slots__ = ["existing_license", "init_data", "webm_key_id", "widevine_pssh_data"]
|
||||||
|
class ExistingLicense(_message.Message):
|
||||||
|
__slots__ = ["license_id", "seconds_since_last_played", "seconds_since_started", "session_usage_table_entry"]
|
||||||
|
LICENSE_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SECONDS_SINCE_LAST_PLAYED_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SECONDS_SINCE_STARTED_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SESSION_USAGE_TABLE_ENTRY_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
license_id: LicenseIdentification
|
||||||
|
seconds_since_last_played: int
|
||||||
|
seconds_since_started: int
|
||||||
|
session_usage_table_entry: bytes
|
||||||
|
def __init__(self, license_id: _Optional[_Union[LicenseIdentification, _Mapping]] = ..., seconds_since_started: _Optional[int] = ..., seconds_since_last_played: _Optional[int] = ..., session_usage_table_entry: _Optional[bytes] = ...) -> None: ...
|
||||||
|
class InitData(_message.Message):
|
||||||
|
__slots__ = ["init_data", "init_data_type", "license_type", "request_id"]
|
||||||
|
class InitDataType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
CENC: LicenseRequest.ContentIdentification.InitData.InitDataType
|
||||||
|
INIT_DATA_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
INIT_DATA_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
LICENSE_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
REQUEST_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
WEBM: LicenseRequest.ContentIdentification.InitData.InitDataType
|
||||||
|
init_data: bytes
|
||||||
|
init_data_type: LicenseRequest.ContentIdentification.InitData.InitDataType
|
||||||
|
license_type: LicenseType
|
||||||
|
request_id: bytes
|
||||||
|
def __init__(self, init_data_type: _Optional[_Union[LicenseRequest.ContentIdentification.InitData.InitDataType, str]] = ..., init_data: _Optional[bytes] = ..., license_type: _Optional[_Union[LicenseType, str]] = ..., request_id: _Optional[bytes] = ...) -> None: ...
|
||||||
|
class WebmKeyId(_message.Message):
|
||||||
|
__slots__ = ["header", "license_type", "request_id"]
|
||||||
|
HEADER_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
LICENSE_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
REQUEST_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
header: bytes
|
||||||
|
license_type: LicenseType
|
||||||
|
request_id: bytes
|
||||||
|
def __init__(self, header: _Optional[bytes] = ..., license_type: _Optional[_Union[LicenseType, str]] = ..., request_id: _Optional[bytes] = ...) -> None: ...
|
||||||
|
class WidevinePsshData(_message.Message):
|
||||||
|
__slots__ = ["license_type", "pssh_data", "request_id"]
|
||||||
|
LICENSE_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
PSSH_DATA_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
REQUEST_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
license_type: LicenseType
|
||||||
|
pssh_data: _containers.RepeatedScalarFieldContainer[bytes]
|
||||||
|
request_id: bytes
|
||||||
|
def __init__(self, pssh_data: _Optional[_Iterable[bytes]] = ..., license_type: _Optional[_Union[LicenseType, str]] = ..., request_id: _Optional[bytes] = ...) -> None: ...
|
||||||
|
EXISTING_LICENSE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
INIT_DATA_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
WEBM_KEY_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
WIDEVINE_PSSH_DATA_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
existing_license: LicenseRequest.ContentIdentification.ExistingLicense
|
||||||
|
init_data: LicenseRequest.ContentIdentification.InitData
|
||||||
|
webm_key_id: LicenseRequest.ContentIdentification.WebmKeyId
|
||||||
|
widevine_pssh_data: LicenseRequest.ContentIdentification.WidevinePsshData
|
||||||
|
def __init__(self, widevine_pssh_data: _Optional[_Union[LicenseRequest.ContentIdentification.WidevinePsshData, _Mapping]] = ..., webm_key_id: _Optional[_Union[LicenseRequest.ContentIdentification.WebmKeyId, _Mapping]] = ..., existing_license: _Optional[_Union[LicenseRequest.ContentIdentification.ExistingLicense, _Mapping]] = ..., init_data: _Optional[_Union[LicenseRequest.ContentIdentification.InitData, _Mapping]] = ...) -> None: ...
|
||||||
|
CLIENT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
CONTENT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
ENCRYPTED_CLIENT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
KEY_CONTROL_NONCE_DEPRECATED_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
KEY_CONTROL_NONCE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
NEW: LicenseRequest.RequestType
|
||||||
|
PROTOCOL_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
RELEASE: LicenseRequest.RequestType
|
||||||
|
RENEWAL: LicenseRequest.RequestType
|
||||||
|
REQUEST_TIME_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
client_id: ClientIdentification
|
||||||
|
content_id: LicenseRequest.ContentIdentification
|
||||||
|
encrypted_client_id: EncryptedClientIdentification
|
||||||
|
key_control_nonce: int
|
||||||
|
key_control_nonce_deprecated: bytes
|
||||||
|
protocol_version: ProtocolVersion
|
||||||
|
request_time: int
|
||||||
|
type: LicenseRequest.RequestType
|
||||||
|
def __init__(self, client_id: _Optional[_Union[ClientIdentification, _Mapping]] = ..., content_id: _Optional[_Union[LicenseRequest.ContentIdentification, _Mapping]] = ..., type: _Optional[_Union[LicenseRequest.RequestType, str]] = ..., request_time: _Optional[int] = ..., key_control_nonce_deprecated: _Optional[bytes] = ..., protocol_version: _Optional[_Union[ProtocolVersion, str]] = ..., key_control_nonce: _Optional[int] = ..., encrypted_client_id: _Optional[_Union[EncryptedClientIdentification, _Mapping]] = ...) -> None: ...
|
||||||
|
|
||||||
|
class MetricData(_message.Message):
|
||||||
|
__slots__ = ["metric_data", "stage_name"]
|
||||||
|
class MetricType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
class TypeValue(_message.Message):
|
||||||
|
__slots__ = ["type", "value"]
|
||||||
|
TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
VALUE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
type: MetricData.MetricType
|
||||||
|
value: int
|
||||||
|
def __init__(self, type: _Optional[_Union[MetricData.MetricType, str]] = ..., value: _Optional[int] = ...) -> None: ...
|
||||||
|
LATENCY: MetricData.MetricType
|
||||||
|
METRIC_DATA_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
STAGE_NAME_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
TIMESTAMP: MetricData.MetricType
|
||||||
|
metric_data: _containers.RepeatedCompositeFieldContainer[MetricData.TypeValue]
|
||||||
|
stage_name: str
|
||||||
|
def __init__(self, stage_name: _Optional[str] = ..., metric_data: _Optional[_Iterable[_Union[MetricData.TypeValue, _Mapping]]] = ...) -> None: ...
|
||||||
|
|
||||||
|
class SignedDrmCertificate(_message.Message):
|
||||||
|
__slots__ = ["drm_certificate", "hash_algorithm", "signature", "signer"]
|
||||||
|
DRM_CERTIFICATE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
HASH_ALGORITHM_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SIGNATURE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SIGNER_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
drm_certificate: bytes
|
||||||
|
hash_algorithm: HashAlgorithmProto
|
||||||
|
signature: bytes
|
||||||
|
signer: SignedDrmCertificate
|
||||||
|
def __init__(self, drm_certificate: _Optional[bytes] = ..., signature: _Optional[bytes] = ..., signer: _Optional[_Union[SignedDrmCertificate, _Mapping]] = ..., hash_algorithm: _Optional[_Union[HashAlgorithmProto, str]] = ...) -> None: ...
|
||||||
|
|
||||||
|
class SignedMessage(_message.Message):
|
||||||
|
__slots__ = ["metric_data", "msg", "oemcrypto_core_message", "remote_attestation", "service_version_info", "session_key", "session_key_type", "signature", "type"]
|
||||||
|
class MessageType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
class SessionKeyType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
CAS_LICENSE: SignedMessage.MessageType
|
||||||
|
CAS_LICENSE_REQUEST: SignedMessage.MessageType
|
||||||
|
EPHERMERAL_ECC_PUBLIC_KEY: SignedMessage.SessionKeyType
|
||||||
|
ERROR_RESPONSE: SignedMessage.MessageType
|
||||||
|
EXTERNAL_LICENSE: SignedMessage.MessageType
|
||||||
|
EXTERNAL_LICENSE_REQUEST: SignedMessage.MessageType
|
||||||
|
LICENSE: SignedMessage.MessageType
|
||||||
|
LICENSE_REQUEST: SignedMessage.MessageType
|
||||||
|
METRIC_DATA_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
MSG_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
OEMCRYPTO_CORE_MESSAGE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
REMOTE_ATTESTATION_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SERVICE_CERTIFICATE: SignedMessage.MessageType
|
||||||
|
SERVICE_CERTIFICATE_REQUEST: SignedMessage.MessageType
|
||||||
|
SERVICE_VERSION_INFO_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SESSION_KEY_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SESSION_KEY_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SIGNATURE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SUB_LICENSE: SignedMessage.MessageType
|
||||||
|
TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
UNDEFINED: SignedMessage.SessionKeyType
|
||||||
|
WRAPPED_AES_KEY: SignedMessage.SessionKeyType
|
||||||
|
metric_data: _containers.RepeatedCompositeFieldContainer[MetricData]
|
||||||
|
msg: bytes
|
||||||
|
oemcrypto_core_message: bytes
|
||||||
|
remote_attestation: bytes
|
||||||
|
service_version_info: VersionInfo
|
||||||
|
session_key: bytes
|
||||||
|
session_key_type: SignedMessage.SessionKeyType
|
||||||
|
signature: bytes
|
||||||
|
type: SignedMessage.MessageType
|
||||||
|
def __init__(self, type: _Optional[_Union[SignedMessage.MessageType, str]] = ..., msg: _Optional[bytes] = ..., signature: _Optional[bytes] = ..., session_key: _Optional[bytes] = ..., remote_attestation: _Optional[bytes] = ..., metric_data: _Optional[_Iterable[_Union[MetricData, _Mapping]]] = ..., service_version_info: _Optional[_Union[VersionInfo, _Mapping]] = ..., session_key_type: _Optional[_Union[SignedMessage.SessionKeyType, str]] = ..., oemcrypto_core_message: _Optional[bytes] = ...) -> None: ...
|
||||||
|
|
||||||
|
class VersionInfo(_message.Message):
|
||||||
|
__slots__ = ["license_sdk_version", "license_service_version"]
|
||||||
|
LICENSE_SDK_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
LICENSE_SERVICE_VERSION_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
license_sdk_version: str
|
||||||
|
license_service_version: str
|
||||||
|
def __init__(self, license_sdk_version: _Optional[str] = ..., license_service_version: _Optional[str] = ...) -> None: ...
|
||||||
|
|
||||||
|
class WidevinePsshData(_message.Message):
|
||||||
|
__slots__ = ["algorithm", "content_id", "crypto_period_index", "crypto_period_seconds", "entitled_keys", "group_ids", "grouped_license", "key_ids", "key_sequence", "policy", "protection_scheme", "provider", "track_type", "type", "video_feature"]
|
||||||
|
class Algorithm(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
class Type(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
class EntitledKey(_message.Message):
|
||||||
|
__slots__ = ["entitlement_key_id", "entitlement_key_size_bytes", "iv", "key", "key_id"]
|
||||||
|
ENTITLEMENT_KEY_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
ENTITLEMENT_KEY_SIZE_BYTES_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
IV_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
KEY_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
KEY_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
entitlement_key_id: bytes
|
||||||
|
entitlement_key_size_bytes: int
|
||||||
|
iv: bytes
|
||||||
|
key: bytes
|
||||||
|
key_id: bytes
|
||||||
|
def __init__(self, entitlement_key_id: _Optional[bytes] = ..., key_id: _Optional[bytes] = ..., key: _Optional[bytes] = ..., iv: _Optional[bytes] = ..., entitlement_key_size_bytes: _Optional[int] = ...) -> None: ...
|
||||||
|
AESCTR: WidevinePsshData.Algorithm
|
||||||
|
ALGORITHM_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
CONTENT_ID_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
CRYPTO_PERIOD_INDEX_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
CRYPTO_PERIOD_SECONDS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
ENTITLED_KEY: WidevinePsshData.Type
|
||||||
|
ENTITLED_KEYS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
ENTITLEMENT: WidevinePsshData.Type
|
||||||
|
GROUPED_LICENSE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
GROUP_IDS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
KEY_IDS_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
KEY_SEQUENCE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
POLICY_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
PROTECTION_SCHEME_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
PROVIDER_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
SINGLE: WidevinePsshData.Type
|
||||||
|
TRACK_TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
TYPE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
UNENCRYPTED: WidevinePsshData.Algorithm
|
||||||
|
VIDEO_FEATURE_FIELD_NUMBER: _ClassVar[int]
|
||||||
|
algorithm: WidevinePsshData.Algorithm
|
||||||
|
content_id: bytes
|
||||||
|
crypto_period_index: int
|
||||||
|
crypto_period_seconds: int
|
||||||
|
entitled_keys: _containers.RepeatedCompositeFieldContainer[WidevinePsshData.EntitledKey]
|
||||||
|
group_ids: _containers.RepeatedScalarFieldContainer[bytes]
|
||||||
|
grouped_license: bytes
|
||||||
|
key_ids: _containers.RepeatedScalarFieldContainer[bytes]
|
||||||
|
key_sequence: int
|
||||||
|
policy: str
|
||||||
|
protection_scheme: int
|
||||||
|
provider: str
|
||||||
|
track_type: str
|
||||||
|
type: WidevinePsshData.Type
|
||||||
|
video_feature: str
|
||||||
|
def __init__(self, key_ids: _Optional[_Iterable[bytes]] = ..., content_id: _Optional[bytes] = ..., crypto_period_index: _Optional[int] = ..., protection_scheme: _Optional[int] = ..., crypto_period_seconds: _Optional[int] = ..., type: _Optional[_Union[WidevinePsshData.Type, str]] = ..., key_sequence: _Optional[int] = ..., group_ids: _Optional[_Iterable[bytes]] = ..., entitled_keys: _Optional[_Iterable[_Union[WidevinePsshData.EntitledKey, _Mapping]]] = ..., video_feature: _Optional[str] = ..., algorithm: _Optional[_Union[WidevinePsshData.Algorithm, str]] = ..., provider: _Optional[str] = ..., track_type: _Optional[str] = ..., policy: _Optional[str] = ..., grouped_license: _Optional[bytes] = ...) -> None: ...
|
||||||
|
|
||||||
|
class LicenseType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
|
||||||
|
class PlatformVerificationStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
|
||||||
|
class ProtocolVersion(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
|
|
||||||
|
class HashAlgorithmProto(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||||
|
__slots__ = []
|
||||||
398
pywidevine/main.py
Normal file
398
pywidevine/main.py
Normal file
@ -0,0 +1,398 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from zlib import crc32
|
||||||
|
|
||||||
|
import click
|
||||||
|
import requests
|
||||||
|
import yaml
|
||||||
|
from construct import ConstructError
|
||||||
|
from google.protobuf.json_format import MessageToDict
|
||||||
|
from unidecode import UnidecodeError, unidecode
|
||||||
|
|
||||||
|
from pywidevine import __version__
|
||||||
|
from pywidevine.cdm import Cdm
|
||||||
|
from pywidevine.device import Device, DeviceTypes
|
||||||
|
from pywidevine.license_protocol_pb2 import FileHashes, LicenseType
|
||||||
|
from pywidevine.pssh import PSSH
|
||||||
|
|
||||||
|
|
||||||
|
@click.group(invoke_without_command=True)
|
||||||
|
@click.option("-v", "--version", is_flag=True, default=False, help="Print version information.")
|
||||||
|
@click.option("-d", "--debug", is_flag=True, default=False, help="Enable DEBUG level logs.")
|
||||||
|
def main(version: bool, debug: bool) -> None:
|
||||||
|
"""pywidevine—Python Widevine CDM implementation."""
|
||||||
|
logging.basicConfig(level=logging.DEBUG if debug else logging.INFO)
|
||||||
|
log = logging.getLogger()
|
||||||
|
|
||||||
|
current_year = datetime.now().year
|
||||||
|
copyright_years = f"2022-{current_year}"
|
||||||
|
|
||||||
|
log.info("pywidevine version %s Copyright (c) %s rlaphoenix, DevLARLEY", __version__, copyright_years)
|
||||||
|
log.info("https://github.com/devine-dl/pywidevine")
|
||||||
|
if version:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@main.command(name="license")
|
||||||
|
@click.argument("device_path", type=Path)
|
||||||
|
@click.argument("pssh", type=PSSH)
|
||||||
|
@click.argument("server", type=str)
|
||||||
|
@click.option("-t", "--type", "license_type", type=click.Choice(LicenseType.keys(), case_sensitive=False),
|
||||||
|
default="STREAMING",
|
||||||
|
help="License Type to Request.")
|
||||||
|
@click.option("-p", "--privacy", is_flag=True, default=False,
|
||||||
|
help="Use Privacy Mode, off by default.")
|
||||||
|
def license_(device_path: Path, pssh: PSSH, server: str, license_type: str, privacy: bool) -> None:
|
||||||
|
"""
|
||||||
|
Make a License Request for PSSH to SERVER using DEVICE.
|
||||||
|
It will return a list of all keys within the returned license.
|
||||||
|
|
||||||
|
This expects the Licence Server to be a simple opaque interface where the Challenge
|
||||||
|
is sent as is (as bytes), and the License response is returned as is (as bytes).
|
||||||
|
This is a common behavior for some License Servers and is our only option for a generic
|
||||||
|
licensing function.
|
||||||
|
|
||||||
|
You may modify this function to change how it sends the Challenge and how it parses
|
||||||
|
the License response. However, for non-generic license calls, I recommend creating a
|
||||||
|
new script that imports and uses the pywidevine module instead. This generic function
|
||||||
|
is only useful as a quick generic license call.
|
||||||
|
|
||||||
|
This is also a great way of showing you how to use pywidevine in your own projects.
|
||||||
|
"""
|
||||||
|
log = logging.getLogger("license")
|
||||||
|
|
||||||
|
# load device
|
||||||
|
device = Device.load(device_path)
|
||||||
|
log.info("[+] Loaded Device (%s L%s)", device.system_id, device.security_level)
|
||||||
|
log.debug(device)
|
||||||
|
|
||||||
|
# load cdm
|
||||||
|
cdm = Cdm.from_device(device)
|
||||||
|
log.info("[+] Loaded CDM")
|
||||||
|
log.debug(cdm)
|
||||||
|
|
||||||
|
# open cdm session
|
||||||
|
session_id = cdm.open()
|
||||||
|
log.info("[+] Opened CDM Session: %s", session_id.hex())
|
||||||
|
|
||||||
|
if privacy:
|
||||||
|
# get service cert for license server via cert challenge
|
||||||
|
service_cert_res = requests.post(
|
||||||
|
url=server,
|
||||||
|
data=cdm.service_certificate_challenge
|
||||||
|
)
|
||||||
|
if service_cert_res.status_code != 200:
|
||||||
|
log.error(
|
||||||
|
"[-] Failed to get Service Privacy Certificate: [%s] %s",
|
||||||
|
service_cert_res.status_code,
|
||||||
|
service_cert_res.text
|
||||||
|
)
|
||||||
|
return
|
||||||
|
service_cert = service_cert_res.content
|
||||||
|
provider_id = cdm.set_service_certificate(session_id, service_cert)
|
||||||
|
log.info("[+] Set Service Privacy Certificate: %s", provider_id)
|
||||||
|
log.debug(service_cert)
|
||||||
|
|
||||||
|
# get license challenge
|
||||||
|
challenge = cdm.get_license_challenge(session_id, pssh, license_type, privacy_mode=True)
|
||||||
|
log.info("[+] Created License Request Message (Challenge)")
|
||||||
|
log.debug(challenge)
|
||||||
|
|
||||||
|
# send license challenge
|
||||||
|
license_res = requests.post(
|
||||||
|
url=server,
|
||||||
|
data=challenge
|
||||||
|
)
|
||||||
|
if license_res.status_code != 200:
|
||||||
|
log.error("[-] Failed to send challenge: [%s] %s", license_res.status_code, license_res.text)
|
||||||
|
return
|
||||||
|
licence = license_res.content
|
||||||
|
log.info("[+] Got License Message")
|
||||||
|
log.debug(licence)
|
||||||
|
|
||||||
|
# parse license challenge
|
||||||
|
cdm.parse_license(session_id, licence)
|
||||||
|
log.info("[+] License Parsed Successfully")
|
||||||
|
|
||||||
|
# print keys
|
||||||
|
for key in cdm.get_keys(session_id):
|
||||||
|
log.info("[%s] %s:%s", key.type, key.kid.hex, key.key.hex())
|
||||||
|
|
||||||
|
# close session, disposes of session data
|
||||||
|
cdm.close(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
@click.argument("device", type=Path)
|
||||||
|
@click.option("-p", "--privacy", is_flag=True, default=False,
|
||||||
|
help="Use Privacy Mode, off by default.")
|
||||||
|
@click.pass_context
|
||||||
|
def test(ctx: click.Context, device: Path, privacy: bool) -> None:
|
||||||
|
"""
|
||||||
|
Test the CDM code by getting Content Keys for Bitmovin's Art of Motion example.
|
||||||
|
https://bitmovin.com/demos/drm
|
||||||
|
https://bitmovin-a.akamaihd.net/content/art-of-motion_drm/mpds/11331.mpd
|
||||||
|
|
||||||
|
The device argument is a Path to a Widevine Device (.wvd) file which contains
|
||||||
|
the device private key among other required information.
|
||||||
|
"""
|
||||||
|
# The PSSH is the same for all tracks both video and audio.
|
||||||
|
# However, this might not be the case for all services/manifests.
|
||||||
|
pssh = PSSH("AAAAW3Bzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAADsIARIQ62dqu8s0Xpa"
|
||||||
|
"7z2FmMPGj2hoNd2lkZXZpbmVfdGVzdCIQZmtqM2xqYVNkZmFsa3IzaioCSEQyAA==")
|
||||||
|
|
||||||
|
# This License Server requires no authorization at all, no cookies, no credentials
|
||||||
|
# nothing. This is often not the case for real services.
|
||||||
|
license_server = "https://cwip-shaka-proxy.appspot.com/no_auth"
|
||||||
|
|
||||||
|
# Specify OFFLINE if it's a PSSH for a download/offline mode title, e.g., the
|
||||||
|
# Download feature on Netflix Apps. Otherwise, use STREAMING or AUTOMATIC.
|
||||||
|
license_type = "STREAMING"
|
||||||
|
|
||||||
|
# this runs the `cdm license` CLI-command code with the data we set above
|
||||||
|
# it will print information as it goes to the terminal
|
||||||
|
ctx.invoke(
|
||||||
|
license_,
|
||||||
|
device_path=device,
|
||||||
|
pssh=pssh,
|
||||||
|
server=license_server,
|
||||||
|
license_type=license_type,
|
||||||
|
privacy=privacy
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
@click.option("-t", "--type", "type_", type=click.Choice([x.name for x in DeviceTypes], case_sensitive=False),
|
||||||
|
required=True, help="Device Type")
|
||||||
|
@click.option("-l", "--level", type=click.IntRange(1, 3), required=True, help="Device Security Level")
|
||||||
|
@click.option("-k", "--key", type=Path, required=True, help="Device RSA Private Key in PEM or DER format")
|
||||||
|
@click.option("-c", "--client_id", type=Path, required=True, help="Widevine ClientIdentification Blob file")
|
||||||
|
@click.option("-v", "--vmp", type=Path, default=None, help="Widevine FileHashes Blob file")
|
||||||
|
@click.option("-o", "--output", type=Path, default=None, help="Output Path or Directory")
|
||||||
|
@click.pass_context
|
||||||
|
def create_device(
|
||||||
|
ctx: click.Context,
|
||||||
|
type_: str,
|
||||||
|
level: int,
|
||||||
|
key: Path,
|
||||||
|
client_id: Path,
|
||||||
|
vmp: Optional[Path] = None,
|
||||||
|
output: Optional[Path] = None
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Create a Widevine Device (.wvd) file from an RSA Private Key (PEM or DER) and Client ID Blob.
|
||||||
|
Optionally also a VMP (Verified Media Path) Blob, which will be stored in the Client ID.
|
||||||
|
"""
|
||||||
|
if not key.is_file():
|
||||||
|
raise click.UsageError("key: Not a path to a file, or it doesn't exist.", ctx)
|
||||||
|
if not client_id.is_file():
|
||||||
|
raise click.UsageError("client_id: Not a path to a file, or it doesn't exist.", ctx)
|
||||||
|
if vmp and not vmp.is_file():
|
||||||
|
raise click.UsageError("vmp: Not a path to a file, or it doesn't exist.", ctx)
|
||||||
|
|
||||||
|
log = logging.getLogger("create-device")
|
||||||
|
|
||||||
|
device = Device(
|
||||||
|
type_=DeviceTypes[type_.upper()],
|
||||||
|
security_level=level,
|
||||||
|
flags=None,
|
||||||
|
private_key=key.read_bytes(),
|
||||||
|
client_id=client_id.read_bytes()
|
||||||
|
)
|
||||||
|
|
||||||
|
if vmp:
|
||||||
|
new_vmp_data = vmp.read_bytes()
|
||||||
|
if device.client_id.vmp_data and device.client_id.vmp_data != new_vmp_data:
|
||||||
|
log.warning("Client ID already has Verified Media Path data")
|
||||||
|
device.client_id.vmp_data = new_vmp_data
|
||||||
|
|
||||||
|
client_info = {}
|
||||||
|
for entry in device.client_id.client_info:
|
||||||
|
client_info[entry.name] = entry.value
|
||||||
|
|
||||||
|
wvd_bin = device.dumps()
|
||||||
|
|
||||||
|
name = f"{client_info['company_name']} {client_info['model_name']}"
|
||||||
|
if client_info.get("widevine_cdm_version"):
|
||||||
|
name += f" {client_info['widevine_cdm_version']}"
|
||||||
|
name += f" {crc32(wvd_bin).to_bytes(4, 'big').hex()}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
name = unidecode(name.strip().lower().replace(" ", "_"))
|
||||||
|
except UnidecodeError as e:
|
||||||
|
raise click.ClickException(f"Failed to sanitize name, {e}")
|
||||||
|
|
||||||
|
if output and output.suffix:
|
||||||
|
if output.suffix.lower() != ".wvd":
|
||||||
|
log.warning(f"Saving WVD with the file extension '{output.suffix}' but '.wvd' is recommended.")
|
||||||
|
out_path = output
|
||||||
|
else:
|
||||||
|
out_dir = output or Path.cwd()
|
||||||
|
out_path = out_dir / f"{name}_{device.system_id}_l{device.security_level}.wvd"
|
||||||
|
|
||||||
|
if out_path.exists():
|
||||||
|
log.error(f"A file already exists at the path '{out_path}', cannot overwrite.")
|
||||||
|
return
|
||||||
|
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path.write_bytes(wvd_bin)
|
||||||
|
|
||||||
|
log.info("Created Widevine Device (.wvd) file, %s", out_path.name)
|
||||||
|
log.info(" + Type: %s", device.type.name)
|
||||||
|
log.info(" + System ID: %s", device.system_id)
|
||||||
|
log.info(" + Security Level: %s", device.security_level)
|
||||||
|
log.info(" + Flags: %s", device.flags)
|
||||||
|
log.info(" + Private Key: %s (%s bit)", bool(device.private_key), device.private_key.size_in_bits())
|
||||||
|
log.info(" + Client ID: %s (%s bytes)", bool(device.client_id), len(device.client_id.SerializeToString()))
|
||||||
|
if device.client_id.vmp_data:
|
||||||
|
file_hashes_ = FileHashes()
|
||||||
|
file_hashes_.ParseFromString(device.client_id.vmp_data)
|
||||||
|
log.info(" + VMP: True (%s signatures)", len(file_hashes_.signatures))
|
||||||
|
else:
|
||||||
|
log.info(" + VMP: False")
|
||||||
|
log.info(" + Saved to: %s", out_path.absolute())
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
@click.argument("wvd_path", type=Path)
|
||||||
|
@click.option("-o", "--out_dir", type=Path, default=None, help="Output Directory")
|
||||||
|
@click.pass_context
|
||||||
|
def export_device(ctx: click.Context, wvd_path: Path, out_dir: Optional[Path] = None) -> None:
|
||||||
|
"""
|
||||||
|
Export a Widevine Device (.wvd) file to an RSA Private Key (PEM and DER) and Client ID Blob.
|
||||||
|
Optionally also a VMP (Verified Media Path) Blob, which will be stored in the Client ID.
|
||||||
|
|
||||||
|
If an output directory is not specified, it will be stored in the current working directory.
|
||||||
|
"""
|
||||||
|
if not wvd_path.is_file():
|
||||||
|
raise click.UsageError("wvd_path: Not a path to a file, or it doesn't exist.", ctx)
|
||||||
|
|
||||||
|
log = logging.getLogger("export-device")
|
||||||
|
log.info("Exporting Widevine Device (.wvd) file, %s", wvd_path.stem)
|
||||||
|
|
||||||
|
if not out_dir:
|
||||||
|
out_dir = Path.cwd()
|
||||||
|
|
||||||
|
out_path = out_dir / wvd_path.stem
|
||||||
|
if out_path.exists():
|
||||||
|
if any(out_path.iterdir()):
|
||||||
|
log.error("Output directory is not empty, cannot overwrite.")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
log.warning("Output directory already exists, but is empty.")
|
||||||
|
else:
|
||||||
|
out_path.mkdir(parents=True)
|
||||||
|
|
||||||
|
device = Device.load(wvd_path)
|
||||||
|
|
||||||
|
log.info(f"L{device.security_level} {device.system_id} {device.type.name}")
|
||||||
|
log.info(f"Saving to: {out_path}")
|
||||||
|
|
||||||
|
device_meta = {
|
||||||
|
"wvd": {
|
||||||
|
"device_type": device.type.name,
|
||||||
|
"security_level": device.security_level,
|
||||||
|
**device.flags
|
||||||
|
},
|
||||||
|
"client_info": {},
|
||||||
|
"capabilities": MessageToDict(device.client_id, preserving_proto_field_name=True)["client_capabilities"]
|
||||||
|
}
|
||||||
|
for client_info in device.client_id.client_info:
|
||||||
|
device_meta["client_info"][client_info.name] = client_info.value
|
||||||
|
|
||||||
|
device_meta_path = out_path / "metadata.yml"
|
||||||
|
device_meta_path.write_text(yaml.dump(device_meta), encoding="utf8")
|
||||||
|
log.info("Exported Device Metadata as metadata.yml")
|
||||||
|
|
||||||
|
if device.private_key:
|
||||||
|
private_key_path = out_path / "private_key.pem"
|
||||||
|
private_key_path.write_text(
|
||||||
|
data=device.private_key.export_key().decode(),
|
||||||
|
encoding="utf8"
|
||||||
|
)
|
||||||
|
private_key_path.with_suffix(".der").write_bytes(
|
||||||
|
device.private_key.export_key(format="DER")
|
||||||
|
)
|
||||||
|
log.info("Exported Private Key as private_key.der and private_key.pem")
|
||||||
|
else:
|
||||||
|
log.warning("No Private Key available")
|
||||||
|
|
||||||
|
if device.client_id:
|
||||||
|
client_id_path = out_path / "client_id.bin"
|
||||||
|
client_id_path.write_bytes(device.client_id.SerializeToString())
|
||||||
|
log.info("Exported Client ID as client_id.bin")
|
||||||
|
else:
|
||||||
|
log.warning("No Client ID available")
|
||||||
|
|
||||||
|
if device.client_id.vmp_data:
|
||||||
|
vmp_path = out_path / "vmp.bin"
|
||||||
|
vmp_path.write_bytes(device.client_id.vmp_data)
|
||||||
|
log.info("Exported VMP (File Hashes) as vmp.bin")
|
||||||
|
else:
|
||||||
|
log.info("No VMP (File Hashes) available")
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
@click.argument("path", type=Path)
|
||||||
|
@click.pass_context
|
||||||
|
def migrate(ctx: click.Context, path: Path) -> None:
|
||||||
|
"""
|
||||||
|
Upgrade from earlier versions of the Widevine Device (.wvd) format.
|
||||||
|
|
||||||
|
The path argument can be a direct path to a Widevine Device (.wvd) file, or a path
|
||||||
|
to a folder of Widevine Devices files.
|
||||||
|
|
||||||
|
The migrated devices are saved to its original location, overwriting the old version.
|
||||||
|
"""
|
||||||
|
if not path.exists():
|
||||||
|
raise click.UsageError(f"path: The path '{path}' does not exist.", ctx)
|
||||||
|
|
||||||
|
log = logging.getLogger("migrate")
|
||||||
|
|
||||||
|
if path.is_dir():
|
||||||
|
devices = list(path.glob("*.wvd"))
|
||||||
|
else:
|
||||||
|
devices = [path]
|
||||||
|
|
||||||
|
migrated = 0
|
||||||
|
for device in devices:
|
||||||
|
log.info("Migrating %s...", device.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_device = Device.migrate(device.read_bytes())
|
||||||
|
except (ConstructError, ValueError) as e:
|
||||||
|
log.error(" - %s", e)
|
||||||
|
continue
|
||||||
|
|
||||||
|
log.debug(new_device)
|
||||||
|
new_device.dump(device)
|
||||||
|
|
||||||
|
log.info(" + Success")
|
||||||
|
migrated += 1
|
||||||
|
|
||||||
|
log.info("Migrated %s/%s devices!", migrated, len(devices))
|
||||||
|
|
||||||
|
|
||||||
|
@main.command("serve", short_help="Serve your local CDM and Widevine Devices Remotely.")
|
||||||
|
@click.argument("config_path", type=Path)
|
||||||
|
@click.option("-h", "--host", type=str, default="127.0.0.1", help="Host to serve from.")
|
||||||
|
@click.option("-p", "--port", type=int, default=8786, help="Port to serve from.")
|
||||||
|
def serve_(config_path: Path, host: str, port: int) -> None:
|
||||||
|
"""
|
||||||
|
Serve your local CDM and Widevine Devices Remotely.
|
||||||
|
|
||||||
|
\b
|
||||||
|
[CONFIG] is a path to a serve config file.
|
||||||
|
See `serve.example.yml` for an example config file.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Host as 127.0.0.1 may block remote access even if port-forwarded.
|
||||||
|
Instead, use 0.0.0.0 and ensure the TCP port you choose is forwarded.
|
||||||
|
"""
|
||||||
|
from pywidevine import serve # isort:skip
|
||||||
|
import yaml # isort:skip
|
||||||
|
|
||||||
|
config = yaml.safe_load(config_path.read_text(encoding="utf8"))
|
||||||
|
serve.run(config, host, port)
|
||||||
442
pywidevine/pssh.py
Normal file
442
pywidevine/pssh.py
Normal file
@ -0,0 +1,442 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import string
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Optional, Union
|
||||||
|
from uuid import UUID
|
||||||
|
from xml.etree.ElementTree import XML
|
||||||
|
|
||||||
|
import construct
|
||||||
|
from construct import Container
|
||||||
|
from google.protobuf.message import DecodeError
|
||||||
|
from pymp4.parser import Box
|
||||||
|
|
||||||
|
from pywidevine.license_protocol_pb2 import WidevinePsshData
|
||||||
|
|
||||||
|
|
||||||
|
class PSSH:
|
||||||
|
"""
|
||||||
|
MP4 PSSH Box-related utilities.
|
||||||
|
Allows you to load, create, and modify various kinds of DRM system headers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class SystemId:
|
||||||
|
Widevine = UUID(hex="edef8ba979d64acea3c827dcd51d21ed")
|
||||||
|
PlayReady = UUID(hex="9a04f07998404286ab92e65be0885f95")
|
||||||
|
|
||||||
|
def __init__(self, data: Union[Container, str, bytes], strict: bool = False):
|
||||||
|
"""
|
||||||
|
Load a PSSH box, WidevineCencHeader, or PlayReadyHeader.
|
||||||
|
|
||||||
|
When loading a WidevineCencHeader or PlayReadyHeader, a new v0 PSSH box will be
|
||||||
|
created and the header will be parsed and stored in the init_data field. However,
|
||||||
|
PlayReadyHeaders (and PlayReadyObjects) are not yet currently parsed and are
|
||||||
|
stored as bytes.
|
||||||
|
|
||||||
|
[Strict mode (strict=True)]
|
||||||
|
|
||||||
|
Supports the following forms of input data in either Base64 or Bytes form:
|
||||||
|
- Full PSSH mp4 boxes (as defined by pymp4 Box).
|
||||||
|
- Full Widevine Cenc Headers (as defined by WidevinePsshData proto).
|
||||||
|
- Full PlayReady Objects and Headers (as defined by Microsoft Docs).
|
||||||
|
|
||||||
|
[Lenient mode (strict=False, default)]
|
||||||
|
|
||||||
|
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 not data:
|
||||||
|
raise ValueError("Data must not be empty.")
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
|
if not isinstance(data, bytes):
|
||||||
|
raise TypeError(f"Expected data to be a {Container}, bytes, or base64, not {data!r}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
box = Box.parse(data)
|
||||||
|
except (IOError, construct.ConstructError): # not a box
|
||||||
|
try:
|
||||||
|
widevine_pssh_data = WidevinePsshData()
|
||||||
|
widevine_pssh_data.ParseFromString(data)
|
||||||
|
data_serialized = widevine_pssh_data.SerializeToString()
|
||||||
|
if data_serialized != data: # not actually a WidevinePsshData
|
||||||
|
raise DecodeError()
|
||||||
|
box = Box.parse(Box.build(dict(
|
||||||
|
type=b"pssh",
|
||||||
|
version=0,
|
||||||
|
flags=0,
|
||||||
|
system_ID=PSSH.SystemId.Widevine,
|
||||||
|
init_data=data_serialized
|
||||||
|
)))
|
||||||
|
except DecodeError: # not a widevine cenc header
|
||||||
|
if "</WRMHEADER>".encode("utf-16-le") in data:
|
||||||
|
# TODO: Actually parse `data` as a PlayReadyHeader object and store that instead
|
||||||
|
box = Box.parse(Box.build(dict(
|
||||||
|
type=b"pssh",
|
||||||
|
version=0,
|
||||||
|
flags=0,
|
||||||
|
system_ID=PSSH.SystemId.PlayReady,
|
||||||
|
init_data=data
|
||||||
|
)))
|
||||||
|
elif strict:
|
||||||
|
raise DecodeError(f"Could not parse data as a {Container} nor a {WidevinePsshData}.")
|
||||||
|
else:
|
||||||
|
# Data is not a WidevineCencHeader nor a PlayReadyHeader.
|
||||||
|
# The license server likely has something custom to parse it.
|
||||||
|
# See doc-string about Lenient mode for more information.
|
||||||
|
box = Box.parse(Box.build(dict(
|
||||||
|
type=b"pssh",
|
||||||
|
version=0,
|
||||||
|
flags=0,
|
||||||
|
system_ID=PSSH.SystemId.Widevine,
|
||||||
|
init_data=data
|
||||||
|
)))
|
||||||
|
|
||||||
|
self.version = box.version
|
||||||
|
self.flags = box.flags
|
||||||
|
self.system_id = box.system_ID
|
||||||
|
self.__key_ids = box.key_IDs
|
||||||
|
self.init_data = box.init_data
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"PSSH<{self.system_id}>(v{self.version}; {self.flags}, {self.key_ids}, {self.init_data})"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.dumps()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def new(
|
||||||
|
cls,
|
||||||
|
system_id: UUID,
|
||||||
|
key_ids: Optional[list[Union[UUID, str, bytes]]] = None,
|
||||||
|
init_data: Optional[Union[WidevinePsshData, str, bytes]] = None,
|
||||||
|
version: int = 0,
|
||||||
|
flags: int = 0
|
||||||
|
) -> PSSH:
|
||||||
|
"""Craft a new version 0 or 1 PSSH Box."""
|
||||||
|
if not system_id:
|
||||||
|
raise ValueError("A System ID must be specified.")
|
||||||
|
if not isinstance(system_id, UUID):
|
||||||
|
raise TypeError(f"Expected system_id to be a UUID, not {system_id!r}")
|
||||||
|
|
||||||
|
if key_ids is not None and not isinstance(key_ids, list):
|
||||||
|
raise TypeError(f"Expected key_ids to be a list not {key_ids!r}")
|
||||||
|
|
||||||
|
if init_data is not None and not isinstance(init_data, (WidevinePsshData, str, bytes)):
|
||||||
|
raise TypeError(f"Expected init_data to be a {WidevinePsshData}, base64, or bytes, not {init_data!r}")
|
||||||
|
|
||||||
|
if not isinstance(version, int):
|
||||||
|
raise TypeError(f"Expected version to be an int not {version!r}")
|
||||||
|
if version not in (0, 1):
|
||||||
|
raise ValueError(f"Invalid version, must be either 0 or 1, not {version}.")
|
||||||
|
|
||||||
|
if not isinstance(flags, int):
|
||||||
|
raise TypeError(f"Expected flags to be an int not {flags!r}")
|
||||||
|
if flags < 0:
|
||||||
|
raise ValueError("Invalid flags, cannot be less than 0.")
|
||||||
|
|
||||||
|
if version == 0 and key_ids is not None and init_data is not None:
|
||||||
|
# v0 boxes use only init_data in the pssh field, but we can use the key_ids within the init_data
|
||||||
|
raise ValueError("Version 0 PSSH boxes must use only init_data, not init_data and key_ids.")
|
||||||
|
elif version == 1:
|
||||||
|
# TODO: I cannot tell if they need either init_data or key_ids exclusively, or both is fine
|
||||||
|
# So for now I will just make sure at least one is supplied
|
||||||
|
if init_data is None and key_ids is None:
|
||||||
|
raise ValueError("Version 1 PSSH boxes must use either init_data or key_ids but neither were provided")
|
||||||
|
|
||||||
|
if init_data is not None:
|
||||||
|
if isinstance(init_data, WidevinePsshData):
|
||||||
|
init_data = init_data.SerializeToString()
|
||||||
|
elif isinstance(init_data, str):
|
||||||
|
if all(c in string.hexdigits for c in init_data):
|
||||||
|
init_data = bytes.fromhex(init_data)
|
||||||
|
else:
|
||||||
|
init_data = base64.b64decode(init_data)
|
||||||
|
elif not isinstance(init_data, bytes):
|
||||||
|
raise TypeError(
|
||||||
|
f"Expecting init_data to be {WidevinePsshData}, hex, base64, or bytes, not {init_data!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
pssh = cls(Box.parse(Box.build(dict(
|
||||||
|
type=b"pssh",
|
||||||
|
version=version,
|
||||||
|
flags=flags,
|
||||||
|
system_ID=system_id,
|
||||||
|
init_data=[init_data, b""][init_data is None]
|
||||||
|
# key_IDs should not be set yet
|
||||||
|
))))
|
||||||
|
|
||||||
|
if key_ids:
|
||||||
|
# We must reinforce the version because pymp4 forces v0 if key_IDs is not set.
|
||||||
|
# The set_key_ids() func will set it efficiently in both init_data and the box where needed.
|
||||||
|
# The version must be reinforced ONLY if we have key_id data or there's a possibility of making
|
||||||
|
# a v1 PSSH box, that did not have key_IDs set in the PSSH box.
|
||||||
|
pssh.version = version
|
||||||
|
pssh.set_key_ids(key_ids)
|
||||||
|
|
||||||
|
return pssh
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key_ids(self) -> list[UUID]:
|
||||||
|
"""
|
||||||
|
Get all Key IDs from within the Box or Init Data, wherever possible.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Version 1 PSSH Boxes
|
||||||
|
- WidevineCencHeaders
|
||||||
|
- PlayReadyHeaders (4.0.0.0->4.3.0.0)
|
||||||
|
"""
|
||||||
|
if self.version == 1 and self.__key_ids:
|
||||||
|
return self.__key_ids
|
||||||
|
|
||||||
|
if self.system_id == PSSH.SystemId.Widevine:
|
||||||
|
# TODO: What if its not a Widevine Cenc Header but the System ID is set as Widevine?
|
||||||
|
cenc_header = WidevinePsshData()
|
||||||
|
cenc_header.ParseFromString(self.init_data)
|
||||||
|
return [
|
||||||
|
# the key_ids value may or may not be hex underlying
|
||||||
|
(
|
||||||
|
UUID(bytes=key_id) if len(key_id) == 16 else # normal
|
||||||
|
UUID(hex=key_id.decode()) if len(key_id) == 32 else # stored as hex
|
||||||
|
UUID(int=int.from_bytes(key_id, "big")) # assuming as number
|
||||||
|
)
|
||||||
|
for key_id in cenc_header.key_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
if self.system_id == PSSH.SystemId.PlayReady:
|
||||||
|
# Assuming init data is a PRO (PlayReadyObject)
|
||||||
|
# https://learn.microsoft.com/en-us/playready/specifications/playready-header-specification
|
||||||
|
pro_data = BytesIO(self.init_data)
|
||||||
|
pro_length = int.from_bytes(pro_data.read(4), "little")
|
||||||
|
if pro_length != len(self.init_data):
|
||||||
|
raise ValueError("The PlayReadyObject seems to be corrupt (too big or small, or missing data).")
|
||||||
|
pro_record_count = int.from_bytes(pro_data.read(2), "little")
|
||||||
|
|
||||||
|
for _ in range(pro_record_count):
|
||||||
|
prr_type = int.from_bytes(pro_data.read(2), "little")
|
||||||
|
prr_length = int.from_bytes(pro_data.read(2), "little")
|
||||||
|
prr_value = pro_data.read(prr_length)
|
||||||
|
if prr_type != 0x01:
|
||||||
|
# No PlayReady Header, skip and hope for something else
|
||||||
|
# TODO: Add support for Embedded License Stores (0x03)
|
||||||
|
continue
|
||||||
|
|
||||||
|
wrm_ns = {"wrm": "http://schemas.microsoft.com/DRM/2007/03/PlayReadyHeader"}
|
||||||
|
prr_header = XML(prr_value.decode("utf-16-le"))
|
||||||
|
prr_header_version = prr_header.get("version")
|
||||||
|
if prr_header_version == "4.0.0.0":
|
||||||
|
key_ids = [
|
||||||
|
x.text
|
||||||
|
for x in prr_header.findall("./wrm:DATA/wrm:KID", wrm_ns)
|
||||||
|
if x.text
|
||||||
|
]
|
||||||
|
elif prr_header_version == "4.1.0.0":
|
||||||
|
key_ids = [
|
||||||
|
x.attrib["VALUE"]
|
||||||
|
for x in prr_header.findall("./wrm:DATA/wrm:PROTECTINFO/wrm:KID", wrm_ns)
|
||||||
|
]
|
||||||
|
elif prr_header_version in ("4.2.0.0", "4.3.0.0"):
|
||||||
|
# TODO: Retain the Encryption Scheme information in v4.3.0.0
|
||||||
|
# This is because some Key IDs can be AES-CTR while some are AES-CBC.
|
||||||
|
# Conversion to WidevineCencHeader could use this information.
|
||||||
|
key_ids = [
|
||||||
|
x.attrib["VALUE"]
|
||||||
|
for x in prr_header.findall("./wrm:DATA/wrm:PROTECTINFO/wrm:KIDS/wrm:KID", wrm_ns)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported PlayReadyHeader version {prr_header_version}")
|
||||||
|
|
||||||
|
return [
|
||||||
|
UUID(bytes=base64.b64decode(key_id))
|
||||||
|
for key_id in key_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
raise ValueError("Unsupported PlayReadyObject, no PlayReadyHeader within the object.")
|
||||||
|
|
||||||
|
raise ValueError(f"This PSSH is not supported by key_ids() property, {self.dumps()}")
|
||||||
|
|
||||||
|
def dump(self) -> bytes:
|
||||||
|
"""Export the PSSH object as a full PSSH box in bytes form."""
|
||||||
|
return Box.build(dict(
|
||||||
|
type=b"pssh",
|
||||||
|
version=self.version,
|
||||||
|
flags=self.flags,
|
||||||
|
system_ID=self.system_id,
|
||||||
|
key_IDs=self.key_ids if self.version == 1 and self.key_ids else None,
|
||||||
|
init_data=self.init_data
|
||||||
|
))
|
||||||
|
|
||||||
|
def dumps(self) -> str:
|
||||||
|
"""Export the PSSH object as a full PSSH box in base64 form."""
|
||||||
|
return base64.b64encode(self.dump()).decode()
|
||||||
|
|
||||||
|
def to_widevine(self) -> None:
|
||||||
|
"""
|
||||||
|
Convert PlayReady PSSH data to Widevine PSSH data.
|
||||||
|
|
||||||
|
There's only a limited amount of information within a PlayReady PSSH header that
|
||||||
|
can be used in a Widevine PSSH Header. The converted data may or may not result
|
||||||
|
in an accepted PSSH. It depends on what the License Server is expecting.
|
||||||
|
"""
|
||||||
|
if self.system_id == PSSH.SystemId.Widevine:
|
||||||
|
raise ValueError("This is already a Widevine PSSH")
|
||||||
|
|
||||||
|
widevine_pssh_data = WidevinePsshData(
|
||||||
|
key_ids=[x.bytes for x in self.key_ids],
|
||||||
|
algorithm="AESCTR"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.version == 1:
|
||||||
|
# ensure both cenc header and box has same Key IDs
|
||||||
|
# v1 uses both this and within init data for basically no reason
|
||||||
|
self.__key_ids = self.key_ids
|
||||||
|
|
||||||
|
self.init_data = widevine_pssh_data.SerializeToString()
|
||||||
|
self.system_id = PSSH.SystemId.Widevine
|
||||||
|
|
||||||
|
def to_playready(
|
||||||
|
self,
|
||||||
|
la_url: Optional[str] = None,
|
||||||
|
lui_url: Optional[str] = None,
|
||||||
|
ds_id: Optional[bytes] = None,
|
||||||
|
decryptor_setup: Optional[str] = None,
|
||||||
|
custom_data: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Convert Widevine PSSH data to PlayReady v4.3.0.0 PSSH data.
|
||||||
|
|
||||||
|
Note that it is impossible to create the CHECKSUM values for AES-CTR Key IDs
|
||||||
|
as you must encrypt the Key ID with the Content Encryption Key using AES-ECB.
|
||||||
|
This may cause software incompatibilities.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
la_url: Contains the URL for the license acquisition Web service.
|
||||||
|
Only absolute URLs are allowed.
|
||||||
|
lui_url: Contains the URL for the license acquisition Web service.
|
||||||
|
Only absolute URLs are allowed.
|
||||||
|
ds_id: Service ID for the domain service.
|
||||||
|
decryptor_setup: This tag may only contain the value "ONDEMAND". It
|
||||||
|
indicates to an application that it should not expect the full
|
||||||
|
license chain for the content to be available for acquisition, or
|
||||||
|
already present on the client machine, prior to setting up the
|
||||||
|
media graph. If this tag is not set then it indicates that an
|
||||||
|
application can enforce the license to be acquired, or already
|
||||||
|
present on the client machine, prior to setting up the media graph.
|
||||||
|
custom_data: The content author can add custom XML inside this
|
||||||
|
element. Microsoft code does not act on any data contained inside
|
||||||
|
this element. The Syntax of this params XML is not validated.
|
||||||
|
"""
|
||||||
|
if self.system_id == PSSH.SystemId.PlayReady:
|
||||||
|
raise ValueError("This is already a PlayReady PSSH")
|
||||||
|
|
||||||
|
key_ids_xml = ""
|
||||||
|
for key_id in self.key_ids:
|
||||||
|
# Note that it's impossible to create the CHECKSUM value without the Key for the KID
|
||||||
|
key_ids_xml += f"""
|
||||||
|
<KID ALGID="AESCTR" VALUE="{base64.b64encode(key_id.bytes).decode()}"></KID>
|
||||||
|
"""
|
||||||
|
|
||||||
|
prr_value = f"""
|
||||||
|
<WRMHEADER xmlns="http://schemas.microsoft.com/DRM/2007/03/PlayReadyHeader" version="4.3.0.0">
|
||||||
|
<DATA>
|
||||||
|
<PROTECTINFO>
|
||||||
|
<KIDS>{key_ids_xml}</KIDS>
|
||||||
|
</PROTECTINFO>
|
||||||
|
{'<LA_URL>%s</LA_URL>' % la_url if la_url else ''}
|
||||||
|
{'<LUI_URL>%s</LUI_URL>' % lui_url if lui_url else ''}
|
||||||
|
{'<DS_ID>%s</DS_ID>' % base64.b64encode(ds_id).decode() if ds_id else ''}
|
||||||
|
{'<DECRYPTORSETUP>%s</DECRYPTORSETUP>' % decryptor_setup if decryptor_setup else ''}
|
||||||
|
{'<CUSTOMATTRIBUTES xmlns="">%s</CUSTOMATTRIBUTES>' % custom_data if custom_data else ''}
|
||||||
|
</DATA>
|
||||||
|
</WRMHEADER>
|
||||||
|
""".encode("utf-16-le")
|
||||||
|
|
||||||
|
prr_length = len(prr_value).to_bytes(2, "little")
|
||||||
|
prr_type = (1).to_bytes(2, "little") # Has PlayReadyHeader
|
||||||
|
pro_record_count = (1).to_bytes(2, "little")
|
||||||
|
pro = pro_record_count + prr_type + prr_length + prr_value
|
||||||
|
pro = (len(pro) + 4).to_bytes(4, "little") + pro
|
||||||
|
|
||||||
|
self.init_data = pro
|
||||||
|
self.system_id = PSSH.SystemId.PlayReady
|
||||||
|
|
||||||
|
def set_key_ids(self, key_ids: list[Union[UUID, str, bytes]]) -> None:
|
||||||
|
"""Overwrite all Key IDs with the specified Key IDs."""
|
||||||
|
if self.system_id != PSSH.SystemId.Widevine:
|
||||||
|
# TODO: Add support for setting the Key IDs in a PlayReady Header
|
||||||
|
raise ValueError(f"Only Widevine PSSH Boxes are supported, not {self.system_id}.")
|
||||||
|
|
||||||
|
key_id_uuids = self.parse_key_ids(key_ids)
|
||||||
|
|
||||||
|
if self.version == 1 or self.__key_ids:
|
||||||
|
# only use v1 box key_ids if version is 1, or it's already being used
|
||||||
|
# this is in case the service stupidly expects it for version 0
|
||||||
|
self.__key_ids = key_id_uuids
|
||||||
|
|
||||||
|
cenc_header = WidevinePsshData()
|
||||||
|
cenc_header.ParseFromString(self.init_data)
|
||||||
|
|
||||||
|
cenc_header.key_ids[:] = [
|
||||||
|
key_id.bytes
|
||||||
|
for key_id in key_id_uuids
|
||||||
|
]
|
||||||
|
|
||||||
|
self.init_data = cenc_header.SerializeToString()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_key_ids(key_ids: list[Union[UUID, str, bytes]]) -> list[UUID]:
|
||||||
|
"""
|
||||||
|
Parse a list of Key IDs in hex, base64, or bytes to UUIDs.
|
||||||
|
|
||||||
|
Raises TypeError if `key_ids` is not a list, or the list contains one
|
||||||
|
or more items that are not a UUID, str, or bytes object.
|
||||||
|
"""
|
||||||
|
if not isinstance(key_ids, list):
|
||||||
|
raise TypeError(f"Expected key_ids to be a list, not {key_ids!r}")
|
||||||
|
|
||||||
|
if not all(isinstance(x, (UUID, str, bytes)) for x in key_ids):
|
||||||
|
raise TypeError("Some items of key_ids are not a UUID, str, or bytes. Unsure how to continue...")
|
||||||
|
|
||||||
|
uuids = [
|
||||||
|
UUID(bytes=key_id_b)
|
||||||
|
for key_id in key_ids
|
||||||
|
for key_id_b in [
|
||||||
|
key_id.bytes if isinstance(key_id, UUID) else
|
||||||
|
(
|
||||||
|
bytes.fromhex(key_id) if all(c in string.hexdigits for c in key_id) else
|
||||||
|
base64.b64decode(key_id)
|
||||||
|
) if isinstance(key_id, str) else
|
||||||
|
key_id
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
return uuids
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("PSSH",)
|
||||||
0
pywidevine/py.typed
Normal file
0
pywidevine/py.typed
Normal file
300
pywidevine/remotecdm.py
Normal file
300
pywidevine/remotecdm.py
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import re
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from Crypto.Hash import SHA1
|
||||||
|
from Crypto.PublicKey import RSA
|
||||||
|
from Crypto.Signature import pss
|
||||||
|
from google.protobuf.message import DecodeError
|
||||||
|
|
||||||
|
from pywidevine.cdm import Cdm
|
||||||
|
from pywidevine.device import Device, DeviceTypes
|
||||||
|
from pywidevine.exceptions import (DeviceMismatch, InvalidInitData, InvalidLicenseMessage, InvalidLicenseType,
|
||||||
|
SignatureMismatch)
|
||||||
|
from pywidevine.key import Key
|
||||||
|
from pywidevine.license_protocol_pb2 import (ClientIdentification, License, LicenseType, SignedDrmCertificate,
|
||||||
|
SignedMessage)
|
||||||
|
from pywidevine.pssh import PSSH
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteCdm(Cdm):
|
||||||
|
"""Remote Accessible CDM using pywidevine's serve schema."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
device_type: Union[DeviceTypes, str],
|
||||||
|
system_id: int,
|
||||||
|
security_level: int,
|
||||||
|
host: str,
|
||||||
|
secret: str,
|
||||||
|
device_name: str
|
||||||
|
):
|
||||||
|
"""Initialize a Widevine Content Decryption Module (CDM)."""
|
||||||
|
if not device_type:
|
||||||
|
raise ValueError("Device Type must be provided")
|
||||||
|
if isinstance(device_type, str):
|
||||||
|
device_type = DeviceTypes[device_type]
|
||||||
|
if not isinstance(device_type, DeviceTypes):
|
||||||
|
raise TypeError(f"Expected device_type to be a {DeviceTypes!r} not {device_type!r}")
|
||||||
|
|
||||||
|
if not system_id:
|
||||||
|
raise ValueError("System ID must be provided")
|
||||||
|
if not isinstance(system_id, int):
|
||||||
|
raise TypeError(f"Expected system_id to be a {int} not {system_id!r}")
|
||||||
|
|
||||||
|
if not security_level:
|
||||||
|
raise ValueError("Security Level must be provided")
|
||||||
|
if not isinstance(security_level, int):
|
||||||
|
raise TypeError(f"Expected security_level to be a {int} not {security_level!r}")
|
||||||
|
|
||||||
|
if not host:
|
||||||
|
raise ValueError("API Host must be provided")
|
||||||
|
if not isinstance(host, str):
|
||||||
|
raise TypeError(f"Expected host to be a {str} not {host!r}")
|
||||||
|
|
||||||
|
if not secret:
|
||||||
|
raise ValueError("API Secret must be provided")
|
||||||
|
if not isinstance(secret, str):
|
||||||
|
raise TypeError(f"Expected secret to be a {str} not {secret!r}")
|
||||||
|
|
||||||
|
if not device_name:
|
||||||
|
raise ValueError("API Device name must be provided")
|
||||||
|
if not isinstance(device_name, str):
|
||||||
|
raise TypeError(f"Expected device_name to be a {str} not {device_name!r}")
|
||||||
|
|
||||||
|
self.device_type = device_type
|
||||||
|
self.system_id = system_id
|
||||||
|
self.security_level = security_level
|
||||||
|
self.host = host
|
||||||
|
self.device_name = device_name
|
||||||
|
|
||||||
|
# spoof client_id and rsa_key just so we can construct via super call
|
||||||
|
super().__init__(device_type, system_id, security_level, ClientIdentification(), RSA.generate(2048))
|
||||||
|
|
||||||
|
self.__session = requests.Session()
|
||||||
|
self.__session.headers.update({
|
||||||
|
"X-Secret-Key": secret
|
||||||
|
})
|
||||||
|
|
||||||
|
r = requests.head(self.host)
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise ValueError(f"Could not test Remote API version [{r.status_code}]")
|
||||||
|
server = r.headers.get("Server")
|
||||||
|
if not server or "pywidevine serve" not in server.lower():
|
||||||
|
raise ValueError(f"This Remote CDM API does not seem to be a pywidevine serve API ({server}).")
|
||||||
|
server_version_re = re.search(r"pywidevine serve v([\d.]+)", server, re.IGNORECASE)
|
||||||
|
if not server_version_re:
|
||||||
|
raise ValueError("The pywidevine server API is not stating the version correctly, cannot continue.")
|
||||||
|
server_version = server_version_re.group(1)
|
||||||
|
if server_version < "1.4.3":
|
||||||
|
raise ValueError(f"This pywidevine serve API version ({server_version}) is not supported.")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_device(cls, device: Device) -> RemoteCdm:
|
||||||
|
raise NotImplementedError("You cannot load a RemoteCdm from a local Device file.")
|
||||||
|
|
||||||
|
def open(self) -> bytes:
|
||||||
|
r = self.__session.get(
|
||||||
|
url=f"{self.host}/{self.device_name}/open"
|
||||||
|
).json()
|
||||||
|
if r['status'] != 200:
|
||||||
|
raise ValueError(f"Cannot Open CDM Session, {r['message']} [{r['status']}]")
|
||||||
|
r = r["data"]
|
||||||
|
|
||||||
|
if int(r["device"]["system_id"]) != self.system_id:
|
||||||
|
raise DeviceMismatch("The System ID specified does not match the one specified in the API response.")
|
||||||
|
|
||||||
|
if int(r["device"]["security_level"]) != self.security_level:
|
||||||
|
raise DeviceMismatch("The Security Level specified does not match the one specified in the API response.")
|
||||||
|
|
||||||
|
return bytes.fromhex(r["session_id"])
|
||||||
|
|
||||||
|
def close(self, session_id: bytes) -> None:
|
||||||
|
r = self.__session.get(
|
||||||
|
url=f"{self.host}/{self.device_name}/close/{session_id.hex()}"
|
||||||
|
).json()
|
||||||
|
if r["status"] != 200:
|
||||||
|
raise ValueError(f"Cannot Close CDM Session, {r['message']} [{r['status']}]")
|
||||||
|
|
||||||
|
def set_service_certificate(self, session_id: bytes, certificate: Optional[Union[bytes, str]]) -> str:
|
||||||
|
if certificate is None:
|
||||||
|
certificate_b64 = None
|
||||||
|
elif isinstance(certificate, str):
|
||||||
|
certificate_b64 = certificate # assuming base64
|
||||||
|
elif isinstance(certificate, bytes):
|
||||||
|
certificate_b64 = base64.b64encode(certificate).decode()
|
||||||
|
else:
|
||||||
|
raise DecodeError(f"Expecting Certificate to be base64 or bytes, not {certificate!r}")
|
||||||
|
|
||||||
|
r = self.__session.post(
|
||||||
|
url=f"{self.host}/{self.device_name}/set_service_certificate",
|
||||||
|
json={
|
||||||
|
"session_id": session_id.hex(),
|
||||||
|
"certificate": certificate_b64
|
||||||
|
}
|
||||||
|
).json()
|
||||||
|
if r["status"] != 200:
|
||||||
|
raise ValueError(f"Cannot Set CDMs Service Certificate, {r['message']} [{r['status']}]")
|
||||||
|
r = r["data"]
|
||||||
|
|
||||||
|
return r["provider_id"]
|
||||||
|
|
||||||
|
def get_service_certificate(self, session_id: bytes) -> Optional[SignedDrmCertificate]:
|
||||||
|
r = self.__session.post(
|
||||||
|
url=f"{self.host}/{self.device_name}/get_service_certificate",
|
||||||
|
json={
|
||||||
|
"session_id": session_id.hex()
|
||||||
|
}
|
||||||
|
).json()
|
||||||
|
if r["status"] != 200:
|
||||||
|
raise ValueError(f"Cannot Get CDMs Service Certificate, {r['message']} [{r['status']}]")
|
||||||
|
r = r["data"]
|
||||||
|
|
||||||
|
service_certificate = r["service_certificate"]
|
||||||
|
if not service_certificate:
|
||||||
|
return None
|
||||||
|
|
||||||
|
service_certificate = base64.b64decode(service_certificate)
|
||||||
|
signed_drm_certificate = SignedDrmCertificate()
|
||||||
|
|
||||||
|
try:
|
||||||
|
signed_drm_certificate.ParseFromString(service_certificate)
|
||||||
|
if signed_drm_certificate.SerializeToString() != service_certificate:
|
||||||
|
raise DecodeError("partial parse")
|
||||||
|
except DecodeError as e:
|
||||||
|
# could be a direct unsigned DrmCertificate, but reject those anyway
|
||||||
|
raise DecodeError(f"Could not parse certificate as a SignedDrmCertificate, {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
pss. \
|
||||||
|
new(RSA.import_key(self.root_cert.public_key)). \
|
||||||
|
verify(
|
||||||
|
msg_hash=SHA1.new(signed_drm_certificate.drm_certificate),
|
||||||
|
signature=signed_drm_certificate.signature
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise SignatureMismatch("Signature Mismatch on SignedDrmCertificate, rejecting certificate")
|
||||||
|
|
||||||
|
return signed_drm_certificate
|
||||||
|
|
||||||
|
def get_license_challenge(
|
||||||
|
self,
|
||||||
|
session_id: bytes,
|
||||||
|
pssh: PSSH,
|
||||||
|
license_type: str = "STREAMING",
|
||||||
|
privacy_mode: bool = True
|
||||||
|
) -> bytes:
|
||||||
|
if not pssh:
|
||||||
|
raise InvalidInitData("A pssh must be provided.")
|
||||||
|
if not isinstance(pssh, PSSH):
|
||||||
|
raise InvalidInitData(f"Expected pssh to be a {PSSH}, not {pssh!r}")
|
||||||
|
|
||||||
|
if not isinstance(license_type, str):
|
||||||
|
raise InvalidLicenseType(f"Expected license_type to be a {str}, not {license_type!r}")
|
||||||
|
if license_type not in LicenseType.keys():
|
||||||
|
raise InvalidLicenseType(
|
||||||
|
f"Invalid license_type value of '{license_type}'. "
|
||||||
|
f"Available values: {LicenseType.keys()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
r = self.__session.post(
|
||||||
|
url=f"{self.host}/{self.device_name}/get_license_challenge/{license_type}",
|
||||||
|
json={
|
||||||
|
"session_id": session_id.hex(),
|
||||||
|
"init_data": pssh.dumps(),
|
||||||
|
"privacy_mode": privacy_mode
|
||||||
|
}
|
||||||
|
).json()
|
||||||
|
if r["status"] != 200:
|
||||||
|
raise ValueError(f"Cannot get Challenge, {r['message']} [{r['status']}]")
|
||||||
|
r = r["data"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
challenge = base64.b64decode(r["challenge_b64"])
|
||||||
|
license_message = SignedMessage()
|
||||||
|
license_message.ParseFromString(challenge)
|
||||||
|
if license_message.SerializeToString() != challenge:
|
||||||
|
raise DecodeError("partial parse")
|
||||||
|
except DecodeError as e:
|
||||||
|
raise InvalidLicenseMessage(f"Failed to parse license request, {e}")
|
||||||
|
|
||||||
|
return license_message.SerializeToString()
|
||||||
|
|
||||||
|
def parse_license(self, session_id: bytes, license_message: Union[SignedMessage, bytes, str]) -> None:
|
||||||
|
if not license_message:
|
||||||
|
raise InvalidLicenseMessage("Cannot parse an empty license_message")
|
||||||
|
|
||||||
|
if isinstance(license_message, str):
|
||||||
|
try:
|
||||||
|
license_message = base64.b64decode(license_message)
|
||||||
|
except (binascii.Error, binascii.Incomplete) as e:
|
||||||
|
raise InvalidLicenseMessage(f"Could not decode license_message as Base64, {e}")
|
||||||
|
|
||||||
|
if isinstance(license_message, bytes):
|
||||||
|
signed_message = SignedMessage()
|
||||||
|
try:
|
||||||
|
signed_message.ParseFromString(license_message)
|
||||||
|
if signed_message.SerializeToString() != license_message:
|
||||||
|
raise DecodeError("partial parse")
|
||||||
|
except DecodeError as e:
|
||||||
|
raise InvalidLicenseMessage(f"Could not parse license_message as a SignedMessage, {e}")
|
||||||
|
license_message = signed_message
|
||||||
|
|
||||||
|
if not isinstance(license_message, SignedMessage):
|
||||||
|
raise InvalidLicenseMessage(f"Expecting license_response to be a SignedMessage, got {license_message!r}")
|
||||||
|
|
||||||
|
if license_message.type != SignedMessage.MessageType.Value("LICENSE"):
|
||||||
|
raise InvalidLicenseMessage(
|
||||||
|
f"Expecting a LICENSE message, not a "
|
||||||
|
f"'{SignedMessage.MessageType.Name(license_message.type)}' message."
|
||||||
|
)
|
||||||
|
|
||||||
|
r = self.__session.post(
|
||||||
|
url=f"{self.host}/{self.device_name}/parse_license",
|
||||||
|
json={
|
||||||
|
"session_id": session_id.hex(),
|
||||||
|
"license_message": base64.b64encode(license_message.SerializeToString()).decode()
|
||||||
|
}
|
||||||
|
).json()
|
||||||
|
if r["status"] != 200:
|
||||||
|
raise ValueError(f"Cannot parse License, {r['message']} [{r['status']}]")
|
||||||
|
|
||||||
|
def get_keys(self, session_id: bytes, type_: Optional[Union[int, str]] = None) -> list[Key]:
|
||||||
|
try:
|
||||||
|
if isinstance(type_, str):
|
||||||
|
License.KeyContainer.KeyType.Value(type_) # only test
|
||||||
|
elif isinstance(type_, int):
|
||||||
|
type_ = License.KeyContainer.KeyType.Name(type_)
|
||||||
|
elif type_ is None:
|
||||||
|
type_ = "ALL"
|
||||||
|
else:
|
||||||
|
raise TypeError(f"Expected type_ to be a {License.KeyContainer.KeyType} or int, not {type_!r}")
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"Could not parse type_ as a {License.KeyContainer.KeyType}, {e}")
|
||||||
|
|
||||||
|
r = self.__session.post(
|
||||||
|
url=f"{self.host}/{self.device_name}/get_keys/{type_}",
|
||||||
|
json={
|
||||||
|
"session_id": session_id.hex()
|
||||||
|
}
|
||||||
|
).json()
|
||||||
|
if r["status"] != 200:
|
||||||
|
raise ValueError(f"Could not get {type_} Keys, {r['message']} [{r['status']}]")
|
||||||
|
r = r["data"]
|
||||||
|
|
||||||
|
return [
|
||||||
|
Key(
|
||||||
|
type_=key["type"],
|
||||||
|
kid=Key.kid_to_uuid(bytes.fromhex(key["key_id"])),
|
||||||
|
key=bytes.fromhex(key["key"]),
|
||||||
|
permissions=key["permissions"]
|
||||||
|
)
|
||||||
|
for key in r["keys"]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("RemoteCdm",)
|
||||||
458
pywidevine/serve.py
Normal file
458
pywidevine/serve.py
Normal file
@ -0,0 +1,458 @@
|
|||||||
|
import base64
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
|
from aiohttp.typedefs import Handler
|
||||||
|
from google.protobuf.message import DecodeError
|
||||||
|
|
||||||
|
from pywidevine.pssh import PSSH
|
||||||
|
|
||||||
|
try:
|
||||||
|
from aiohttp import web
|
||||||
|
except ImportError:
|
||||||
|
print(
|
||||||
|
"Missing the extra dependencies for serve functionality. "
|
||||||
|
"You may install them under poetry with `poetry install -E serve`, "
|
||||||
|
"or under pip with `pip install pywidevine[serve]`."
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
from pywidevine import __version__
|
||||||
|
from pywidevine.cdm import Cdm
|
||||||
|
from pywidevine.device import Device
|
||||||
|
from pywidevine.exceptions import (InvalidContext, InvalidInitData, InvalidLicenseMessage, InvalidLicenseType,
|
||||||
|
InvalidSession, SignatureMismatch, TooManySessions)
|
||||||
|
|
||||||
|
routes = web.RouteTableDef()
|
||||||
|
|
||||||
|
|
||||||
|
async def _startup(app: web.Application) -> None:
|
||||||
|
app["cdms"] = {}
|
||||||
|
app["config"]["devices"] = {
|
||||||
|
path.stem: path
|
||||||
|
for x in app["config"]["devices"]
|
||||||
|
for path in [Path(x)]
|
||||||
|
}
|
||||||
|
for device in app["config"]["devices"].values():
|
||||||
|
if not device.is_file():
|
||||||
|
raise FileNotFoundError(f"Device file does not exist: {device}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _cleanup(app: web.Application) -> None:
|
||||||
|
app["cdms"].clear()
|
||||||
|
del app["cdms"]
|
||||||
|
app["config"].clear()
|
||||||
|
del app["config"]
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get("/")
|
||||||
|
async def ping(_: Any) -> web.Response:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 200,
|
||||||
|
"message": "Pong!"
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get("/{device}/open")
|
||||||
|
async def open_(request: web.Request) -> web.Response:
|
||||||
|
secret_key = request.headers["X-Secret-Key"]
|
||||||
|
device_name = request.match_info["device"]
|
||||||
|
user = request.app["config"]["users"][secret_key]
|
||||||
|
|
||||||
|
if device_name not in user["devices"] or device_name not in request.app["config"]["devices"]:
|
||||||
|
# we don't want to be verbose with the error as to not reveal device names
|
||||||
|
# by trial and error to users that are not authorized to use them
|
||||||
|
return web.json_response({
|
||||||
|
"status": 403,
|
||||||
|
"message": f"Device '{device_name}' is not found or you are not authorized to use it."
|
||||||
|
}, status=403)
|
||||||
|
|
||||||
|
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
|
||||||
|
if not cdm:
|
||||||
|
device = Device.load(request.app["config"]["devices"][device_name])
|
||||||
|
cdm = request.app["cdms"][(secret_key, device_name)] = Cdm.from_device(device)
|
||||||
|
|
||||||
|
try:
|
||||||
|
session_id = cdm.open()
|
||||||
|
except TooManySessions as e:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": str(e)
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"status": 200,
|
||||||
|
"message": "Success",
|
||||||
|
"data": {
|
||||||
|
"session_id": session_id.hex(),
|
||||||
|
"device": {
|
||||||
|
"system_id": cdm.system_id,
|
||||||
|
"security_level": cdm.security_level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get("/{device}/close/{session_id}")
|
||||||
|
async def close(request: web.Request) -> web.Response:
|
||||||
|
secret_key = request.headers["X-Secret-Key"]
|
||||||
|
device_name = request.match_info["device"]
|
||||||
|
session_id = bytes.fromhex(request.match_info["session_id"])
|
||||||
|
|
||||||
|
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
|
||||||
|
if not cdm:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"No Cdm session for {device_name} has been opened yet. No session to close."
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cdm.close(session_id)
|
||||||
|
except InvalidSession:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"status": 200,
|
||||||
|
"message": f"Successfully closed Session '{session_id.hex()}'."
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/{device}/set_service_certificate")
|
||||||
|
async def set_service_certificate(request: web.Request) -> web.Response:
|
||||||
|
secret_key = request.headers["X-Secret-Key"]
|
||||||
|
device_name = request.match_info["device"]
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
for required_field in ("session_id", "certificate"):
|
||||||
|
if required_field == "certificate":
|
||||||
|
has_field = required_field in body # it needs the key, but can be empty/null
|
||||||
|
else:
|
||||||
|
has_field = body.get(required_field)
|
||||||
|
if not has_field:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"Missing required field '{required_field}' in JSON body."
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# get session id
|
||||||
|
session_id = bytes.fromhex(body["session_id"])
|
||||||
|
|
||||||
|
# get cdm
|
||||||
|
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
|
||||||
|
if not cdm:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"No Cdm session for {device_name} has been opened yet. No session to use."
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# set service certificate
|
||||||
|
certificate = body.get("certificate")
|
||||||
|
try:
|
||||||
|
provider_id = cdm.set_service_certificate(session_id, certificate)
|
||||||
|
except InvalidSession:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
|
||||||
|
}, status=400)
|
||||||
|
except DecodeError as e:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"Invalid Service Certificate, {e}"
|
||||||
|
}, status=400)
|
||||||
|
except SignatureMismatch:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": "Signature Validation failed on the Service Certificate, rejecting."
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"status": 200,
|
||||||
|
"message": f"Successfully {['set', 'unset'][not certificate]} the Service Certificate.",
|
||||||
|
"data": {
|
||||||
|
"provider_id": provider_id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/{device}/get_service_certificate")
|
||||||
|
async def get_service_certificate(request: web.Request) -> web.Response:
|
||||||
|
secret_key = request.headers["X-Secret-Key"]
|
||||||
|
device_name = request.match_info["device"]
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
for required_field in ("session_id",):
|
||||||
|
if not body.get(required_field):
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"Missing required field '{required_field}' in JSON body."
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# get session id
|
||||||
|
session_id = bytes.fromhex(body["session_id"])
|
||||||
|
|
||||||
|
# get cdm
|
||||||
|
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
|
||||||
|
if not cdm:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"No Cdm session for {device_name} has been opened yet. No session to use."
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# get service certificate
|
||||||
|
try:
|
||||||
|
service_certificate = cdm.get_service_certificate(session_id)
|
||||||
|
except InvalidSession:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
if service_certificate:
|
||||||
|
service_certificate_b64 = base64.b64encode(service_certificate.SerializeToString()).decode()
|
||||||
|
else:
|
||||||
|
service_certificate_b64 = None
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"status": 200,
|
||||||
|
"message": "Successfully got the Service Certificate.",
|
||||||
|
"data": {
|
||||||
|
"service_certificate": service_certificate_b64
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/{device}/get_license_challenge/{license_type}")
|
||||||
|
async def get_license_challenge(request: web.Request) -> web.Response:
|
||||||
|
secret_key = request.headers["X-Secret-Key"]
|
||||||
|
device_name = request.match_info["device"]
|
||||||
|
license_type = request.match_info["license_type"]
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
for required_field in ("session_id", "init_data"):
|
||||||
|
if not body.get(required_field):
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"Missing required field '{required_field}' in JSON body."
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# get session id
|
||||||
|
session_id = bytes.fromhex(body["session_id"])
|
||||||
|
|
||||||
|
# get privacy mode flag
|
||||||
|
privacy_mode = body.get("privacy_mode", True)
|
||||||
|
|
||||||
|
# get cdm
|
||||||
|
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
|
||||||
|
if not cdm:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"No Cdm session for {device_name} has been opened yet. No session to use."
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# enforce service certificate (opt-in)
|
||||||
|
if request.app["config"].get("force_privacy_mode"):
|
||||||
|
privacy_mode = True
|
||||||
|
if not cdm.get_service_certificate(session_id):
|
||||||
|
return web.json_response({
|
||||||
|
"status": 403,
|
||||||
|
"message": "No Service Certificate set but Privacy Mode is Enforced."
|
||||||
|
}, status=403)
|
||||||
|
|
||||||
|
# get init data
|
||||||
|
init_data = PSSH(body["init_data"])
|
||||||
|
|
||||||
|
# get challenge
|
||||||
|
try:
|
||||||
|
license_request = cdm.get_license_challenge(
|
||||||
|
session_id=session_id,
|
||||||
|
pssh=init_data,
|
||||||
|
license_type=license_type,
|
||||||
|
privacy_mode=privacy_mode
|
||||||
|
)
|
||||||
|
except InvalidSession:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
|
||||||
|
}, status=400)
|
||||||
|
except InvalidInitData as e:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"Invalid Init Data, {e}"
|
||||||
|
}, status=400)
|
||||||
|
except InvalidLicenseType:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"Invalid License Type '{license_type}'"
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"status": 200,
|
||||||
|
"message": "Success",
|
||||||
|
"data": {
|
||||||
|
"challenge_b64": base64.b64encode(license_request).decode()
|
||||||
|
}
|
||||||
|
}, status=200)
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/{device}/parse_license")
|
||||||
|
async def parse_license(request: web.Request) -> web.Response:
|
||||||
|
secret_key = request.headers["X-Secret-Key"]
|
||||||
|
device_name = request.match_info["device"]
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
for required_field in ("session_id", "license_message"):
|
||||||
|
if not body.get(required_field):
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"Missing required field '{required_field}' in JSON body."
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# get session id
|
||||||
|
session_id = bytes.fromhex(body["session_id"])
|
||||||
|
|
||||||
|
# get cdm
|
||||||
|
cdm: Optional[Cdm] = request.app["cdms"].get((secret_key, device_name))
|
||||||
|
if not cdm:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"No Cdm session for {device_name} has been opened yet. No session to use."
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# parse the license message
|
||||||
|
try:
|
||||||
|
cdm.parse_license(session_id, body["license_message"])
|
||||||
|
except InvalidSession:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
|
||||||
|
}, status=400)
|
||||||
|
except InvalidLicenseMessage as e:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"Invalid License Message, {e}"
|
||||||
|
}, status=400)
|
||||||
|
except InvalidContext as e:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"Invalid Context, {e}"
|
||||||
|
}, status=400)
|
||||||
|
except SignatureMismatch:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": "Signature Validation failed on the License Message, rejecting."
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"status": 200,
|
||||||
|
"message": "Successfully parsed and loaded the Keys from the License message."
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post("/{device}/get_keys/{key_type}")
|
||||||
|
async def get_keys(request: web.Request) -> web.Response:
|
||||||
|
secret_key = request.headers["X-Secret-Key"]
|
||||||
|
device_name = request.match_info["device"]
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
for required_field in ("session_id",):
|
||||||
|
if not body.get(required_field):
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"Missing required field '{required_field}' in JSON body."
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# get session id
|
||||||
|
session_id = bytes.fromhex(body["session_id"])
|
||||||
|
|
||||||
|
# get key type
|
||||||
|
key_type: Optional[str] = request.match_info["key_type"]
|
||||||
|
if key_type == "ALL":
|
||||||
|
key_type = None
|
||||||
|
|
||||||
|
# get cdm
|
||||||
|
cdm = request.app["cdms"].get((secret_key, device_name))
|
||||||
|
if not cdm:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"No Cdm session for {device_name} has been opened yet. No session to use."
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# get keys
|
||||||
|
try:
|
||||||
|
keys = cdm.get_keys(session_id, key_type)
|
||||||
|
except InvalidSession:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"Invalid Session ID '{session_id.hex()}', it may have expired."
|
||||||
|
}, status=400)
|
||||||
|
except ValueError as e:
|
||||||
|
return web.json_response({
|
||||||
|
"status": 400,
|
||||||
|
"message": f"The Key Type value '{key_type}' is invalid, {e}"
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# get the keys in json form
|
||||||
|
keys_json = [
|
||||||
|
{
|
||||||
|
"key_id": key.kid.hex,
|
||||||
|
"key": key.key.hex(),
|
||||||
|
"type": key.type,
|
||||||
|
"permissions": key.permissions,
|
||||||
|
}
|
||||||
|
for key in keys
|
||||||
|
if not key_type or key.type == key_type
|
||||||
|
]
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"status": 200,
|
||||||
|
"message": "Success",
|
||||||
|
"data": {
|
||||||
|
"keys": keys_json
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@web.middleware
|
||||||
|
async def authentication(request: web.Request, handler: Handler) -> web.Response:
|
||||||
|
secret_key = request.headers.get("X-Secret-Key")
|
||||||
|
|
||||||
|
if request.path != "/" and not secret_key:
|
||||||
|
request.app.logger.debug(f"{request.remote} did not provide authorization.")
|
||||||
|
response = web.json_response({
|
||||||
|
"status": "401",
|
||||||
|
"message": "Secret Key is Empty."
|
||||||
|
}, status=401)
|
||||||
|
elif request.path != "/" and secret_key not in request.app["config"]["users"]:
|
||||||
|
request.app.logger.debug(f"{request.remote} failed authentication with '{secret_key}'.")
|
||||||
|
response = web.json_response({
|
||||||
|
"status": "401",
|
||||||
|
"message": "Secret Key is Invalid, the Key is case-sensitive."
|
||||||
|
}, status=401)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
response = await handler(request) # type: ignore[assignment]
|
||||||
|
except web.HTTPException as e:
|
||||||
|
request.app.logger.error(f"An unexpected error has occurred, {e}")
|
||||||
|
response = web.json_response({
|
||||||
|
"status": 500,
|
||||||
|
"message": e.reason
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
response.headers.update({
|
||||||
|
"Server": f"https://github.com/devine-dl/pywidevine serve v{__version__}"
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def run(config: dict, host: Optional[Union[str, web.HostSequence]] = None, port: Optional[int] = None) -> None:
|
||||||
|
app = web.Application(middlewares=[authentication])
|
||||||
|
app.on_startup.append(_startup)
|
||||||
|
app.on_cleanup.append(_cleanup)
|
||||||
|
app.add_routes(routes)
|
||||||
|
app["config"] = config
|
||||||
|
web.run_app(app, host=host, port=port)
|
||||||
18
pywidevine/session.py
Normal file
18
pywidevine/session.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from Crypto.Random import get_random_bytes
|
||||||
|
|
||||||
|
from pywidevine.key import Key
|
||||||
|
from pywidevine.license_protocol_pb2 import SignedDrmCertificate
|
||||||
|
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
def __init__(self, number: int):
|
||||||
|
self.number = number
|
||||||
|
self.id = get_random_bytes(16)
|
||||||
|
self.service_certificate: Optional[SignedDrmCertificate] = None
|
||||||
|
self.context: dict[bytes, tuple[bytes, bytes]] = {}
|
||||||
|
self.keys: list[Key] = []
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("Session",)
|
||||||
12
pywidevine/utils.py
Normal file
12
pywidevine/utils.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def get_binary_path(*names: str) -> Optional[Path]:
|
||||||
|
"""Get the path of the first found binary name."""
|
||||||
|
for name in names:
|
||||||
|
path = shutil.which(name)
|
||||||
|
if path:
|
||||||
|
return Path(path)
|
||||||
|
return None
|
||||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
protobuf
|
||||||
|
pymp4==1.4.0
|
||||||
|
pycryptodome
|
||||||
|
click
|
||||||
|
requests
|
||||||
|
Unidecode
|
||||||
|
PyYAML
|
||||||
|
aiohttp
|
||||||
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\devine-dl\Documents\WVDs\test_device_001.wvd'
|
||||||
|
|
||||||
|
# List of User's by Secret Key. The Secret Key must be supplied by the User to use the API.
|
||||||
|
users:
|
||||||
|
fvYBh0C3fRAxlvyJcynD1see3GmNbIiC: # secret key, a-zA-Z-09{32} is recommended, case-sensitive
|
||||||
|
username: jane # only for internal logging, user will not see this name
|
||||||
|
devices: # list of allowed devices by filename
|
||||||
|
- test_key_001
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# All clients must provide a service certificate for privacy mode.
|
||||||
|
# If the client does not provide a certificate, privacy mode may or may not be used.
|
||||||
|
# Enforcing Privacy Mode helps protect the identity of the device and is recommended.
|
||||||
|
force_privacy_mode: true
|
||||||
Loading…
Reference in New Issue
Block a user