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