pax_global_header 0000666 0000000 0000000 00000000064 14717325345 0014525 g ustar 00root root 0000000 0000000 52 comment=e87d1e3b5fc70883adc02ef2e2d9b0eb360b83ba
python-proton-core-0.4.0/ 0000775 0000000 0000000 00000000000 14717325345 0015314 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/.gitignore 0000664 0000000 0000000 00000000201 14717325345 0017275 0 ustar 00root root 0000000 0000000 build/
dist/
MANIFEST
*.pyc
*.egg-info/
.vscode/
*.lock
__SOURCE_APP
.env
cov.xml
html
.idea/
.pybuild/
.coverage
report.xml
venv python-proton-core-0.4.0/.gitlab-ci.yml 0000664 0000000 0000000 00000000234 14717325345 0017747 0 ustar 00root root 0000000 0000000 variables:
ALLOW_LINTING_FAILURE: "true"
include:
- project: 'ProtonVPN/Linux/integration/ci-libraries'
ref: develop
file: 'develop-pipeline.yml'
python-proton-core-0.4.0/LICENSE 0000664 0000000 0000000 00000104515 14717325345 0016327 0 ustar 00root root 0000000 0000000 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.4.0/README.md 0000664 0000000 0000000 00000000657 14717325345 0016603 0 ustar 00root root 0000000 0000000 # 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.4.0/debian/ 0000775 0000000 0000000 00000000000 14717325345 0016536 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/debian/.gitignore 0000664 0000000 0000000 00000000245 14717325345 0020527 0 ustar 00root root 0000000 0000000 files
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.4.0/debian/changelog 0000664 0000000 0000000 00000012032 14717325345 0020406 0 ustar 00root root 0000000 0000000 proton-core (0.4.0) unstable; urgency=medium
* Require python >= 3.9 to allow libraries using newer language features
-- Alexandru Cheltuitor Tue, 19 Nov 2024 14:00:00 +0000
proton-core (0.3.3) unstable; urgency=medium
* Amend type hinting
-- Josep Llaneras Wed, 18 Sep 2024 09:53:48 +0200
proton-core (0.3.2) unstable; urgency=medium
* ProtonSSO : allow selecting the keyring backend (unspecified: load default keyring)
* External contribution from 'wesinator' : fix hostname segment regex
-- Xavier Piroux Wed, 11 Sep 2024 12:53:42 +0200
proton-core (0.3.1) unstable; urgency=medium
* Minor changes following feedback/review
-- Luke Titley Fri, 30 Aug 2024 16:37:12 +0200
proton-core (0.3.0) unstable; urgency=medium
* Allow clients to support 'If-Modified-Since'
-- Luke Titley Tue, 27 Aug 2024 16:37:12 +0200
proton-core (0.2.1) unstable; urgency=medium
* Make logs less verbose
-- Josep Llaneras Fri, 02 Aug 2024 11:37:12 +0200
proton-core (0.2.0) unstable; urgency=medium
* Add dynamic module validation
-- Alexandru Cheltuitor Mon, 27 May 2024 10:00:00 +0200
proton-core (0.1.19) unstable; urgency=medium
* Sanitize DNS response
-- Josep Llaneras Thu, 23 May 2024 18:01:11 +0200
proton-core (0.1.18) unstable; urgency=medium
* Fix invalid modulus error when logging in
-- Josep Llaneras Tue, 30 Apr 2024 16:48:51 +0200
proton-core (0.1.17) unstable; urgency=medium
* Session forking
-- Robin Delcros Fri, 01 Mar 2024 14:21:24 +0100
proton-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.4.0/debian/compat 0000664 0000000 0000000 00000000003 14717325345 0017735 0 ustar 00root root 0000000 0000000 11
python-proton-core-0.4.0/debian/control 0000664 0000000 0000000 00000001212 14717325345 0020135 0 ustar 00root root 0000000 0000000 Source: 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.9
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.4.0/debian/copyright 0000664 0000000 0000000 00000000512 14717325345 0020467 0 ustar 00root root 0000000 0000000 Format: 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.4.0/debian/rules 0000775 0000000 0000000 00000000132 14717325345 0017612 0 ustar 00root root 0000000 0000000 #!/usr/bin/make -f
#export DH_VERBOSE=1
%:
dh $@ --with python3 --buildsystem=pybuild
python-proton-core-0.4.0/docs/ 0000775 0000000 0000000 00000000000 14717325345 0016244 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/docs/conf.py 0000664 0000000 0000000 00000003661 14717325345 0017551 0 ustar 00root root 0000000 0000000 # 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.4.0/docs/index.rst 0000664 0000000 0000000 00000000744 14717325345 0020112 0 ustar 00root root 0000000 0000000 .. 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.4.0/docs/keyring.rst 0000664 0000000 0000000 00000000363 14717325345 0020450 0 ustar 00root root 0000000 0000000 Keyring
=======
.. autoclass:: proton.keyring._base.Keyring
:members:
:private-members:
:special-members: __getitem__, __setitem__, __delitem__
.. autoclass:: proton.keyring.textfile.KeyringBackendJsonFiles
:private-members:
python-proton-core-0.4.0/docs/loader.rst 0000664 0000000 0000000 00000000132 14717325345 0020240 0 ustar 00root root 0000000 0000000 Component loader
================
.. autoclass:: proton.loader.loader.Loader
:members: python-proton-core-0.4.0/docs/session.rst 0000664 0000000 0000000 00000000412 14717325345 0020456 0 ustar 00root root 0000000 0000000 Session
=======
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.4.0/docs/sso.rst 0000664 0000000 0000000 00000000273 14717325345 0017604 0 ustar 00root root 0000000 0000000 Single Sign On (SSO)
====================
.. autoclass:: proton.sso.ProtonSSO
:members:
:private-members: _acquire_session_lock, _release_session_lock
:special-members: __init__ python-proton-core-0.4.0/docs/views.rst 0000664 0000000 0000000 00000000241 14717325345 0020130 0 ustar 00root root 0000000 0000000 Views
=====
.. autoclass:: proton.views.BasicView
:members:
:special-members: __init__
.. autoclass:: proton.views.basiccli.BasicCLIView
:members: python-proton-core-0.4.0/proton/ 0000775 0000000 0000000 00000000000 14717325345 0016635 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/proton/keyring/ 0000775 0000000 0000000 00000000000 14717325345 0020305 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/proton/keyring/__init__.py 0000664 0000000 0000000 00000001314 14717325345 0022415 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/keyring/_base.py 0000664 0000000 0000000 00000010526 14717325345 0021734 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/keyring/exceptions.py 0000664 0000000 0000000 00000002034 14717325345 0023037 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/keyring/textfile.py 0000664 0000000 0000000 00000005303 14717325345 0022504 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/loader/ 0000775 0000000 0000000 00000000000 14717325345 0020103 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/proton/loader/__init__.py 0000664 0000000 0000000 00000001360 14717325345 0022214 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/loader/__main__.py 0000664 0000000 0000000 00000001672 14717325345 0022203 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/loader/loader.py 0000664 0000000 0000000 00000025716 14717325345 0021736 0 ustar 00root root 0000000 0000000 """
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 inspect
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,
validate_params: Optional[dict] = 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
:param validate_params: pass custom arguments to backends during `_validate`
Could be used in cases when the validity of a backend is to be dynamically
evaluated upon calling `get()`
:type validate_params: Optional[dict], 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 inspect.signature(entry.cls._validate).parameters.get("validate_params"): # noqa: E501 pylint: disable=protected-access
entry_is_valid = entry.cls._validate(validate_params) # noqa: E501 pylint: disable=protected-access
else:
entry_is_valid = entry.cls._validate() # pylint: disable=protected-access
if entry_is_valid:
return 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("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.4.0/proton/session/ 0000775 0000000 0000000 00000000000 14717325345 0020320 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/proton/session/__init__.py 0000664 0000000 0000000 00000001432 14717325345 0022431 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/session/api.py 0000664 0000000 0000000 00000104167 14717325345 0021454 0 ustar 00root root 0000000 0000000 """
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 pathlib import Path
from typing import *
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
from ..utils import ExecutionEnvironment
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,
return_raw=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
:param return_raw: If set to true, the function will return a :class:`RawResponse` object
instead of a dict, this class contains the header and status information along with the
json response.
:type return_raw: bool, optional
:return: Deserialized JSON reply or a RawResponse object if return_raw is True
:rtype: dict | RawResponse
"""
# 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,
return_raw=return_raw)
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
async def async_fork(self, child_client_id: str, payload: str=None, independent: int=0, user_code: int=None, no_condition_check=False) -> str:
"""Try to fork the current session with parameters and return the fork selector if successful.
:param child_client_id: The clientID that the client creating the child session will use.
:type child_client_id: str
:param payload: Will be downloaded by the child client, and can contain encrypted sensitive information.
:type payload: str, optional
:param independent: set to 1 if the newly forked session has to be independent, else 0.
:type independent: int, optional
:param user_code: If provided, the selector will not be randomly chosen, but rather the one already returned will be used.
:type user_code: int, optional
:param no_condition_check: Internal flag to disable locking, defaults to False
:type no_condition_check: bool, optional
:return selector: Fork selector to be used by method `async_import_fork()`
:rtype: str
"""
data = {'ChildClientID' : child_client_id, 'Independent': independent}
if payload is not None:
data['Payload'] = payload
if user_code is not None:
data['UserCode'] = user_code
ret = await self.async_api_request('/auth/v4/sessions/forks', data, method='POST', no_condition_check=no_condition_check)
return ret['Selector']
async def async_import_fork(self, selector: str, no_condition_check=False) -> str:
"""Try to import the session fork specified by the selector.
:param selector: Obtained via a successful call to `async_fork()`
:type selector: str
:param no_condition_check: Internal flag to disable locking, defaults to False
:type no_condition_check: bool, optional
:return payload: The payload sent by the parent session when initiating the fork.
:rtype: str
"""
self._requests_lock(no_condition_check)
try:
ret = await self.async_api_request(f"/auth/v4/sessions/forks/{selector}", method='GET', no_condition_check=True)
self.__UID = ret['UID']
self.__RefreshToken = ret['RefreshToken']
self.__AccessToken = ret['AccessToken']
self.__Scopes = ret['Scopes']
return ret['Payload']
finally:
self._requests_unlock(no_condition_check)
# 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)
fork = sync_wrapper(async_fork)
import_fork = sync_wrapper(async_import_fork)
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,
return_raw=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,
return_raw=return_raw)
def _verify_modulus(self, armored_modulus) -> bytes:
if self.__gnupg_for_modulus is None:
import gnupg
# The modulus key is imported in a separate GPG keyring (rather than on the user's
# default one) by modifying the gnupg home directory. The reason for doing this is
# that we received crash reports due to users messing up with their default GPG
# keyring permissions. In this case, python-gnupg fails silently when importing
# Proton's modulus key, which then causes the signature verification errors.
proton_gnupg_dir = Path(ExecutionEnvironment().path_cache) / "gnupg"
proton_gnupg_dir.mkdir(exist_ok=True)
self.__gnupg_for_modulus = gnupg.GPG(gnupghome=proton_gnupg_dir)
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.4.0/proton/session/environments.py 0000664 0000000 0000000 00000004352 14717325345 0023425 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/session/exceptions.py 0000664 0000000 0000000 00000011466 14717325345 0023063 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/session/formdata.py 0000664 0000000 0000000 00000002464 14717325345 0022475 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/session/srp/ 0000775 0000000 0000000 00000000000 14717325345 0021124 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/proton/session/srp/README.md 0000664 0000000 0000000 00000001762 14717325345 0022411 0 ustar 00root root 0000000 0000000 # 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.4.0/proton/session/srp/__init__.py 0000664 0000000 0000000 00000001506 14717325345 0023237 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/session/srp/_ctsrp.py 0000664 0000000 0000000 00000023401 14717325345 0022770 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/session/srp/_pysrp.py 0000664 0000000 0000000 00000011744 14717325345 0023021 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/session/srp/pmhash.py 0000664 0000000 0000000 00000002506 14717325345 0022761 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/session/srp/util.py 0000664 0000000 0000000 00000005163 14717325345 0022460 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/session/transports/ 0000775 0000000 0000000 00000000000 14717325345 0022537 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/proton/session/transports/__init__.py 0000664 0000000 0000000 00000001514 14717325345 0024651 0 ustar 00root root 0000000 0000000 """
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, RawResponse
from .aiohttp import AiohttpTransport
from .auto import AutoTransport
from .alternativerouting import AlternativeRoutingTransport
python-proton-core-0.4.0/proton/session/transports/aiohttp.py 0000664 0000000 0000000 00000015170 14717325345 0024565 0 ustar 00root root 0000000 0000000 """
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, RawResponse
import json, base64, asyncio, aiohttp, hashlib
from OpenSSL import crypto
from typing import Iterable, Union, Optional
NOT_MODIFIED = 304
# 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, return_raw=False
) -> dict | RawResponse:
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 return_raw:
return RawResponse(ret.status, tuple(ret.headers.items()),
await self._parse_json(ret, allow_unmodified=True))
ret_json = await self._parse_json(ret)
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)
async def _parse_json(self, ret, allow_unmodified=False):
if allow_unmodified and ret.status == NOT_MODIFIED:
return None
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
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.4.0/proton/session/transports/alternativerouting.py 0000664 0000000 0000000 00000016000 14717325345 0027034 0 ustar 00root root 0000000 0000000 """
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 = []
proton_api_not_available_errors = []
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
proton_api_not_available_errors.append(e)
except Exception as e:
results_fail.append(e)
for task in pending:
task.cancel()
if proton_api_not_available_errors:
raise proton_api_not_available_errors[0]
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, return_raw=False
):
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, return_raw=return_raw)
python-proton-core-0.4.0/proton/session/transports/auto.py 0000664 0000000 0000000 00000011751 14717325345 0024066 0 ustar 00root root 0000000 0000000 """
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,
return_raw=False
):
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, return_raw=return_raw), 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.4.0/proton/session/transports/base.py 0000664 0000000 0000000 00000006223 14717325345 0024026 0 ustar 00root root 0000000 0000000 """
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
from dataclasses import dataclass
from typing import Optional, Any, Tuple
@dataclass
class RawResponse:
"""
A response that contains the status code and headers along with the body
as json. This gives more context to clients when receiving a response.
This type is returned where return_raw is set to True.
:param status_code: The status code of the response
:param headers: The headers in the response
:param json: The body the response parsed as json
"""
status_code: int
headers: Tuple[Tuple[str, Any]]
json: Optional[dict]
def find_first_header(self, key, default=None):
"""
Searches for the given key in the headers and returns the first value
if found, otherwise returns the default value.
"""
for k, v in self.headers:
if key == k:
return v
return default
class Transport:
"""
The base class of all transports. This class should be subclassed to
implement the async_api_request method, which is the main method that
is used to make requests to the API.
A transport abstracts away the details of how requests are made to the API,
for example, it could be using requests, aiohttp, or any other library.
"""
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.4.0/proton/session/transports/requests.py 0000664 0000000 0000000 00000012225 14717325345 0024766 0 ustar 00root root 0000000 0000000 """
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, RawResponse
import json
NOT_MODIFIED = 304
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
def _parse_json(self, ret, allow_unmodified=False):
if allow_unmodified and ret.status_code == NOT_MODIFIED:
return None
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
async def async_api_request(
self, endpoint,
jsondata=None, data=None, additional_headers=None,
method=None, params=None, return_raw=False
):
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)
if return_raw:
return RawResponse(ret.status_code, tuple(ret.headers.items()),
self._parse_json(ret, allow_unmodified=True))
ret_json = self._parse_json(ret)
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.4.0/proton/session/transports/utils/ 0000775 0000000 0000000 00000000000 14717325345 0023677 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/proton/session/transports/utils/__init__py 0000664 0000000 0000000 00000000000 14717325345 0025720 0 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/proton/session/transports/utils/dns.py 0000664 0000000 0000000 00000020106 14717325345 0025034 0 ustar 00root root 0000000 0000000 """
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 re
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
# Regular expression used in _is_valid_hostname.
# Each hostname segment (the strings in between the period characters) is only valid if:
# - it has a minimum of 1 character and maximum of 63,
# - it only contains alphanumeric characters or the hyphen but
# - it does not start or end with a hyphen.
_VALID_HOSTNAME_SEGMENT = re.compile(r"(?!-)[A-Z\d-]{1,63}(? 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:
hostname = record[1:].decode('ascii')
# Only hostnames with a valid format are accepted in TXT records.
# This is to avoid possible security exploits where the TXT record contains
# a full URL (hostname + path), which e.g. could trigger SSO redirects.
if not cls._is_valid_hostname(hostname):
raise DNSParsingException(f"Invalid hostname in TXT record: {hostname}")
answers.append((int(rec_ttl), hostname))
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)")
try:
ipv4_address = ipaddress.IPv4Address(record)
except ValueError as exc:
raise DNSParsingException(f"Invalid IP address in A record: {record}") from exc
answers.append((int(rec_ttl), ipv4_address))
else:
logging.debug(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
@classmethod
def _is_valid_hostname(cls, hostname):
if len(hostname) > 255:
return False
# Strip exactly one dot from the right, if present.
if hostname[-1] == ".":
hostname = hostname[:-1]
# The hostname is valid if all its segments are valid.
return all(cls._VALID_HOSTNAME_SEGMENT.match(segment) for segment in hostname.split("."))
python-proton-core-0.4.0/proton/sso/ 0000775 0000000 0000000 00000000000 14717325345 0017441 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/proton/sso/__init__.py 0000664 0000000 0000000 00000001316 14717325345 0021553 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/sso/__main__.py 0000664 0000000 0000000 00000017667 14717325345 0021554 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/sso/sso.py 0000664 0000000 0000000 00000033726 14717325345 0020632 0 ustar 00root root 0000000 0000000 """
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", keyring_backend_name=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
:param keyring_backend_name: Name of Keyring backend to load (see :class:`proton.keyring.Keyring`), defaults to None
:type keyring_backend_name: 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
self.__keyring_backend_name = keyring_backend_name
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(self.__keyring_backend_name)
elif not isinstance(self.__keyring_backend, type(Keyring.get_from_factory(self.__keyring_backend_name))):
# 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.4.0/proton/utils/ 0000775 0000000 0000000 00000000000 14717325345 0017775 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/proton/utils/__init__.py 0000664 0000000 0000000 00000001432 14717325345 0022106 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/utils/environment.py 0000664 0000000 0000000 00000011351 14717325345 0022714 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/utils/metaclasses.py 0000664 0000000 0000000 00000001611 14717325345 0022652 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/views/ 0000775 0000000 0000000 00000000000 14717325345 0017772 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/proton/views/__init__.py 0000664 0000000 0000000 00000001317 14717325345 0022105 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/views/_base.py 0000664 0000000 0000000 00000005216 14717325345 0021421 0 ustar 00root root 0000000 0000000 """
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.4.0/proton/views/basiccli.py 0000664 0000000 0000000 00000007415 14717325345 0022124 0 ustar 00root root 0000000 0000000 """
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 # nosec B105
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.4.0/requirements.txt 0000664 0000000 0000000 00000000015 14717325345 0020574 0 ustar 00root root 0000000 0000000 -e ".[test]"
python-proton-core-0.4.0/rpmbuild/ 0000775 0000000 0000000 00000000000 14717325345 0017132 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/rpmbuild/BUILD/ 0000775 0000000 0000000 00000000000 14717325345 0017771 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/rpmbuild/BUILD/.gitkeep 0000664 0000000 0000000 00000000000 14717325345 0021410 0 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/rpmbuild/BUILDROOT/ 0000775 0000000 0000000 00000000000 14717325345 0020475 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/rpmbuild/BUILDROOT/.gitkeep 0000664 0000000 0000000 00000000000 14717325345 0022114 0 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/rpmbuild/SOURCES/ 0000775 0000000 0000000 00000000000 14717325345 0020255 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/rpmbuild/SOURCES/.gitkeep 0000664 0000000 0000000 00000000000 14717325345 0021674 0 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/rpmbuild/SPECS/ 0000775 0000000 0000000 00000000000 14717325345 0020007 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/rpmbuild/SPECS/.gitkeep 0000664 0000000 0000000 00000000000 14717325345 0021426 0 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/rpmbuild/SPECS/package.spec 0000664 0000000 0000000 00000011100 14717325345 0022247 0 ustar 00root root 0000000 0000000 %define unmangled_name proton-core
%define version 0.4.0
%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
* Tue Nov 19 2024 Alexandru Cheltuitor 0.4.0
- Require python >= 3.9 to allow libraries using newer language features
* Wed Sep 18 2024 Josep Llaneras 0.3.3
- Amend type hinting
* Wed Sep 11 2024 Xavier Piroux 0.3.2
- ProtonSSO : allow selecting the keyring backend (unspecified: load default keyring)
- External contribution from 'wesinator' : fix hostname segment regex
* Fri Aug 30 2024 Luke Titley 0.3.1
- Minor changes following feedback/review
* Tue Aug 27 2024 Luke Titley 0.3.0
- Allow clients to support 'If-Modified-Since'
* Fri Aug 02 2024 Josep Llaneras 0.2.1
- Make logs less verbose
* Mon May 27 2024 Alexandru Cheltuitor 0.2.0
- Add dynamic module validation
* Thu May 23 2024 Josep Llaneras 0.1.19
- Sanitize DNS response
* Tue Apr 30 2024 Josep Llaneras 0.1.18
- Fix invalid modulus error when logging in
* Fri Mar 01 2024 Robin Delcros 0.1.17
- Session forking
* Thu Nov 16 2023 Laurent Fasnacht 0.1.16
- fixing (another) race condition in async_refresh()
* Tue 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.4.0/rpmbuild/SRPMS/ 0000775 0000000 0000000 00000000000 14717325345 0020036 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/rpmbuild/SRPMS/.gitkeep 0000664 0000000 0000000 00000000000 14717325345 0021455 0 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/setup.cfg 0000664 0000000 0000000 00000000365 14717325345 0017141 0 ustar 00root root 0000000 0000000 [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 =
tests python-proton-core-0.4.0/setup.py 0000775 0000000 0000000 00000003257 14717325345 0017040 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python
from setuptools import setup, find_namespace_packages
setup(
name="proton-core",
version="0.4.0",
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.9",
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.4.0/tests/ 0000775 0000000 0000000 00000000000 14717325345 0016456 5 ustar 00root root 0000000 0000000 python-proton-core-0.4.0/tests/test_aiohttp_transport.py 0000664 0000000 0000000 00000014410 14717325345 0023653 0 ustar 00root root 0000000 0000000 """
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
from proton.session.transports.base import RawResponse
HTTP_STATUS_OK = 200
HTTP_STATUS_NOT_MODIFIED = 304
CODE_SUCCESS = 1000
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 = HTTP_STATUS_OK
post_mock.return_value.__aenter__.return_value.headers = {"content-type": "application/json"}
post_mock.return_value.__aenter__.return_value.json = AsyncMock(
return_value={"Code": CODE_SUCCESS}
)
# 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
}
class TestAiohttpTransportRawResult(unittest.IsolatedAsyncioTestCase):
def _setup(self, get_mock, status, headers, json):
# Mock the GET response
get_mock.return_value.__aenter__.return_value.status = status
get_mock.return_value.__aenter__.return_value.headers = headers
get_mock.return_value.__aenter__.return_value.json = AsyncMock(
return_value=json
)
session = Session()
aiohttp_transport = AiohttpTransport(session)
return session, aiohttp_transport
@patch("proton.session.transports.aiohttp.aiohttp.ClientSession.get")
async def test_async_api_request_get_raw(self, get_mock):
# Setup
session, aiohttp_transport = self._setup(get_mock,
status=HTTP_STATUS_OK,
headers={"content-type": "application/json"},
json={"Code": CODE_SUCCESS})
# Test
response = await aiohttp_transport.async_api_request("/endpoint", return_raw=True)
# Checks
assert isinstance(response, RawResponse), "The response should be a RawResponse object."
assert response.status_code == HTTP_STATUS_OK
assert response.find_first_header("content-type") == "application/json"
assert response.json == {"Code": CODE_SUCCESS}
get_mock.assert_called_once()
@patch("proton.session.transports.aiohttp.aiohttp.ClientSession.get")
async def test_async_api_request_last_modified(self, get_mock):
# Setup
session, aiohttp_transport = self._setup(get_mock,
status=HTTP_STATUS_NOT_MODIFIED,
headers={},
json=None)
# Test
response = await aiohttp_transport.async_api_request("/endpoint", return_raw=True)
# Checks
assert isinstance(response, RawResponse), "The response should be a RawResponse object."
assert response.status_code == HTTP_STATUS_NOT_MODIFIED
assert response.find_first_header("content-type", None) is None
assert response.json is None
get_mock.assert_called_once()
python-proton-core-0.4.0/tests/test_alternativerouting.py 0000664 0000000 0000000 00000002410 14717325345 0024012 0 ustar 00root root 0000000 0000000 """
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.4.0/tests/test_api.py 0000664 0000000 0000000 00000015723 14717325345 0020650 0 ustar 00root root 0000000 0000000 """
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.4.0/tests/test_autotransport.py 0000664 0000000 0000000 00000006333 14717325345 0023021 0 ustar 00root root 0000000 0000000 """
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.4.0/tests/test_basekeyring.py 0000664 0000000 0000000 00000006760 14717325345 0022403 0 ustar 00root root 0000000 0000000 """
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.4.0/tests/test_dns_requests.py 0000664 0000000 0000000 00000026744 14717325345 0022623 0 ustar 00root root 0000000 0000000 """
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)
@pytest.mark.parametrize("hostname, valid", [
("ec2-3-127-37-78.eu-central-1.compute.amazonaws.com", True),
("hostnames.can.end.with.one.period.", True),
("a"*257, False), # hostnames have a 256-char limit
("-hostname", False), # hostnames cannot start with hyphen
("hostname-", False), # hostnames cannot end with hyphen
("a"*64 + ".blah.com", False), # hostname segments have a 63-char limit
("blah..com", False), # hostname segments should have a lest 1 char
("special-chars!.com", False), # hostname segments only allow alphanumeric chars and hyphens
("vpn-api.proton.me/malicious/", False) # hostname with potentially malicious path
])
def test_valid_hostname_in_A_record(self, hostname, valid):
assert DNSParser._is_valid_hostname(hostname) is valid
python-proton-core-0.4.0/tests/test_environment.py 0000664 0000000 0000000 00000004317 14717325345 0022440 0 ustar 00root root 0000000 0000000 """
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.4.0/tests/test_loader.py 0000664 0000000 0000000 00000011043 14717325345 0021334 0 ustar 00root root 0000000 0000000 """
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.4.0/tests/test_protonsso.py 0000664 0000000 0000000 00000021165 14717325345 0022142 0 ustar 00root root 0000000 0000000 """
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.4.0/tests/test_requests_transport.py 0000664 0000000 0000000 00000010373 14717325345 0024062 0 ustar 00root root 0000000 0000000 """
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
from proton.session.transports.base import RawResponse
HTTP_STATUS_OK = 200
HTTP_STATUS_NOT_MODIFIED = 304
CODE_SUCCESS = 1000
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")
}
class TestRequestsTransportRawResult(unittest.IsolatedAsyncioTestCase):
def _setup(self, status, headers, json):
# Mock requests get call.
req_session = Mock(spec=requests.Session)
req_session.headers = headers
req_session.get.return_value.headers = headers
req_session.get.return_value.json.return_value = json
req_session.get.return_value.status_code = status
session = Session()
return session, RequestsTransport(session, req_session), req_session
async def test_async_api_request_get_raw(self):
session, requests_transport, req_session = self._setup(
HTTP_STATUS_OK,
{"content-type": "application/json"},
{"Code": CODE_SUCCESS}
)
# SUT.
response = await requests_transport.async_api_request("/endpoint", return_raw=True)
# Checks
assert isinstance(response, RawResponse), "The response should be a RawResponse object."
assert response.status_code == HTTP_STATUS_OK
assert response.find_first_header("content-type") == "application/json"
assert response.json == {"Code": CODE_SUCCESS}
req_session.get.assert_called_once()
async def test_async_api_request_last_modified(self):
# Setup
session, requests_transport, req_session = self._setup(
HTTP_STATUS_NOT_MODIFIED,
{},
None
)
# Test
response = await requests_transport.async_api_request("/endpoint", return_raw=True)
# Checks
assert isinstance(response, RawResponse), "The response should be a RawResponse object."
assert response.status_code == HTTP_STATUS_NOT_MODIFIED
assert response.find_first_header("content-type", None) is None
assert response.json is None
req_session.get.assert_called_once()
python-proton-core-0.4.0/tests/test_session.py 0000664 0000000 0000000 00000020243 14717325345 0021553 0 ustar 00root root 0000000 0000000 """
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 asyncio
import unittest
import os
from unittest.mock import AsyncMock
import pyotp
from proton.session import Session
from proton.session.exceptions import ProtonAPIError
from proton.session.transports import TransportFactory
class TestSession(unittest.IsolatedAsyncioTestCase):
async def test_ping(self):
s = Session()
assert await s.async_api_request('/tests/ping') == {'Code': 1000}
async def test_session_refresh(self):
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)
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"
class TestSessionUsingApi(unittest.IsolatedAsyncioTestCase):
"""This class contain test that will use the atlas environment of Proton API to
test session related features.
Note that for session forking, we use the 'windows-vpn' app version because we need
the 'FULL' scope, and as time of writing it's not available for 'linux-vpn' app version."""
_APP_VERSION = 'windows-vpn@4.1.0'
_USER_AGENT = 'ProtonVPN/4.0.0 (windows; debian/n/a)'
_CHILD_CLIENT_ID = 'windows-vpn'
_parent_session = None
_auth_mutex = asyncio.Lock()
@classmethod
def setUpClass(cls):
cls._env_backup = os.environ.copy()
atlas_scientist = os.environ.get('UNIT_TEST_ATLAS_SCIENTIST')
if atlas_scientist:
os.environ['PROTON_API_ENVIRONMENT'] = f"atlas:{atlas_scientist}"
else:
os.environ['PROTON_API_ENVIRONMENT'] = 'atlas'
@classmethod
def tearDownClass(cls):
os.environ = cls._env_backup
async def _init_parent_session(self):
async with self._auth_mutex:
if self._parent_session is not None:
return
parent_session = Session(appversion=self._APP_VERSION, user_agent=self._USER_AGENT)
await parent_session.async_authenticate('twofa', 'a')
otp = pyotp.TOTP("4R5YJICSS6N72KNN3YRTEGLJCEKIMSKJ").now()
two_fa_succeeded = await parent_session.async_provide_2fa(otp)
assert two_fa_succeeded
self._parent_session = parent_session
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_session_fork_ok(self):
"""Session forking expected to succeed"""
self._skip_if_no_internal_environments()
await self._init_parent_session()
secret_payload = "MySuperSecretPayload"
selector = await self._parent_session.async_fork(payload=secret_payload, child_client_id=self._CHILD_CLIENT_ID)
child_session = Session(appversion=self._APP_VERSION, user_agent=self._USER_AGENT)
clear_payload = await child_session.async_import_fork(selector)
assert clear_payload == secret_payload
r = await child_session.async_api_request("/auth/v4/sessions", method='GET')
assert r['Code'] == 1000
assert len(r['Sessions']) > 0
async def test_session_fork_not_ok(self):
"""
1/ Make the fork failing in missing the required ChildClientID parameter.
2/ Make the import fork failing in altering the selector.
"""
self._skip_if_no_internal_environments()
await self._init_parent_session()
secret_payload = "MySuperSecretPayload"
with self.assertRaises(ProtonAPIError) as cm:
await self._parent_session.async_fork(child_client_id='')
assert 'ChildClientID is required' in cm.exception.message
selector = await self._parent_session.async_fork(payload=secret_payload, child_client_id=self._CHILD_CLIENT_ID)
child_session = Session(appversion=self._APP_VERSION, user_agent=self._USER_AGENT)
altered_selector = selector + '+crap'
with self.assertRaises(ProtonAPIError) as cm:
await child_session.async_import_fork(altered_selector)
assert 'Invalid selector' in cm.exception.message
python-proton-core-0.4.0/tests/test_session_pickle.py 0000664 0000000 0000000 00000002405 14717325345 0023102 0 ustar 00root root 0000000 0000000 """
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.4.0/tests/test_textfilekeyring.py 0000664 0000000 0000000 00000006171 14717325345 0023311 0 ustar 00root root 0000000 0000000 """
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.4.0/tests/test_tlsverification.py 0000664 0000000 0000000 00000012221 14717325345 0023272 0 ustar 00root root 0000000 0000000 """
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.4.0/tests/testdata.py 0000664 0000000 0000000 00000035215 14717325345 0020647 0 ustar 00root root 0000000 0000000 """
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.4.0/tests/testserver.py 0000664 0000000 0000000 00000005672 14717325345 0021250 0 ustar 00root root 0000000 0000000 """
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)