commit 97b8a79be81fd83c1c87363bf1efa6c47026eacb Author: larley <121249322+DevLARLEY@users.noreply.github.com> Date: Wed Aug 20 19:37:46 2025 +0200 Initial Commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1efa2ae --- /dev/null +++ b/README.md @@ -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 + + + + + +## 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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7698ed2 --- /dev/null +++ b/pyproject.toml @@ -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 ", "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" diff --git a/pywidevine/__init__.py b/pywidevine/__init__.py new file mode 100644 index 0000000..659ea25 --- /dev/null +++ b/pywidevine/__init__.py @@ -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" diff --git a/pywidevine/cdm.py b/pywidevine/cdm.py new file mode 100644 index 0000000..256cedf --- /dev/null +++ b/pywidevine/cdm.py @@ -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",) diff --git a/pywidevine/device.py b/pywidevine/device.py new file mode 100644 index 0000000..afa438b --- /dev/null +++ b/pywidevine/device.py @@ -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") diff --git a/pywidevine/exceptions.py b/pywidevine/exceptions.py new file mode 100644 index 0000000..3d6c5c7 --- /dev/null +++ b/pywidevine/exceptions.py @@ -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.""" diff --git a/pywidevine/key.py b/pywidevine/key.py new file mode 100644 index 0000000..c5f8b31 --- /dev/null +++ b/pywidevine/key.py @@ -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",) diff --git a/pywidevine/license_protocol.proto b/pywidevine/license_protocol.proto new file mode 100644 index 0000000..cd2fe4f --- /dev/null +++ b/pywidevine/license_protocol.proto @@ -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; +} diff --git a/pywidevine/license_protocol_pb2.py b/pywidevine/license_protocol_pb2.py new file mode 100644 index 0000000..92ae262 --- /dev/null +++ b/pywidevine/license_protocol_pb2.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: license_protocol.proto +# Protobuf Python Version: 4.25.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x16license_protocol.proto\x12\x1bpywidevine_license_protocol\"\xbd\x01\n\x15LicenseIdentification\x12\x12\n\nrequest_id\x18\x01 \x01(\x0c\x12\x12\n\nsession_id\x18\x02 \x01(\x0c\x12\x13\n\x0bpurchase_id\x18\x03 \x01(\x0c\x12\x36\n\x04type\x18\x04 \x01(\x0e\x32(.pywidevine_license_protocol.LicenseType\x12\x0f\n\x07version\x18\x05 \x01(\x05\x12\x1e\n\x16provider_session_token\x18\x06 \x01(\x0c\"\xf1\x18\n\x07License\x12>\n\x02id\x18\x01 \x01(\x0b\x32\x32.pywidevine_license_protocol.LicenseIdentification\x12;\n\x06policy\x18\x02 \x01(\x0b\x32+.pywidevine_license_protocol.License.Policy\x12>\n\x03key\x18\x03 \x03(\x0b\x32\x31.pywidevine_license_protocol.License.KeyContainer\x12\x1a\n\x12license_start_time\x18\x04 \x01(\x03\x12*\n\x1bremote_attestation_verified\x18\x05 \x01(\x08:\x05\x66\x61lse\x12\x1d\n\x15provider_client_token\x18\x06 \x01(\x0c\x12\x19\n\x11protection_scheme\x18\x07 \x01(\r\x12\x17\n\x0fsrm_requirement\x18\x08 \x01(\x0c\x12\x12\n\nsrm_update\x18\t \x01(\x0c\x12w\n\x1cplatform_verification_status\x18\n \x01(\x0e\x32\x37.pywidevine_license_protocol.PlatformVerificationStatus:\x18PLATFORM_NO_VERIFICATION\x12\x11\n\tgroup_ids\x18\x0b \x03(\x0c\x1a\xae\x04\n\x06Policy\x12\x17\n\x08\x63\x61n_play\x18\x01 \x01(\x08:\x05\x66\x61lse\x12\x1a\n\x0b\x63\x61n_persist\x18\x02 \x01(\x08:\x05\x66\x61lse\x12\x18\n\tcan_renew\x18\x03 \x01(\x08:\x05\x66\x61lse\x12\"\n\x17rental_duration_seconds\x18\x04 \x01(\x03:\x01\x30\x12$\n\x19playback_duration_seconds\x18\x05 \x01(\x03:\x01\x30\x12#\n\x18license_duration_seconds\x18\x06 \x01(\x03:\x01\x30\x12,\n!renewal_recovery_duration_seconds\x18\x07 \x01(\x03:\x01\x30\x12\x1a\n\x12renewal_server_url\x18\x08 \x01(\t\x12 \n\x15renewal_delay_seconds\x18\t \x01(\x03:\x01\x30\x12)\n\x1erenewal_retry_interval_seconds\x18\n \x01(\x03:\x01\x30\x12\x1f\n\x10renew_with_usage\x18\x0b \x01(\x08:\x05\x66\x61lse\x12\'\n\x18\x61lways_include_client_id\x18\x0c \x01(\x08:\x05\x66\x61lse\x12*\n\x1fplay_start_grace_period_seconds\x18\r \x01(\x03:\x01\x30\x12-\n\x1esoft_enforce_playback_duration\x18\x0e \x01(\x08:\x05\x66\x61lse\x12*\n\x1csoft_enforce_rental_duration\x18\x0f \x01(\x08:\x04true\x1a\xbc\x10\n\x0cKeyContainer\x12\n\n\x02id\x18\x01 \x01(\x0c\x12\n\n\x02iv\x18\x02 \x01(\x0c\x12\x0b\n\x03key\x18\x03 \x01(\x0c\x12G\n\x04type\x18\x04 \x01(\x0e\x32\x39.pywidevine_license_protocol.License.KeyContainer.KeyType\x12`\n\x05level\x18\x05 \x01(\x0e\x32?.pywidevine_license_protocol.License.KeyContainer.SecurityLevel:\x10SW_SECURE_CRYPTO\x12_\n\x13required_protection\x18\x06 \x01(\x0b\x32\x42.pywidevine_license_protocol.License.KeyContainer.OutputProtection\x12`\n\x14requested_protection\x18\x07 \x01(\x0b\x32\x42.pywidevine_license_protocol.License.KeyContainer.OutputProtection\x12Q\n\x0bkey_control\x18\x08 \x01(\x0b\x32<.pywidevine_license_protocol.License.KeyContainer.KeyControl\x12y\n operator_session_key_permissions\x18\t \x01(\x0b\x32O.pywidevine_license_protocol.License.KeyContainer.OperatorSessionKeyPermissions\x12q\n\x1cvideo_resolution_constraints\x18\n \x03(\x0b\x32K.pywidevine_license_protocol.License.KeyContainer.VideoResolutionConstraint\x12(\n\x19\x61nti_rollback_usage_table\x18\x0b \x01(\x08:\x05\x66\x61lse\x12\x13\n\x0btrack_label\x18\x0c \x01(\t\x1a\x33\n\nKeyControl\x12\x19\n\x11key_control_block\x18\x01 \x01(\x0c\x12\n\n\x02iv\x18\x02 \x01(\x0c\x1a\x9c\x05\n\x10OutputProtection\x12`\n\x04hdcp\x18\x01 \x01(\x0e\x32G.pywidevine_license_protocol.License.KeyContainer.OutputProtection.HDCP:\tHDCP_NONE\x12\x66\n\ncgms_flags\x18\x02 \x01(\x0e\x32G.pywidevine_license_protocol.License.KeyContainer.OutputProtection.CGMS:\tCGMS_NONE\x12y\n\rhdcp_srm_rule\x18\x03 \x01(\x0e\x32N.pywidevine_license_protocol.License.KeyContainer.OutputProtection.HdcpSrmRule:\x12HDCP_SRM_RULE_NONE\x12$\n\x15\x64isable_analog_output\x18\x04 \x01(\x08:\x05\x66\x61lse\x12%\n\x16\x64isable_digital_output\x18\x05 \x01(\x08:\x05\x66\x61lse\"y\n\x04HDCP\x12\r\n\tHDCP_NONE\x10\x00\x12\x0b\n\x07HDCP_V1\x10\x01\x12\x0b\n\x07HDCP_V2\x10\x02\x12\r\n\tHDCP_V2_1\x10\x03\x12\r\n\tHDCP_V2_2\x10\x04\x12\r\n\tHDCP_V2_3\x10\x05\x12\x1b\n\x16HDCP_NO_DIGITAL_OUTPUT\x10\xff\x01\"C\n\x04\x43GMS\x12\r\n\tCGMS_NONE\x10*\x12\r\n\tCOPY_FREE\x10\x00\x12\r\n\tCOPY_ONCE\x10\x02\x12\x0e\n\nCOPY_NEVER\x10\x03\"6\n\x0bHdcpSrmRule\x12\x16\n\x12HDCP_SRM_RULE_NONE\x10\x00\x12\x0f\n\x0b\x43URRENT_SRM\x10\x01\x1a\xba\x01\n\x19VideoResolutionConstraint\x12\x1d\n\x15min_resolution_pixels\x18\x01 \x01(\r\x12\x1d\n\x15max_resolution_pixels\x18\x02 \x01(\r\x12_\n\x13required_protection\x18\x03 \x01(\x0b\x32\x42.pywidevine_license_protocol.License.KeyContainer.OutputProtection\x1a\x9d\x01\n\x1dOperatorSessionKeyPermissions\x12\x1c\n\rallow_encrypt\x18\x01 \x01(\x08:\x05\x66\x61lse\x12\x1c\n\rallow_decrypt\x18\x02 \x01(\x08:\x05\x66\x61lse\x12\x19\n\nallow_sign\x18\x03 \x01(\x08:\x05\x66\x61lse\x12%\n\x16\x61llow_signature_verify\x18\x04 \x01(\x08:\x05\x66\x61lse\"l\n\x07KeyType\x12\x0b\n\x07SIGNING\x10\x01\x12\x0b\n\x07\x43ONTENT\x10\x02\x12\x0f\n\x0bKEY_CONTROL\x10\x03\x12\x14\n\x10OPERATOR_SESSION\x10\x04\x12\x0f\n\x0b\x45NTITLEMENT\x10\x05\x12\x0f\n\x0bOEM_CONTENT\x10\x06\"z\n\rSecurityLevel\x12\x14\n\x10SW_SECURE_CRYPTO\x10\x01\x12\x14\n\x10SW_SECURE_DECODE\x10\x02\x12\x14\n\x10HW_SECURE_CRYPTO\x10\x03\x12\x14\n\x10HW_SECURE_DECODE\x10\x04\x12\x11\n\rHW_SECURE_ALL\x10\x05\"\xbd\r\n\x0eLicenseRequest\x12\x44\n\tclient_id\x18\x01 \x01(\x0b\x32\x31.pywidevine_license_protocol.ClientIdentification\x12U\n\ncontent_id\x18\x02 \x01(\x0b\x32\x41.pywidevine_license_protocol.LicenseRequest.ContentIdentification\x12\x45\n\x04type\x18\x03 \x01(\x0e\x32\x37.pywidevine_license_protocol.LicenseRequest.RequestType\x12\x14\n\x0crequest_time\x18\x04 \x01(\x03\x12$\n\x1ckey_control_nonce_deprecated\x18\x05 \x01(\x0c\x12S\n\x10protocol_version\x18\x06 \x01(\x0e\x32,.pywidevine_license_protocol.ProtocolVersion:\x0bVERSION_2_0\x12\x19\n\x11key_control_nonce\x18\x07 \x01(\r\x12W\n\x13\x65ncrypted_client_id\x18\x08 \x01(\x0b\x32:.pywidevine_license_protocol.EncryptedClientIdentification\x1a\x8f\t\n\x15\x43ontentIdentification\x12p\n\x12widevine_pssh_data\x18\x01 \x01(\x0b\x32R.pywidevine_license_protocol.LicenseRequest.ContentIdentification.WidevinePsshDataH\x00\x12\x62\n\x0bwebm_key_id\x18\x02 \x01(\x0b\x32K.pywidevine_license_protocol.LicenseRequest.ContentIdentification.WebmKeyIdH\x00\x12m\n\x10\x65xisting_license\x18\x03 \x01(\x0b\x32Q.pywidevine_license_protocol.LicenseRequest.ContentIdentification.ExistingLicenseH\x00\x12_\n\tinit_data\x18\x04 \x01(\x0b\x32J.pywidevine_license_protocol.LicenseRequest.ContentIdentification.InitDataH\x00\x1ay\n\x10WidevinePsshData\x12\x11\n\tpssh_data\x18\x01 \x03(\x0c\x12>\n\x0clicense_type\x18\x02 \x01(\x0e\x32(.pywidevine_license_protocol.LicenseType\x12\x12\n\nrequest_id\x18\x03 \x01(\x0c\x1ao\n\tWebmKeyId\x12\x0e\n\x06header\x18\x01 \x01(\x0c\x12>\n\x0clicense_type\x18\x02 \x01(\x0e\x32(.pywidevine_license_protocol.LicenseType\x12\x12\n\nrequest_id\x18\x03 \x01(\x0c\x1a\xbe\x01\n\x0f\x45xistingLicense\x12\x46\n\nlicense_id\x18\x01 \x01(\x0b\x32\x32.pywidevine_license_protocol.LicenseIdentification\x12\x1d\n\x15seconds_since_started\x18\x02 \x01(\x03\x12!\n\x19seconds_since_last_played\x18\x03 \x01(\x03\x12!\n\x19session_usage_table_entry\x18\x04 \x01(\x0c\x1a\x8c\x02\n\x08InitData\x12u\n\x0einit_data_type\x18\x01 \x01(\x0e\x32W.pywidevine_license_protocol.LicenseRequest.ContentIdentification.InitData.InitDataType:\x04\x43\x45NC\x12\x11\n\tinit_data\x18\x02 \x01(\x0c\x12>\n\x0clicense_type\x18\x03 \x01(\x0e\x32(.pywidevine_license_protocol.LicenseType\x12\x12\n\nrequest_id\x18\x04 \x01(\x0c\"\"\n\x0cInitDataType\x12\x08\n\x04\x43\x45NC\x10\x01\x12\x08\n\x04WEBM\x10\x02\x42\x14\n\x12\x63ontent_id_variant\"0\n\x0bRequestType\x12\x07\n\x03NEW\x10\x01\x12\x0b\n\x07RENEWAL\x10\x02\x12\x0b\n\x07RELEASE\x10\x03\"\xf3\x01\n\nMetricData\x12\x12\n\nstage_name\x18\x01 \x01(\t\x12\x46\n\x0bmetric_data\x18\x02 \x03(\x0b\x32\x31.pywidevine_license_protocol.MetricData.TypeValue\x1a_\n\tTypeValue\x12@\n\x04type\x18\x01 \x01(\x0e\x32\x32.pywidevine_license_protocol.MetricData.MetricType\x12\x10\n\x05value\x18\x02 \x01(\x03:\x01\x30\"(\n\nMetricType\x12\x0b\n\x07LATENCY\x10\x01\x12\r\n\tTIMESTAMP\x10\x02\"K\n\x0bVersionInfo\x12\x1b\n\x13license_sdk_version\x18\x01 \x01(\t\x12\x1f\n\x17license_service_version\x18\x02 \x01(\t\"\xf6\x05\n\rSignedMessage\x12\x44\n\x04type\x18\x01 \x01(\x0e\x32\x36.pywidevine_license_protocol.SignedMessage.MessageType\x12\x0b\n\x03msg\x18\x02 \x01(\x0c\x12\x11\n\tsignature\x18\x03 \x01(\x0c\x12\x13\n\x0bsession_key\x18\x04 \x01(\x0c\x12\x1a\n\x12remote_attestation\x18\x05 \x01(\x0c\x12<\n\x0bmetric_data\x18\x06 \x03(\x0b\x32\'.pywidevine_license_protocol.MetricData\x12\x46\n\x14service_version_info\x18\x07 \x01(\x0b\x32(.pywidevine_license_protocol.VersionInfo\x12\x64\n\x10session_key_type\x18\x08 \x01(\x0e\x32\x39.pywidevine_license_protocol.SignedMessage.SessionKeyType:\x0fWRAPPED_AES_KEY\x12\x1e\n\x16oemcrypto_core_message\x18\t \x01(\x0c\"\xec\x01\n\x0bMessageType\x12\x13\n\x0fLICENSE_REQUEST\x10\x01\x12\x0b\n\x07LICENSE\x10\x02\x12\x12\n\x0e\x45RROR_RESPONSE\x10\x03\x12\x1f\n\x1bSERVICE_CERTIFICATE_REQUEST\x10\x04\x12\x17\n\x13SERVICE_CERTIFICATE\x10\x05\x12\x0f\n\x0bSUB_LICENSE\x10\x06\x12\x17\n\x13\x43\x41S_LICENSE_REQUEST\x10\x07\x12\x0f\n\x0b\x43\x41S_LICENSE\x10\x08\x12\x1c\n\x18\x45XTERNAL_LICENSE_REQUEST\x10\t\x12\x14\n\x10\x45XTERNAL_LICENSE\x10\n\"S\n\x0eSessionKeyType\x12\r\n\tUNDEFINED\x10\x00\x12\x13\n\x0fWRAPPED_AES_KEY\x10\x01\x12\x1d\n\x19\x45PHERMERAL_ECC_PUBLIC_KEY\x10\x02\"\xc7\x0e\n\x14\x43lientIdentification\x12Q\n\x04type\x18\x01 \x01(\x0e\x32;.pywidevine_license_protocol.ClientIdentification.TokenType:\x06KEYBOX\x12\r\n\x05token\x18\x02 \x01(\x0c\x12P\n\x0b\x63lient_info\x18\x03 \x03(\x0b\x32;.pywidevine_license_protocol.ClientIdentification.NameValue\x12\x1d\n\x15provider_client_token\x18\x04 \x01(\x0c\x12\x17\n\x0flicense_counter\x18\x05 \x01(\r\x12\x61\n\x13\x63lient_capabilities\x18\x06 \x01(\x0b\x32\x44.pywidevine_license_protocol.ClientIdentification.ClientCapabilities\x12\x10\n\x08vmp_data\x18\x07 \x01(\x0c\x12_\n\x12\x64\x65vice_credentials\x18\x08 \x03(\x0b\x32\x43.pywidevine_license_protocol.ClientIdentification.ClientCredentials\x1a(\n\tNameValue\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x1a\xd6\x08\n\x12\x43lientCapabilities\x12\x1b\n\x0c\x63lient_token\x18\x01 \x01(\x08:\x05\x66\x61lse\x12\x1c\n\rsession_token\x18\x02 \x01(\x08:\x05\x66\x61lse\x12+\n\x1cvideo_resolution_constraints\x18\x03 \x01(\x08:\x05\x66\x61lse\x12u\n\x10max_hdcp_version\x18\x04 \x01(\x0e\x32P.pywidevine_license_protocol.ClientIdentification.ClientCapabilities.HdcpVersion:\tHDCP_NONE\x12\x1e\n\x16oem_crypto_api_version\x18\x05 \x01(\r\x12(\n\x19\x61nti_rollback_usage_table\x18\x06 \x01(\x08:\x05\x66\x61lse\x12\x13\n\x0bsrm_version\x18\x07 \x01(\r\x12\x1d\n\x0e\x63\x61n_update_srm\x18\x08 \x01(\x08:\x05\x66\x61lse\x12\x7f\n\x1esupported_certificate_key_type\x18\t \x03(\x0e\x32W.pywidevine_license_protocol.ClientIdentification.ClientCapabilities.CertificateKeyType\x12\x98\x01\n\x1a\x61nalog_output_capabilities\x18\n \x01(\x0e\x32].pywidevine_license_protocol.ClientIdentification.ClientCapabilities.AnalogOutputCapabilities:\x15\x41NALOG_OUTPUT_UNKNOWN\x12(\n\x19\x63\x61n_disable_analog_output\x18\x0b \x01(\x08:\x05\x66\x61lse\x12\x1f\n\x14resource_rating_tier\x18\x0c \x01(\r:\x01\x30\"\x80\x01\n\x0bHdcpVersion\x12\r\n\tHDCP_NONE\x10\x00\x12\x0b\n\x07HDCP_V1\x10\x01\x12\x0b\n\x07HDCP_V2\x10\x02\x12\r\n\tHDCP_V2_1\x10\x03\x12\r\n\tHDCP_V2_2\x10\x04\x12\r\n\tHDCP_V2_3\x10\x05\x12\x1b\n\x16HDCP_NO_DIGITAL_OUTPUT\x10\xff\x01\"i\n\x12\x43\x65rtificateKeyType\x12\x0c\n\x08RSA_2048\x10\x00\x12\x0c\n\x08RSA_3072\x10\x01\x12\x11\n\rECC_SECP256R1\x10\x02\x12\x11\n\rECC_SECP384R1\x10\x03\x12\x11\n\rECC_SECP521R1\x10\x04\"\x8d\x01\n\x18\x41nalogOutputCapabilities\x12\x19\n\x15\x41NALOG_OUTPUT_UNKNOWN\x10\x00\x12\x16\n\x12\x41NALOG_OUTPUT_NONE\x10\x01\x12\x1b\n\x17\x41NALOG_OUTPUT_SUPPORTED\x10\x02\x12!\n\x1d\x41NALOG_OUTPUT_SUPPORTS_CGMS_A\x10\x03\x1au\n\x11\x43lientCredentials\x12Q\n\x04type\x18\x01 \x01(\x0e\x32;.pywidevine_license_protocol.ClientIdentification.TokenType:\x06KEYBOX\x12\r\n\x05token\x18\x02 \x01(\x0c\"s\n\tTokenType\x12\n\n\x06KEYBOX\x10\x00\x12\x1a\n\x16\x44RM_DEVICE_CERTIFICATE\x10\x01\x12\"\n\x1eREMOTE_ATTESTATION_CERTIFICATE\x10\x02\x12\x1a\n\x16OEM_DEVICE_CERTIFICATE\x10\x03\"\xbb\x01\n\x1d\x45ncryptedClientIdentification\x12\x13\n\x0bprovider_id\x18\x01 \x01(\t\x12)\n!service_certificate_serial_number\x18\x02 \x01(\x0c\x12\x1b\n\x13\x65ncrypted_client_id\x18\x03 \x01(\x0c\x12\x1e\n\x16\x65ncrypted_client_id_iv\x18\x04 \x01(\x0c\x12\x1d\n\x15\x65ncrypted_privacy_key\x18\x05 \x01(\x0c\"\xba\x07\n\x0e\x44rmCertificate\x12>\n\x04type\x18\x01 \x01(\x0e\x32\x30.pywidevine_license_protocol.DrmCertificate.Type\x12\x15\n\rserial_number\x18\x02 \x01(\x0c\x12\x1d\n\x15\x63reation_time_seconds\x18\x03 \x01(\r\x12\x1f\n\x17\x65xpiration_time_seconds\x18\x0c \x01(\r\x12\x12\n\npublic_key\x18\x04 \x01(\x0c\x12\x11\n\tsystem_id\x18\x05 \x01(\r\x12\"\n\x16test_device_deprecated\x18\x06 \x01(\x08\x42\x02\x18\x01\x12\x13\n\x0bprovider_id\x18\x07 \x01(\t\x12N\n\rservice_types\x18\x08 \x03(\x0e\x32\x37.pywidevine_license_protocol.DrmCertificate.ServiceType\x12M\n\talgorithm\x18\t \x01(\x0e\x32\x35.pywidevine_license_protocol.DrmCertificate.Algorithm:\x03RSA\x12\x0e\n\x06rot_id\x18\n \x01(\x0c\x12Q\n\x0e\x65ncryption_key\x18\x0b \x01(\x0b\x32\x39.pywidevine_license_protocol.DrmCertificate.EncryptionKey\x1ar\n\rEncryptionKey\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12M\n\talgorithm\x18\x02 \x01(\x0e\x32\x35.pywidevine_license_protocol.DrmCertificate.Algorithm:\x03RSA\"L\n\x04Type\x12\x08\n\x04ROOT\x10\x00\x12\x10\n\x0c\x44\x45VICE_MODEL\x10\x01\x12\n\n\x06\x44\x45VICE\x10\x02\x12\x0b\n\x07SERVICE\x10\x03\x12\x0f\n\x0bPROVISIONER\x10\x04\"\x86\x01\n\x0bServiceType\x12\x18\n\x14UNKNOWN_SERVICE_TYPE\x10\x00\x12\x16\n\x12LICENSE_SERVER_SDK\x10\x01\x12\x1c\n\x18LICENSE_SERVER_PROXY_SDK\x10\x02\x12\x14\n\x10PROVISIONING_SDK\x10\x03\x12\x11\n\rCAS_PROXY_SDK\x10\x04\"d\n\tAlgorithm\x12\x15\n\x11UNKNOWN_ALGORITHM\x10\x00\x12\x07\n\x03RSA\x10\x01\x12\x11\n\rECC_SECP256R1\x10\x02\x12\x11\n\rECC_SECP384R1\x10\x03\x12\x11\n\rECC_SECP521R1\x10\x04\"\xce\x01\n\x14SignedDrmCertificate\x12\x17\n\x0f\x64rm_certificate\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x12\x41\n\x06signer\x18\x03 \x01(\x0b\x32\x31.pywidevine_license_protocol.SignedDrmCertificate\x12G\n\x0ehash_algorithm\x18\x04 \x01(\x0e\x32/.pywidevine_license_protocol.HashAlgorithmProto\"\xf6\x05\n\x10WidevinePsshData\x12\x0f\n\x07key_ids\x18\x02 \x03(\x0c\x12\x12\n\ncontent_id\x18\x04 \x01(\x0c\x12\x1b\n\x13\x63rypto_period_index\x18\x07 \x01(\r\x12\x19\n\x11protection_scheme\x18\t \x01(\r\x12\x1d\n\x15\x63rypto_period_seconds\x18\n \x01(\r\x12H\n\x04type\x18\x0b \x01(\x0e\x32\x32.pywidevine_license_protocol.WidevinePsshData.Type:\x06SINGLE\x12\x14\n\x0ckey_sequence\x18\x0c \x01(\r\x12\x11\n\tgroup_ids\x18\r \x03(\x0c\x12P\n\rentitled_keys\x18\x0e \x03(\x0b\x32\x39.pywidevine_license_protocol.WidevinePsshData.EntitledKey\x12\x15\n\rvideo_feature\x18\x0f \x01(\t\x12N\n\talgorithm\x18\x01 \x01(\x0e\x32\x37.pywidevine_license_protocol.WidevinePsshData.AlgorithmB\x02\x18\x01\x12\x14\n\x08provider\x18\x03 \x01(\tB\x02\x18\x01\x12\x16\n\ntrack_type\x18\x05 \x01(\tB\x02\x18\x01\x12\x12\n\x06policy\x18\x06 \x01(\tB\x02\x18\x01\x12\x1b\n\x0fgrouped_license\x18\x08 \x01(\x0c\x42\x02\x18\x01\x1az\n\x0b\x45ntitledKey\x12\x1a\n\x12\x65ntitlement_key_id\x18\x01 \x01(\x0c\x12\x0e\n\x06key_id\x18\x02 \x01(\x0c\x12\x0b\n\x03key\x18\x03 \x01(\x0c\x12\n\n\x02iv\x18\x04 \x01(\x0c\x12&\n\x1a\x65ntitlement_key_size_bytes\x18\x05 \x01(\r:\x02\x33\x32\"5\n\x04Type\x12\n\n\x06SINGLE\x10\x00\x12\x0f\n\x0b\x45NTITLEMENT\x10\x01\x12\x10\n\x0c\x45NTITLED_KEY\x10\x02\"(\n\tAlgorithm\x12\x0f\n\x0bUNENCRYPTED\x10\x00\x12\n\n\x06\x41\x45SCTR\x10\x01\"\xd1\x01\n\nFileHashes\x12\x0e\n\x06signer\x18\x01 \x01(\x0c\x12\x45\n\nsignatures\x18\x02 \x03(\x0b\x32\x31.pywidevine_license_protocol.FileHashes.Signature\x1al\n\tSignature\x12\x10\n\x08\x66ilename\x18\x01 \x01(\t\x12\x14\n\x0ctest_signing\x18\x02 \x01(\x08\x12\x12\n\nSHA512Hash\x18\x03 \x01(\x0c\x12\x10\n\x08main_exe\x18\x04 \x01(\x08\x12\x11\n\tsignature\x18\x05 \x01(\x0c*8\n\x0bLicenseType\x12\r\n\tSTREAMING\x10\x01\x12\x0b\n\x07OFFLINE\x10\x02\x12\r\n\tAUTOMATIC\x10\x03*\xd9\x01\n\x1aPlatformVerificationStatus\x12\x17\n\x13PLATFORM_UNVERIFIED\x10\x00\x12\x15\n\x11PLATFORM_TAMPERED\x10\x01\x12\x1e\n\x1aPLATFORM_SOFTWARE_VERIFIED\x10\x02\x12\x1e\n\x1aPLATFORM_HARDWARE_VERIFIED\x10\x03\x12\x1c\n\x18PLATFORM_NO_VERIFICATION\x10\x04\x12-\n)PLATFORM_SECURE_STORAGE_SOFTWARE_VERIFIED\x10\x05*D\n\x0fProtocolVersion\x12\x0f\n\x0bVERSION_2_0\x10\x14\x12\x0f\n\x0bVERSION_2_1\x10\x15\x12\x0f\n\x0bVERSION_2_2\x10\x16*\x86\x01\n\x12HashAlgorithmProto\x12\x1e\n\x1aHASH_ALGORITHM_UNSPECIFIED\x10\x00\x12\x18\n\x14HASH_ALGORITHM_SHA_1\x10\x01\x12\x1a\n\x16HASH_ALGORITHM_SHA_256\x10\x02\x12\x1a\n\x16HASH_ALGORITHM_SHA_384\x10\x03\x42$\n com.rlaphoenix.pywidevine.protosH\x03') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'license_protocol_pb2', _globals) +if _descriptor._USE_C_DESCRIPTORS == False: + _globals['DESCRIPTOR']._options = None + _globals['DESCRIPTOR']._serialized_options = b'\n com.rlaphoenix.pywidevine.protosH\003' + _globals['_DRMCERTIFICATE'].fields_by_name['test_device_deprecated']._options = None + _globals['_DRMCERTIFICATE'].fields_by_name['test_device_deprecated']._serialized_options = b'\030\001' + _globals['_WIDEVINEPSSHDATA'].fields_by_name['algorithm']._options = None + _globals['_WIDEVINEPSSHDATA'].fields_by_name['algorithm']._serialized_options = b'\030\001' + _globals['_WIDEVINEPSSHDATA'].fields_by_name['provider']._options = None + _globals['_WIDEVINEPSSHDATA'].fields_by_name['provider']._serialized_options = b'\030\001' + _globals['_WIDEVINEPSSHDATA'].fields_by_name['track_type']._options = None + _globals['_WIDEVINEPSSHDATA'].fields_by_name['track_type']._serialized_options = b'\030\001' + _globals['_WIDEVINEPSSHDATA'].fields_by_name['policy']._options = None + _globals['_WIDEVINEPSSHDATA'].fields_by_name['policy']._serialized_options = b'\030\001' + _globals['_WIDEVINEPSSHDATA'].fields_by_name['grouped_license']._options = None + _globals['_WIDEVINEPSSHDATA'].fields_by_name['grouped_license']._serialized_options = b'\030\001' + _globals['_LICENSETYPE']._serialized_start=10442 + _globals['_LICENSETYPE']._serialized_end=10498 + _globals['_PLATFORMVERIFICATIONSTATUS']._serialized_start=10501 + _globals['_PLATFORMVERIFICATIONSTATUS']._serialized_end=10718 + _globals['_PROTOCOLVERSION']._serialized_start=10720 + _globals['_PROTOCOLVERSION']._serialized_end=10788 + _globals['_HASHALGORITHMPROTO']._serialized_start=10791 + _globals['_HASHALGORITHMPROTO']._serialized_end=10925 + _globals['_LICENSEIDENTIFICATION']._serialized_start=56 + _globals['_LICENSEIDENTIFICATION']._serialized_end=245 + _globals['_LICENSE']._serialized_start=248 + _globals['_LICENSE']._serialized_end=3433 + _globals['_LICENSE_POLICY']._serialized_start=764 + _globals['_LICENSE_POLICY']._serialized_end=1322 + _globals['_LICENSE_KEYCONTAINER']._serialized_start=1325 + _globals['_LICENSE_KEYCONTAINER']._serialized_end=3433 + _globals['_LICENSE_KEYCONTAINER_KEYCONTROL']._serialized_start=2128 + _globals['_LICENSE_KEYCONTAINER_KEYCONTROL']._serialized_end=2179 + _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION']._serialized_start=2182 + _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION']._serialized_end=2850 + _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION_HDCP']._serialized_start=2604 + _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION_HDCP']._serialized_end=2725 + _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION_CGMS']._serialized_start=2727 + _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION_CGMS']._serialized_end=2794 + _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION_HDCPSRMRULE']._serialized_start=2796 + _globals['_LICENSE_KEYCONTAINER_OUTPUTPROTECTION_HDCPSRMRULE']._serialized_end=2850 + _globals['_LICENSE_KEYCONTAINER_VIDEORESOLUTIONCONSTRAINT']._serialized_start=2853 + _globals['_LICENSE_KEYCONTAINER_VIDEORESOLUTIONCONSTRAINT']._serialized_end=3039 + _globals['_LICENSE_KEYCONTAINER_OPERATORSESSIONKEYPERMISSIONS']._serialized_start=3042 + _globals['_LICENSE_KEYCONTAINER_OPERATORSESSIONKEYPERMISSIONS']._serialized_end=3199 + _globals['_LICENSE_KEYCONTAINER_KEYTYPE']._serialized_start=3201 + _globals['_LICENSE_KEYCONTAINER_KEYTYPE']._serialized_end=3309 + _globals['_LICENSE_KEYCONTAINER_SECURITYLEVEL']._serialized_start=3311 + _globals['_LICENSE_KEYCONTAINER_SECURITYLEVEL']._serialized_end=3433 + _globals['_LICENSEREQUEST']._serialized_start=3436 + _globals['_LICENSEREQUEST']._serialized_end=5161 + _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION']._serialized_start=3944 + _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION']._serialized_end=5111 + _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_WIDEVINEPSSHDATA']._serialized_start=4391 + _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_WIDEVINEPSSHDATA']._serialized_end=4512 + _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_WEBMKEYID']._serialized_start=4514 + _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_WEBMKEYID']._serialized_end=4625 + _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_EXISTINGLICENSE']._serialized_start=4628 + _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_EXISTINGLICENSE']._serialized_end=4818 + _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_INITDATA']._serialized_start=4821 + _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_INITDATA']._serialized_end=5089 + _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_INITDATA_INITDATATYPE']._serialized_start=5055 + _globals['_LICENSEREQUEST_CONTENTIDENTIFICATION_INITDATA_INITDATATYPE']._serialized_end=5089 + _globals['_LICENSEREQUEST_REQUESTTYPE']._serialized_start=5113 + _globals['_LICENSEREQUEST_REQUESTTYPE']._serialized_end=5161 + _globals['_METRICDATA']._serialized_start=5164 + _globals['_METRICDATA']._serialized_end=5407 + _globals['_METRICDATA_TYPEVALUE']._serialized_start=5270 + _globals['_METRICDATA_TYPEVALUE']._serialized_end=5365 + _globals['_METRICDATA_METRICTYPE']._serialized_start=5367 + _globals['_METRICDATA_METRICTYPE']._serialized_end=5407 + _globals['_VERSIONINFO']._serialized_start=5409 + _globals['_VERSIONINFO']._serialized_end=5484 + _globals['_SIGNEDMESSAGE']._serialized_start=5487 + _globals['_SIGNEDMESSAGE']._serialized_end=6245 + _globals['_SIGNEDMESSAGE_MESSAGETYPE']._serialized_start=5924 + _globals['_SIGNEDMESSAGE_MESSAGETYPE']._serialized_end=6160 + _globals['_SIGNEDMESSAGE_SESSIONKEYTYPE']._serialized_start=6162 + _globals['_SIGNEDMESSAGE_SESSIONKEYTYPE']._serialized_end=6245 + _globals['_CLIENTIDENTIFICATION']._serialized_start=6248 + _globals['_CLIENTIDENTIFICATION']._serialized_end=8111 + _globals['_CLIENTIDENTIFICATION_NAMEVALUE']._serialized_start=6722 + _globals['_CLIENTIDENTIFICATION_NAMEVALUE']._serialized_end=6762 + _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES']._serialized_start=6765 + _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES']._serialized_end=7875 + _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES_HDCPVERSION']._serialized_start=7496 + _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES_HDCPVERSION']._serialized_end=7624 + _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES_CERTIFICATEKEYTYPE']._serialized_start=7626 + _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES_CERTIFICATEKEYTYPE']._serialized_end=7731 + _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES_ANALOGOUTPUTCAPABILITIES']._serialized_start=7734 + _globals['_CLIENTIDENTIFICATION_CLIENTCAPABILITIES_ANALOGOUTPUTCAPABILITIES']._serialized_end=7875 + _globals['_CLIENTIDENTIFICATION_CLIENTCREDENTIALS']._serialized_start=7877 + _globals['_CLIENTIDENTIFICATION_CLIENTCREDENTIALS']._serialized_end=7994 + _globals['_CLIENTIDENTIFICATION_TOKENTYPE']._serialized_start=7996 + _globals['_CLIENTIDENTIFICATION_TOKENTYPE']._serialized_end=8111 + _globals['_ENCRYPTEDCLIENTIDENTIFICATION']._serialized_start=8114 + _globals['_ENCRYPTEDCLIENTIDENTIFICATION']._serialized_end=8301 + _globals['_DRMCERTIFICATE']._serialized_start=8304 + _globals['_DRMCERTIFICATE']._serialized_end=9258 + _globals['_DRMCERTIFICATE_ENCRYPTIONKEY']._serialized_start=8827 + _globals['_DRMCERTIFICATE_ENCRYPTIONKEY']._serialized_end=8941 + _globals['_DRMCERTIFICATE_TYPE']._serialized_start=8943 + _globals['_DRMCERTIFICATE_TYPE']._serialized_end=9019 + _globals['_DRMCERTIFICATE_SERVICETYPE']._serialized_start=9022 + _globals['_DRMCERTIFICATE_SERVICETYPE']._serialized_end=9156 + _globals['_DRMCERTIFICATE_ALGORITHM']._serialized_start=9158 + _globals['_DRMCERTIFICATE_ALGORITHM']._serialized_end=9258 + _globals['_SIGNEDDRMCERTIFICATE']._serialized_start=9261 + _globals['_SIGNEDDRMCERTIFICATE']._serialized_end=9467 + _globals['_WIDEVINEPSSHDATA']._serialized_start=9470 + _globals['_WIDEVINEPSSHDATA']._serialized_end=10228 + _globals['_WIDEVINEPSSHDATA_ENTITLEDKEY']._serialized_start=10009 + _globals['_WIDEVINEPSSHDATA_ENTITLEDKEY']._serialized_end=10131 + _globals['_WIDEVINEPSSHDATA_TYPE']._serialized_start=10133 + _globals['_WIDEVINEPSSHDATA_TYPE']._serialized_end=10186 + _globals['_WIDEVINEPSSHDATA_ALGORITHM']._serialized_start=10188 + _globals['_WIDEVINEPSSHDATA_ALGORITHM']._serialized_end=10228 + _globals['_FILEHASHES']._serialized_start=10231 + _globals['_FILEHASHES']._serialized_end=10440 + _globals['_FILEHASHES_SIGNATURE']._serialized_start=10332 + _globals['_FILEHASHES_SIGNATURE']._serialized_end=10440 +# @@protoc_insertion_point(module_scope) diff --git a/pywidevine/license_protocol_pb2.pyi b/pywidevine/license_protocol_pb2.pyi new file mode 100644 index 0000000..44f543e --- /dev/null +++ b/pywidevine/license_protocol_pb2.pyi @@ -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__ = [] diff --git a/pywidevine/main.py b/pywidevine/main.py new file mode 100644 index 0000000..bef2dd5 --- /dev/null +++ b/pywidevine/main.py @@ -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) diff --git a/pywidevine/pssh.py b/pywidevine/pssh.py new file mode 100644 index 0000000..b81111a --- /dev/null +++ b/pywidevine/pssh.py @@ -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 "".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""" + + """ + + prr_value = f""" + + + + {key_ids_xml} + + {'%s' % la_url if la_url else ''} + {'%s' % lui_url if lui_url else ''} + {'%s' % base64.b64encode(ds_id).decode() if ds_id else ''} + {'%s' % decryptor_setup if decryptor_setup else ''} + {'%s' % custom_data if custom_data else ''} + + + """.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",) diff --git a/pywidevine/py.typed b/pywidevine/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/pywidevine/remotecdm.py b/pywidevine/remotecdm.py new file mode 100644 index 0000000..7e79ce0 --- /dev/null +++ b/pywidevine/remotecdm.py @@ -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",) diff --git a/pywidevine/serve.py b/pywidevine/serve.py new file mode 100644 index 0000000..07b0602 --- /dev/null +++ b/pywidevine/serve.py @@ -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) diff --git a/pywidevine/session.py b/pywidevine/session.py new file mode 100644 index 0000000..7cb816f --- /dev/null +++ b/pywidevine/session.py @@ -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",) diff --git a/pywidevine/utils.py b/pywidevine/utils.py new file mode 100644 index 0000000..6556d3e --- /dev/null +++ b/pywidevine/utils.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d7c9931 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +protobuf +pymp4==1.4.0 +pycryptodome +click +requests +Unidecode +PyYAML +aiohttp \ No newline at end of file diff --git a/serve.example.yml b/serve.example.yml new file mode 100644 index 0000000..83c99ae --- /dev/null +++ b/serve.example.yml @@ -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