pax_global_header00006660000000000000000000000064145254276440014527gustar00rootroot0000000000000052 comment=f5038d94dc0d8f8ea7a8075bcdc092e27862624b python-proton-core-0.1.16/000077500000000000000000000000001452542764400154025ustar00rootroot00000000000000python-proton-core-0.1.16/.gitignore000066400000000000000000000002011452542764400173630ustar00rootroot00000000000000build/ dist/ MANIFEST *.pyc *.egg-info/ .vscode/ *.lock __SOURCE_APP .env cov.xml html .idea/ .pybuild/ .coverage report.xml venvpython-proton-core-0.1.16/.gitlab-ci.yml000066400000000000000000000002341452542764400200350ustar00rootroot00000000000000variables: ALLOW_LINTING_FAILURE: "true" include: - project: 'ProtonVPN/Linux/integration/ci-libraries' ref: develop file: 'develop-pipeline.yml' python-proton-core-0.1.16/LICENSE000066400000000000000000001045151452542764400164150ustar00rootroot00000000000000 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 . python-proton-core-0.1.16/README.md000066400000000000000000000006571452542764400166710ustar00rootroot00000000000000# Proton core The `proton-core` component contains core logic used by the other Proton components. ## Development Even though our CI pipelines always test and build releases using Linux distribution packages, you can use pip to setup your development environment as follows: ```shell python3 -m venv venv source venv/bin/activate pip install -r requirements.txt ``` ### Tests You can run the tests with: ```shell pytest ``` python-proton-core-0.1.16/debian/000077500000000000000000000000001452542764400166245ustar00rootroot00000000000000python-proton-core-0.1.16/debian/.gitignore000066400000000000000000000002451452542764400206150ustar00rootroot00000000000000files debhelper-build-stamp python3-proton-core.postinst.debhelper python3-proton-core.prerm.debhelper python3-proton-core.substvars .debhelper/ python3-proton-core python-proton-core-0.1.16/debian/changelog000066400000000000000000000064411452542764400205030ustar00rootroot00000000000000proton-core (0.1.16) unstable; urgency=medium * fixing (another) race condition in async_refresh() -- Laurent Fasnacht Thu, 16 Nov 2023 13:05:53 +0100 proton-core (0.1.15) unstable; urgency=medium * fixing race condition in async_refresh() -- Xavier Piroux Tue, 24 Oct 2023 11:29:39 +0200 proton-core (0.1.14) unstable; urgency=medium * Fix crash on Python 3.12 -- Josep Llaneras Tue, 24 Oct 2023 10:26:36 +0200 proton-core (0.1.13) unstable; urgency=medium * Amend setup.py * Add minimum required python version -- Alexandru Cheltuitor Thu, 19 Oct 2023 13:00:00 +0100 proton-core (0.1.12) unstable; urgency=medium * async_api_request() : raise Exception instead of return None in case of error * AutoTransport.find_available_transport() can raise ProtonAPINotReachable -- Xavier Piroux Thu, 13 Jul 2023 08:04:00 +0200 proton-core (0.1.11) unstable; urgency=medium * API URL : https://vpn-api.proton.me * fixed Alternative Routing : support IP addresses -- Xavier Piroux Fri, 12 May 2023 13:48:11 +0200 proton-core (0.1.10) unstable; urgency=medium * Add license -- Alexandru Cheltuitor Wed, 19 Apr 2023 00:00:00 +0100 proton-core (0.1.9) unstable; urgency=medium * proton-sso: fixing 2fa -- Xavier Piroux Thu, 06 Apr 2023 06:22:46 +0200 proton-core (0.1.8) unstable; urgency=medium * Allow running proton.sso module -- Josep Llaneras Mon, 27 Mar 2023 14:58:52 +0200 proton-core (0.1.7) unstable; urgency=medium * Hide SSO CLI -- Alexandru Cheltuitor Wed, 15 Mar 2023 11:00:00 +0100 proton-core (0.1.6) unstable; urgency=medium * Fix invalid attribute -- Josep Llaneras Tue, 07 Mar 2023 18:36:40 +0100 proton-core (0.1.5) unstable; urgency=medium * Do not leak timeout errors when selecting transport -- Josep Llaneras Mon, 06 Mar 2023 13:00:00 +0100 proton-core (0.1.4) unstable; urgency=medium * Fix alternative routing crash during domain refresh -- Josep Llaneras Fri, 03 Mar 2023 19:16:11 +0100 proton-core (0.1.3) unstable; urgency=medium * Recursively create product folders -- Alexandru Cheltuitor Mon, 13 Feb 2023 14:00:00 +0100 proton-core (0.1.2) unstable; urgency=medium * Rely on API for username validation -- Alexandru Cheltuitor Thu, 09 Feb 2023 14:00:00 +0100 proton-core (0.1.1) unstable; urgency=medium * Handle aiohttp timeout error -- Josep Llaneras Wed, 08 Feb 2023 16:29:58 +0100 proton-core (0.1.0) unstable; urgency=medium * Support posting form-encoded data -- Josep Llaneras Fri, 20 Jan 2023 14:49:18 +0100 proton-core (0.0.2) UNRELEASED; urgency=medium * Make Loader.get_all thread safe. -- Josep Llaneras Wed, 14 Sep 2022 18:10:08 +0200 proton-core (0.0.1) UNRELEASED; urgency=medium * Initial release. -- Xavier Piroux Fri, 28 Jan 2022 16:56:27 +0100 python-proton-core-0.1.16/debian/compat000066400000000000000000000000031452542764400200230ustar00rootroot0000000000000011 python-proton-core-0.1.16/debian/control000066400000000000000000000012121452542764400202230ustar00rootroot00000000000000Source: proton-core Section: python Priority: optional Maintainer: Xavier Piroux Build-Depends: debhelper (>= 9), dh-python, python3-all, python3-setuptools, python3-bcrypt, python3-gnupg, python3-openssl, python3-requests, python3-aiohttp, python3-importlib-metadata, python3-pyotp Standards-Version: 4.1.1 X-Python3-Version: >= 3.2 Package: python3-proton-core Conflicts: python3-proton-client Architecture: all Depends: ${python3:Depends}, ${misc:Depends}, python3-bcrypt, python3-gnupg, python3-openssl, python3-requests, python3-aiohttp, python3-importlib-metadata Description: ProtonVPN client core library (python3) python-proton-core-0.1.16/debian/copyright000066400000000000000000000005121452542764400205550ustar00rootroot00000000000000Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Source: https://github.com/ProtonVPN/ Upstream-Name: python3-proton-core Files: * Copyright: 2023 Proton AG License: GPL-3 The full text of the GPL version 3 is distributed in /usr/share/common-licenses/GPL-3 on Debian systems. python-proton-core-0.1.16/debian/rules000077500000000000000000000001321452542764400177000ustar00rootroot00000000000000#!/usr/bin/make -f #export DH_VERBOSE=1 %: dh $@ --with python3 --buildsystem=pybuild python-proton-core-0.1.16/docs/000077500000000000000000000000001452542764400163325ustar00rootroot00000000000000python-proton-core-0.1.16/docs/conf.py000066400000000000000000000036611452542764400176370ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # # import os # import sys # sys.path.insert(0, os.path.abspath('.')) # -- Project information ----------------------------------------------------- project = 'python-proton-core' copyright = '2021, Proton' author = 'Proton' # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "sphinx.ext.autodoc" ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'sphinx_rtd_theme' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Order documentation in the same order as source autodoc_member_order = 'bysource' python-proton-core-0.1.16/docs/index.rst000066400000000000000000000007441452542764400202000ustar00rootroot00000000000000.. python-proton-core documentation master file, created by sphinx-quickstart on Sat Dec 25 21:03:59 2021. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Welcome to python-proton-core's documentation! ============================================== .. toctree:: :maxdepth: 2 :caption: Contents: loader sso session keyring views Indices and tables ================== * :ref:`genindex` python-proton-core-0.1.16/docs/keyring.rst000066400000000000000000000003631452542764400205360ustar00rootroot00000000000000Keyring ======= .. autoclass:: proton.keyring._base.Keyring :members: :private-members: :special-members: __getitem__, __setitem__, __delitem__ .. autoclass:: proton.keyring.textfile.KeyringBackendJsonFiles :private-members: python-proton-core-0.1.16/docs/loader.rst000066400000000000000000000001321452542764400203260ustar00rootroot00000000000000Component loader ================ .. autoclass:: proton.loader.loader.Loader :members:python-proton-core-0.1.16/docs/session.rst000066400000000000000000000004121452542764400205440ustar00rootroot00000000000000Session ======= Session object -------------- .. autoclass:: proton.session.Session :members: :special-members: __init__ :undoc-members: .. _exceptions: Exceptions ---------- .. automodule:: proton.session.exceptions :members: :inherited-members:python-proton-core-0.1.16/docs/sso.rst000066400000000000000000000002731452542764400176720ustar00rootroot00000000000000Single Sign On (SSO) ==================== .. autoclass:: proton.sso.ProtonSSO :members: :private-members: _acquire_session_lock, _release_session_lock :special-members: __init__python-proton-core-0.1.16/docs/views.rst000066400000000000000000000002411452542764400202160ustar00rootroot00000000000000Views ===== .. autoclass:: proton.views.BasicView :members: :special-members: __init__ .. autoclass:: proton.views.basiccli.BasicCLIView :members:python-proton-core-0.1.16/proton/000077500000000000000000000000001452542764400167235ustar00rootroot00000000000000python-proton-core-0.1.16/proton/keyring/000077500000000000000000000000001452542764400203735ustar00rootroot00000000000000python-proton-core-0.1.16/proton/keyring/__init__.py000066400000000000000000000013141452542764400225030ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from ._base import Keyring __all__ = ["Keyring"] python-proton-core-0.1.16/proton/keyring/_base.py000066400000000000000000000105261452542764400220220ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ import re from typing import Union from proton.loader import Loader class Keyring: """Base class for keyring implementations. Keyrings emulate a dictionary, with: * keys: lower case alphanumeric strings (dashes are allowed) * values: JSON-serializable list or dictionary. """ def __init__(self): pass @classmethod def get_from_factory(cls, backend: str = None) -> "Keyring": """ :param backend: Optional. Specific backend name. If backend is passed then it will attempt to get that specific backend, otherwise it will attempt to get the default backend. The definition of default is as follows: - The backend passes the `_validate()` - The backend with the highest `_get_priority()` value :raises RuntimeError: if there's no available backend """ keyring_backend = Loader.get("keyring", class_name=backend) return keyring_backend() def __getitem__(self, key: str): """Get an item from the keyring :param key: Key (lowercaps alphanumeric, dashes are allowed) :type key: str :raises TypeError: if key is not of valid type :raises ValueError: if key doesn't satisfy constraints :raises KeyError: if key does not exist :raises KeyringLocked: if keyring is locked when it shouldn't be :raises KeyringError: if there's something broken with keyring """ self._ensure_key_is_valid(key) return self._get_item(key) def __delitem__(self, key: str): """Remove an item from the keyring :param key: Key (lowercaps alphanumeric, dashes are allowed) :type key: str :raises TypeError: if key is not of valid type :raises ValueError: if key doesn't satisfy constraints :raises KeyError: if key does not exist :raises KeyringLocked: if keyring is locked when it shouldn't be :raises KeyringError: if there's something broken with keyring """ self._ensure_key_is_valid(key) self._del_item(key) def __setitem__(self, key: str, value: Union[dict, list]): """Add or replace an item in the keyring :param key: Key (lowercaps alphanumeric, dashes are allowed) :type key: str :param value: Value to set. It has to be json-serializable. :type value: dict or list :raises TypeError: if key or value is not of valid type :raises ValueError: if key or value doesn't satisfy constraints :raises KeyringLocked: if keyring is locked when it shouldn't be :raises KeyringError: if there's something broken with keyring """ self._ensure_key_is_valid(key) self._ensure_value_is_valid(value) self._set_item(key, value) def _get_item(self, key: str): raise NotImplementedError def _del_item(self, key: str): raise NotImplementedError def _set_item(self, key: str, value: Union[dict, list]): raise NotImplementedError def _ensure_key_is_valid(self, key): """Ensure key satisfies requirements""" if type(key) != str: raise TypeError(f"Invalid key for keyring: {key!r}") if not re.match(r'^[a-z0-9-]+$', key): raise ValueError("Keyring key should be alphanumeric") def _ensure_value_is_valid(self, value): """Ensure value satisfies requirements""" if not isinstance(value, dict) and not isinstance(value, list): raise TypeError(f"Provided value {value} is not a valid type (expect dict or list)") @classmethod def _get_priority(cls) -> int: return None @classmethod def _validate(cls): return False python-proton-core-0.1.16/proton/keyring/exceptions.py000066400000000000000000000020341452542764400231250ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ class KeyringError(Exception): """Base class for Proton API specific exceptions""" def __init__(self, message, additional_context=None): self.message = message self.additional_context = additional_context super().__init__(self.message) class KeyringLocked(KeyringError): """When keyring is locked but it shouldn't be, this exception is raised""" python-proton-core-0.1.16/proton/keyring/textfile.py000066400000000000000000000053031452542764400225720ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ import json import os from ..utils import ExecutionEnvironment from ._base import Keyring from .exceptions import KeyringError class KeyringBackendJsonFiles(Keyring): """Primitive data storage implementation, to be used when no better keyring is present. It stores each entry a json in the configuration path. """ def __init__(self, path_config=None): super().__init__() self.__path_base = path_config or ExecutionEnvironment().path_config def _get_item(self, key): filepath = self.__get_filename_for_key(key) if not os.path.exists(filepath): raise KeyError(key) try: with open(filepath, 'r') as f: return json.load(f) except json.JSONDecodeError as e: self._del_item(key) raise KeyError(key) from e def _del_item(self, key): filepath = self.__get_filename_for_key(key) if not os.path.exists(filepath): raise KeyError(key) os.unlink(filepath) def _set_item(self, key, value): try: with open(self.__get_filename_for_key(key), 'w') as f: json.dump(value, f) except TypeError as e: # The value we got is not serializable, thus a type error is thrown, # we re-raise it as a ValueError because the value that was provided was in # in un-expected format/type raise ValueError(value) from e except FileNotFoundError as e: # if the path was not previously created for some reason, # we get a FileNotFoundError raise KeyringError(key) from e def __get_filename_for_key(self, key): return os.path.join(self.__path_base, f'keyring-{key}.json') @classmethod def _get_priority(cls) -> int: return -1000 @classmethod def _validate(cls): is_able_to_write_in_dir = True try: ExecutionEnvironment().path_config except: # noqa is_able_to_write_in_dir = False return is_able_to_write_in_dir python-proton-core-0.1.16/proton/loader/000077500000000000000000000000001452542764400201715ustar00rootroot00000000000000python-proton-core-0.1.16/proton/loader/__init__.py000066400000000000000000000013601452542764400223020ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from .loader import Loader as LoaderClass Loader = LoaderClass() __all__ = ['Loader']python-proton-core-0.1.16/proton/loader/__main__.py000066400000000000000000000016721452542764400222710ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ if __name__ == '__main__': from . import Loader print("Available loaders:") for type_name in sorted(Loader.type_names): print(f' - {type_name}:') for prio, class_name, cls in Loader.get_all(type_name): print(f' - {class_name:30s} [{prio}]') python-proton-core-0.1.16/proton/loader/loader.py000066400000000000000000000247421452542764400220220ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from __future__ import annotations from importlib import metadata import os import threading import warnings from collections import namedtuple from typing import Optional from ..utils import Singleton PluggableComponent = namedtuple('PluggableComponent', ['priority', 'class_name', 'cls']) PluggableComponentName = namedtuple('PluggableComponentName', ['type_name', 'class_name']) class Loader(metaclass=Singleton): """This is the loader for pluggable components. These components are identified by a type name (string) and a class name (also a string). In normal use, one will only use :meth:`get`, as follows: .. code-block:: from proton.loader import Loader # Note the parenthesis to instanciate an object, as Loader.get() returns a class. my_keyring = Loader.get('keyring')() You can influence which component to use using the ``PROTON_LOADER_OVERRIDES`` environment variable. It's a comma separated list of ``type_name=class_name`` (to force ``class_name`` to be used) and ``type_name=-class_name`` (to exclude ``class_name`` from the options considered). To find the candidates, ``Loader`` will use entry points, that are to be defined in setup.py, as follows: .. code-block:: setup( #[...], entry_points={ "proton_loader_keyring": [ "json = proton.keyring.textfile:KeyringBackendJsonFiles" ] }, #[...] ) The class pointed by these entrypoints should implement the following class methods: * :meth:`_get_priority`: return a numeric value, larger ones have higher priority. If it's ``None``, then this class won't be considered * :meth:`_validate`: check if the object can indeed be used (might be expensive/annoying). If it returns ``False``, then the backend won't be considered for the rest of the session. If :meth:`_validate` is not defined, then it's assumed that it will always succeed. To display the list of valid values, you can use ``python3 -m proton.loader``. """ __loader_prefix = 'proton_loader_' def __init__(self): self.__known_types = {} self.__name_resolution_cache = {} self.__lock = threading.Lock() def get(self, type_name: str, class_name: Optional[str] = None) -> type: """Get the implementation for type_name. :param type_name: extension type :type type_name: str :param class_name: specific implementation to get, defaults to None (use preferred one) :type class_name: Optional[str], optional :raises RuntimeError: if no valid implementation can be found, or if PROTON_LOADER_OVERRIDES is invalid. :return: the class implementing type_name. (careful: it's a class, not an object!) :rtype: class """ acceptable_classes = self.get_all(type_name) for entry in acceptable_classes: # If caller specified the class he wanted, then we check only that. if class_name is not None: if entry.class_name == class_name: return entry.cls else: continue # Invalid priority, just continue (this will fail anyway because we have ordered the list in get_all, but for what it costs I prefer to go through the list) if entry.priority is None: continue # If we have a _validate class method, try to see if the object is indeed acceptable if hasattr(entry.cls, '_validate'): if entry.cls._validate(): return entry.cls else: # If not, remove that from the acceptable types definitely (it's broken) self.__known_types[type_name] = dict([(k,v) for k, v in self.__known_types[type_name].items() if v != entry.cls]) else: return entry.cls raise RuntimeError(f"Loader: couldn't find an acceptable implementation for {type_name}.") @property def type_names(self) -> list[str]: """ :return: Return a list of the known type names :rtype: list[str] """ return [x[len(self.__loader_prefix):] for x in self._proton_entry_point_groups.keys()] @property def _proton_entry_point_groups(self): metadata_entry_points = metadata.entry_points() try: # importlib.metadata.entry_points() uses the selectable interface in python >= 3.10 groups = metadata_entry_points.groups except AttributeError: # importlib.metadata.entry_points() uses the dict interface in python < 3.10 return { k: v for k, v in metadata_entry_points.items() if k.startswith(self.__loader_prefix) } return { group: metadata_entry_points.select(group=group) for group in groups if group.startswith(self.__loader_prefix) } def get_all(self, type_name: str) -> list[PluggableComponent]: """Get a list of all implementations for ``type_name``. :param type_name: type of implementation to query for :type type_name: str :raises RuntimeError: if ``PROTON_LOADER_OVERRIDES`` has conflicts :return: Implementation for type_name (this includes the ones that are disabled) :rtype: list[PluggableComponent] """ # If we don't have already loaded the entry points, just do so with self.__lock: # We use a lock here because a known type should only be available after it has been loaded. if type_name not in self.__known_types: metadata_group_name = self._get_metadata_group_for_typename(type_name) entry_points = self._proton_entry_point_groups.get(metadata_group_name, ()) self.__known_types[type_name] = {} for ep in entry_points: if ep.name in self.__known_types[type_name]: del self.__known_types[type_name] raise RuntimeError(f"Loader error : found 2 modules with same name (that would create security issues)") try: self.__known_types[type_name][ep.name] = ep.load() except AttributeError: warnings.warn(f"Loader: couldn't load {type_name}/{ep.name}, is it installed properly?", RuntimeWarning, stacklevel=2) continue self.__name_resolution_cache[self.__known_types[type_name][ep.name]] = PluggableComponentName(type_name, ep.name) # We do this at runtime, because we want to make sure we can change it after start. overrides = os.environ.get('PROTON_LOADER_OVERRIDES', '') overrides = [x.strip() for x in overrides.split()] overrides = [x[len(type_name)+1:] for x in overrides if x.startswith(f'{type_name}=')] force_class = set([x for x in overrides if not x.startswith('-')]) if len(force_class) == 1: force_class = list(force_class)[0] if force_class in self.__known_types[type_name]: acceptable_entry_points = [force_class] elif len(force_class) > 1: raise RuntimeError(f"Loader: PROTON_LOADER_OVERRIDES contains multiple force for {type_name}") else: # Load all entry_points, except those that are excluded by PROTON_LOADER_OVERRIDES acceptable_entry_points = [] for k in self.__known_types[type_name].keys(): if '-' + k not in overrides: acceptable_entry_points.append(k) acceptable_classes = [(v._get_priority(), k, v) for k, v in self.__known_types[type_name].items() if k in acceptable_entry_points] acceptable_classes += [(None, k, v) for k, v in self.__known_types[type_name].items() if k not in acceptable_entry_points] acceptable_classes_with_prio = [PluggableComponent(priority, class_name, v) for priority, class_name, v in acceptable_classes if priority is not None] acceptable_classes_without_prio = [PluggableComponent(priority, class_name, v) for priority, class_name, v in acceptable_classes if priority is None] # Sort the entries with priority, highest first acceptable_classes_with_prio.sort(reverse=True) return acceptable_classes_with_prio + acceptable_classes_without_prio def get_name(self, cls: type) -> Optional[PluggableComponentName]: """Return the type_name and class_name corresponding to the class in parameter. This is useful for inverse lookups (i.e. for logs for instance) :return: ``Tuple (type_name, class_name)`` :rtype: Optional[PluggableComponentName] """ return self.__name_resolution_cache.get(cls, None) def reset(self) -> None: """Erase the loader cache. (useful for tests)""" self.__known_types = {} self.__name_resolution_cache = {} def set_all(self, type_name: str, implementations : dict[str, type]): """Set a defined set of implementation for a given ``type_name``. This method is probably useful only for testing. :param type_name: Type :type type_name: str :param implementations: Dictionary implementation name -> implementation class :type implementations: dict[str, class] """ self.__known_types[type_name] = implementations for class_name, cls in implementations.items(): self.__name_resolution_cache[cls] = PluggableComponentName(type_name, class_name) def _get_metadata_group_for_typename(self, type_name: str) -> str: """Return the metadata group name for type_name :param type_name: type_name :type type_name: str :return: metadata group name :rtype: str """ return self.__loader_prefix + type_name python-proton-core-0.1.16/proton/session/000077500000000000000000000000001452542764400204065ustar00rootroot00000000000000python-proton-core-0.1.16/proton/session/__init__.py000066400000000000000000000014321452542764400225170ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from .api import Session # noqa from .formdata import FormData, FormField # noqa from .exceptions import ProtonAPIError # noqa python-proton-core-0.1.16/proton/session/api.py000066400000000000000000000747321452542764400215460ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from __future__ import annotations from typing import * from proton import session from .exceptions import ProtonCryptoError, ProtonAPIError, ProtonAPIAuthenticationNeeded, ProtonAPI2FANeeded, ProtonAPIMissingScopeError, ProtonAPIHumanVerificationNeeded from .srp import User as PmsrpUser from .environments import Environment from ..loader import Loader import asyncio import base64 import random SRP_MODULUS_KEY = """-----BEGIN PGP PUBLIC KEY BLOCK----- xjMEXAHLgxYJKwYBBAHaRw8BAQdAFurWXXwjTemqjD7CXjXVyKf0of7n9Ctm L8v9enkzggHNEnByb3RvbkBzcnAubW9kdWx1c8J3BBAWCgApBQJcAcuDBgsJ BwgDAgkQNQWFxOlRjyYEFQgKAgMWAgECGQECGwMCHgEAAPGRAP9sauJsW12U MnTQUZpsbJb53d0Wv55mZIIiJL2XulpWPQD/V6NglBd96lZKBmInSXX/kXat Sv+y0io+LR8i2+jV+AbOOARcAcuDEgorBgEEAZdVAQUBAQdAeJHUz1c9+KfE kSIgcBRE3WuXC4oj5a2/U3oASExGDW4DAQgHwmEEGBYIABMFAlwBy4MJEDUF hcTpUY8mAhsMAAD/XQD8DxNI6E78meodQI+wLsrKLeHn32iLvUqJbVDhfWSU WO4BAMcm1u02t4VKw++ttECPt+HUgPUq5pqQWe5Q2cW4TMsE =Y4Mw -----END PGP PUBLIC KEY BLOCK-----""" SRP_MODULUS_KEY_FINGERPRINT = "248097092b458509c508dac0350585c4e9518f26" def sync_wrapper(f): def wrapped_f(*a, **kw): try: loop = asyncio.get_running_loop() newloop = False except RuntimeError: newloop = True if not newloop: raise RuntimeError("It's forbidden to call sync_wrapped functions from an async one, please await directly the async one") loop = asyncio.new_event_loop() try: return loop.run_until_complete(f(*a, **kw)) finally: loop.close() wrapped_f.__doc__ = f"Synchronous wrapper for :meth:`{f.__name__}`" return wrapped_f class Session: def __init__(self, appversion : str = "Other", user_agent:str="None"): """Get a session towards the Proton API. :param appversion: version for the new Session object, defaults to ``"Other"`` :type appversion: str, optional :param user_agent: user agent to use, defaults to ``"None"``. It should be of the following syntax: * Linux based -> ``ClientName/client.version (Linux; Distro/distro_version)`` * Non-linux based -> ``ClientName/client.version (OS)`` :type user_agent: str, optional """ self.__appversion = appversion self.__user_agent = user_agent self.__UID = None self.__AccessToken = None self.__RefreshToken = None self.__Scopes = None self.__AccountName = None #Extra data that we want to persist (used if we load a session from a subclass) self.__extrastate = {} # Temporary storage for 2FA object self.__2FA = None #Refresh revision (incremented each time a refresh is done) #This allows knowing if a refresh should be done or if it is already in progress self.__refresh_revision = 0 #Lazy initialized by modulus decryption self.__gnupg_for_modulus = None #Lazy initialized by api request self.__transport = None self.__transport_factory = None self.transport_factory = None #Lazy initialized by request lock/unlock self.__can_run_requests = None #Lazy initialized by environment: self.__environment = None self.__persistence_observers = [] async def async_api_request(self, endpoint, jsondata=None, data=None, additional_headers=None, method=None, params=None, no_condition_check=False): """Do an API request. This call can return any of the exceptions defined in :mod:`proton.session.exceptions`. :param endpoint: API endpoint :type endpoint: str :param jsondata: JSON serializable dict to send as request data :type jsondata: dict :param data: data to be sent as either `multipart/form-data` or `application/x-www-form-urlencoded`. `multipart/form-data` is used when required, for example if data includes fields with a file-like value (i.e. is an instance of io.IOBase). :type data: FormData :param additional_headers: additional headers to send :type additional_headers: dict :param method: HTTP method (get|post|put|delete|patch) :type method: str :param params: URL parameters to append to the URL. If a dictionary or list of tuples ``[(key, value)]`` is provided, form-encoding will take place. :type params: str, dict or iterable :param no_condition_check: Internal flag to disable locking, defaults to False :type no_condition_check: bool, optional :return: Deserialized JSON reply :rtype: dict """ # We might need to loop attempts = 3 stored_exception = None # in case of too many attempts, raise instead of returning None while attempts > 0: attempts -= 1 try: refresh_revision_at_start = self.__refresh_revision return await self.__async_api_request_internal(endpoint, jsondata, data, additional_headers, method, params, no_condition_check) except ProtonAPIError as e: stored_exception = e # We have a missing scope. if e.http_code == 403: # If we need a 2FA authentication, then ask for it by sending a specific exception. if self.needs_twofa: raise ProtonAPI2FANeeded.from_proton_api_error(e) else: # Otherwise, just throw the 403 raise ProtonAPIMissingScopeError.from_proton_api_error(e) #401: token expired elif e.http_code == 401: #If we can refresh, than do it and retry if await self.async_refresh(only_when_refresh_revision_is=refresh_revision_at_start, no_condition_check=no_condition_check): continue #Else, fail :-( else: raise ProtonAPIAuthenticationNeeded.from_proton_api_error(e) #422 + 9001: Human verification needed elif e.http_code == 422 and e.body_code == 9001: raise ProtonAPIHumanVerificationNeeded.from_proton_api_error(e) #Invalid human verification token elif e.body_code == 12087: raise ProtonAPIHumanVerificationNeeded.from_proton_api_error(e) #These are codes which require and immediate retry elif e.http_code in (408, 502): continue #These not, let's retry more gracefully elif e.http_code in (429, 503): await self.__sleep_for_exception(e) continue #Something else, throw raise raise stored_exception # if we have reached that point without returning any value, an exception should have been stored async def async_authenticate(self, username: str, password: str, client_secret: str = None, no_condition_check: bool = False, additional_headers=None) -> bool: """Authenticate against Proton API :param username: Proton account username :type username: str :param password: Proton account password :type password: str :param client_secret: Client Secret for SRP :type client_secret: str, optional :param no_condition_check: Internal flag to disable locking, defaults to False :type no_condition_check: bool, optional :param additional_headers: additional headers to send :type additional_headers: dict :return: True if authentication succeeded, False otherwise. :rtype: bool """ self._requests_lock(no_condition_check) await self.async_logout(no_condition_check=True) try: req_data = {"Username": username} if client_secret is not None: req_data["ClientSecret"] = client_secret info_response = await self.__async_api_request_internal("/auth/info", req_data, no_condition_check=True, additional_headers=additional_headers) modulus = self._verify_modulus(info_response['Modulus']) server_challenge = base64.b64decode(info_response["ServerEphemeral"]) salt = base64.b64decode(info_response["Salt"]) version = info_response["Version"] usr = PmsrpUser(password, modulus) client_challenge = usr.get_challenge() client_proof = usr.process_challenge(salt, server_challenge, version) if client_proof is None: raise ProtonCryptoError('Invalid challenge') # Send response payload = { "Username": username, "ClientEphemeral": base64.b64encode(client_challenge).decode( 'utf8' ), "ClientProof": base64.b64encode(client_proof).decode('utf8'), "SRPSession": info_response["SRPSession"], } if client_secret is not None: payload["ClientSecret"] = client_secret try: auth_response = await self.__async_api_request_internal("/auth", payload, no_condition_check=True, additional_headers=additional_headers) except ProtonAPIError as e: if e.body_code == 8002: return False raise if "ServerProof" not in auth_response: return False usr.verify_session(base64.b64decode(auth_response["ServerProof"])) if not usr.authenticated(): raise ProtonCryptoError('Invalid server proof') self.__UID = auth_response['UID'] self.__AccessToken = auth_response['AccessToken'] self.__RefreshToken = auth_response['RefreshToken'] self.__Scopes = auth_response["Scopes"] self.__AccountName = username if '2FA' in auth_response: self.__2FA = auth_response['2FA'] else: self.__2FA = None return True finally: self._requests_unlock(no_condition_check) async def async_provide_2fa(self, code : str, no_condition_check=False, additional_headers=None) -> bool: """Provide Two Factor Authentication Code to the API. :param code: 2FA code :type code: str :param no_condition_check: Internal flag to disable locking, defaults to False :type no_condition_check: bool, optional :return: True if 2FA succeeded, False otherwise. :rtype: bool :raises ProtonAPIAuthenticationNeeded: if 2FA failed, and the session was reset by the API backend (this is normally the case) """ self._requests_lock(no_condition_check) try: ret = await self.__async_api_request_internal('/auth/2fa', { "TwoFactorCode": code }, no_condition_check=True, additional_headers=additional_headers) self.__Scopes = ret['Scopes'] if ret.get('Code') == 1000: self.__2FA = None return True return False except ProtonAPIError as e: if e.body_code == 8002: # 2FA jail, we need to start over (beware, we might hit login jails too) #Needs re-login self._clear_local_data() raise ProtonAPIAuthenticationNeeded.from_proton_api_error(e) if e.http_code == 401: return False raise finally: self._requests_unlock(no_condition_check) async def async_refresh(self, only_when_refresh_revision_is=None, no_condition_check=False, additional_headers=None): """Refresh tokens. Refresh AccessToken with a valid RefreshToken. If the RefreshToken is invalid then the user will have to re-authenticate. :return: True if refresh succeeded, False otherwise (doesn't throw an exception) :rtype: bool """ #If we have the correct revision, and it doesn't match, then just exit if only_when_refresh_revision_is is not None and only_when_refresh_revision_is != self.__refresh_revision: # If we have the wrong revision, then this indicates that we have two refresh running in parallel. # Thanksfully, we can simply wait for the other to complete and return successfully. await self._requests_wait(no_condition_check) return True self._requests_lock(no_condition_check) #Increment the refresh revision counter, so we don't refresh multiple times self.__refresh_revision += 1 attempts = 3 try: while attempts > 0: attempts -= 1 try: refresh_response = await self.__async_api_request_internal('/auth/refresh', { "ResponseType": "token", "GrantType": "refresh_token", "RefreshToken": self.__RefreshToken, "RedirectURI": "http://protonmail.ch" }, no_condition_check=True, additional_headers=additional_headers) self.__AccessToken = refresh_response["AccessToken"] self.__RefreshToken = refresh_response["RefreshToken"] self.__Scopes = refresh_response["Scopes"] return True except ProtonAPIError as e: #https://confluence.protontech.ch/display/API/Authentication%2C+sessions%2C+and+tokens#Authentication,sessions,andtokens-RefreshingSessions if e.http_code == 409: #409 Conflict - Indicates a race condition on the DB, and the request should be performed again continue #We're probably jailed, just retry later elif e.http_code in (429, 503): await self.__sleep_for_exception(e) continue elif e.http_code in (400, 422): #Needs re-login self._clear_local_data() return False return False finally: self._requests_unlock(no_condition_check) async def async_logout(self, no_condition_check=False, additional_headers=None): """Logout from API. :return: True if logout was successful (or nothing was done) :rtype: bool """ self._requests_lock(no_condition_check) previous_account_name = self.AccountName try: # No-op if not authenticated (but we do this inside the lock, so data is persisted nevertheless) if not self.authenticated: self._clear_local_data() return True ret = await self.__async_api_request_internal('/auth', method='DELETE', no_condition_check=True, additional_headers=additional_headers) # Erase any information we have about the session self._clear_local_data() return True except ProtonAPIError as e: # If we get a 401, then we should erase data (session doesn't exist on the server), and we're fine if e.http_code == 401: self._clear_local_data() return True # We don't know what is going on, throw raise finally: self._requests_unlock(no_condition_check, previous_account_name) async def async_lock(self, no_condition_check=False, additional_headers=None): """ Lock the current user (remove PASSWORD and LOCKED scopes)""" self._requests_lock(no_condition_check) try: ret = await self.__async_api_request_internal('/users/lock', method='PUT', no_condition_check=True, additional_headers=additional_headers) ret = await self.__async_api_request_internal('/auth/scopes', no_condition_check=True, additional_headers=additional_headers) self.__Scopes = ret['Scopes'] return True finally: self._requests_unlock(no_condition_check) #FIXME: clear user keys #FIXME: implement unlock async def async_human_verif_request_code(self, address=None, phone=None, additional_headers=None): """Request a verification code. Either address (email address) or phone (phone number) should be specified.""" assert address is not None ^ phone is not None # nosec (we use email validation by default if both are provided, but it's not super clean if the dev doesn't know about it) if address is not None: data = {'Type': 'email', 'Destination': {'Address': address}} elif phone is not None: data = {'Type': 'sms', 'Destination': {'Phone': phone}} return await self.async_api_request('/users/code', data, additional_headers=additional_headers).get('Code', 0) == 1000 async def async_human_verif_provide_token(self, method, token): pass # Wrappers to provide non-asyncio API api_request = sync_wrapper(async_api_request) authenticate = sync_wrapper(async_authenticate) provide_2fa = sync_wrapper(async_provide_2fa) logout = sync_wrapper(async_logout) refresh = sync_wrapper(async_refresh) lock = sync_wrapper(async_lock) human_verif_request_code = sync_wrapper(async_human_verif_request_code) human_verif_provide_token = sync_wrapper(async_human_verif_provide_token) def register_persistence_observer(self, observer: object): """Register an observer that will be notified of any persistent state change of the session :param observer: Observer to register. It has to provide the following interface (see :class:`proton.sso.ProtonSSO` for an actual implementation): * ``_acquire_session_lock(account_name : str, session_data : dict)`` * ``_release_session_lock(account_name : str, new_session_data : dict)`` :type observer: object """ self.__persistence_observers.append(observer) def _clear_local_data(self) -> None: """Clear locally cache data for logout (or equivalently, when the session is "lost").""" self.__UID = None self.__AccessToken = None self.__RefreshToken = None self.__Scopes = None self.__2FA = None self.__extrastate = {} @property def transport_factory(self): """Set/read the factory used for transports (i.e. how to reach the API). If the property is set to a class, it will be wrapped in a factory. If the property is set to None, then the default ``transport`` will be obtained from :class:`.Loader`. """ return self.__transport_factory @transport_factory.setter def transport_factory(self, new_transport_factory): from .transports import TransportFactory from ..loader import Loader self.__transport = None # If we don't set a new transport factory, then let's create a default one if new_transport_factory is None: default_transport = Loader.get('transport') self.__transport_factory = TransportFactory(default_transport) elif isinstance(new_transport_factory, TransportFactory): self.__transport_factory = new_transport_factory else: self.__transport_factory = TransportFactory(new_transport_factory) @property def appversion(self) -> str: """:return: The appversion defined at construction (used for creating requests by transports) :rtype: str""" return self.__appversion @property def user_agent(self) -> str: """:return: The user_agent defined at construction (used for creating requests by transports) :rtype: str""" return self.__user_agent @property def authenticated(self) -> bool: """:return: True if session is authenticated, False otherwise. :rtype: bool """ return self.__UID is not None @property def UID(self) -> Optional[str]: """:return: the session UID, None if not authenticated :rtype: str, optional """ return self.__UID @property def Scopes(self) -> Optional[list[str]]: """:return: list of scopes of the current session, None if unknown or not defined. :rtype: list[str], optional """ return self.__Scopes @property def AccountName(self) -> str: """:return: session account name (mostly used for SSO) :rtype: str """ return self.__AccountName @property def AccessToken(self) -> str: """:return: return the access token for API calls (used by transports) :rtype: str """ return self.__AccessToken @property def needs_twofa(self) -> bool: """:return: True if a 2FA authentication is needed, False otherwise. :rtype: bool """ if self.Scopes is None: return False return 'twofactor' in self.Scopes @property def environment(self): """Get/set the environment in use for that session. It can be only set once at the beginning of the session's object lifetime, as changing the environment can lead to security hole. If the new value is: * None: do nothing * a string: will use :meth:`Loader.get("environment", newvalue)` to get the actual class. * an environment: use it """ if self.__environment is None: from proton.loader import Loader self.__environment = Loader.get('environment')() return self.__environment @environment.setter def environment(self, newvalue): # Do nothing if we set to None if newvalue is None: return if isinstance(newvalue, str): newvalue = Loader.get("environment", newvalue)() if not isinstance(newvalue, Environment): raise TypeError("environment should be a subclass of Environment") #Same environment => nothing to do if self.__environment == newvalue: return if self.__environment is not None: raise ValueError("Cannot change environment of an established session (that would create security issues)!") self.__environment = newvalue def __setstate__(self, data): # If we're running an unpickle, then the object constructor hasn't been called, so we need to populate __dict__ for attr, default in (('gnupg_for_modulus', None), ('can_run_requests', None), ('transport', None), ('persistence_observers', [])): if '_Session__' + attr not in self.__dict__: self.__dict__['_Session__' + attr] = default # Restore data from LastUseData if we don't have it already (allow pickle load) for attr, default in (('2FA', None), ('appversion', 'Other'), ('user_agent', 'None'), ('refresh_revision', 0)): if '_Session__' + attr not in self.__dict__: self.__dict__['_Session__' + attr] = data.get('LastUseData', {}).get(attr, default) # We don't pickle the transport, so if not set just use the default if '_Session__transport_factory' not in self.__dict__: self.transport_factory = None self.__UID = data.get('UID', None) self.__AccessToken = data.get('AccessToken', None) self.__RefreshToken = data.get('RefreshToken', None) self.__Scopes = data.get('Scopes', None) self.__AccountName = data.get('AccountName', None) #Reset transport (user agent etc might have changed) self.__transport = None #get environment as stored in the session if data.get('Environment', None) is not None: self.__environment: Environment = Loader.get("environment", data.get('Environment', None))() else: self.__environment = None # Store everything we don't know about in extrastate self.__extrastate = dict([(k, v) for k, v in data.items() if k not in ('UID','AccessToken','RefreshToken','Scopes','AccountName','Environment', 'LastUseData')]) def __getstate__(self): # If we don't have an UID, then we're not logged in and we don't want to store a specific state if self.UID is None: data = {} else: data = { #Session data 'UID': self.UID, 'AccessToken': self.__AccessToken, 'RefreshToken': self.__RefreshToken, 'Scopes': self.Scopes, 'Environment': self.environment.name, 'AccountName': self.__AccountName, 'LastUseData': { '2FA': self.__2FA, 'appversion': self.__appversion, 'user_agent': self.__user_agent, 'refresh_revision': self.__refresh_revision, } } # Add the additional extra state data that we might have data.update(self.__extrastate) return data def _requests_lock(self, no_condition_check=False): """Lock the session, this has to be done when doing requests that affect the session state (i.e. :meth:`authenticate` for instance), to prevent race conditions. Internally, this is done using :class:`asyncio.Event`. :param no_condition_check: Internal flag to disable locking, defaults to False :type no_condition_check: bool, optional """ if no_condition_check: return if self.__can_run_requests is None: self.__can_run_requests = asyncio.Event() self.__can_run_requests.clear() # Lock observers (we're about to modify the session) account_name = self.AccountName session_data = self.__getstate__() for observer in self.__persistence_observers: observer._acquire_session_lock(account_name, session_data) def _requests_unlock(self, no_condition_check=False, account_name=None): """Unlock the session, this has to be done after doing requests that affect the session state (i.e. :meth:`authenticate` for instance), to prevent race conditions. :param no_condition_check: Internal flag to disable locking, defaults to False :type no_condition_check: bool, optional :param account_name: Allow providing explicitly the account_name of the session, useful when it's for a logout when the session might not exist any more :type no_condition_check: str, optional """ if no_condition_check: return if self.__can_run_requests is None: self.__can_run_requests = asyncio.Event() self.__can_run_requests.set() # Only store data if we have an actual account (session not logged in shouldn't store data) # If we have a known account, use it if self.AccountName is not None: account_name = self.AccountName session_data = self.__getstate__() else: session_data = None # Unlock observers (we might have modified the session) # It's important to do it in reverse order, as otherwise there's a risk of deadlocks for observer in reversed(self.__persistence_observers): observer._release_session_lock(account_name, session_data) async def _requests_wait(self, no_condition_check=False): """Wait for session unlock. :param no_condition_check: Internal flag to disable locking, defaults to False :type no_condition_check: bool, optional """ if no_condition_check or self.__can_run_requests is None: return await self.__can_run_requests.wait() async def __sleep_for_exception(self, e): if e.http_headers.get('retry-after','-').isnumeric(): await asyncio.sleep(int(e.http_headers.get('retry-after'))) else: await asyncio.sleep(3+random.random()*5) # nosec (no crypto risk here of using an unsafe generator) async def __async_api_request_internal( self, endpoint, jsondata=None, data=None, additional_headers=None, method=None, params=None, no_condition_check=False ): """Internal function to do an API request (without clever exception handling and retrying). See :meth:`async_api_request` for the parameters specification.""" # Should (and can we) create a transport if self.__transport is None and self.__transport_factory is not None: self.__transport = self.__transport_factory(self) if self.__transport is None: raise RuntimeError("Could not instanciate a transport, are required dependencies installed?") await self._requests_wait(no_condition_check) return await self.__transport.async_api_request(endpoint, jsondata, data, additional_headers, method, params) def _verify_modulus(self, armored_modulus) -> bytes: if self.__gnupg_for_modulus is None: import gnupg # Verify modulus self.__gnupg_for_modulus = gnupg.GPG() self.__gnupg_for_modulus.import_keys(SRP_MODULUS_KEY) # gpg.decrypt verifies the signature too, and returns the parsed data. # By using gpg.verify the data is not returned verified = self.__gnupg_for_modulus.decrypt(armored_modulus) if not (verified.valid and verified.fingerprint.lower() == SRP_MODULUS_KEY_FINGERPRINT): raise ProtonCryptoError('Invalid modulus') return base64.b64decode(verified.data.strip()) python-proton-core-0.1.16/proton/session/environments.py000066400000000000000000000043521452542764400235130ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ import abc from typing import Union, Optional class Environment(metaclass=abc.ABCMeta): @property def name(cls): cls_name = cls.__class__.__name__ assert cls_name.endswith('Environment'), "Incorrectly named class" # nosec (dev should ensure that to avoid issues) return cls_name[:-11].lower() @property def http_extra_headers(self): #This can be overriden, but by default we don't add extra headers return {} @property @abc.abstractmethod def http_base_url(self): pass @property @abc.abstractmethod def tls_pinning_hashes(self): pass @property @abc.abstractmethod def tls_pinning_hashes_ar(self): pass def __eq__(self, other): if other is None: return False return self.name == other.name class ProdEnvironment(Environment): @classmethod def _get_priority(cls): return 10 @property def http_base_url(self): return "https://vpn-api.proton.me" @property def tls_pinning_hashes(self): return set([ "CT56BhOTmj5ZIPgb/xD5mH8rY3BLo/MlhP7oPyJUEDo=", "35Dx28/uzN3LeltkCBQ8RHK0tlNSa2kCpCRGNp34Gxc=", "qYIukVc63DEITct8sFT7ebIq5qsWmuscaIKeJx+5J5A=", ]) @property def tls_pinning_hashes_ar(self): return set([ "EU6TS9MO0L/GsDHvVc9D5fChYLNy5JdGYpJw0ccgetM=", "iKPIHPnDNqdkvOnTClQ8zQAIKG0XavaPkcEo0LBAABA=", "MSlVrBCdL0hKyczvgYVSRNm88RicyY04Q2y5qrBt0xA=", "C2UxW0T1Ckl9s+8cXfjXxlEqwAfPM4HiW2y3UdtBeCw=" ]) python-proton-core-0.1.16/proton/session/exceptions.py000066400000000000000000000114661452542764400231510ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from __future__ import annotations from typing import Optional class ProtonError(Exception): """Base class for Proton API specific exceptions""" def __init__(self, message, additional_context=None): self.message = message self.additional_context = additional_context super().__init__(self.message) class ProtonCryptoError(ProtonError): """Exception thrown when something is wrong on the crypto side. In general this has to be handled as being fatal, as something is super-wrong.""" class ProtonUnsupportedAuthVersionError(ProtonCryptoError): """When the auth_version returned by the API is lower then what is currently supported. This is usually fixed with a login via the webclient.""" class ProtonAPIError(ProtonError): """Exception that is raised whenever the API call didn't return a 1000/1001 code. Logic for handling these depend on the call (see API doc) """ def __init__(self, http_code, http_headers, json_data): self._http_code = http_code self._http_headers = http_headers self._json_data = json_data super().__init__(f'[HTTP/{self.http_code}, {self.body_code}] {self.error}') @property def http_code(self) -> int: """:return: HTTP error code (401, 403, 422...) :rtype: int """ return self._http_code @property def http_headers(self) -> dict: """:return: Dictionary of HTTP headers of the error reply :rtype: dict """ return self._http_headers @property def json_data(self) -> dict: """:return: JSON data of the error reply :rtype: dict """ return self._json_data @property def body_code(self) -> int: """:return: Body error code ("Code" in JSON) :rtype: int """ return self._json_data['Code'] @property def error(self) -> str: """:return: Body error message ("Error" in JSON) :rtype: str """ return self._json_data['Error'] @classmethod def from_proton_api_error(cls, e : "ProtonAPIError"): """Construct an instance of this class, based on a ProtonAPIError (this allows to downcast to a more specific exception) :param e: Initial API exception :type e: ProtonAPIError :return: An instance of the current class :rtype: Any """ return cls(e._http_code, e._http_headers, e._json_data) class ProtonAPINotReachable(ProtonError): """Exception thrown when the transport couldn't reach the API. One may try using a different transport, or later if the error is transient.""" class ProtonAPINotAvailable(ProtonError): """Exception thrown when the API is reachable (i.e. at the TLS level), but doesn't work. This is definitive for that transport, it will not work by retrying in the same conditions.""" class ProtonAPIUnexpectedError(ProtonError): """Something went wrong, but we don't know how to handle it. Good luck :-)""" class ProtonAPIAuthenticationNeeded(ProtonAPIError): """We tried to call a route that requires authentication, but we don't have it. This should be solved by calling session.authenticate() with valid credentials""" class ProtonAPI2FANeeded(ProtonAPIError): """We need 2FA authentication, but it's not done yet. This should be solved by calling session.provide_2fa() with valid 2FA""" class ProtonAPIMissingScopeError(ProtonAPIError): """We don't have a required scope. This might be because of user rights, but also might require a call to unlock.""" class ProtonAPIHumanVerificationNeeded(ProtonAPIError): """Human verification is needed for this API call to succeed.""" @property def HumanVerificationToken(self) -> Optional[str]: """Get the Token for human verification""" return self.json_data.get('Details', {}).get('HumanVerificationToken', None) @property def HumanVerificationMethods(self) -> list[str]: """Return a list of allowed human verification methods. :return: human verification methods :rtype: list[str] """ return self.json_data.get('Details', {}).get('Methods', []) python-proton-core-0.1.16/proton/session/formdata.py000066400000000000000000000024641452542764400225630ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from typing import List, Any, Optional class FormField: """FormData entry.""" def __init__( self, name: str, value: Any, filename: Optional[str] = None, content_type: Optional[str] = None ): self.name = name self.value = value self.filename = filename self.content_type = content_type class FormData: """Data to be sent as form-encoded data, like an HTML form would.""" def __init__(self, fields: Optional[List[FormField]] = None): self.fields = fields or [] def add(self, field: FormField): """Appends a new field in the form.""" self.fields.append(field) python-proton-core-0.1.16/proton/session/srp/000077500000000000000000000000001452542764400212125ustar00rootroot00000000000000python-proton-core-0.1.16/proton/session/srp/README.md000066400000000000000000000017621452542764400224770ustar00rootroot00000000000000# Secure Remote Password submodule This submodule provides the interface to the custom implementation of ProtonMail's SRP API. It automatically tries to load the constant time ctypes + OpenSSL implementation, and on failure it uses the native long int implementation. It is based on [pysrp](https://github.com/cocagne/pysrp). ## Examples ### Authenticate against the API ```python from proton.srp import User usr = User(password, modulus) client_challenge = usr.get_challenge() # Get server challenge and user salt... client_proof = usr.process_challenge(salt, server_challenge, version) # Send client proof... usr.verify_session(server_proof) if usr.authenticated(): print("Logged in!") ``` ### Generate new random verifier ```python from proton.srp import User usr = User(password, modulus) generated_salt, generated_v = usr.compute_v() ``` ### Generate verifier given salt ```python from proton.srp import User usr = User(password, modulus) generated_salt, generated_v = usr.compute_v(salt) ``` python-proton-core-0.1.16/proton/session/srp/__init__.py000066400000000000000000000015061452542764400233250ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from . import _pysrp _mod = None try: from . import _ctsrp _mod = _ctsrp except (ImportError, OSError): pass if not _mod: _mod = _pysrp User = _mod.User python-proton-core-0.1.16/proton/session/srp/_ctsrp.py000066400000000000000000000234011452542764400230560ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ # N A large safe prime (N = 2q+1, where q is prime) # All arithmetic is done modulo N. # g A generator modulo N # k Multiplier parameter (k = H(N, g) in SRP-6a, k = 3 for legacy SRP-6) # s User's salt # I Username # p Cleartext Password # H() One-way hash function # ^ (Modular) Exponentiation # u Random scrambling parameter # a,b Secret ephemeral values # A,B Public ephemeral values # x Private key (derived from p and s) # v Password verifier from __future__ import division import ctypes import sys, os from .pmhash import pmhash from .util import PM_VERSION, SRP_LEN_BYTES, SALT_LEN_BYTES, hash_password dlls = list() platform = sys.platform if platform == 'darwin': dlls.append(ctypes.cdll.LoadLibrary('libssl.dylib')) elif 'win' in platform: for d in ('libeay32.dll', 'libssl32.dll', 'ssleay32.dll'): try: dlls.append(ctypes.cdll.LoadLibrary(d)) except Exception: #nosec pass else: try: dlls.append(ctypes.cdll.LoadLibrary('libssl.so.10')) except OSError: try: dlls.append(ctypes.cdll.LoadLibrary('libssl.so.1.0.0')) except OSError: dlls.append(ctypes.cdll.LoadLibrary('libssl.so')) class BIGNUM_Struct(ctypes.Structure): _fields_ = [("d", ctypes.c_void_p), ("top", ctypes.c_int), ("dmax", ctypes.c_int), ("neg", ctypes.c_int), ("flags", ctypes.c_int)] class BN_CTX_Struct(ctypes.Structure): _fields_ = [("_", ctypes.c_byte)] BIGNUM = ctypes.POINTER(BIGNUM_Struct) BN_CTX = ctypes.POINTER(BN_CTX_Struct) def load_func(name, args, returns=ctypes.c_int): d = sys.modules[__name__].__dict__ f = None for dll in dlls: try: f = getattr(dll, name) f.argtypes = args f.restype = returns d[name] = f return except Exception: #nosec pass raise ImportError('Unable to load required functions from SSL dlls') load_func('BN_new', [], BIGNUM) load_func('BN_free', [BIGNUM], None) load_func('BN_clear', [BIGNUM], None) load_func('BN_set_flags', [BIGNUM, ctypes.c_int], None) load_func('BN_CTX_new', [], BN_CTX) load_func('BN_CTX_free', [BN_CTX], None) load_func('BN_cmp', [BIGNUM, BIGNUM], ctypes.c_int) load_func('BN_num_bits', [BIGNUM], ctypes.c_int) load_func('BN_add', [BIGNUM, BIGNUM, BIGNUM]) load_func('BN_sub', [BIGNUM, BIGNUM, BIGNUM]) load_func('BN_mul', [BIGNUM, BIGNUM, BIGNUM, BN_CTX]) load_func('BN_div', [BIGNUM, BIGNUM, BIGNUM, BIGNUM, BN_CTX]) load_func('BN_mod_exp', [BIGNUM, BIGNUM, BIGNUM, BIGNUM, BN_CTX]) load_func('BN_rand', [BIGNUM, ctypes.c_int, ctypes.c_int, ctypes.c_int]) load_func('BN_bn2bin', [BIGNUM, ctypes.c_char_p]) load_func('BN_bin2bn', [ctypes.c_char_p, ctypes.c_int, BIGNUM], BIGNUM) load_func('BN_hex2bn', [ctypes.POINTER(BIGNUM), ctypes.c_char_p]) load_func('BN_bn2hex', [BIGNUM], ctypes.c_char_p) load_func('CRYPTO_free', [ctypes.c_char_p]) load_func('RAND_seed', [ctypes.c_char_p, ctypes.c_int]) def new_bn(): bn = BN_new() BN_set_flags(bn, 0x04) # BN_FLAG_CONSTTIME return bn def bn_num_bytes(a): return ((BN_num_bits(a) + 7) // 8) # noqa def bn_mod(rem, m, d, ctx): return BN_div(None, rem, m, d, ctx) # noqa def bn_is_zero(n): return n[0].top == 0 def bn_to_bytes(n, num_bytes): b = ctypes.create_string_buffer(bn_num_bytes(n)) BN_bn2bin(n, b) # noqa return b.raw[::-1].ljust(num_bytes, b'\0') def bytes_to_bn(dest_bn, bytes): BN_bin2bn(bytes[::-1], len(bytes), dest_bn) # noqa def bn_hash(hash_class, dest, n1, n2): h = hash_class() h.update(bn_to_bytes(n1, SRP_LEN_BYTES)) h.update(bn_to_bytes(n2, SRP_LEN_BYTES)) d = h.digest() bytes_to_bn(dest, d) def bn_hash_k(hash_class, dest, g, N, width): h = hash_class() bin1 = ctypes.create_string_buffer(width) bin2 = ctypes.create_string_buffer(width) BN_bn2bin(g, bin1) # noqa BN_bn2bin(N, bin2) # noqa h.update(bin1) h.update(bin2[::-1]) bytes_to_bn(dest, h.digest()) def calculate_x(hash_class, dest, salt, password, modulus, version): exp = hash_password( hash_class, password, salt, bn_to_bytes(modulus, SRP_LEN_BYTES), version ) bytes_to_bn(dest, exp) def update_hash(h, n): h.update(bn_to_bytes(n, SRP_LEN_BYTES)) def calculate_client_challenge(hash_class, A, B, K): h = hash_class() update_hash(h, A) update_hash(h, B) h.update(K) return h.digest() def calculate_server_challenge(hash_class, A, M, K): h = hash_class() update_hash(h, A) h.update(M) h.update(K) return h.digest() def get_ngk(hash_class, n_bin, g_hex, ctx): N = new_bn() # noqa g = new_bn() # noqa k = new_bn() # noqa bytes_to_bn(N, n_bin) BN_hex2bn(g, g_hex) # noqa bn_hash_k(hash_class, k, g, N, SRP_LEN_BYTES) return N, g, k class User(object): def __init__(self, password, n_bin, g_hex=b"2", bytes_a=None, bytes_A=None): # noqa if bytes_a and len(bytes_a) != 32: raise ValueError("32 bytes required for bytes_a") if not isinstance(password, str) or len(password) == 0: raise ValueError("Invalid password") self.password = password.encode() self.a = new_bn() # noqa self.A = new_bn() # noqa self.B = new_bn() # noqa self.S = new_bn() # noqa self.u = new_bn() # noqa self.x = new_bn() # noqa self.v = new_bn() # noqa self.tmp1 = new_bn() # noqa self.tmp2 = new_bn() # noqa self.tmp3 = new_bn() # noqa self.ctx = BN_CTX_new() # noqa self.M = None self.K = None self.expected_server_proof = None self._authenticated = False self.bytes_s = None self.hash_class = pmhash self.N, self.g, self.k = get_ngk(self.hash_class, n_bin, g_hex, self.ctx) # noqa if bytes_a: bytes_to_bn(self.a, bytes_a) else: BN_rand(self.a, 256, 0, 0) # noqa if bytes_A: bytes_to_bn(self.A, bytes_A) else: BN_mod_exp(self.A, self.g, self.a, self.N, self.ctx) # noqa def __del__(self): if not hasattr(self, 'a'): return # __init__ threw exception. no clean up required BN_free(self.a) # noqa BN_free(self.A) # noqa BN_free(self.B) # noqa BN_free(self.S) # noqa BN_free(self.u) # noqa BN_free(self.x) # noqa BN_free(self.v) # noqa BN_free(self.N) # noqa BN_free(self.g) # noqa BN_free(self.k) # noqa BN_free(self.tmp1) # noqa BN_free(self.tmp2) # noqa BN_free(self.tmp3) # noqa BN_CTX_free(self.ctx) # noqa def authenticated(self): return self._authenticated def get_ephemeral_secret(self): return bn_to_bytes(self.a, SRP_LEN_BYTES) def get_session_key(self): return self.K if self._authenticated else None def get_challenge(self): return bn_to_bytes(self.A, SRP_LEN_BYTES) # Returns M or None if SRP-6a safety check is violated def process_challenge( self, bytes_s, bytes_server_challenge, version=PM_VERSION ): self.bytes_s = bytes_s bytes_to_bn(self.B, bytes_server_challenge) # SRP-6a safety check if bn_is_zero(self.B): return None bn_hash(self.hash_class, self.u, self.A, self.B) # SRP-6a safety check if bn_is_zero(self.u): return None calculate_x( self.hash_class, self.x, self.bytes_s, self.password, self.N, version ) BN_mod_exp(self.v, self.g, self.x, self.N, self.ctx) # noqa # S = (B - k*(g^x)) ^ (a + ux) BN_mul(self.tmp1, self.u, self.x, self.ctx) # noqa BN_add(self.tmp2, self.a, self.tmp1) # noqa tmp2 = (a + ux) BN_mod_exp(self.tmp1, self.g, self.x, self.N, self.ctx) # noqa BN_mul(self.tmp3, self.k, self.tmp1, self.ctx) # noqa tmp3 = k*(g^x) BN_sub(self.tmp1, self.B, self.tmp3) # noqa tmp1 = (B - K*(g^x)) BN_mod_exp(self.S, self.tmp1, self.tmp2, self.N, self.ctx) # noqa self.K = bn_to_bytes(self.S, SRP_LEN_BYTES) self.M = calculate_client_challenge( self.hash_class, self.A, self.B, self.K ) self.expected_server_proof = calculate_server_challenge( self.hash_class, self.A, self.M, self.K ) return self.M def verify_session(self, server_proof): if self.expected_server_proof == server_proof: self._authenticated = True def compute_v(self, bytes_s=None, version=PM_VERSION): if bytes_s is None: salt = new_bn() BN_rand(salt, 10*8, 0, 0) # noqa self.bytes_s = bn_to_bytes(salt, SALT_LEN_BYTES) else: self.bytes_s = bytes_s calculate_x( self.hash_class, self.x, self.bytes_s, self.password, self.N, version ) BN_mod_exp(self.v, self.g, self.x, self.N, self.ctx) return self.bytes_s, bn_to_bytes(self.v, SRP_LEN_BYTES) # --------------------------------------------------------- # Init # RAND_seed(os.urandom(32), 32) # noqa python-proton-core-0.1.16/proton/session/srp/_pysrp.py000066400000000000000000000117441452542764400231070ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ # N A large safe prime (N = 2q+1, where q is prime) # All arithmetic is done modulo N. # g A generator modulo N # k Multiplier parameter (k = H(N, g) in SRP-6a, k = 3 for legacy SRP-6) # s User's salt # I Username # p Cleartext Password # H() One-way hash function # ^ (Modular) Exponentiation # u Random scrambling parameter # a,b Secret ephemeral values # A,B Public ephemeral values # x Private key (derived from p and s) # v Password verifier from .pmhash import pmhash from .util import (PM_VERSION, SRP_LEN_BYTES, SALT_LEN_BYTES, bytes_to_long, custom_hash, get_random_of_length, hash_password, long_to_bytes) def get_ng(n_bin, g_hex): return bytes_to_long(n_bin), int(g_hex, 16) def hash_k(hash_class, g, modulus, width): h = hash_class() h.update(g.to_bytes(width, 'little')) h.update(modulus.to_bytes(width, 'little')) return bytes_to_long(h.digest()) def calculate_x(hash_class, salt, password, modulus, version): exp = hash_password( hash_class, password, salt, long_to_bytes(modulus, SRP_LEN_BYTES), version ) return bytes_to_long(exp) def calculate_client_proof(hash_class, A, B, K): h = hash_class() h.update(long_to_bytes(A, SRP_LEN_BYTES)) h.update(long_to_bytes(B, SRP_LEN_BYTES)) h.update(K) return h.digest() def calculate_server_proof(hash_class, A, M, K): h = hash_class() h.update(long_to_bytes(A, SRP_LEN_BYTES)) h.update(M) h.update(K) return h.digest() class User(object): def __init__(self, password, n_bin, g_hex=b"2", bytes_a=None, bytes_A=None): # noqa if bytes_a and len(bytes_a) != 32: raise ValueError("32 bytes required for bytes_a") if not isinstance(password, str) or len(password) == 0: raise ValueError("Invalid password") self.N, self.g = get_ng(n_bin, g_hex) self.hash_class = pmhash self.k = hash_k( self.hash_class, self.g, self.N, SRP_LEN_BYTES ) self.p = password.encode() if bytes_a: self.a = bytes_to_long(bytes_a) else: self.a = get_random_of_length(32) if bytes_A: self.A = bytes_to_long(bytes_A) else: self.A = pow(self.g, self.a, self.N) self.v = None self.M = None self.K = None self.expected_server_proof = None self._authenticated = False self.bytes_s = None self.S = None self.B = None self.u = None self.x = None def authenticated(self): return self._authenticated def get_ephemeral_secret(self): return long_to_bytes(self.a, SRP_LEN_BYTES) def get_session_key(self): return self.K if self._authenticated else None def get_challenge(self): return long_to_bytes(self.A, SRP_LEN_BYTES) # Returns M or None if SRP-6a safety check is violated def process_challenge( self, bytes_s, bytes_server_challenge, version=PM_VERSION ): self.bytes_s = bytes_s self.B = bytes_to_long(bytes_server_challenge) # SRP-6a safety check if (self.B % self.N) == 0: return None self.u = custom_hash(self.hash_class, self.A, self.B) # SRP-6a safety check if self.u == 0: return None self.x = calculate_x(self.hash_class, self.bytes_s, self.p, self.N, version) self.v = pow(self.g, self.x, self.N) self.S = pow( (self.B - self.k * self.v), (self.a + self.u * self.x), self.N ) self.K = long_to_bytes(self.S, SRP_LEN_BYTES) self.M = calculate_client_proof(self.hash_class, self.A, self.B, self.K) # noqa self.expected_server_proof = calculate_server_proof( self.hash_class, self.A, self.M, self.K ) return self.M def verify_session(self, server_proof): if self.expected_server_proof == server_proof: self._authenticated = True def compute_v(self, bytes_s=None, version=PM_VERSION): self.bytes_s = long_to_bytes(get_random_of_length(SALT_LEN_BYTES), SALT_LEN_BYTES) if bytes_s is None else bytes_s # noqa self.x = calculate_x(self.hash_class, self.bytes_s, self.p, self.N, version) return self.bytes_s, long_to_bytes(pow(self.g, self.x, self.N), SRP_LEN_BYTES) # noqa python-proton-core-0.1.16/proton/session/srp/pmhash.py000066400000000000000000000025061452542764400230470ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ # Custom expanded version of SHA512 import hashlib class PMHash: digest_size = 256 name = 'PMHash' def __init__(self, b=b""): self.b = b def update(self, b): self.b += b def digest(self): return hashlib.sha512( self.b + b'\0' ).digest() + hashlib.sha512( self.b + b'\1' ).digest() + hashlib.sha512( self.b + b'\2' ).digest() + hashlib.sha512( self.b + b'\3' ).digest() def hexdigest(self): return self.digest().hex() def copy(self): return PMHash(self.b) def pmhash(b=b""): return PMHash(b) python-proton-core-0.1.16/proton/session/srp/util.py000066400000000000000000000051631452542764400225460ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ import base64 import bcrypt import os from proton.session.exceptions import ProtonUnsupportedAuthVersionError PM_VERSION = 4 SRP_LEN_BYTES = 256 SALT_LEN_BYTES = 10 def bcrypt_b64_encode(s): # The joy of bcrypt bcrypt_base64 = b"./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" # noqa std_base64chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" # noqa s = base64.b64encode(s) return s.translate(bytes.maketrans(std_base64chars, bcrypt_base64)) def hash_password_3(hash_class, password, salt, modulus): salt = (salt + b"proton")[:16] salt = bcrypt_b64_encode(salt)[:22] hashed = bcrypt.hashpw(password, b"$2y$10$" + salt) return hash_class(hashed + modulus).digest() def hash_password(hash_class, password, salt, modulus, version): if version == 4 or version == 3: return hash_password_3(hash_class, password, salt, modulus) # If the auth_version is lower then the # supported value 3 (which were dropped in 2018). In such a case, the user # needs to first login via web so that the auth version can be properly updated. # # This usually happens on older accounts that haven't been used in a while or # account that rarely login via the web client. raise ProtonUnsupportedAuthVersionError( "Account auth_version is not supported. " "Login via webclient for it to be updated." ) def bytes_to_long(s): return int.from_bytes(s, 'little') def long_to_bytes(n, num_bytes): return n.to_bytes(num_bytes, 'little') def get_random(nbytes): return bytes_to_long(os.urandom(nbytes)) def get_random_of_length(nbytes): offset = (nbytes * 8) - 1 return get_random(nbytes) | (1 << offset) def custom_hash(hash_class, *args, **kwargs): h = hash_class() for s in args: if s is not None: data = long_to_bytes(s, SRP_LEN_BYTES) if isinstance(s, int) else s h.update(data) return bytes_to_long(h.digest()) python-proton-core-0.1.16/proton/session/transports/000077500000000000000000000000001452542764400226255ustar00rootroot00000000000000python-proton-core-0.1.16/proton/session/transports/__init__.py000066400000000000000000000014771452542764400247470ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from .base import TransportFactory from .aiohttp import AiohttpTransport from .auto import AutoTransport from .alternativerouting import AlternativeRoutingTransport python-proton-core-0.1.16/proton/session/transports/aiohttp.py000066400000000000000000000143121452542764400246500ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from __future__ import annotations from proton.session.formdata import FormData from .. import Session from ..exceptions import * from .base import Transport import json, base64, asyncio, aiohttp, hashlib from OpenSSL import crypto from typing import Iterable, Union, Optional # It's stupid, but we have to inherit from aiohttp.Fingerprint to trigger the correct logic in aiohttp class AiohttpCertkeyFingerprint(aiohttp.Fingerprint): def __init__(self, fingerprints: Optional[Iterable[Union[bytes, str]]]) -> None: if fingerprints is not None: self._fingerprints = [] for fp in fingerprints: if type(fp) == str: self._fingerprints.append(base64.b64decode(fp)) else: self._fingerprints.append(fp) else: self._fingerprints = None def check(self, transport: asyncio.Transport) -> None: if not transport.get_extra_info("sslcontext"): return # Can't check anything if we don't have fingerprints if self._fingerprints is None: return sslobj = transport.get_extra_info("ssl_object") cert = sslobj.getpeercert(binary_form=True) cert_obj = crypto.load_certificate(crypto.FILETYPE_ASN1, cert) pubkey_obj = cert_obj.get_pubkey() pubkey = crypto.dump_publickey(crypto.FILETYPE_ASN1, pubkey_obj) pubkey_hash = hashlib.sha256(pubkey).digest() if pubkey_hash not in self._fingerprints: # Dump certificate, so we can diagnose if needed with: # base64 -d|openssl x509 -text -inform DER raise ProtonAPINotReachable(f"TLS pinning verification failed: {base64.b64encode(cert)}") class AiohttpTransport(Transport): def __init__(self, session: Session, form_data_transformer: FormDataTransformer = None): super().__init__(session) self._form_data_transformer = form_data_transformer or FormDataTransformer() @classmethod def _get_priority(cls): return 10 @property def tls_pinning_hashes(self): return self._environment.tls_pinning_hashes @property def http_base_url(self): return self._environment.http_base_url async def async_api_request( self, endpoint, jsondata=None, data=None, additional_headers=None, method=None, params=None ): if self.tls_pinning_hashes is not None: ssl_specs = AiohttpCertkeyFingerprint(self.tls_pinning_hashes) else: # Validate SSL normally if we didn't have fingerprints import ssl ssl_specs = ssl.create_default_context() ssl_specs.verify_mode = ssl.CERT_REQUIRED headers = { 'x-pm-appversion': self._session.appversion, 'User-Agent': self._session.user_agent, } if self._session.authenticated: headers['x-pm-uid'] = self._session.UID headers['Authorization'] = 'Bearer ' + self._session.AccessToken headers.update(self._environment.http_extra_headers) async with aiohttp.ClientSession(headers=headers) as s: # If we don't have an explicit method, default to get if there's no data, post otherwise if method is None: if not jsondata and not data: fct = s.get else: fct = s.post else: fct = { 'get': s.get, 'post': s.post, 'put': s.put, 'delete': s.delete, 'patch': s.patch }.get(method.lower()) if fct is None: raise ValueError("Unknown method: {}".format(method)) form_data = self._form_data_transformer.to_aiohttp_form_data(data) if data else None try: async with fct( self.http_base_url + endpoint, headers=additional_headers, json=jsondata, data=form_data, params=params, ssl=ssl_specs ) as ret: if ret.headers['content-type'] != 'application/json': raise ProtonAPINotReachable("API returned non-json results") try: ret_json = await ret.json() except json.decoder.JSONDecodeError: raise ProtonAPIError(ret.status, dict(ret.headers), {}) if ret_json['Code'] not in [1000, 1001]: raise ProtonAPIError(ret.status, dict(ret.headers), ret_json) return ret_json except aiohttp.ClientError as e: raise ProtonAPINotReachable("Connection error.") from e except asyncio.TimeoutError as e: raise ProtonAPINotReachable("Timeout error.") from e except ProtonAPINotReachable: raise except ProtonAPIError: raise except Exception as e: raise ProtonAPIUnexpectedError(e) class FormDataTransformer: @staticmethod def to_aiohttp_form_data(form_data: FormData) -> aiohttp.FormData: """ Converts proton.session.data.FormData into aiohttp.FormData. https://docs.aiohttp.org/en/stable/client_reference.html#formdata """ result = aiohttp.FormData() for field in form_data.fields: result.add_field( name=field.name, value=field.value, content_type=field.content_type, filename=field.filename ) return result python-proton-core-0.1.16/proton/session/transports/alternativerouting.py000066400000000000000000000155731452542764400271400ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from dataclasses import dataclass from typing import Awaitable, List import aiohttp from ..exceptions import * from .aiohttp import AiohttpTransport import json, base64, struct, time, asyncio, random, itertools from urllib.parse import urlparse from ..api import sync_wrapper from .utils.dns import DNSParser, DNSResponseError @dataclass class AlternativeRoutingDNSQueryAnswer: """Contains the result of a successful DNS query to retrieve the alternative routing server domain.""" expiration_time: float domain: str class AlternativeRoutingTransport(AiohttpTransport): DNS_PROVIDERS = [ #dns.google (("8.8.4.4", "8.8.8.8"), ("2001:4860:4860::8844", "2001:4860:4860::8888"), '/dns-query'), #dns11.quad9.net (("149.112.112.11", "9.9.9.11"), ("2620:fe::fe:11", "2620:fe::11"), '/dns-query'), ] STRUCT_REPLY_COUNTS = struct.Struct('>HHHH') STRUCT_REC_FORMAT = struct.Struct('>HHIH') #Delay between DNS requests DELAY_DNS_REQUEST = 2 TIMEOUT_DNS_REQUEST = 10 @classmethod def _get_priority(cls): return 5 def __init__(self, session): super().__init__(session) self._alternative_routes = [] @classmethod def _compute_ar_domain(cls, host): return b'd' + base64.b32encode(host.encode('ascii')).strip(b'=') + b".protonpro.xyz" async def _async_dns_query( self, domain, dns_server_ip, dns_server_path, delay=0 ) -> List[AlternativeRoutingDNSQueryAnswer]: import aiohttp if delay > 0: await asyncio.sleep(delay) ardomain = self._compute_ar_domain(domain) dns_request = DNSParser.build_query(ardomain, qtype=16, qclass=1) # TXT IN dot_url = f'https://{dns_server_ip}{dns_server_path}' async with aiohttp.ClientSession() as session: async with session.post(dot_url, headers=[("Content-Type","application/dns-message")], data=dns_request) as r: reply_data = await r.content.read() try: dns_answers = DNSParser.parse(reply_data) except DNSResponseError as e: raise ProtonAPINotReachable(str(e)) now = time.time() # Tuples (TTL, data) answers = [] for rec_ttl, rec_val in dns_answers: answers.append(AlternativeRoutingDNSQueryAnswer( expiration_time=now + rec_ttl, domain=rec_val) ) return answers @property def _http_domain(self): return urlparse(super().http_base_url).netloc async def _get_alternative_routes(self): # We generate a random list of dns servers, # we query them following that order, simultaneoulsy on IPv4/IPv6 choices_ipv4 = [] choices_ipv6 = [] for dns_server_ipv4s, dns_server_ipv6s, dns_server_path in self.DNS_PROVIDERS: for ip in dns_server_ipv4s: choices_ipv4.append((ip, dns_server_path)) for ip in dns_server_ipv6s: choices_ipv6.append((ip, dns_server_path)) random.shuffle(choices_ipv4) random.shuffle(choices_ipv6) pending = [] i = 0 for ipv4, ipv6 in itertools.zip_longest(choices_ipv4, choices_ipv6, fillvalue=None): if i * self.DELAY_DNS_REQUEST > self.TIMEOUT_DNS_REQUEST: break if ipv4 is not None: pending.append(asyncio.create_task(self._async_dns_query(self._http_domain, ipv4[0], ipv4[1], delay=i * self.DELAY_DNS_REQUEST))) if ipv6 is not None: pending.append(asyncio.create_task(self._async_dns_query(self._http_domain, f'[{ipv6[0]}]', ipv6[1], delay=i * self.DELAY_DNS_REQUEST))) i += 1 results_ok = [] results_fail = [] final_timestamp = time.time() + self.TIMEOUT_DNS_REQUEST while len(pending) > 0 and len(results_ok) == 0: done, pending = await asyncio.wait(pending, timeout=max(0.1, final_timestamp - time.time()), return_when=asyncio.FIRST_COMPLETED) for task in done: try: results_ok += task.result() except ProtonAPINotAvailable as e: # That means that we were able to do a resolution, but it explicitly failed # Cancel tasks and raise exception for task in pending: task.cancel() raise except Exception as e: results_fail.append(e) for task in pending: task.cancel() if len(results_ok) == 0: if len(self._alternative_routes) > 0: # We have routes, but we were not able to resolve new ones. Just keep the old ones return else: # No routes, and failed to get new ones raise ProtonAPINotReachable("Couldn't resolve any alternative routing names") domains = [x.domain for x in results_ok] # Filter names that are in our results (we don't want duplicates) self._alternative_routes = [ x for x in self._alternative_routes if x.domain not in domains and x.expiration_time >= time.time() ] # Add the results self._alternative_routes += results_ok # Sort them so we have the most recent on top self._alternative_routes.sort(key=lambda x: x.expiration_time, reverse=True) @property def http_base_url(self): if len(self._alternative_routes) == 0: raise ProtonAPINotReachable("AlternativeRouting transport doesn't have any route") path = urlparse(super().http_base_url).path return f'https://{self._alternative_routes[0].domain}{path}' @property def tls_pinning_hashes(self): return self._environment.tls_pinning_hashes_ar async def async_api_request( self, endpoint, jsondata=None, data=None, additional_headers=None, method=None, params=None ): if len(self._alternative_routes) == 0 or self._alternative_routes[0].expiration_time < time.time(): await self._get_alternative_routes() return await super().async_api_request(endpoint, jsondata, data, additional_headers, method, params) python-proton-core-0.1.16/proton/session/transports/auto.py000066400000000000000000000116701452542764400241540ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from asyncio import transports, TimeoutError from typing import List from unittest.mock import Mock from urllib.parse import urlparse import json, base64, struct, time, asyncio, random, itertools from ..exceptions import * from .base import Transport from .aiohttp import AiohttpTransport from .alternativerouting import AlternativeRoutingTransport from ..api import sync_wrapper class AutoTransport(Transport): # We assume that a given transport fails after that number of seconds TRANSPORT_TIMEOUT = 15 @classmethod def _get_priority(cls): return 100 def __init__(self, session, transport_choices: List[Transport] = None, transport_timeout: int = None): super().__init__(session) self._current_transport = None self._transport_choices = transport_choices or [ (0, AiohttpTransport), (5, AlternativeRoutingTransport) ] self._transport_timeout = transport_timeout or self.TRANSPORT_TIMEOUT @property def is_available(self) -> bool: return self._current_transport is not None @property def transport_choices(self): return self._transport_choices @transport_choices.setter def transport_choices(self, newvalue): self._transport_choices = [] for timeout, cls in newvalue: if not isinstance(cls, Transport): raise TypeError("Transports should be a subclass of Transport") self._transport_choices.append((timeout, cls)) self._transport_choices.sort(key=lambda x: x[0]) async def _ping_via_transport(self, timeout, transport): await asyncio.sleep(timeout) ping_url = "/tests/ping" try: result = await asyncio.wait_for(transport.async_api_request(ping_url), self._transport_timeout) except TimeoutError as error: raise ProtonAPINotReachable( f"{type(transport).__name__} transport not available: unable to reach {ping_url}" ) from error if result != {"Code": 1000}: raise ProtonAPINotAvailable( f"{type(transport).__name__} transport received unexpected response from {ping_url}:\n" f"{result}" ) return transport async def find_available_transport(self): pending = [] for timeout, cls in self._transport_choices: transport = cls(self._session) pending.append(asyncio.create_task(self._ping_via_transport(timeout, transport))) results_ok = [] results_fail = [] final_timestamp = time.time() + self._transport_timeout while len(pending) > 0 and len(results_ok) == 0: done, pending = await asyncio.wait(pending, timeout=max(0.1, final_timestamp - time.time()), return_when=asyncio.FIRST_COMPLETED) for task in done: try: results_ok.append(task.result()) except (ProtonAPINotAvailable, ProtonAPINotReachable) as e: # That means that we were able to get to the API (wasn't reachable or was mitm'ed) results_fail.append(e) except Exception as e: # Unhandled exception, we might want to understand what is going on for task in pending: task.cancel() raise for task in pending: task.cancel() if not results_ok: raise ProtonAPINotReachable("No working transports found") self._current_transport = results_ok[0] async def async_api_request( self, endpoint, jsondata=None, data=None, additional_headers=None, method=None, params=None ): tries_left = 3 while tries_left > 0: tries_left -= 1 if self._current_transport is None: await self.find_available_transport() try: return await asyncio.wait_for(self._current_transport.async_api_request(endpoint, jsondata, data, additional_headers, method, params), self._transport_timeout) except asyncio.TimeoutError: # Reset transport self._current_transport = None raise ProtonAPINotReachable("Timeout accessing the API") # we should not reach that point except in case of Timeout python-proton-core-0.1.16/proton/session/transports/base.py000066400000000000000000000036561452542764400241230ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ import weakref class Transport: def __init__(self, session): self.__session = weakref.ref(session) @property def _session(self): return self.__session() @property def _environment(self): #Shortcut to access environment return self._session.environment def __eq__(self, other): # It's the same transport if it's the same type (that's what users would generally assume) return self.__class__ == other.__class__ async def is_working(self): try: return await self.async_api_request('/tests/ping').get('Code') == '1000' except: return False async def async_api_request( self, endpoint, jsondata=None, additional_headers=None, method=None, params=None ): raise NotImplementedError("async_api_request should be implemented") class TransportFactory: def __init__(self, cls, *args, **kwargs): self._cls = cls self._args = args self._kwargs = kwargs def __call__(self, session): return self._cls(session, *self._args, **self._kwargs) def __eq__(self, other): # It's the same transport if it's the same type (that's what users would generally assume) return self._cls == other._cls python-proton-core-0.1.16/proton/session/transports/requests.py000066400000000000000000000113351452542764400250550ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ import io import requests from ..formdata import FormData from ..exceptions import * from .base import Transport import json class RequestsTransport(Transport): """ This is a simple transport based on the requests library, it's not advised to use in production """ def __init__(self, session, requests_session: requests.Session = None): super().__init__(session) self._s = requests_session or requests.Session() @classmethod def _get_priority(cls): try: return 3 except ImportError: return None async def async_api_request( self, endpoint, jsondata=None, data=None, additional_headers=None, method=None, params=None ): self._s.headers['x-pm-appversion'] = self._session.appversion self._s.headers['User-Agent'] = self._session.user_agent if self._session.authenticated: self._s.headers['x-pm-uid'] = self._session.UID self._s.headers['Authorization'] = 'Bearer ' + self._session.AccessToken # If we don't have an explicit method, default to get if there's no data, post otherwise if method is None: if not jsondata and not data: fct = self._s.get else: fct = self._s.post else: fct = { 'get': self._s.get, 'post': self._s.post, 'put': self._s.put, 'delete': self._s.delete, 'patch': self._s.patch }.get(method.lower()) if fct is None: raise ValueError("Unknown method: {}".format(method)) data_dict = self._get_requests_data(data) if data else None files_dict = self._get_requests_files(data) if data else None try: ret = fct( self._environment.http_base_url + endpoint, headers=additional_headers, json=jsondata, data=data_dict, files=files_dict, params=params ) except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e: raise ProtonAPINotReachable(e) except (Exception, requests.exceptions.BaseHTTPError) as e: raise ProtonAPIUnexpectedError(e) try: ret_json = ret.json() except json.decoder.JSONDecodeError: raise ProtonAPIError(ret.status_code, dict(ret.headers), {}) if ret_json['Code'] not in [1000, 1001]: raise ProtonAPIError(ret.status_code, dict(ret.headers), ret_json) return ret_json @staticmethod def _get_requests_data(form_data: FormData) -> dict: """ Converts the FormData instance to a dict that can be passed as the data parameter in requests (e.g. `requests.post(url, data=data)`. File-like fields are ignored, use `_get_requests_files` for those. """ return { field.name: field.value for field in form_data.fields if not isinstance(field.value, io.IOBase) } @staticmethod def _get_requests_files(form_data: FormData) -> dict: """ Extracts the file-like fields to a dict that can be passed as the `files` parameter in requests (e.g. `requests.post(url, files=files`). """ # From https://requests.readthedocs.io/en/latest/api/#requests.request: # files – (optional) Dictionary of 'name': file-like-objects # (or {'name': file-tuple}) for multipart encoding upload. file-tuple # can be a 2-tuple ('filename', fileobj), 3-tuple ('filename', fileobj, 'content_type') # or a 4-tuple ('filename', fileobj, 'content_type', custom_headers), # where 'content-type' is a string defining the content type of the # given file and custom_headers a dict-like object containing additional # headers to add for the file. return { field.name: (field.filename, field.value, field.content_type) for field in form_data.fields if isinstance(field.value, io.IOBase) } python-proton-core-0.1.16/proton/session/transports/utils/000077500000000000000000000000001452542764400237655ustar00rootroot00000000000000python-proton-core-0.1.16/proton/session/transports/utils/__init__py000066400000000000000000000000001452542764400260060ustar00rootroot00000000000000python-proton-core-0.1.16/proton/session/transports/utils/dns.py000066400000000000000000000151311452542764400251240ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ import ipaddress import logging import struct import typing import random class DNSResponseError(Exception): pass class DNSParsingException(DNSResponseError): pass class DNSParser: """Parse response from any DNS resolvers""" STRUCT_REPLY_COUNTS = struct.Struct('>HHHH') STRUCT_REC_FORMAT = struct.Struct('>HHIH') _MINIMUM_RECORD_LENGTH = 12 # => Transaction ID/Flags/#Questions/#Answers/#AuthorRRs/#AdditRRs # type definitions IPvxAddress = typing.Union[ipaddress.IPv4Address, ipaddress.IPv6Address] ParsedData = typing.Union[str, IPvxAddress] @classmethod def parse(cls, reply_data) -> typing.Optional[typing.List[typing.Tuple[int, ParsedData]]]: """ parse DNS reply and returns list of : TTL, address(IP or CNAME)""" if len(reply_data) < cls._MINIMUM_RECORD_LENGTH: raise DNSParsingException(f"(truncated reply)") # Match reply code (0x0 = OK) dns_rcode = reply_data[3] & 0xf # ensure we have something to parse if dns_rcode == 0x3: #NXDOMAIN, this is fatal raise DNSResponseError("No alternative routing exists for this environment (NXDOMAIN)") elif dns_rcode != 0x0: raise DNSResponseError(f"DNS response error code: {dns_rcode}") # Get counts offset = 4 dns_qdcount, dns_ancount, dns_nscount, dns_arcount = cls.STRUCT_REPLY_COUNTS.unpack_from(reply_data[offset:]) offset += cls.STRUCT_REPLY_COUNTS.size # skip questions for dns_qd_idx in range(dns_qdcount): length, data = cls._get_name(reply_data, offset) # We ignore QTYPE/QCLASS offset += length + 4 # tuples (TTL, data) answers = [] # answers for dns_an_idx in range(dns_ancount): length, data = cls._get_name(reply_data, offset) offset += length try: rec_type, rec_class, rec_ttl, rec_dlen = cls.STRUCT_REC_FORMAT.unpack_from(reply_data[offset:]) except struct.error: raise DNSParsingException(f"(truncated record headers)") offset += cls.STRUCT_REC_FORMAT.size record = reply_data[offset:offset + rec_dlen] if offset + rec_dlen > len(reply_data): raise DNSParsingException(f"(truncated reply while parsing record)") offset += rec_dlen if rec_type == 0x10 and rec_class == 0x01: # IN TXT if record[0] != rec_dlen - 1: raise DNSParsingException(f"(length of TXT record doesn't match REC_DLEN)") if record[0] != len(record) - 1: raise DNSParsingException(f"(length of TXT record doesn't actual record data)") try: answers.append((int(rec_ttl), record[1:].decode('ascii'))) except UnicodeDecodeError: raise DNSParsingException(f"(UnicodeDecodeError in TXT record)") elif rec_type == 0x01 and rec_class == 0x01: # IN A if len(record) != 4: raise DNSParsingException(f"(length of A record doesn't match)") answers.append((int(rec_ttl), ipaddress.ip_address(record))) else: logging.warning(f"record type currently not supported: {rec_type}... skip") return answers @staticmethod def _get_name(buffer: bytes, offset=0): # Length that we've parsed (for that specific record) parsed_length = 0 # Have we jumped to somewhere else? has_jumped = False # Parts we've seen until now parts = [] # While we're in the buffer, and we are not on a null byte (terminator) while offset < len(buffer) and buffer[offset] != 0: # Read the length of the part, in one byte length = buffer[offset] # If the length starts with two one bytes, then it's a pointer if length & 0b1100_0000 == 0b1100_0000: offset = ((buffer[offset] & 0b0011_1111) << 8) + buffer[offset + 1] # Pointers have length 2 if not has_jumped: parsed_length += 2 # We're not any more in the current record, stop counting has_jumped = True else: # Real entry # Add the part if offset + 1 + length > len(buffer): raise DNSParsingException(f"DNS resolution failed (non-parsable value)") parts.append(buffer[offset + 1:offset + 1 + length]) # Add length, and the length byte if not has_jumped: parsed_length += length + 1 offset += length + 1 # This is for the 0-byte that terminates a name if not has_jumped: parsed_length += 1 return parsed_length, parts @classmethod def _build_simple_query(cls, domain: bytes, qtype: int, qclass: int): """internal utility to build the simplest DNS request we need""" id: bytes = struct.pack('!H', random.randint(0, 65535)) qtype: bytes = struct.pack('!H', qtype) qclass: bytes = struct.pack('!H', qclass) # it's a query with a single question, no AN, no RR, no AR return id + b"\x01\x20\x00\x01\x00\x00\x00\x00\x00\x00" + domain + qtype + qclass @classmethod def build_query(cls, fqdn: typing.Union[str, bytes], qtype, qclass): """Build a very simple dns request that is just a query with no AN, no RR, no AR, and a single question""" if type(fqdn) == str: domain = b''.join([bytes([len(el)]) + el.encode('ascii') for el in fqdn.split('.')]) + b'\x00' elif type(fqdn) == bytes: domain = b''.join([bytes([len(el)]) + el for el in fqdn.split(b'.')]) + b'\x00' else: raise TypeError("fqdn can only be str or bytes") query = cls._build_simple_query(domain, qtype, qclass) return query python-proton-core-0.1.16/proton/sso/000077500000000000000000000000001452542764400175275ustar00rootroot00000000000000python-proton-core-0.1.16/proton/sso/__init__.py000066400000000000000000000013161452542764400216410ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from .sso import ProtonSSO __all__ = ['ProtonSSO'] python-proton-core-0.1.16/proton/sso/__main__.py000066400000000000000000000176671452542764400216420ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from os import environ from proton import session from proton.session import exceptions from proton.session.api import Session import sys from proton.sso.sso import ProtonSSO from ..views._base import BasicView import enum class ProtonSSOPresenterCredentialLogicState(enum.Enum): CALL_BASE_FUNCTION = 0 NEEDS_AUTHENTICATE = 1 NEEDS_TWOFA = 2 class ProtonSSOPresenter: def __init__(self, view : BasicView, appversion=None, user_agent=None): from .sso import ProtonSSO self._view = view self._session = None self._provided_account_name = None self._client_secret: str = None kwargs_sso = {} if appversion is not None: kwargs_sso["appversion"] = appversion if user_agent is not None: kwargs_sso["user_agent"] = user_agent self._sso = ProtonSSO(**kwargs_sso) def set_session(self, account_name = None): self._provided_account_name = account_name if account_name is not None: self._session = self._sso.get_session(account_name) else: self._session = self._sso.get_default_session() def set_environment(self, environment): self._session.environment = environment def set_client_secret(self, client_secret): self._client_secret = client_secret def CredentialsLogic(base_function): import functools @functools.wraps(base_function) def wrapped_function(self : 'ProtonSSOPresenter', *a, **kw): from proton.session.exceptions import ProtonAPIAuthenticationNeeded, ProtonAPI2FANeeded, ProtonAPIMissingScopeError state = ProtonSSOPresenterCredentialLogicState.CALL_BASE_FUNCTION while True: try: if state == ProtonSSOPresenterCredentialLogicState.CALL_BASE_FUNCTION: return base_function(self, *a, **kw) elif state == ProtonSSOPresenterCredentialLogicState.NEEDS_AUTHENTICATE: account_name, password, twofa = self._view.ask_credentials(self._provided_account_name is None, True, False) if account_name is None: account_name = self._provided_account_name if password is None: break ret = self._session.authenticate(account_name, password, client_secret=self._client_secret), if ret: if self._session.needs_twofa: state = ProtonSSOPresenterCredentialLogicState.NEEDS_TWOFA else: state = ProtonSSOPresenterCredentialLogicState.CALL_BASE_FUNCTION else: self._view.display_error("Invalid credentials!") # Remain in NEEDS_AUTHENTICATE state elif state == ProtonSSOPresenterCredentialLogicState.NEEDS_TWOFA: account_name, password, twofa = self._view.ask_credentials(False, False, True) if twofa is None: break ret = self._session.provide_2fa(twofa) if ret: state = ProtonSSOPresenterCredentialLogicState.CALL_BASE_FUNCTION else: self._view.display_error("Invalid 2FA code!") except ProtonAPIAuthenticationNeeded: state = ProtonSSOPresenterCredentialLogicState.NEEDS_AUTHENTICATE except ProtonAPI2FANeeded: state = ProtonSSOPresenterCredentialLogicState.NEEDS_TWOFA return wrapped_function @CredentialsLogic def login(self): # This will force a login call if needed self._session.api_request('/users') def logout(self): self._session.logout() def unlock(self): self._session.fetch_user_key() def lock(self): self._session.lock() def set_default(self): account_name = self._session.AccountName if account_name is not None: self._sso.set_default_account(account_name) def list(self): sessions = [self._sso.get_session(s) for s in self._sso.sessions] sessions = [s for s in sessions if s.AccountName is not None] self._view.display_session_list(sessions) def main(): import argparse parser = argparse.ArgumentParser('proton-sso', description="Tool to manage user SSO sessions") parser.add_argument('--appversion', help="App version") parser.add_argument('--user-agent', help="User Agent") subparsers = parser.add_subparsers(help='action', dest='action', required=True) parser_login = subparsers.add_parser('login', help='Sign into an account') parser_login.add_argument('--unlock', action='store_true', help="Unlock and store user keys") parser_login.add_argument('--set-default', action='store_true', help="Set this account as default") parser_login.add_argument('--env', type=str, help="Environment to use") parser_login.add_argument('--client-secret', type=str, help="Some API require a client secret") parser_login.add_argument('account', type=str, help="Proton account") parser_logout = subparsers.add_parser('logout', help='Sign out of an account') parser_logout.add_argument('account', type=str, help="Proton account (default session if omitted)", nargs="?") parser_lock = subparsers.add_parser('lock', help='Lock a session and erased stored user keys') parser_lock.add_argument('account', type=str, help="Proton account (default session if omitted)", nargs="?") parser_unlock = subparsers.add_parser('unlock', help='Unlock a session and store user keys') parser_unlock.add_argument('account', type=str, help="Proton account (default session if omitted)", nargs="?") parser_unlock = subparsers.add_parser('set-default', help='Sets the account as default') parser_unlock.add_argument('account', type=str, help="Proton account") parser_list = subparsers.add_parser('list', help='List the currently logged-in account') args = parser.parse_args() from proton.loader import Loader view = Loader.get('basicview')() presenter = ProtonSSOPresenter(view, appversion=args.appversion, user_agent=args.user_agent) # All action except list require an active account if args.action != 'list': presenter.set_session(args.account) if args.action == 'login': if args.env is not None: presenter.set_environment(args.env) if args.client_secret is not None: presenter.set_client_secret(args.client_secret) presenter.login() if args.unlock: presenter.unlock() else: presenter.lock() if args.set_default: presenter.set_default() elif args.action == 'logout': presenter.logout() elif args.action == 'lock': presenter.lock() elif args.action == 'unlock': presenter.unlock() elif args.action == 'list': presenter.list() elif args.action == 'set-default': presenter.set_default() else: raise NotImplementedError(f"Action {args.action} is not yet implemented") if __name__ == '__main__': main() python-proton-core-0.1.16/proton/sso/sso.py000066400000000000000000000332331452542764400207110ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from __future__ import annotations import base64 import fcntl import os import re from typing import TYPE_CHECKING, Optional from proton.keyring import Keyring if TYPE_CHECKING: from ..session import Session # We don't necessarily need it to be a singleton, it doesn't harm in itself if multiple instances are created class ProtonSSO: """Proton Single Sign On implementation. This allows session persistence for the current user. The general approach for this is to create a SSO instance, and then to get either a specific or the default session, and work from there: .. code-block:: from proton.sso import ProtonSSO sso = ProtonSSO() session = sso.get_default_session() # or: session = sso.get_session('pro') # get session for account pro Note that it is advised not to try to "guess" the state of the session, but instead to just try to use it, and handle any exception that would arise. This object uses advisory locks (using ``flock``) to protect the session from multiple conflicting changes. This does not guarantee that Session objects are immune to what happens in another process (i.e. imagine if one process terminates the session.), but at least makes it consistent. In the future, it would be nice to use an IPC mechanism to make sure other processes are aware of the state change. """ def __init__(self, appversion : str = "Other", user_agent: str ="None"): """Create a SSO instance :param appversion: Application version (see :class:`proton.session.Session`), defaults to "Other" :type appversion: str, optional :param user_agent: User agent version (see :class:`proton.session.Session`), defaults to "None" :type user_agent: str, optional """ # Store appversion and user_agent for subsequent sessions self._appversion = appversion self._user_agent = user_agent from ..utils import ExecutionEnvironment self._adv_locks_path = ExecutionEnvironment().path_runtime self._adv_locks = {} self._session_data_cache = {} # This is a global lock, we use it when we modify the indexes self._global_adv_lock = open(os.path.join(self._adv_locks_path, f'proton-sso.lock'), 'w') self.__keyring_backend = None def __encode_name(self, account_name) -> str: """Helper function to convert an account_name into a safe alphanumeric string. :param account_name: normalized account_name :type account_name: str :return: base32 encoded string, without padding. :rtype: str """ return base64.b32encode(account_name.encode('utf8')).decode('ascii').rstrip('=').lower() def __keyring_key_name(self, account_name : str) -> str: """Helper function to get the keyring key for account_name :param account_name: normalized account_name :type account_name: str :return: keyring key :rtype: str """ return f'proton-sso-account-{self.__encode_name(account_name)}' def __keyring_index_name(self) -> str: """Helper function to get the keyring key to store the index (i.e. account names in order) :return: keyring key :rtype: str """ return f'proton-sso-accounts' @property def _keyring(self) -> "Keyring": """Shortcut to get the default keyring backend :return: an instance of the default Keyring :rtype: Keyring """ if self.__keyring_backend is None: self.__keyring_backend = Keyring.get_from_factory() elif not isinstance(self.__keyring_backend, type(Keyring.get_from_factory())): # If the current keyring does not match the keyring we were using previously, # then something must've changed in the env and we should raise an exception. raise RuntimeError("Keyring backends do not match") return self.__keyring_backend @property def sessions(self) -> list[str]: """Returns the account names for the current system user :return: list of normalized account_names :rtype: list[str] """ # We might remove invalid session and clean the index, so create a full lock on the SSO object fcntl.flock(self._global_adv_lock, fcntl.LOCK_EX) try: keyring = self._keyring try: keyring_index = keyring[self.__keyring_index_name()] except KeyError: keyring_index = [] cleaned_index = [account_name for account_name in keyring_index if len(self._get_session_data(account_name)) > 0] if cleaned_index != keyring_index: keyring[self.__keyring_index_name()] = cleaned_index # Try to remove any account from keyring that we've removed from SSO for removed_account in set(keyring_index).difference(cleaned_index): try: del keyring[self.__keyring_key_name(removed_account)] except KeyError: pass return cleaned_index finally: fcntl.flock(self._global_adv_lock, fcntl.LOCK_UN) def get_session(self, account_name : Optional[str], override_class : Optional[type] = None) -> "Session": """Get the session identified by account_name :param account_name: account name to use. If None will return an empty session (can be used as a factory) :type account_name: Optional[str] :param override_class: Class to use for the session to be returned, by default will use proton.session.Session :type override_class: Optional[type] :return: the Session object. It will be an empty session if there's no session for account_name :rtype: Session """ from ..session import Session if override_class is None: override_class = Session session = override_class(self._appversion, self._user_agent) session.register_persistence_observer(self) # If we have an account, then let's fetch the data from it. Otherwise we just ignore and return a blank session if account_name is not None: try: session_data = self._get_session_data(account_name) except KeyError: session_data = None else: session_data = None if session_data is not None: session.__setstate__(session_data) return session def get_default_session(self, override_class : Optional[type] = None) -> "Session": """Get the default session for the system user. It will always be one valid session if one exists. :param override_class: Class to use for the session to be returned, see :meth:`get_session`. :type override_class: Optional[type] :return: the Session object. It will be an empty session if there's no session at all :rtype: Session """ sessions = self.sessions if len(sessions) == 0: account_name = None else: account_name = sessions[0] return self.get_session(account_name, override_class) def set_default_account(self, account_name : str): """Set the default account for user to be account_name :param account_name: the account_name to use as default :type account_name: str :raises KeyError: if the account name is unknown """ # We might be reordering accounts, so let's lock the full sso so we can't have concurrent actions here fcntl.flock(self._global_adv_lock, fcntl.LOCK_EX) try: keyring = self._keyring try: keyring_index = keyring[self.__keyring_index_name()] except KeyError: keyring_index = [] if account_name not in keyring_index: raise KeyError(account_name) new_keyring_index = [account_name] + [x for x in keyring_index if x != account_name] if new_keyring_index != keyring_index: keyring[self.__keyring_index_name()] = new_keyring_index finally: fcntl.flock(self._global_adv_lock, fcntl.LOCK_UN) def _get_session_data(self, account_name : str) -> dict: """Helper function to get data of a session, returns an empty dict if no data is present :param account_name: normalized account name :type account_name: str :return: content of the session data, empty dict if it doesn't exist. :rtype: dict """ try: data = self._keyring[self.__keyring_key_name(account_name)] except KeyError: data = {} # This is an encapsulation violation (we're not supposed to know that the account name is stored in AccountName) # It allows us nevertheless to validate that the session contains actual data, which is good to not break if a # Session implementation is invalid. if data.get('AccountName') != account_name: data = {} return data def _acquire_session_lock(self, account_name : str, current_data : dict) -> None: """Observer pattern for :class:`proton.session.Session` (see :meth:`proton.session.Session.register_persistence_observer`). It is called when the Session object is getting locked, because it's expected to be changed and we want to avoid race conditions. :param account_name: account name of the session :type account_name: str :param current_data: current session data serialized as a dictionary :type current_data: dict """ if not account_name: # Don't do anything, we don't know the account yet! return self._adv_locks[account_name] = open(os.path.join(self._adv_locks_path, f'proton-sso-{self.__encode_name(account_name)}.lock'), 'w') # This is a blocking call. # FIXME: this is Linux specific fcntl.flock(self._adv_locks[account_name], fcntl.LOCK_EX) self._session_data_cache[account_name] = current_data def _release_session_lock(self, account_name : str, new_data : dict) -> None: """Observer pattern for :class:`proton.session.Session` (see :meth:`proton.session.Session.register_persistence_observer`). It is called when the Session object is getting unlocked. If the data between has changed since :meth:`_acquire_session_lock` was called, it will be persisted in the keyring. :param account_name: account name of the session :type account_name: str :param new_data: current session data serialized as a dictionary :type new_data: dict """ if not account_name: # Don't do anything, we don't know the account yet! return if new_data is not None and len(new_data) > 0 and new_data.get('AccountName', None) != account_name: raise ValueError("Sessions need to store a valid AccountName in order to store data.") # Don't do anything if data hasn't changed if account_name in self._session_data_cache: if self._session_data_cache[account_name] == new_data: return del self._session_data_cache[account_name] # We might be reordering accounts, so let's lock the full sso so we can't have concurrent actions here fcntl.flock(self._global_adv_lock, fcntl.LOCK_EX) try: keyring = self._keyring # Get current data try: keyring_entry = keyring[self.__keyring_key_name(account_name)] except KeyError: keyring_entry = {} try: keyring_index = keyring[self.__keyring_index_name()] except KeyError: keyring_index = [] # By default, we don't change anything new_keyring_index = keyring_index # No data, this is probably a logout if new_data is None or len(new_data) == 0: # Discard from the index new_keyring_index = [x for x in keyring_index if x != account_name] # Delete the entry if we had some data previously if len(keyring_entry) > 0: del keyring[self.__keyring_key_name(account_name)] # We have new data else: # If this is a new entry, then append the index with the account (we leave the default as is) if account_name not in keyring_index: new_keyring_index = keyring_index + [account_name] # Store the new data keyring[self.__keyring_key_name(account_name)] = new_data # We only store the new index if it has changed (wouldn't harm to do it anyway) if new_keyring_index != keyring_index: keyring[self.__keyring_index_name()] = new_keyring_index finally: fcntl.flock(self._global_adv_lock, fcntl.LOCK_UN) if account_name in self._adv_locks: # FIXME: this is Linux specific fcntl.flock(self._adv_locks[account_name], fcntl.LOCK_UN) python-proton-core-0.1.16/proton/utils/000077500000000000000000000000001452542764400200635ustar00rootroot00000000000000python-proton-core-0.1.16/proton/utils/__init__.py000066400000000000000000000014321452542764400221740ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from .metaclasses import Singleton from .environment import ExecutionEnvironment __all__ = ['Singleton','ExecutionEnvironment']python-proton-core-0.1.16/proton/utils/environment.py000066400000000000000000000113511452542764400230020ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from .metaclasses import Singleton import os import shutil # Try to get the BaseDirectory module try: from xdg import BaseDirectory except ImportError: BaseDirectory = None class ExecutionEnvironment(metaclass=Singleton): PROTON_DIR_NAME = "Proton" def __init__(self): # If we run as a system user, use system paths if os.getuid() == 0: self._setup_as_system_user() else: # If we run as a normal user self._setup_as_regular_user() @property def path_config(self): self.generate_dirs(self._path_config) return self._path_config @property def path_cache(self): self.generate_dirs(self._path_cache) return self._path_cache @property def path_logs(self): self.generate_dirs(self._path_logs) return self._path_logs @property def path_runtime(self): self.generate_dirs(self._path_runtime) return self._path_runtime @property def systemd_unit(self): return self._path_systemd_unit def generate_dirs(self, path): if os.path.isdir(path): return os.makedirs(path, mode=0o700, exist_ok=True) def _setup_as_system_user(self): self._path_config = f'/etc/{self.PROTON_DIR_NAME}' self._path_cache = f'/var/cache/{self.PROTON_DIR_NAME}' self._path_logs = f'/var/log/{self.PROTON_DIR_NAME}' self._path_runtime = f'/run/{self.PROTON_DIR_NAME}' self._path_systemd_unit = '/etc/systemd/system' def _setup_as_regular_user(self): config_home, cache_home, runtime_dir = self._get_dir_paths() self._path_config = os.path.join(config_home, self.PROTON_DIR_NAME) self._path_cache = os.path.join(cache_home, self.PROTON_DIR_NAME) self._path_logs = os.path.join(cache_home, self.PROTON_DIR_NAME, 'logs') self._path_runtime = os.path.join(runtime_dir, self.PROTON_DIR_NAME) self._path_systemd_unit = os.path.join(config_home, "systemd", "user") def _get_dir_paths(self): # If BaseDirectory is found then we can extract valuable data from it if BaseDirectory: config_home = BaseDirectory.xdg_config_home cache_home = BaseDirectory.xdg_cache_home runtime_dir = BaseDirectory.get_runtime_dir() else: # Otherwise use default constructed from $HOME environment variable home = os.environ.get('HOME', None) if home is None: raise RuntimeError("Cannot figure out where to place files, is $HOME defined?") config_home = os.path.join(home, '.config') cache_home = os.path.join(home, '.cache') runtime_dir = f'/run/user/{os.getuid()}' return config_home, cache_home, runtime_dir class ProductExecutionEnvironment(ExecutionEnvironment): """ This class serves the purpose of helping in standardizing folder structure across products. Thus each product should derive from `ProductExecutionEnvironment` and setting the class property `PRODUCT` to match its correspondent product. This should help to more easily find files and improving cross-product collaboration. """ PRODUCT = None def __init__(self): super().__init__() if self.PRODUCT is None: raise RuntimeError("`PRODUCT` is not set") @property def path_config(self): path = os.path.join(super().path_config, self.PRODUCT) self.generate_dirs(path) return path @property def path_cache(self): path = os.path.join(super().path_cache, self.PRODUCT) self.generate_dirs(path) return path @property def path_logs(self): path = os.path.join(super().path_logs, self.PRODUCT) self.generate_dirs(path) return path @property def path_runtime(self): path = os.path.join(super().path_runtime, self.PRODUCT) self.generate_dirs(path) return path class VPNExecutionEnvironment(ProductExecutionEnvironment): """Execution environment dedicated for the VPN product.""" PRODUCT = "VPN" python-proton-core-0.1.16/proton/utils/metaclasses.py000066400000000000000000000016111452542764400227400ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ class Singleton(type): _instances = {} def __call__(cls, *args, **kwargs): if cls not in cls._instances: cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) return cls._instances[cls]python-proton-core-0.1.16/proton/views/000077500000000000000000000000001452542764400200605ustar00rootroot00000000000000python-proton-core-0.1.16/proton/views/__init__.py000066400000000000000000000013171452542764400221730ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from ._base import BasicView __all__ = ['BasicView']python-proton-core-0.1.16/proton/views/_base.py000066400000000000000000000052161452542764400215070ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from __future__ import annotations from abc import ABCMeta, abstractmethod from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from ..session import Session class BasicView(metaclass = ABCMeta): @abstractmethod def display_error(self, message : str) -> None: """Display an error message. No action is expected from user. :param message: Message to display :type message: str """ pass @abstractmethod def display_notice(self, message : str) -> None: """Display a message. No action is expected from user. :param message: Message to display :type message: str """ pass @abstractmethod def display_session_list(self, sessions : list["Session"], ask_to_select_one : bool = False) -> Optional["Session"]: """Display a list of Sessions, and optionally ask the user to select one of them. :param sessions: List of sessions :type sessions: list[Session] :param ask_to_select_one: ask user to select a session, defaults to False :type ask_to_select_one: bool, optional :return: the session selected by user (if asked for it), None otherwise (or if user has cancelled) :rtype: Optional[Session] """ pass @abstractmethod def ask_credentials(self, ask_login : bool = False, ask_password : bool = False, ask_2fa : bool = False) -> tuple[Optional[str], Optional[str], Optional[str]]: """Ask user for credentials. :param ask_login: Ask for user name, defaults to False :type ask_login: bool, optional :param ask_password: Ask for the password, defaults to False :type ask_password: bool, optional :param ask_2fa: Ask for a 2FA code, defaults to False :type ask_2fa: bool, optional :return: A tuple (login, password, 2fa). Values are None if not asked from the user, or if user cancelled. :rtype: tuple[Optional[str], Optional[str], Optional[str]] """ pass python-proton-core-0.1.16/proton/views/basiccli.py000066400000000000000000000074001452542764400222040ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from __future__ import annotations from ._base import BasicView import getpass import sys from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from ..session import Session class BasicCLIView(BasicView): """Implementation of :class:`proton.views.BasicView` for a CLI. It's really just print + input calls.""" def __init__(self): pass @classmethod def _get_priority(cls): return 0 def display_error(self, message: str) -> None: print("Error: ", message, file=sys.stderr) def display_notice(self, message: str) -> None: print(message) def _session_to_string(self, s: "Session", default_session: "Session") -> str: flags = [] if s == default_session: flags.append('default') if s.environment.name != 'prod': flags.append(f'env:{s.environment.name}') if len(flags) > 0: flags_str = f" [{', '.join(flags)}]" else: flags_str = '' return f'{s.AccountName}{flags_str}' def display_session_list(self, sessions : list["Session"], ask_to_select_one : bool = False) -> None: if len(sessions) == 0: print("No active sessions") else: print(f"Active session list [{len(sessions)}]:") print('') sorted_sessions = list(sorted(sessions, key=lambda x: x.AccountName)) for session_id, s in enumerate(sorted_sessions): if ask_to_select_one: print(f' [{session_id+1:2d}] {self._session_to_string(s, sessions[0])}') else: print(f"- {self._session_to_string(s, sessions[0])}") if ask_to_select_one: while True: user_input = input("Please select a session: ") # nosec (Python 3 only code) if user_input.isnumeric(): user_input_idx = int(user_input) - 1 if user_input_idx >= 0 and user_input_idx < len(sorted_sessions): return sorted_sessions[user_input_idx] else: print("Invalid input!") else: for s in sorted_sessions: if s.AccountName == user_input: return s print("Invalid input!") def ask_credentials(self, ask_login: bool = False, ask_password: bool = False, ask_2fa: bool = False) -> tuple[Optional[str], Optional[str], Optional[str]]: login = None password = None twofa = None if ask_login: login = input("Please enter your user name: ") # nosec (Python 3 only code) if login == '': login = None if ask_password: password = getpass.getpass() if password == '': password = None if ask_2fa: twofa = input("Please enter your 2FA code: ") # nosec (Python 3 only code) if twofa == '' or not twofa.isnumeric(): twofa = None return login, password, twofa python-proton-core-0.1.16/requirements.txt000066400000000000000000000000151452542764400206620ustar00rootroot00000000000000-e ".[test]" python-proton-core-0.1.16/rpmbuild/000077500000000000000000000000001452542764400172205ustar00rootroot00000000000000python-proton-core-0.1.16/rpmbuild/BUILD/000077500000000000000000000000001452542764400200575ustar00rootroot00000000000000python-proton-core-0.1.16/rpmbuild/BUILD/.gitkeep000066400000000000000000000000001452542764400214760ustar00rootroot00000000000000python-proton-core-0.1.16/rpmbuild/BUILDROOT/000077500000000000000000000000001452542764400205635ustar00rootroot00000000000000python-proton-core-0.1.16/rpmbuild/BUILDROOT/.gitkeep000066400000000000000000000000001452542764400222020ustar00rootroot00000000000000python-proton-core-0.1.16/rpmbuild/SOURCES/000077500000000000000000000000001452542764400203435ustar00rootroot00000000000000python-proton-core-0.1.16/rpmbuild/SOURCES/.gitkeep000066400000000000000000000000001452542764400217620ustar00rootroot00000000000000python-proton-core-0.1.16/rpmbuild/SPECS/000077500000000000000000000000001452542764400200755ustar00rootroot00000000000000python-proton-core-0.1.16/rpmbuild/SPECS/.gitkeep000066400000000000000000000000001452542764400215140ustar00rootroot00000000000000python-proton-core-0.1.16/rpmbuild/SPECS/package.spec000066400000000000000000000066661452542764400223620ustar00rootroot00000000000000%define unmangled_name proton-core %define version 0.1.16 %define release 1 Prefix: %{_prefix} Name: python3-%{unmangled_name} Version: %{version} Release: %{release}%{?dist} Summary: %{unmangled_name} library Group: ProtonVPN License: GPLv3 Vendor: Proton Technologies AG URL: https://github.com/ProtonVPN/%{unmangled_name} Source0: %{unmangled_name}-%{version}.tar.gz BuildArch: noarch BuildRoot: %{_tmppath}/%{unmangled_name}-%{version}-%{release}-buildroot BuildRequires: python3-bcrypt BuildRequires: python3-gnupg BuildRequires: python3-pyOpenSSL BuildRequires: python3-requests BuildRequires: python3-aiohttp BuildRequires: python3-importlib-metadata BuildRequires: python3-pyotp BuildRequires: python3-setuptools Requires: python3-bcrypt Requires: python3-gnupg Requires: python3-pyOpenSSL Requires: python3-requests Requires: python3-aiohttp Requires: python3-importlib-metadata Conflicts: python3-proton-client %{?python_disable_dependency_generator} %description Package %{unmangled_name} library. %prep %setup -n %{unmangled_name}-%{version} -n %{unmangled_name}-%{version} %build python3 setup.py build %install python3 setup.py install --single-version-externally-managed -O1 --root=$RPM_BUILD_ROOT --record=INSTALLED_FILES %files -f INSTALLED_FILES %{python3_sitelib}/proton/ %{python3_sitelib}/proton_core-%{version}*.egg-info/ %defattr(-,root,root) %changelog * Thu Nov 16 2023 Laurent Fasnacht 0.1.16 - fixing (another) race condition in async_refresh() * Wed Oct 24 2023 Xavier Piroux 0.1.15 - fixing race condition in async_refresh() * Tue Oct 24 2023 Josep Llaneras 0.1.14 - Fix crash on Python 3.12 * Thu Oct 19 2023 Alexandru Cheltuitor 0.1.13 - Amend setup.py - Add minimum required python version * Thu Jul 13 2023 Xavier Piroux 0.1.12 - async_api_request() : raise Exception instead of return None in case of error * Fri May 12 2023 Xavier Piroux 0.1.11 - API URL : https://vpn-api.proton.me - fixed Alternative Routing : support IP addresses * Wed Apr 19 2023 Alexandru Cheltuitor 0.1.10 - Add license * Thu Apr 06 2023 Xavier Piroux 0.1.9 - proton-sso: fixing 2fa * Mon Mar 27 2023 Josep Llaneras 0.1.8 - Allow running proton.sso module * Tue Mar 07 2023 Alexandru Cheltuitor 0.1.7 - Hide SSO CLI * Tue Mar 07 2023 Josep Llaneras 0.1.6 - Fix invalid attribute * Mon Mar 06 2023 Josep Llaneras 0.1.5 - Do not leak timeout errors when selecting transport * Fri Mar 03 2023 Josep Llaneras 0.1.4 - Fix alternative routing crash during domain refresh * Mon Feb 13 2023 Alexandru Cheltuitor 0.1.3 - Recursively create product folders * Thu Feb 09 2023 Alexandru Cheltuitor 0.1.2 - Rely on API for username validation * Wed Feb 08 2023 Josep Llaneras 0.1.1 - Handle aiohttp timeout error * Fri Jan 20 2023 Josep Llaneras 0.1.0 - Support posting form-encoded data * Wed Sep 14 2022 Josep Llaneras 0.0.2 - Make Loader.get_all thread safe. * Wed Jun 1 2022 Xavier Piroux 0.0.1 - First RPM release python-proton-core-0.1.16/rpmbuild/SRPMS/000077500000000000000000000000001452542764400201245ustar00rootroot00000000000000python-proton-core-0.1.16/rpmbuild/SRPMS/.gitkeep000066400000000000000000000000001452542764400215430ustar00rootroot00000000000000python-proton-core-0.1.16/setup.cfg000066400000000000000000000003651452542764400172270ustar00rootroot00000000000000[flake8] ignore = C901, W503, E402 max-line-length = 100 [metadata] long_description = file: README.md long_description_content_type = text/markdown [tool:pytest] addopts = --cov=proton --cov-report html --cov-report term testpaths = testspython-proton-core-0.1.16/setup.py000077500000000000000000000032601452542764400171200ustar00rootroot00000000000000#!/usr/bin/env python from setuptools import setup, find_namespace_packages setup( name="proton-core", version="0.1.16", description="Proton Technologies API wrapper", author="Proton Technologies", author_email="contact@protonmail.com", url="https://github.com/ProtonMail/python-proton-core", install_requires=["requests", "bcrypt", "python-gnupg", "pyopenssl", "aiohttp"], extras_require={ "test": ["pytest", "pyotp", "pytest-cov", "flake8"] }, entry_points={ "proton_loader_keyring": [ "json = proton.keyring.textfile:KeyringBackendJsonFiles" ], "proton_loader_transport": [ "requests = proton.session.transports.requests:RequestsTransport", "alternativerouting = proton.session.transports.alternativerouting:AlternativeRoutingTransport", "aiohttp = proton.session.transports.aiohttp:AiohttpTransport", "auto = proton.session.transports.auto:AutoTransport", ], "proton_loader_environment": [ "prod = proton.session.environments:ProdEnvironment", ], "proton_loader_basicview": [ "cli = proton.views.basiccli:BasicCLIView" ] }, packages=find_namespace_packages(include=['proton.*']), include_package_data=True, python_requires=">=3.8", license="GPLv3", platforms="OS Independent", classifiers=[ "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python", "Topic :: Security", ] ) python-proton-core-0.1.16/tests/000077500000000000000000000000001452542764400165445ustar00rootroot00000000000000python-proton-core-0.1.16/tests/test_aiohttp_transport.py000066400000000000000000000075701452542764400237520ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ import unittest from io import StringIO from unittest.mock import patch, AsyncMock, Mock from proton.session import Session from proton.session.formdata import FormData, FormField from proton.session.transports import AiohttpTransport from proton.session.transports.aiohttp import FormDataTransformer class TestAiohttpTransport(unittest.IsolatedAsyncioTestCase): @patch("proton.session.transports.aiohttp.aiohttp.ClientSession.post") async def test_async_api_request_posts_form_data_with_data_param(self, post_mock): session = Session() form_data_transformer_mock = Mock(spec=FormDataTransformer) aiohttp_transport = AiohttpTransport(session, form_data_transformer_mock) # Mock POST response. post_mock.return_value.__aenter__.return_value.status = 200 post_mock.return_value.__aenter__.return_value.headers = {"content-type": "application/json"} post_mock.return_value.__aenter__.return_value.json = AsyncMock( return_value={"Code": 1000} ) # Form data to be posted. form_data = FormData() form_data.add(FormField(name="foo", value="bar")) # SUT. await aiohttp_transport.async_api_request("/endpoint", data=form_data) # Assert that the form data has been transformed to aiohttp.FormData. form_data_transformer_mock.to_aiohttp_form_data.assert_called_once_with(form_data) expected_payload_to_be_posted = form_data_transformer_mock.to_aiohttp_form_data.return_value # Assert that the POST call is done with the transformed form data. post_mock.assert_called_once() posted_payload = post_mock.call_args.kwargs["data"] assert posted_payload is expected_payload_to_be_posted class TestFormDataTransformer(unittest.TestCase): @patch("proton.session.transports.aiohttp.aiohttp.FormData") def test_to_aiohttp_form_data(self, _aiohttp_form_data_mock): form_data_transformer = FormDataTransformer() # Form data to be transformed: form_data = FormData() # Add a simple field to the form. first_field_name, first_field_value = "foo", "bar" form_data.add(FormField(name=first_field_name, value=first_field_value)) # Add a file to the form. second_field_name = "file" second_field_value = StringIO("File content.") second_field_filename = "file.txt" second_field_content_type = "text/plain" form_data.add(FormField( name=second_field_name, value=second_field_value, filename=second_field_filename, content_type=second_field_content_type )) # SUT. result = form_data_transformer.to_aiohttp_form_data(form_data) # Assert that aiohttp.FormData was created with the form data passed above. assert result.add_field.call_count == 2 assert result.add_field.call_args_list[0].kwargs == { "name": first_field_name, "value": first_field_value, "content_type": None, "filename": None } assert result.add_field.call_args_list[1].kwargs == { "name": second_field_name, "value": second_field_value, "content_type": second_field_content_type, "filename": second_field_filename } python-proton-core-0.1.16/tests/test_alternativerouting.py000066400000000000000000000024101452542764400241000ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ import unittest, os class TestAlternativeRouting(unittest.IsolatedAsyncioTestCase): def setUp(self): self._env_backup = os.environ.copy() def tearDown(self): os.environ = self._env_backup async def test_alternative_routing_works_on_prod(self): from proton.session import Session from proton.session.transports.alternativerouting import AlternativeRoutingTransport os.environ['PROTON_API_ENVIRONMENT'] = 'prod' s = Session() s.transport_factory = AlternativeRoutingTransport assert await s.async_api_request('/tests/ping') == {'Code': 1000} python-proton-core-0.1.16/tests/test_api.py000066400000000000000000000157231452542764400207360ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ import unittest import base64 from testdata import srp_instances, modulus_instances from testserver import TestServer from proton.session.srp.util import PM_VERSION from proton.session.api import Session from proton.session.exceptions import ProtonUnsupportedAuthVersionError class SRPTestCases: class SRPTestBase(unittest.TestCase): def test_invalid_version(self): modulus = bytes.fromhex(srp_instances[0]['Modulus']) salt = base64.b64decode(srp_instances[0]['Salt']) with self.assertRaises(ProtonUnsupportedAuthVersionError): usr = self.user('pass', modulus) salt, usr.compute_v(salt, 2) with self.assertRaises(ProtonUnsupportedAuthVersionError): usr = self.user('pass', modulus) salt, usr.compute_v(salt, 5) def test_compute_v(self): for instance in srp_instances: if instance["Exception"] is not None: with self.assertRaises(instance['Exception']): usr = self.user( instance["Password"], bytes.fromhex(instance["Modulus"]) ) usr.compute_v( base64.b64decode(instance["Salt"]), PM_VERSION ) else: usr = self.user( instance["Password"], bytes.fromhex(instance["Modulus"]) ) salt, v = usr.compute_v( base64.b64decode(instance["Salt"]), PM_VERSION ) self.assertEqual( instance["Salt"], base64.b64encode(salt).decode('utf8'), "Wrong salt while generating v, " + "instance: {}...".format(str(instance)[:30]) ) self.assertEqual( instance["Verifier"], base64.b64encode(v).decode('utf8'), "Wrong verifier while generating v, " + "instance: {}...".format(str(instance)[:30]) ) def test_generate_v(self): for instance in srp_instances: if instance["Exception"] is not None: continue usr = self.user( instance["Password"], bytes.fromhex(instance["Modulus"]) ) generated_salt, generated_v = usr.compute_v() computed_salt, computed_v = usr.compute_v(generated_salt) self.assertEqual( generated_salt, computed_salt, "Wrong salt while generating v, " + "instance: {}...".format(str(instance)[:30]) ) self.assertEqual( generated_v, computed_v, "Wrong verifier while generating v, " + "instance: {}...".format(str(instance)[:30]) ) def test_srp(self): for instance in srp_instances: if instance["Exception"]: continue server = TestServer() server.setup( instance["Username"], bytes.fromhex(instance["Modulus"]), base64.b64decode(instance["Verifier"]) ) server_challenge = server.get_challenge() usr = self.user( instance["Password"], bytes.fromhex(instance["Modulus"]) ) client_challenge = usr.get_challenge() client_proof = usr.process_challenge( base64.b64decode(instance["Salt"]), server_challenge, PM_VERSION ) server_proof = server.process_challenge( client_challenge, client_proof ) usr.verify_session(server_proof) self.assertIsNotNone( client_proof, "SRP exchange failed, " "client_proof is none for instance: {}...".format( str(instance)[:30] ) ) self.assertEqual( server.get_session_key(), usr.get_session_key(), "Secrets do not match, instance: {}...".format( str(instance)[:30] ) ) self.assertTrue( server.get_authenticated(), "Server is not correctly authenticated, " + "instance: {}...".format( str(instance)[:30] ) ) self.assertTrue( usr.authenticated(), "User is not correctly authenticated, " + "instance: {}...".format( str(instance)[:30] ) ) class TestCTSRPClass(SRPTestCases.SRPTestBase): def setUp(self): try: from proton.session.srp._ctsrp import User as CTUser except (ImportError, OSError): self.skipTest("Couldn't load C implementation of the SRP code, so skip this test.") self.user = CTUser class TestPYSRPClass(SRPTestCases.SRPTestBase): def setUp(self): from proton.session.srp._pysrp import User as PYUser self.user = PYUser class TestModulus(unittest.TestCase): def test_modulus_verification(self): session = Session('dummy') for instance in modulus_instances: if instance["Exception"] is not None: with self.assertRaises(instance['Exception']): session._verify_modulus(instance["SignedModulus"]) else: self.assertEqual( base64.b64decode(instance["Decoded"]), session._verify_modulus(instance["SignedModulus"]), "Error verifying modulus in instance: " + str(instance)[:30] + "..." ) if __name__ == '__main__': unittest.main() python-proton-core-0.1.16/tests/test_autotransport.py000066400000000000000000000063331452542764400231070ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from unittest.mock import Mock, AsyncMock import pytest import asyncio import os import time import unittest from proton.session import Session from proton.session.transports.auto import AutoTransport from proton.session.transports.requests import RequestsTransport from proton.session.exceptions import ProtonAPINotReachable class TestAuto(unittest.IsolatedAsyncioTestCase): def setUp(self): self._env_backup = os.environ.copy() def tearDown(self): os.environ = self._env_backup async def test_auto_works_on_prod(self): os.environ['PROTON_API_ENVIRONMENT'] = 'prod' s = Session() s.transport_factory = AutoTransport assert await s.async_api_request('/tests/ping') == {'Code': 1000} async def test_auto_transport_is_not_available_when_all_transports_choices_time_out_pinging_rest_api(self): mock_transport_type = Mock() transport_timeout = 0.001 auto_transport = AutoTransport( session=Session(), transport_choices=[(0, mock_transport_type)], transport_timeout=transport_timeout ) mock_transport = Mock() mock_transport_type.return_value = mock_transport # Force a timeout from `/tests/ping` when checking if the transport is available. async def force_transport_timeout(url): await asyncio.sleep(transport_timeout + 1) mock_transport.async_api_request.side_effect = force_transport_timeout with pytest.raises(ProtonAPINotReachable): await auto_transport.find_available_transport() mock_transport.async_api_request.assert_called_once_with('/tests/ping') assert not auto_transport.is_available async def test_auto_transport_is_not_available_when_all_transport_choices_receive_an_unexpected_ping_response(self): mock_transport_type = Mock() auto_transport = AutoTransport( session=Session(), transport_choices=[(0, mock_transport_type)] ) mock_transport = Mock() mock_transport_type.return_value = mock_transport # Force an unexpected response from `/tests/ping` when checking if the transport is available. async def force_unexpected_ping_response(url): return "foobar" mock_transport.async_api_request.side_effect = force_unexpected_ping_response with pytest.raises(ProtonAPINotReachable): await auto_transport.find_available_transport() mock_transport.async_api_request.assert_called_once_with('/tests/ping') assert not auto_transport.is_available python-proton-core-0.1.16/tests/test_basekeyring.py000066400000000000000000000067601452542764400224710ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from unittest.mock import patch import pytest from proton.keyring import Keyring @patch("proton.keyring._base.Keyring._get_item") def test_get_item_from_keyring(_get_item_mock): _get_item_mock.return_value = "first" k = Keyring() assert k["test-get"] == "first" _get_item_mock.assert_called_once_with("test-get") @patch("proton.keyring._base.Keyring._set_item") def test_set_item(_set_item_mock): k = Keyring() k["test-set"] = ["arg1"] _set_item_mock.assert_called_once_with("test-set", ["arg1"]) @patch("proton.keyring._base.Keyring._get_item") @patch("proton.keyring._base.Keyring._del_item") def test_del_item(_del_item_mock, _get_item_mock): _get_item_mock.return_value = "first" k = Keyring() del k["test-delete"] _del_item_mock.assert_called_once_with("test-delete") def test_raise_exception_not_implemented_methods(): keyring = Keyring() with pytest.raises(NotImplementedError): _ = keyring["test"] with pytest.raises(NotImplementedError): keyring["test"] = ["test"] with pytest.raises(NotImplementedError): del keyring["test"] @pytest.mark.parametrize("key", [1, [], {}, None, tuple()]) def test_get_item_raises_exception_invalid_key_type(key): with pytest.raises(TypeError): _ = Keyring()[key] @pytest.mark.parametrize("key", ["!", "A", "ç", "+", "*", "ã", "\\", "?", "="]) def test_get_item_raises_exception_invalid_key_value(key): with pytest.raises(ValueError): _ = Keyring()[key] @patch("proton.keyring._base.Keyring._get_item") @pytest.mark.parametrize("key", [1, [], {}, None, tuple()]) def test_del_item_raises_exception_invalid_key_type(_get_item_mock, key): k = Keyring() _get_item_mock.return_value = None with pytest.raises(TypeError): del k[key] @patch("proton.keyring._base.Keyring._get_item") @pytest.mark.parametrize("key", ["!", "A", "ç", "+", "*", "ã", "\\", "?", "="]) def test_del_item_raises_exception_invalid_key_value(_get_item_mock, key): k = Keyring() _get_item_mock.return_value = None with pytest.raises(ValueError): del k[key] @pytest.mark.parametrize("key", [1, [], {}, None, tuple()]) def test_set_item_raises_exception_invalid_key_type(key): with pytest.raises(TypeError): Keyring()[key] = "test" @pytest.mark.parametrize("key", ["!", "A", "ç", "+", "*", "ã", "\\", "?", "="]) def test_set_item_raises_exception_invalid_key_value(key): with pytest.raises(ValueError): Keyring()[key] = "test" @pytest.mark.parametrize("value", [1, "test", None, tuple()]) def test_set_item_raises_exception_invalid_value_type(value): with pytest.raises(TypeError): Keyring()["test-key"] = value def test_get_from_factory_raises_exception_due_to_non_existent_backend(): with pytest.raises(RuntimeError): Keyring.get_from_factory("non-existent-backend") python-proton-core-0.1.16/tests/test_dns_requests.py000066400000000000000000000252101452542764400226740ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ import base64 import dataclasses import ipaddress import typing import pytest from proton.session.transports.utils.dns import DNSParser, DNSResponseError @dataclasses.dataclass class _DnsParsingTestData: name: str domain: str #expected_ar_domain: bytes expected_dns_request: bytes dns_reply: bytes expected_parsed_reply: typing.List[dict] ar_old_domain_data = _DnsParsingTestData( name = "legacy domain", domain = "api.protonvpn.ch", #expected_ar_domain = b'\x1bdMFYGSLTQOJXXI33OOZYG4LTDNA\tprotonpro\x03xyz\x00', expected_dns_request = b'\xfa\x83\x01 \x00\x01\x00\x00\x00\x00\x00\x00\x1bdMFYGSLTQOJXXI33OOZYG4LTDNA\tprotonpro\x03xyz\x00\x00\x10\x00\x01', dns_reply = b'\xfa\x83\x81\x80\x00\x01\x00\x02\x00\x00\x00\x00\x1bdMFYGSLTQOJXXI33OOZYG4LTDNA\tprotonpro\x03xyz\x00\x00\x10\x00\x01\xc0\x0c\x00\x10\x00\x01\x00\x00\x00x\x0032ec2-3-127-37-78.eu-central-1.compute.amazonaws.com\xc0\x0c\x00\x10\x00\x01\x00\x00\x00x\x0054ec2-54-93-234-150.eu-central-1.compute.amazonaws.com', expected_parsed_reply = [(120, "ec2-3-127-37-78.eu-central-1.compute.amazonaws.com"), (120, "ec2-54-93-234-150.eu-central-1.compute.amazonaws.com"),] ) ar_current_domain_data1 = _DnsParsingTestData( name = "current domain", domain = "vpn-api.proton.me", #expected_ar_domain = b'\x1ddOZYG4LLBOBUS44DSN52G63RONVSQ\tprotonpro\x03xyz\x00', expected_dns_request = b'\xcc\x72\x01 \x00\x01\x00\x00\x00\x00\x00\x00\x1ddOZYG4LLBOBUS44DSN52G63RONVSQ\tprotonpro\x03xyz\x00\x00\x10\x00\x01', dns_reply = b'\xcc\x72\x81\x80\x00\x01\x00\x03\x00\x00\x00\x00\x1ddOZYG4LLBOBUS44DSN52G63RONVSQ\tprotonpro\x03xyz\x00\x00\x10\x00\x01\xc0\x0c\x00\x05\x00\x01\x00\x00\x00x\x00\x06\x03vpn\xc0*\xc0I\x00\x10\x00\x01\x00\x00\x00x\x00\x0e\r18.185.75.113\xc0I\x00\x10\x00\x01\x00\x00\x00x\x00\x0e\r18.196.59.154', expected_parsed_reply = [(120, '18.185.75.113'), (120, '18.196.59.154'),], ) ar_current_domain_data2 = _DnsParsingTestData( name = "current domain", domain = "vpn-api.proton.me", #expected_ar_domain = b'\x1ddOZYG4LLBOBUS44DSN52G63RONVSQ\tprotonpro\x03xyz\x00', expected_dns_request = b'\x00\x00\x01 \x00\x01\x00\x00\x00\x00\x00\x00\x1ddOZYG4LLBOBUS44DSN52G63RONVSQ\tprotonpro\x03xyz\x00\x00\x10\x00\x01', dns_reply = b'\x00\x00\x81\x80\x00\x01\x00\x03\x00\x00\x00\x00\x1ddOZYG4LLBOBUS44DSN52G63RONVSQ\tprotonpro\x03xyz\x00\x00\x10\x00\x01\xc0\x0c\x00\x05\x00\x01\x00\x00\x00x\x00\x06\x03vpn\xc0*\xc0I\x00\x10\x00\x01\x00\x00\x00x\x00\x0e\r35.158.124.21\xc0I\x00\x10\x00\x01\x00\x00\x00x\x00\x0b\n3.72.109.7', expected_parsed_reply = [(120, '35.158.124.21'), (120, '3.72.109.7')], ) standard_legacy_domain = _DnsParsingTestData( name = "legacy domain", domain = "api.protonvpn.ch", expected_dns_request = b'\xdf\r\x01 \x00\x01\x00\x00\x00\x00\x00\x00\x03api\tprotonvpn\x02ch\x00\x00\x01\x00\x01', dns_reply = b'\xdf\r\x81\x80\x00\x01\x00\x01\x00\x03\x00\x03\x03api\tprotonvpn\x02ch\x00\x00\x01\x00\x01\xc0\x0c\x00\x01\x00\x01\x00\x00\x04\xb0\x00\x04\xb9\x9f\x9f\xaa\xc0\x10\x00\x02\x00\x01\x00\x00\x04\xb0\x00\x06\x03ns2\xc0\x10\xc0\x10\x00\x02\x00\x01\x00\x00\x04\xb0\x00\x06\x03ns3\xc0\x10\xc0\x10\x00\x02\x00\x01\x00\x00\x04\xb0\x00\x06\x03ns1\xc0\x10\xc0b\x00\x01\x00\x01\x00\x00\x04\xb0\x00\x04\xb9F*\x96\xc0>\x00\x01\x00\x01\x00\x00\x04\xb0\x00\x04\xb0w\xc8\x96\xc0P\x00\x01\x00\x01\x00\x00\x04\xb0\x00\x04\xcd\x84/\x01', expected_parsed_reply = [(1200, ipaddress.ip_address('185.159.159.170')),], ) standard_current_domain = _DnsParsingTestData( name = "current domain", domain = "vpn-api.proton.me", expected_dns_request = b'jW\x01 \x00\x01\x00\x00\x00\x00\x00\x00\x07vpn-api\x06proton\x02me\x00\x00\x01\x00\x01', dns_reply = b'jW\x81\x80\x00\x01\x00\x01\x00\x03\x00\x03\x07vpn-api\x06proton\x02me\x00\x00\x01\x00\x01\xc0\x0c\x00\x01\x00\x01\x00\x00\x04\xb0\x00\x04\xb9\x9f\x9f\x94\xc0\x14\x00\x02\x00\x01\x00\x00\x04\xb0\x00\x06\x03ns3\xc0\x14\xc0\x14\x00\x02\x00\x01\x00\x00\x04\xb0\x00\x06\x03ns1\xc0\x14\xc0\x14\x00\x02\x00\x01\x00\x00\x04\xb0\x00\x06\x03ns2\xc0\x14\xc0Q\x00\x01\x00\x01\x00\x00\x04\xb0\x00\x04\xb9F*\x96\xc0c\x00\x01\x00\x01\x00\x00\x04\xb0\x00\x04\xb0w\xc8\x96\xc0?\x00\x01\x00\x01\x00\x00\x04\xb0\x00\x04\xcd\x84/\x01', expected_parsed_reply = [(1200, ipaddress.ip_address('185.159.159.148')),], ) other_reply_data = [ _DnsParsingTestData( name="full DNS reply legacy domain", domain = "api.protonvpn.ch", expected_dns_request = None, dns_reply = \ b"\x09\x8c\x81\x80\x00\x01\x00\x01\x00\x00\x00\x01\x07\x76\x70\x6e" \ b"\x2d\x61\x70\x69\x06\x70\x72\x6f\x74\x6f\x6e\x02\x6d\x65\x00\x00" \ b"\x01\x00\x01\xc0\x0c\x00\x01\x00\x01\x00\x00\x03\x8b\x00\x04\xb9" \ b"\x9f\x9f\x94\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00", expected_parsed_reply = [(907, ipaddress.ip_address('185.159.159.148'))], ), _DnsParsingTestData( name="full DNS reply current domain", domain = "vpn-api.proton.me", expected_dns_request = None, dns_reply = \ b"\xaa\xfc\x81\x80\x00\x01\x00\x01\x00\x03\x00\x04\x03\x61\x70\x69" \ b"\x09\x70\x72\x6f\x74\x6f\x6e\x76\x70\x6e\x02\x63\x68\x00\x00\x01" \ b"\x00\x01\xc0\x0c\x00\x01\x00\x01\x00\x00\x04\xb0\x00\x04\xb9\x9f" \ b"\x9f\xaa\xc0\x10\x00\x02\x00\x01\x00\x00\x04\xb0\x00\x06\x03\x6e" \ b"\x73\x32\xc0\x10\xc0\x10\x00\x02\x00\x01\x00\x00\x04\xb0\x00\x06" \ b"\x03\x6e\x73\x31\xc0\x10\xc0\x10\x00\x02\x00\x01\x00\x00\x04\xb0" \ b"\x00\x06\x03\x6e\x73\x33\xc0\x10\xc0\x50\x00\x01\x00\x01\x00\x00" \ b"\x04\xb0\x00\x04\xb9\x46\x2a\x96\xc0\x3e\x00\x01\x00\x01\x00\x00" \ b"\x04\xb0\x00\x04\xb0\x77\xc8\x96\xc0\x62\x00\x01\x00\x01\x00\x00" \ b"\x04\xb0\x00\x04\xcd\x84\x2f\x01\x00\x00\x29\x10\x00\x00\x00\x00" \ b"\x00\x00\x00", expected_parsed_reply = [(1200, ipaddress.ip_address('185.159.159.170'))], ), ] class TestDNSParser: def _test_ar_input_data(self, input_data: _DnsParsingTestData): ar_domain = b'd' + base64.b32encode(input_data.domain.encode('ascii')).strip(b'=') + b".protonpro.xyz" print(f"Testing Alternative Routing DNS for {input_data.name} : {input_data.domain} => AR domain = {ar_domain}") dns_query = DNSParser.build_query(ar_domain, qtype=16, qclass=1) # TXT IN print(f"DNS query : {dns_query}") # the request 2 first bytes are randomly generated and may not match assert dns_query[2:] == input_data.expected_dns_request[2:] assert len(dns_query) == len(input_data.expected_dns_request) dns_answers = DNSParser.parse(input_data.dns_reply) print(f"DNS answers : {dns_answers}") assert set(dns_answers) == set(input_data.expected_parsed_reply) def test_ar_legacy_domain(self): self._test_ar_input_data(input_data=ar_old_domain_data) def test_ar_current_domain1(self): self._test_ar_input_data(input_data=ar_current_domain_data1) def test_ar_current_domain2(self): self._test_ar_input_data(input_data=ar_current_domain_data2) def _test_normal_input_data(self, input_data: _DnsParsingTestData): print(f"Testing standard DNS for {input_data.name} : {input_data.domain}") print(input_data.expected_dns_request) dns_query = DNSParser.build_query(input_data.domain, qtype=1, qclass=1) # A IN print(f"DNS query : {dns_query}") # the request 2 first bytes are randomly generated and may not match assert dns_query[2:] == input_data.expected_dns_request[2:] assert len(dns_query) == len(input_data.expected_dns_request) dns_answers = DNSParser.parse(input_data.dns_reply) print(f"DNS answers : {dns_answers}") assert set(dns_answers) == set(input_data.expected_parsed_reply) def test_normal_query_legacy_domain(self): self._test_normal_input_data(standard_legacy_domain) def test_normal_query_current_domain(self): self._test_normal_input_data(standard_current_domain) def test_generic_parsing(self): for input_data in other_reply_data: print(f"Parsing other DNS reply : {input_data.name}") dns_answers = DNSParser.parse(input_data.dns_reply) print(f"DNS answers : {dns_answers}") assert set(dns_answers) == set(input_data.expected_parsed_reply) @pytest.mark.parametrize("description, invalid_input", [ ("Empty reply", b''), ("Super small reply (1)", b'x'), ("Super small reply (7)", b'\xfa\x83\x81\x80\x00\x01\x00'), ("Unicode Decode error", b'\xfa\x83\x81\x80\x00\x01\x00\x02\x00\x00\x00\x00\x1bdMFYGSLTQOJXXI33OOZYG4LTDNA\tprotonpro\x03xyz\x00\x00\x10\x00\x01\xc0\x0c\x00\x10\x00\x01\x00\x00\x00x\x0032ec2\xcc3-127-37-78.eu-central-1.compute.amazonaws.com\xc0\x0c\x00\x10\x00\x01\x00\x00\x00x\x0054ec2-54-93-234-150.eu-central-1.compute.amazonaws.com'), ("Wrong query reply", b'\xfa\x83\x81\x80\x00\x01\x00\x02\x00\x00\x00\x00\x1bdMFYGSLTQOJXXI33OOZYG4LTDNA\nprotonpro\x03xyz'), ("Truncated query reply", b'\xfa\x83\x81\x80\x00\x01\x00\x02\x00\x00\x00\x00\x1bdMFYGSLTQOJXXI33OOZYG4LTDNA\tproto'), ("Truncated TXT record value", b'\x00\x00\x81\x80\x00\x01\x00\x03\x00\x00\x00\x00\x1ddOZYG4LLBOBUS44DSN52G63RONVSQ\tprotonpro\x03xyz\x00\x00\x10\x00\x01\xc0\x0c\x00\x05\x00\x01\x00\x00\x00x\x00\x06\x03vpn\xc0*\xc0I\x00\x10\x00\x01\x00\x00\x00x\x00\x0e\r35.15'), ("Truncated A record value", b'jW\x81\x80\x00\x01\x00\x01\x00\x03\x00\x03\x07vpn-api\x06proton\x02me\x00\x00\x01\x00\x01\xc0\x0c\x00\x01\x00\x01\x00\x00\x04\xb0\x00\x04\xb9\x9f\x9f'), ("Truncated A record headers", b'jW\x81\x80\x00\x01\x00\x01\x00\x03\x00\x03\x07vpn-api\x06proton\x02me\x00\x00\x01\x00\x01\xc0\x0c\x00\x01\x00'), ]) def test_incorrect_records(self, description: str, invalid_input: bytes): print(f"{description}") with pytest.raises(DNSResponseError): _ = DNSParser.parse(invalid_input) python-proton-core-0.1.16/tests/test_environment.py000066400000000000000000000043171452542764400225260ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from proton.utils.environment import ProductExecutionEnvironment import shutil import pytest from unittest.mock import Mock, patch import os @pytest.fixture def config_mock(tmp_path): d = tmp_path / "etc" d.mkdir() yield d shutil.rmtree(str(d)) @pytest.fixture def cache_mock(tmp_path): d = tmp_path / "var" / "cache" d.mkdir(parents=True) yield d shutil.rmtree(str(d)) @pytest.fixture def runtime_mock(tmp_path): d = tmp_path / "run" d.mkdir(parents=True) yield d shutil.rmtree(str(d)) @patch("proton.utils.environment.BaseDirectory") @patch("proton.utils.environment.os.getuid") def test_successfully_create_product_dirs_when_creating_new_product_class( get_uid_mock, base_directory_mock, config_mock, cache_mock, runtime_mock ): get_uid_mock.return_value = 1 base_directory_mock.xdg_config_home = config_mock base_directory_mock.xdg_cache_home = cache_mock base_directory_mock.get_runtime_dir.return_value = runtime_mock class MockEnv(ProductExecutionEnvironment): PRODUCT = "mock" assert MockEnv().path_config == str(config_mock / "Proton" / "mock") assert MockEnv().path_cache == str(cache_mock / "Proton" / "mock") assert MockEnv().path_logs == str(cache_mock / "Proton" / "logs" / "mock") assert MockEnv().path_runtime == str(runtime_mock / "Proton" / "mock") def test_raises_exception_when_creating_new_product_class_and_not_setting_product_class_property(): class MockEnv(ProductExecutionEnvironment): ... with pytest.raises(RuntimeError): MockEnv() python-proton-core-0.1.16/tests/test_loader.py000066400000000000000000000110431452542764400214220ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ import unittest import os from proton.session.environments import Environment class DummyTest1Environment(Environment): @classmethod def _get_priority(cls): import os if os.environ.get('PROTON_API_ENVIRONMENT', '') == 'dummytest1': return 100 else: return -100 @property def http_base_url(self): return "https://dummy1.protonvpn.ch" @property def tls_pinning_hashes(self): return None @property def tls_pinning_hashes_ar(self): return None class DummyTest2Environment(Environment): @classmethod def _get_priority(cls): import os if os.environ.get('PROTON_API_ENVIRONMENT', '') == 'dummytest2': return 100 else: return -100 @property def http_base_url(self): return "https://dummy2.protonvpn.ch" @property def tls_pinning_hashes(self): return None @property def tls_pinning_hashes_ar(self): return None class DummyTest3Environment(Environment): @classmethod def _get_priority(cls): import os if os.environ.get('PROTON_API_ENVIRONMENT', '') == 'dummytest3': return 100 else: return -100 @property def http_base_url(self): return "https://dummy3.protonvpn.ch" @property def tls_pinning_hashes(self): return None @property def tls_pinning_hashes_ar(self): return None class LoaderTest(unittest.TestCase): def setUp(self): from proton.loader import Loader self._loader = Loader self._loader.reset() def tearDown(self): self._loader.reset() self._loader = None def test_default(self): from proton.session.environments import ProdEnvironment assert self._loader.get('environment') == ProdEnvironment assert len(self._loader.get_all('environment')) >= 1 # by default, we have at least 1 environment : the default one def test_environments_explicit(self): from proton.session.environments import ProdEnvironment self._loader.set_all('environment', {'prod': ProdEnvironment, 'dummytest1': DummyTest1Environment, 'dummytest2': DummyTest2Environment}) assert len(self._loader.get_all('environment')) == 3 os.environ['PROTON_API_ENVIRONMENT'] = 'prod' assert self._loader.get('environment') == ProdEnvironment assert len(self._loader.get_all('environment')) == 3 os.environ['PROTON_API_ENVIRONMENT'] = 'dummytest2' assert self._loader.get('environment') == DummyTest2Environment assert len(self._loader.get_all('environment')) == 3 os.environ['PROTON_API_ENVIRONMENT'] = 'dummytest1' assert self._loader.get('environment') == DummyTest1Environment assert len(self._loader.get_all('environment')) == 3 assert self._loader.get_name(ProdEnvironment) == ('environment','prod') assert self._loader.get_name(DummyTest1Environment) == ('environment','dummytest1') assert self._loader.get_name(DummyTest2Environment) == ('environment','dummytest2') # This ones are not loaded since we used set_all assert self._loader.get_name(DummyTest3Environment) is None def test_environments(self): from proton.session.environments import ProdEnvironment if len(self._loader.get_all('environment')) == 0: self.skipTest("No environments, probably because we have not entry points set up.") os.environ['PROTON_API_ENVIRONMENT'] = 'prod' assert self._loader.get('environment') == ProdEnvironment with self.assertRaises(RuntimeError): _ = self._loader.get('environment', 'unknown') os.environ['PROTON_API_ENVIRONMENT'] = 'unknown' assert self._loader.get('environment') == ProdEnvironment assert self._loader.get_name(ProdEnvironment) == ('environment','prod') python-proton-core-0.1.16/tests/test_protonsso.py000066400000000000000000000211651452542764400222300ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ import unittest import os class TestProtonSSO(unittest.IsolatedAsyncioTestCase): def setUp(self): self._env_backup = os.environ.copy() def tearDown(self): os.environ = self._env_backup def _skip_if_no_internal_environments(self): try: from proton.session_internal.environments import AtlasEnvironment except (ImportError, ModuleNotFoundError): self.skipTest("Couldn't load proton-core-internal environments, they are probably not installed on this machine, so skip this test.") async def test_sessions(self): from proton.sso import ProtonSSO sso = ProtonSSO() fake_account_name = 'test-proton-sso-session' fake_account2_name = 'test-proton-sso-session2@pm.me' test_data_1 = {'test': 'data'} test_data_2 = {'test2': 'data2'} for i in range(2): sso._acquire_session_lock(fake_account_name, {}) sso._release_session_lock(fake_account_name,{'AccountName':fake_account_name,**test_data_1}) assert fake_account_name in sso.sessions assert sso._get_session_data(fake_account_name) == {'AccountName':fake_account_name,**test_data_1} sso.set_default_account(fake_account_name) assert sso.sessions[0] == fake_account_name sso._acquire_session_lock(fake_account2_name, {}) sso._release_session_lock(fake_account2_name,{'AccountName':fake_account2_name,**test_data_2}) assert fake_account_name in sso.sessions assert fake_account2_name in sso.sessions assert sso._get_session_data(fake_account_name) == {'AccountName':fake_account_name,**test_data_1} assert sso._get_session_data(fake_account2_name) == {'AccountName':fake_account2_name,**test_data_2} assert sso.sessions[0] == fake_account_name sso.set_default_account(fake_account2_name) assert sso.sessions[0] == fake_account2_name sso.set_default_account(fake_account_name) assert sso.sessions[0] == fake_account_name sso._acquire_session_lock(fake_account_name, {'AccountName':fake_account_name,**test_data_1}) sso._release_session_lock(fake_account_name, {'AccountName':fake_account_name,**test_data_2}) assert sso.sessions[0] == fake_account_name assert fake_account_name in sso.sessions assert fake_account2_name in sso.sessions assert sso._get_session_data(fake_account_name) == {'AccountName':fake_account_name,**test_data_2} assert sso._get_session_data(fake_account2_name) == {'AccountName':fake_account2_name,**test_data_2} sso._acquire_session_lock(fake_account_name,{'AccountName':fake_account_name,**test_data_2}) sso._release_session_lock(fake_account_name, None) with self.assertRaises(KeyError): sso.set_default_account(fake_account_name) assert fake_account_name not in sso.sessions assert fake_account2_name in sso.sessions assert sso._get_session_data(fake_account_name) == {} assert sso._get_session_data(fake_account2_name) == {'AccountName':fake_account2_name,**test_data_2} sso._acquire_session_lock(fake_account2_name, {'AccountName':fake_account2_name,**test_data_2}) sso._release_session_lock(fake_account2_name, None) assert fake_account_name not in sso.sessions assert fake_account2_name not in sso.sessions assert sso._get_session_data(fake_account_name) == {} assert sso._get_session_data(fake_account2_name) == {} async def test_with_real_session(self): from proton.sso import ProtonSSO self._skip_if_no_internal_environments() os.environ['PROTON_API_ENVIRONMENT'] = 'atlas' sso = ProtonSSO() if 'pro' in sso.sessions: assert await sso.get_session('pro').async_logout() s = sso.get_session('pro') assert await s.async_authenticate('pro','pro') assert await s.async_api_request('/tests/ping') == {'Code': 1000} assert await s.async_logout() async def test_default_session(self): from proton.sso import ProtonSSO from proton.session.exceptions import ProtonAPIAuthenticationNeeded self._skip_if_no_internal_environments() os.environ['PROTON_API_ENVIRONMENT'] = 'atlas' sso = ProtonSSO() while len(sso.sessions) > 0: assert await sso.get_default_session().async_logout() assert len(sso.sessions) == 0 s = sso.get_default_session() assert (await s.async_api_request('/tests/ping'))['Code'] == 1000 assert len(sso.sessions) == 0 assert await s.async_authenticate('pro','pro') assert len(sso.sessions) == 1 assert s.AccountName == 'pro' assert (await s.async_api_request('/users'))['Code'] == 1000 sso2 = ProtonSSO() assert len(sso2.sessions) == 1 s2 = sso2.get_default_session() assert s2.AccountName == 'pro' await s2.async_logout() assert len(sso2.sessions) == 0 assert len(sso.sessions) == 0 with self.assertRaises(ProtonAPIAuthenticationNeeded): assert (await s.async_api_request('/users'))['Code'] == 1000 async def test_broken_index(self): from proton.loader import Loader from proton.sso import ProtonSSO sso = ProtonSSO() keyring = Loader.get('keyring')() keyring[sso._ProtonSSO__keyring_index_name()] = ['pro'] keyring[sso._ProtonSSO__keyring_key_name('pro')] = {'additional_data': 'abc123'} assert 'pro' not in sso.sessions async def test_broken_data(self): from proton.sso import ProtonSSO sso = ProtonSSO() sso._acquire_session_lock('pro', None) with self.assertRaises(ValueError): sso._release_session_lock('pro', {'abc':'123'}) sso._acquire_session_lock('pro', None) sso._release_session_lock('pro', {}) sso._acquire_session_lock('pro', None) sso._release_session_lock('pro', None) async def test_additional_data(self): from proton.sso import ProtonSSO from proton.session import Session from proton.session.exceptions import ProtonAPIAuthenticationNeeded self._skip_if_no_internal_environments() os.environ['PROTON_API_ENVIRONMENT'] = 'atlas' class SessionWithAdditionalData(Session): def __init__(self, *a, **kw): self.additional_data = None super().__init__(*a, **kw) def __setstate__(self, data): self.additional_data = data.get('additional_data', None) super().__setstate__(dict([(k, v) for k, v in data.items() if k not in ('additional_data',)])) def __getstate__(self): d = super().__getstate__() if self.additional_data is not None: d['additional_data'] = self.additional_data return d async def set_additional_data(self, v): self._requests_lock() self.additional_data = v self._requests_unlock() sso = ProtonSSO() while len(sso.sessions) > 0: assert await sso.get_default_session().async_logout() s = sso.get_default_session(SessionWithAdditionalData) assert await s.async_authenticate('pro','pro') await s.set_additional_data('abc123') s = sso.get_default_session(SessionWithAdditionalData) assert s.additional_data == 'abc123' s = sso.get_default_session() with self.assertRaises(AttributeError): assert s.additional_data == 'abc123' # Call to force persistence save s._requests_lock() s._requests_unlock() # We should still have additional data s = sso.get_default_session(SessionWithAdditionalData) assert s.additional_data == 'abc123'python-proton-core-0.1.16/tests/test_requests_transport.py000066400000000000000000000044411452542764400241470ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ import unittest from io import StringIO from unittest.mock import Mock import requests from proton.session import Session from proton.session.formdata import FormData, FormField from proton.session.transports.requests import RequestsTransport class TestRequestsTransport(unittest.IsolatedAsyncioTestCase): async def test_async_api_request_posts_form_data_with_data_param(self): session = Session() # Mock requests post call. requests_session = Mock(spec=requests.Session) requests_session.headers = {} # Allow setting headers. requests_session.post.return_value.json.return_value = {"Code": 1000} requests_transport = RequestsTransport(session, requests_session) # Build form data. form_data = FormData() # Add a simple field to the form. form_data.add(FormField(name="foo", value="bar")) # Add a file to the form. file = StringIO("File content.") form_data.add(FormField( name="file", value=file, filename="file.txt", content_type="text/plain" )) # SUT. await requests_transport.async_api_request("/endpoint", data=form_data) # Adding the data kwarg should have triggered a POST call. requests_session.post.assert_called_once() # The posted data/files should be the ones in our FormData instance. posted_data = requests_session.post.call_args.kwargs["data"] assert posted_data == {"foo": "bar"} posted_files = requests_session.post.call_args.kwargs["files"] assert posted_files == { "file": ("file.txt", file, "text/plain") } python-proton-core-0.1.16/tests/test_session.py000066400000000000000000000113371452542764400216450ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ import unittest import os class TestSession(unittest.IsolatedAsyncioTestCase): async def test_ping(self): from proton.session import Session s = Session() assert await s.async_api_request('/tests/ping') == {'Code': 1000} async def test_session_refresh(self): from proton.session.transports import TransportFactory from unittest.mock import AsyncMock session_state = { "UID": "7pqrddjjxmbqpmxcqzg3utlscjgw74xq", "AccessToken": "lvg7emrif23lwi3mgvpqlqfscbzzidni", "RefreshToken": "phormswshlqr7mzvgjfml26kcincqfv3", "Scopes": ["self", "parent", "user", "loggedin", "vpn", "verified"], "Environment": "prod", "AccountName": "vpnfree", "LastUseData": { "2FA": { "Enabled": 0, "FIDO2": { "AuthenticationOptions": None, "RegisteredKeys": [] }, "TOTP": 0 }, "appversion": "linux-vpn@4.0.0", "user_agent": "ProtonVPN/4.0.0 (Linux; debian/n/a)", "refresh_revision": 0 } } refresh_reply = { 'Code': 1000, 'AccessToken': 'uu7eg2d6dudlgvcsyk2plkgktwmwjdbr', 'ExpiresIn': 3600, 'TokenType': 'Bearer', 'Scope': 'self parent user loggedin vpn verified', 'Scopes': ['self', 'parent', 'user', 'loggedin', 'vpn', 'verified'], 'Uid': '7pqrddjjxmbqpmxcqzg3utlscjgw74xq', 'UID': '7pqrddjjxmbqpmxcqzg3utlscjgw74xq', 'RefreshToken': 'cuxdyjphk4snlgfjouffsj2behzsuvgs', 'LocalID': 0 } class MyMockCalls: callback_async_api_request = None async def async_api_request(self, endpoint, *args, **kwargs): return await self.callback_async_api_request(endpoint, *args, **kwargs) mock_calls = MyMockCalls() def _repr_session(session: "Session"): return f"{{UID={session.UID} , AccessToken={session.AccessToken}}}" class MyMockTransport: def __init__(self, session: "Session", *args, **kwargs) -> None: self._session = session self.mock_calls = mock_calls async def async_api_request(self, endpoint, *args, **kwargs): return await self.mock_calls.async_api_request(self._session, endpoint, *args, **kwargs) from proton.session import Session from proton.session.exceptions import ProtonAPIError s = Session() s.transport_factory = TransportFactory(cls=MyMockTransport) async def mock_func_auth(session: "Session", endpoint, *args, **kwargs): if session.AccessToken == "lvg7emrif23lwi3mgvpqlqfscbzzidni": if endpoint == "/vpn/someroute": raise ProtonAPIError(401, {}, {"Code": 401, "Error": ["...?..."]}) elif endpoint == "/auth/refresh" and args[0]["RefreshToken"] == "phormswshlqr7mzvgjfml26kcincqfv3": return refresh_reply elif session.AccessToken == "uu7eg2d6dudlgvcsyk2plkgktwmwjdbr": if endpoint == "/vpn/someroute": return {"Code": 1000, "SomeRouteData": {"DataKey": "DataValue"}} raise ValueError(f"Unexpected request for {_repr_session(session)} and {endpoint=}") mock_calls.callback_async_api_request = AsyncMock(side_effect=mock_func_auth) s.__setstate__(session_state) assert s.AccountName == session_state["AccountName"] r = await s.async_api_request("/vpn/someroute") assert r == {"Code": 1000, "SomeRouteData": {"DataKey": "DataValue"}} mock_calls = mock_calls.callback_async_api_request.mock_calls assert len(mock_calls) == 3 _, args, _ = mock_calls[0] assert args[1] == "/vpn/someroute" _, args, _ = mock_calls[1] assert args[1] == "/auth/refresh" _, args, _ = mock_calls[2] assert args[1] == "/vpn/someroute" python-proton-core-0.1.16/tests/test_session_pickle.py000066400000000000000000000024051452542764400231700ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ import unittest, pickle, os class TestSessionPickle(unittest.IsolatedAsyncioTestCase): def setUp(self): self._env_backup = os.environ.copy() def tearDown(self): os.environ = self._env_backup async def test_pickle(self): from proton.session import Session os.environ['PROTON_API_ENVIRONMENT'] = 'prod' s = Session() pickled_session = pickle.loads(pickle.dumps(s)) assert isinstance(pickled_session, Session) assert s.__dict__ == pickled_session.__dict__ # we can't do much more testing as we don't log in in API in the tests... python-proton-core-0.1.16/tests/test_textfilekeyring.py000066400000000000000000000061711452542764400233770ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from proton.keyring.textfile import KeyringBackendJsonFiles import tempfile import pytest import json import os from proton.keyring.exceptions import KeyringError @pytest.fixture def mock_path_config(): with tempfile.TemporaryDirectory(prefix="test_textfile_keyring") as tmpdirname: yield tmpdirname def test_get_item(mock_path_config): test_get_values = {"test-key": "test-value"} test_key_fp = os.path.join(mock_path_config, "keyring-test-get-keyring.json") with open(test_key_fp, "w") as f: json.dump(test_get_values, f) k = KeyringBackendJsonFiles(path_config=mock_path_config) assert k._get_item("test-get-keyring") == test_get_values def test_del_item(mock_path_config): test_key_fp = os.path.join(mock_path_config, "keyring-test-del-keyring.json") with open(test_key_fp, "w") as f: json.dump({"test-del-key": "test-del-value"}, f) k = KeyringBackendJsonFiles(path_config=mock_path_config) k._del_item("test-del-keyring") assert not os.path.isfile(test_key_fp) def test_set_item(mock_path_config): k = KeyringBackendJsonFiles(path_config=mock_path_config) k._set_item("test-set-keyring", {"set-test-key": "set-test-value"}) assert os.path.isfile(os.path.join(mock_path_config, "keyring-test-set-keyring.json")) def test_get_item_raises_exception_filepath_does_not_exist(mock_path_config): k = KeyringBackendJsonFiles(path_config=mock_path_config) with pytest.raises(KeyError): k._get_item("test-get-keyring") def test_get_item_raises_exception_corrupted_json_data(mock_path_config): test_key_fp = os.path.join(mock_path_config, "keyring-test-get-keyring.json") with open(test_key_fp, "w") as f: f.write("{\"test:}") k = KeyringBackendJsonFiles(path_config=mock_path_config) with pytest.raises(KeyError): k._get_item("test-get-keyring") def test_del_item_raises_exception_filepath_does_not_exist(mock_path_config): k = KeyringBackendJsonFiles(path_config=mock_path_config) with pytest.raises(KeyError): k._del_item("test-del-fail") def test_set_item_raises_exception_unable_to_write_in_path(): k = KeyringBackendJsonFiles(path_config="fake-dirpath") with pytest.raises(KeyringError): k._set_item("test", ["test"]) def test_set_item_serialize_invalid_json_object_raises_exception(mock_path_config): k = KeyringBackendJsonFiles(path_config=mock_path_config) with pytest.raises(ValueError): k._set_item("test", {1, 2, 3, 4, 5}) python-proton-core-0.1.16/tests/test_tlsverification.py000066400000000000000000000122211452542764400233600ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ import unittest class TestTLSValidation(unittest.IsolatedAsyncioTestCase): async def test_successful(self): from proton.session import Session from proton.session.environments import ProdEnvironment s = Session() s.environment = ProdEnvironment() assert await s.async_api_request('/tests/ping') == {'Code': 1000} async def test_without_pinning(self): from proton.session import Session from proton.session.environments import ProdEnvironment class ProdWithoutPinningEnvironment(ProdEnvironment): @property def tls_pinning_hashes(self): return None @property def tls_pinning_hashes_ar(self): return None s = Session() s.environment = ProdWithoutPinningEnvironment() assert await s.async_api_request('/tests/ping') == {'Code': 1000} async def test_bad_pinning_url_changed(self): from proton.session import Session from proton.session.environments import ProdEnvironment from proton.session.exceptions import ProtonAPINotReachable from proton.session.transports.aiohttp import AiohttpTransport class BrokenProdEnvironment(ProdEnvironment): @property def http_base_url(self): # This is one of the URLs, but it uses different certificates than prod api, so pinning will fail return "https://www.protonvpn.com/api/" s = Session() s.environment = BrokenProdEnvironment() s.transport_factory = AiohttpTransport with self.assertRaises(ProtonAPINotReachable) as e: assert await s.async_api_request('/tests/ping') == {'Code': 1000} assert str(e.exception).startswith('TLS pinning verification failed') async def test_bad_pinning_fingerprint_changed(self): from proton.session import Session from proton.session.environments import ProdEnvironment from proton.session.exceptions import ProtonAPINotReachable from proton.session.transports.aiohttp import AiohttpTransport class BrokenProdEnvironment(ProdEnvironment): @property def tls_pinning_hashes(self): # This is an invalid hash return set([ "aaaaaaakFkM8qJClsuWgUzxgBkePfRCkRpqUesyDmeE=", ]) s = Session() s.environment = BrokenProdEnvironment() s.transport_factory = AiohttpTransport with self.assertRaises(ProtonAPINotReachable) as e: assert await s.async_api_request('/tests/ping') == {'Code': 1000} assert str(e.exception).startswith('TLS pinning verification failed') async def test_pinning_disabled(self): from proton.session import Session from proton.session.environments import ProdEnvironment from proton.session.exceptions import ProtonAPINotReachable class PinningDisabledProdEnvironment(ProdEnvironment): @property def http_base_url(self): # This is one of the URLs, but it uses different certificates than prod api, so pinning would fail if it was used return "https://www.protonvpn.com/api/" @property def tls_pinning_hashes(self): return None s = Session() s.environment = PinningDisabledProdEnvironment() with self.assertRaises(ProtonAPINotReachable) as e: assert await s.async_api_request('/tests/ping') == {'Code': 1000} # Will probably return "API returned non-json results" assert not str(e.exception).startswith('TLS pinning verification failed') async def test_bad_ssl(self): from proton.session import Session from proton.session.environments import ProdEnvironment from proton.session.exceptions import ProtonAPINotReachable from proton.session.transports.aiohttp import AiohttpTransport class BrokenProdEnvironment(ProdEnvironment): @property def http_base_url(self): # This will break, as it's a self signed certificate return "https://self-signed.badssl.com/" @property def tls_pinning_hashes(self): return None s = Session() s.environment = BrokenProdEnvironment() s.transport_factory = AiohttpTransport with self.assertRaises(ProtonAPINotReachable) as e: assert await s.async_api_request('/tests/ping') == {'Code': 1000} python-proton-core-0.1.16/tests/testdata.py000066400000000000000000000352151452542764400207350ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from proton.session.exceptions import ProtonCryptoError srp_instances = [ { 'Username': 'test', 'Password': 'test', 'Modulus': '1B64DF29DEDD8656245DB7EEE751442AD9CF1DAFC5A71A94076385C2FBF9FA7AD63E94CB365EC94EBA5BE131CF63D3930CAC4755DE6D0625C24DD9A906551D216601222EBA94FF50C78B8B26DBF27636F4019F1700BA091287462CFFAD4F88B22D66BBF8993090865E46D077ECF1DB78CB2AB0D036AD786B046B5D93BD473C95779914CB93F607FD7EFB9D34161951263CE794BF181FB301EE444D170999EAFF9427CC4151BD91A755F1A184009C1418B16EEC7BFC2D5F88D42B38A4CC176B73EAB132FE37DD7E1162DCA1D13E81A6F10F090DE77EB8CC492CD0B19BB6FC151F5B4AD56B14308D582D86471390C4223400AEE3D5E94C973FB997D59F8A9F309F', # noqa 'Verifier': 'rhmWw1f4YsGq/cVgIePVSaot9Zj2kGNLxIgPytirz9/Nd8X8a28ZvFnWMQD0Jj8IgJPfO4EsI2nU5NuFqIbPI4OKs6s0nWvEHXkdtzxT1n451MGThvZ6o7I+0Ofi1Dh6Rgkv3MxVL/6cNev3EDyhcvh5w9hUIOk5OfRDcFMKv8ht5OYI5a/++m1++x58LQyrrTsRMMIcvDFXsjmMj4Ch3leP02S56cxDl6IQTosU+JcXGHsgNDf90aDnlsDFMGt6As1FSQm8bw0Yat+P02IZrXURQKsMLOKxdwb8xWySVymXAyjcJ7Z9ZabGjCX6Lelo9Wiz0hIEGE7nnOeVwzJKaA==', # noqa 'Salt': 'Jl54BOeNTVl8Ng==', 'Exception': None }, { 'Username': 'LeadingZerosSalt', 'Password': 'test', 'Modulus': '1B64DF29DEDD8656245DB7EEE751442AD9CF1DAFC5A71A94076385C2FBF9FA7AD63E94CB365EC94EBA5BE131CF63D3930CAC4755DE6D0625C24DD9A906551D216601222EBA94FF50C78B8B26DBF27636F4019F1700BA091287462CFFAD4F88B22D66BBF8993090865E46D077ECF1DB78CB2AB0D036AD786B046B5D93BD473C95779914CB93F607FD7EFB9D34161951263CE794BF181FB301EE444D170999EAFF9427CC4151BD91A755F1A184009C1418B16EEC7BFC2D5F88D42B38A4CC176B73EAB132FE37DD7E1162DCA1D13E81A6F10F090DE77EB8CC492CD0B19BB6FC151F5B4AD56B14308D582D86471390C4223400AEE3D5E94C973FB997D59F8A9F309F', 'Verifier': 'P3YwOj/6yNdZEe9sJoCtXKCvp/KUOtT8TwQXa1i410CgJJqmOaPjb460JoDl3a+2p5p3MXBQ+EvEYe2C9INuNDgQRSvLSAwAczJehPPl8vYYDfbda8kdKSI7iV3l4acA8oLyLSfZpdPNj+YQL/cjfBbHJssir+Fhm3wclSk0NvBjk5DgmDow3E9HzGqfcZTSqVDH7sSqrfR8K1r7wFjm9WDkv27agUXdgJJtQidqsRBz7IAY00NtMFiyoFjh8qUqRkV5/RX2KdAbqQWDBfrIAaQ6GWLJ1T66RTq4IyOtdWD6edJcRFnI8qNyNCjRAXrZrSNtPbo88ho79O9oOmEhmw==', 'Salt': 'AA54BOeNTVl8kg==', 'Exception': None }, { 'Username': 'TrailingZerosSalt', 'Password': 'test', 'Modulus': '1B64DF29DEDD8656245DB7EEE751442AD9CF1DAFC5A71A94076385C2FBF9FA7AD63E94CB365EC94EBA5BE131CF63D3930CAC4755DE6D0625C24DD9A906551D216601222EBA94FF50C78B8B26DBF27636F4019F1700BA091287462CFFAD4F88B22D66BBF8993090865E46D077ECF1DB78CB2AB0D036AD786B046B5D93BD473C95779914CB93F607FD7EFB9D34161951263CE794BF181FB301EE444D170999EAFF9427CC4151BD91A755F1A184009C1418B16EEC7BFC2D5F88D42B38A4CC176B73EAB132FE37DD7E1162DCA1D13E81A6F10F090DE77EB8CC492CD0B19BB6FC151F5B4AD56B14308D582D86471390C4223400AEE3D5E94C973FB997D59F8A9F309F', 'Verifier': 's5KfuE1T7TtZDVvYCfOe/DTlfIw0eMUYc9oNMfDP9KduIhiczGEejjZnDo9ztrs+Cw/EBUSdfR8Hl1S0EOQPCpWCP/utUHU1mx0pOk/A5TlX0uug52nbuPLzICJKWHjwzTHbLntCrioMOh6ARyIOvi819dwpU42VrEPtQpxfNg2yMsfdRyq3Y08IRa4eZorDkM0idA3z6loIQlUNH+kE+xq4rLMjFvn5+jujNWJhaGrM4xv+nosiGVpUYAyzbyQXtvYjSS79Z/37AfhKilhldOBk8SgF8/+rjX/3frOdPbGcLIvlQsm39drnsJkoyR/fLnWeo23LSuc8ROPfmg2adA==', 'Salt': 'Jl54BOeNTVl8AA==', 'Exception': None }, { 'Username': 'abc123', 'Password': 'LongerPassword', 'Modulus': 'FB6443D98DA170445C9795DC351398F1DE1518FB2827F757E8805C5F43DC2927499060A929171245B20FAED4F0EF5611276430A1943F6FD8E7999D8F40407494EE2FD147B31ECC1D59AC7F63E9266CE6EE58FA9B54D3FF3F712F1F210353E7714730A7A787D36D7B7D0940F16A30263EAD448C09BE1EA9F322FE8A844A30C4B900747F30057F33CF850BD717D0AB8008BE6EB333D30F02C1575601F8077307FCA6DA0DBE0156C485E30343A371E9083B58F8F57DB049F46A9ACEE00C1A702E99D04C0777543B3A25B8B33BF35C6E95332E0C907FB357A46A28DB073510DC7903F0E14B5B6DD11945F0D19B7E3939D942E8808D8BFFF2A4AE35E4EECE4AB069BD', # noqa 'Verifier': '9U87bD4BYK3aDRQntSh0pR4AneEQ97Dd3rfysHfShFatlZGjntcJqAheiz6BAJPNnk+ui4Ps842bBZdrXMcfI9kftWra/eByMA1uzJ16rH9UQy2gpUH2hBRqoSMWGduiwnFp168NGAfAX+Zp9ce+N4J4t9E04arJnnfUfjja6iMK9wzzpXZn6XcdpbRJ5EeY8FO8Y6I0Y7TM8sxfeeSyGLhFSZLvLoXFjALi48zXSjNNw7GJBcH+hbejeYFiIf0cSK7sPFn8O5CJFXXCmjO5wx4KdYuTH5y919guttwdS70M67iVlISyyXTHcpxH6967IsbWJms/pNoMrjXT2xauCA==', # noqa 'Salt': 'hyzJpo9GoQaQZg==', 'Exception': None }, { 'Username': 'VeryLongUsername_123456789', 'Password': 'VeryLongAndSecurePassword', 'Modulus': 'C3358515FE673964104A3BCC015F3079F6711ACC6E1C35CA4AED5A85C345B258160B73E32A1DC1F1BD8389FE96B7A6EFD1CA8A6265186FE256BD67DB5507A81CA5BBBA1AFC6E854794343F2DF91182A4FAEC84FA6FDC3028C85DE344EF545D5A5668CFBE00D0A98E7DF9C4E3833BFE49E5E938F4658D23EB791757852A520C7908471C249324FD87B382A17973CBB962E20FCD598051CC43E792013412603294ACAFE51185E6D57AFBAC13F83E8B1ACCC296094B2B6D76B8DF9210FF00FBAFEB9ED333C123AF5E0E8607E1EE01AC80A7FBEC194952050C100D1CF0C58740509DC82A0EE6F82536F8AD0E651284E8277407BB625DDA23A5CD906B00B76D21DADD', # noqa 'Verifier': 'AuYwtTLuT5coocpDHcZCQMqMQbFevrcxnqoijrOveXe7+h4XqdzpakUnKxJ8n+IrHt5wo2Nhsgdipw7woeb1lskt3WBdJ6KCTHCwjOcBUlHpMnJbea7ere7qCl+ar7LQOE/hh1xu2uotSBFMCDrPxoVLX00/jwtfFnr3e9he0tZ2+lZgUgPqKWo3OT4SthM9N9ILMQ4GSW5s5HgPw1QVhTXXQ6Nr6fwW5ID+ViyPS2n66WdCV2JcVwTza90/pYT3ZEvw/DOzD91v09lIdarp3a4c2/lbLORbmUiip0wHu4gLa5jv/J73xZEOGg54REwHrlx6eOgl31TdFjexC1n/EA==', # noqa 'Salt': 'cujclj9P6zluIQ==', 'Exception': None }, { 'Username': 'TestUsername', 'Password': 'Test Password With Spaces', 'Modulus': '530B86096FE2BA84CBD4518EE26386BFF353127BC9545537B55148374619392CB90C05EA35F0032583D9928EB6309642236DF0D2EB00736BD8ACC349AE8094C97F330AEE0F493E76D1C092C98C61B880C942C54D9BA0AE2483AAD6A125E601685ECDD8BD984C4F15EDDEC8D21F873C5CE2F47B16069D95023F6CAF2E4C25703DCF2E09C66E3927E19D4C6377D5C31F9EB2D881E820BCB97747274C3F465DF3C17BFAC9C37DF6B255209697755AC068517A191364BF7D7A3A3B4321606A540590D70A1A0AEEA580B7731B89D2D2FD4EC5D0AB65ED91757041C1E088ABC8C4BAC2B9B586DB036F035F04BC9ED6ADE0713CFCB85BAE556C4D3FA2A00B9CE55D04C4', # noqa 'Verifier': '9TcpTx2TZ2oyPXMtQ+jn+rTIGxl6MpOMnazV5tGMlcNg00VujDzqsEi3O0pwB2r1x0T9H4UMi7/l+1qy0HlsdG53OTO4ij+z+gRuA5xQAb/521g34q+/zJobBYuHISfV+LaHRgn1Z9VMYURkTNF66WyHMn1nd7swzGE+zWxBa0gN1d4HFW1h9VjLUOeOGGp4UwWIR2+pSO50hJO6yGC8pQgEZhmyM0VfliWWzKsLMxRdfS4eHArvKsVOLN/IChSRKjeiDSSkOVGQZyNUoZDpg4AsGBLeXJovlbdjQoM/8pByJJpX71Ze6WZIltTRyjTYirYORDrLNCQr8DHpE6hLKA==', # noqa 'Salt': 'vSds4CNJcRFDog==', 'Exception': None }, { 'Username': 'Test1', 'Password': '', 'Salt': 'vSds4CNJcRFDog==', 'Modulus': '6B4DDFC843BE2777F709F7E3379E308581D3976CF4A85F37C44302816BAE75F0E99C038A763E864A2367AFDA5C06875DF0990D1121196809B0D8D44423DBF43BC66164341E0CCC3A09637D04DE96146492935827372197B3B470CBFCFDEF5B9245DF5761D0E9267DF9293E32D9F5A503F827AE90E39414C90F19A6D4CCDE664227268D6C8164E9C570A0E79968CDF5597260502FAC9FDB9C585F2BD37597FD7149AE066EE0ED1B3958958DCB697DCD878E097FB8543AD0CAA99407A6F991DC70F137DABE97E8D07CE3D58B922F8BEF3C862C8CE224501E953B17E94B8F79EDD1B8E287087B172EC217FA5183CD1D4C3C66B950A06BAD64DA1C0DDCAF91BB5FBF', # noqa 'Exception': ValueError }, { 'Username': 'Test2', 'Password': 'PasswordThatIsLongerThan72CharsAndContainsSomeRandomStuffThatNoOneCaresAbout', # noqa 'Modulus': 'C376377AC6C62697E211C0BE80DA3C5F7B7381AB92D94536B406594AD0C1BFE90A424E36085CD2F553F370ED863E72597210DD94A19B0DA4257CD18EEDAF33A71E5BD7657DB25BE602F0430FE4762D9F6100DD319D7E5870DC0583D0782832E68E8A12C1AA0B8018FB71D5F3C9AEB80208DE62CAB066FCC80E274F32199AA2193882E256A86E2B8993C7278CE470BA4A9B315AF33C029967EB96C470987F440F7CD4688072B98DC0B57686B580DE76BF10F3D277D24CAD012A83A98A834F90A22A29D113F272A38E750DF3188ACFCADC8642B7F9847CE4F721EBF1D9105BE33CDC19A01080A9427F4F76B27D3FCF8926A5C4884C42D1B6D052C2F744452283DB', # noqa 'Verifier': 'Ft8p5GMgowgwSRb2jJ7uoTkvptOQmNqH+Aov/Lawsc5vbaBIIoIwnF03jiGVEEO9kVVTZG/+5zQorvY5voZJrgJev+I/LR0WbzIY+Tqm7BbVRSARc7jkTFHXYFFiasuOR5myTDPyctxyfTQzAwGX7MccC82nWUuPn/yWsAgqppvVatC+QUilEKALvHqjnupJoLay0ZfmLOkV8eeXUQFkOcRzGFYtkCSD5aCQYBMZYkuLm/4rUEQsYQ9GyluGDNfYm0kDUy9oq0ujJdDciSvFAw3arIANsuEDmGg/eo0/1iZLywcZPzS20Cu07KIgQ+Ct2HemCmgFDJKQ2CBnW+HvFQ==', # noqa 'Salt': 'lSIG/btGTkKS4Q==', 'Exception': None }, { 'Username': 'Test3', 'Password': 'Test3', 'Modulus': '6BF8F261CD7B1C9125CCB16F6FDCB59E1E7835E2E1C83D43B51DD319CCB6EFDC56F775448538F40B2259DCF464829E9E2E8CFE9E8A5658A5429BBCF65928EBF91276896148B920E1A3719BFC07ABDE69265B9296079E539F3B20B4FD88457DFD7776F300F79A6B01F5438F80C05A2E27D1903C2AED087C8D1566919FDA443D61E61BF5095CCD5F59E9B7C12E6210138A2B48EC39311A12442E298BA94D994E0038725706084EF993F5D10884E9ED1235A2C72E5E8A26F5FF0BF77B1F98D84F93CB9A3598DE647CF2ACD85E91541593834E7253DC417262488E02D4BA53873B1F7FBA17AE90A189AB7562E74691F396CD8C99311C9B6D4810528FC3698895BEE6', # noqa 'Verifier': 'drKnuCh+aADaBMQdO+CM3USFXb5s4qAOgKyk2vxGLi6Gu8Z+SLskGYwx25djGgLqDlo6OncjlS1KPkc+euklc9CoPKDn0ZI3qGKSE4JK7LytiqC2IbpNa3j6jFQu020suyLYtAPJ+9IW6mvgiRi1dKuBlEArSAHz4aAO/ThKxjSArombm8F+tIQG4dznQWe2l7XzAmB9sFsYplDnnAtcydWtBiM1lnYqPJ3APWB/J1+r7YUNw/GPt8PuCz3tVDxyUJoe061iLQmPBsKfNpuSKBgwmMidjwN6UBbuLRhOyYhNO+sk6ER479NuYg6O7lvbnRdS4ELJs84Q2LmKLa3wGg==', # noqa 'Salt': 'rPelup77AgUCQg==', 'Exception': None }, { 'Username': 'Test4', 'Password': 'Test4', 'Modulus': 'F3FC06AB4FCED0A9E73D85826EA1C21AEEED978C3C938524265E32DBFA0287979278ED80990E854421BF186116E538F8302E749A683B9272795F70352CD98554E6FBA7714BA0AB5C02A1CC641BB931F50E0A8F8FB31446C21950A3620E514F1ABEEE102BB8B225DE5EF34B0064010EE70CF7371D2D0FC154586E42F99701BEAAE1EBFC041A7975A782A3455AA4EDEA9ED0B126E4DEC746E5CCC696B9511E61DC0C26A7C39438F99C71CAA47F1BEF3FF25FB8C61D20A9D191E5B56273FA90C0D310A0296E2B86156EEEC27536A3252AE4ED3AEF6708E6C51D464E0EE15EC4B50FAA22095790D4FE93A6BDCF572DA36850015B3DE882407DCFB37EA45176E230E0', # noqa 'Verifier': 'ED24sqbunfInO0jSyO+x2nbva3Uc4jetsw0oGZhYwo31azz8vU6lI1t6B8+a0fNR9yUxPU/Fr03wx8MH0JkIWulqDCYkU4THrnL6Uu6xOJEX4RRReT/l5SJA4zHbT0Zk7XFBvwUXtRa3mJRedJkR/bqpp7QUOobdKQKdm1n2l+ktgAq4hHuxLS1BUSvMVJh2B3bFc4BLgTAj9EQA8VEZVetSniMejNBdcEPFDnEXgFNddVPoWuuTpd0jWikeFXSyVT7q5D+Jg1UvO3wXWarYbGnn2lYubv9WY6spgLwbJv796YuFcso9/7dtPRpT/TJDqmHTYsoPbNYl017uODCjSw==', # noqa 'Salt': 'dRuDue2lP/mG2w==', 'Exception': None }, { 'Username': 'Test5', 'Password': 'Test5', 'Modulus': '434EAA334EFC1225D376B2D38DC6FDF4E1E1182AB4B47803AE9DAC05865D1A91D95E7F3018B9893EBCFDED5EA6CAD29F953175EE25ED35AE4D9B37999360503D9D3EB8B31A2A64139A9928C7CD4600C433012CE52D105AB4718EC2F525FA4F2F4E5B4376BD3A8698E3C6CE60E98646E71EB26B18565EB90C3367FFB9CD4FCF8FDF75F6E9DE30D252DAED835BEF13D21EDFFB8163E56EBCFB2D884AE2C7778E28279F61252F5E24C79103B16078A980E8F6ED9A62A51187B7166A7AE335BE785A4DE8C5E5BE4EF1B0B9DBF8C3C42C445BD236B279DB53F28F7685ABC440A09B8CCC4D6B1723866ABBDEE916CC5F68EEF5F902A2E7A15805788DBE74FA1F20AEEC', # noqa 'Verifier': 'F1MlhQI02Ek1HSGe1ow3wYbzjUJicjbMePOqBZynRpLDd3cD6meWe8coXKS9z63jOPORh9c9hSi/lgwDS+lhYPD50/xTRrKoq4rMdo0gHOazBCRbjTddN8NW3jPYIPpdNSuxr4ReJCc9rNizvjIpqNYb0q02KD3CewEtvZDhmeIl0Y/b82yahlB5Dkx0sEJ4KPCqPYru8BhnOl1/CTxz/CCIyQhHNt700vFGRLoewxCKhIteb+sGHFRerC35OOJIsuJc3Snf1o800YQATpxVxnqEzrYUs2ofXIRNGOFX8WU9PP+zFx7mAbp6KvSfoWVQB+a263p9D/3TzAN0kflm0A==', # noqa 'Salt': 'JNmIaPOOZEzaWg==', 'Exception': None }, { 'Username': 'Test6', 'Password': 'Test6', 'Modulus': '5BA65673D3C677FF42CD223C6130CE991DB345B2647DD4ED19881FEB6D695AC5FB33B402E813CF6B829372E13C611740A37B231F3034F1142950F8B48A55250FAAAC29FBE12B18BE3852258FC8DE37F53238EE68DF8BAF41B4BF4ECE551D7DE91B428A2EB78DDB86C8F154B2593D18EEE3331441D98F86AFCCCAF51424E8E20ACB86EC478FA7F596805DE700532070EB8F90E66FB050385ACFE7ED45F044144962A97327237EB77F2A9A810D18E9FB366070E88315852F597D991B291484F17E2BC011528A0E9F7551667C6740A41397E229EB24C9735F814546AFA17E5A999B4CFE65A08EBFF5EB58F90BF2BDACD04BD00967AF01CCA06608B15716BE0F05AA', # noqa 'Verifier': 'H9/tTIo5U2GClndXSJW7JHQdw3XmiZfPztBJvM7UNbrL851hJdXCpypXRvS3NCdt5AIr3ic48ll3NKFDmA6ORdPsE9vII0IdOWMxWIU0enpPi2cb8AbvVXS8RF6+LPOwOF8wDfro+/gDoj7ofk2hnbUdnbf9bjKxVau+V+NQJPL7m1P8XTxAhP3IYf3flfWjhmNisasPIXkBxwMM/rVg/9Wqkzrzpoo2eaaMolQirmBZe8gdJlsURSR1v7PPaCYP8rRY7RZMV2RWUm/W8o56n2iKm2F9ldRFveMnI9W+aUrnI7lAT/H7PvP1hPDXPwVnhhPlYHk8ovj9q1KGl12MBA==', # noqa 'Salt': 'ZLpMUnhDn1QjkQ==', 'Exception': None } ] modulus_instances = [ { 'SignedModulus': '-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\n091HBWnHlR+qphOhmi9ZrTWMnPT/jXqWzUh7F8CShuXIfHe5srT4y3BoBi85N89ceDhety3oVKoaS9sTQ6hVoRjjCulEuNQ5L6uN+9jG/f3/c3yVYjl6d9P1ktLsS21p3+2dQEAcNP0SQvMIdJPva1aBWsaoHKA3nzOp7pCIJHRw2Xx7T8AwzndW8r6KcNeZSLltj3FBIbWmKsaA8d3x+Db2D4M2Rngdf/eW2CQ39RlMvPdefMISs3jKSwduCJKCKbhYh6WSCjpgXrombuYIiMynfx38IibvSIURLOhXC9JKXY0k+bCPxZpt5iloe/11wK4ZSwuhYLEukD1ulvR1rw==\n-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwl4EARYIABAFAlwB1jwJEDUFhcTpUY8mAABQpAD/VWjPiBcTZLU9t9GcLPtI\ntv2iIdcvaOJg3hpl/XyEmAoA/0jNeiOMHl0Hpd4PoF/SCqmO/gDZDByy+t1n\n5xsxCLEM\n=a1KZ\n-----END PGP SIGNATURE-----\n', 'Decoded': '091HBWnHlR+qphOhmi9ZrTWMnPT/jXqWzUh7F8CShuXIfHe5srT4y3BoBi85N89ceDhety3oVKoaS9sTQ6hVoRjjCulEuNQ5L6uN+9jG/f3/c3yVYjl6d9P1ktLsS21p3+2dQEAcNP0SQvMIdJPva1aBWsaoHKA3nzOp7pCIJHRw2Xx7T8AwzndW8r6KcNeZSLltj3FBIbWmKsaA8d3x+Db2D4M2Rngdf/eW2CQ39RlMvPdefMISs3jKSwduCJKCKbhYh6WSCjpgXrombuYIiMynfx38IibvSIURLOhXC9JKXY0k+bCPxZpt5iloe/11wK4ZSwuhYLEukD1ulvR1rw==', 'Exception': None }, { 'SignedModulus': '-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\n091HBWnHlR+qphOhmi9ZerfnPT/jXqWzUh7F8CShuXIfHe5srT4y3BoBi85N89ceDhety3oVKoaS9sTQ6hVoRjjCulEuNQ5L6uN+9jG/f3/c3yVYjl6d9P1ktLsS21p3+2dQEAcNP0SQvMIdJPva1aBWsaoHKA3nzOp7pCIJHRw2Xx7T8AwzndW8r6KcNeZSLltj3FBIbWmKsaA8d3x+Db2D4M2Rngdf/eW2CQ39RlMvPdefMISs3jKSwduCJKCKbhYh6WSCjpgXrombuYIiMynfx38IibvSIURLOhXC9JKXY0k+bCPxZpt5iloe/11wK4ZSwuhYLEukD1ulvR1rw==\n-----BEGIN PGP SIGNATURE-----\nVersion: ProtonMail\nComment: https://protonmail.com\n\nwl4EARYIABAFAlwB1jwJEDUFhcTpUY8mAABQpAD/VWjPiBcTZLU9t9GcLPtI\ntv2iIdcvaOJg3hpl/XyEmAoA/0jNeiOMHl0Hpd4PoF/SCqmO/gDZDByy+t1n\n5xsxCLEM\n=a1KZ\n-----END PGP SIGNATURE-----\n', 'Decoded': None, 'Exception': ProtonCryptoError } ]python-proton-core-0.1.16/tests/testserver.py000066400000000000000000000056721452542764400213360ustar00rootroot00000000000000""" Copyright (c) 2023 Proton AG This file is part of Proton. Proton 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. Proton 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 ProtonVPN. If not, see . """ from proton.session.srp.pmhash import pmhash from proton.session.srp.util import (bytes_to_long, custom_hash, get_random_of_length, SRP_LEN_BYTES, long_to_bytes) class TestServer: def setup(self, username, modulus, verifier): self.hash_class = pmhash self.generator = 2 self._authenticated = False self.user = username.encode() self.modulus = bytes_to_long(modulus) self.verifier = bytes_to_long(verifier) self.b = get_random_of_length(32) self.B = ( self.calculate_k() * self.verifier + pow( self.generator, self.b, self.modulus ) ) % self.modulus self.secret = None self.A = None self.u = None self.key = None def calculate_server_proof(self, client_proof): h = self.hash_class() h.update(long_to_bytes(self.A, SRP_LEN_BYTES)) h.update(client_proof) h.update(long_to_bytes(self.secret, SRP_LEN_BYTES)) return h.digest() def calculate_client_proof(self): h = self.hash_class() h.update(long_to_bytes(self.A, SRP_LEN_BYTES)) h.update(long_to_bytes(self.B, SRP_LEN_BYTES)) h.update(long_to_bytes(self.secret, SRP_LEN_BYTES)) return h.digest() def calculate_k(self): h = self.hash_class() h.update(self.generator.to_bytes(SRP_LEN_BYTES, 'little')) h.update(long_to_bytes(self.modulus, SRP_LEN_BYTES)) return bytes_to_long(h.digest()) def get_challenge(self): return long_to_bytes(self.B, SRP_LEN_BYTES) def get_session_key(self): return long_to_bytes(self.secret, SRP_LEN_BYTES) # if self._authenticated else None def get_authenticated(self): return self._authenticated def process_challenge(self, client_challenge, client_proof): self.A = bytes_to_long(client_challenge) self.u = custom_hash(self.hash_class, self.A, self.B) self.secret = pow( ( self.A * pow(self.verifier, self.u, self.modulus) ), self.b, self.modulus ) if client_proof != self.calculate_client_proof(): return False self._authenticated = True return self.calculate_server_proof(client_proof)