Initial commit

This commit is contained in:
Alexandru Cheltuitor 2020-10-29 16:49:42 +00:00
commit b2ccd90117
No known key found for this signature in database
GPG key ID: 74418CFCDE482CF8
22 changed files with 1618 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
build/
dist/
MANIFEST
*.pyc
*.egg-info/
.vscode/
*.lock

22
LICENSE Normal file
View file

@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2020 Proton Technologies AG
Copyright (c) 2012 Tom Cocagne
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

4
MANIFEST.in Normal file
View file

@ -0,0 +1,4 @@
include LICENSE
include *.txt
recursive-include proton *.py
recursive-include proton *.rst

13
Pipfile Normal file
View file

@ -0,0 +1,13 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[dev-packages]
pytest = "*"
[packages]
requests = ">=2.18.4"
bcrypt = ">=3.1.4"
python-gnupg = ">=0.4.1"
pyopenssl = ">=17.5"

2
README.md Normal file
View file

@ -0,0 +1,2 @@
proton-client
=============

1
proton/__init__.py Normal file
View file

@ -0,0 +1 @@
from .api import Session, ProtonError

223
proton/api.py Normal file
View file

@ -0,0 +1,223 @@
import base64
import json
import gnupg
import requests
from .cert_pinning import TLSPinningAdapter
from .srp import User as PmsrpUser
class ProtonError(Exception):
def __init__(self, ret):
self.code = ret['Code']
self.error = ret['Error']
try:
self.headers = ret["Headers"]
except KeyError:
self.headers = ""
super().__init__("[{}] {} {}".format(
self.code, self.error, self.headers
))
class Session:
_base_headers = {
"x-pm-apiversion": "3",
"Accept": "application/vnd.protonmail.v1+json"
}
_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-----"""
@staticmethod
def load(dump, TLSPinning=True):
api_url = dump['api_url']
appversion = dump['appversion']
user_agent = dump['User-Agent']
cookies = dump.get('cookies', {})
s = Session(api_url, appversion, user_agent, TLSPinning=TLSPinning)
requests.utils.add_dict_to_cookiejar(s.s.cookies, cookies)
s._session_data = dump['session_data']
if s.UID is not None:
s.s.headers['x-pm-uid'] = s.UID
s.s.headers['Authorization'] = 'Bearer ' + s.AccessToken
return s
def dump(self):
return {
'api_url': self.__api_url,
'appversion': self.__appversion,
'User-Agent': self.__user_agent,
'cookies': self.s.cookies.get_dict(),
'session_data': self._session_data
}
def __init__(self, api_url, appversion="Other", user_agent="None", TLSPinning=True, ClientSecret=None):
self.__api_url = api_url
self.__appversion = appversion
self.__user_agent = user_agent
self.__clientsecret = ClientSecret
## Verify modulus
self.__gnupg = gnupg.GPG()
self.__gnupg.import_keys(self._srp_modulus_key)
self._session_data = {}
self.s = requests.Session()
if TLSPinning:
self.s.mount(self.__api_url, TLSPinningAdapter())
self.s.headers['x-pm-appversion'] = appversion
self.s.headers['User-Agent'] = user_agent
def api_request(self, endpoint, jsondata=None, additional_headers=None, method=None):
fct = self.s.post
if method is None:
if jsondata is None:
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))
ret = fct(
self.__api_url + endpoint,
headers = additional_headers,
json = jsondata
)
try:
ret = ret.json()
except json.decoder.JSONDecodeError:
raise ProtonError(
{
"Code": ret.status_code,
"Error": ret.reason,
"Headers": ret.headers
}
)
if ret['Code'] != 1000:
raise ProtonError(ret)
return ret
def authenticate(self, username, password):
self.logout()
payload = {"Username": username}
if self.__clientsecret:
payload['ClientSecret'] = self.__clientsecret
info_response = self.api_request("/auth/info", payload)
d = self.__gnupg.decrypt(info_response['Modulus'])
if not d.valid:
raise ValueError('Invalid modulus')
modulus = base64.b64decode(d.data.strip())
challenge = base64.b64decode(info_response["ServerEphemeral"])
salt = base64.b64decode(info_response["Salt"])
session = info_response["SRPSession"]
version = info_response["Version"]
usr = PmsrpUser(password, modulus)
A = usr.get_challenge()
M = usr.process_challenge(salt, challenge, version)
if M is None:
raise ValueError('Invalid challenge')
## Send response
payload = {
"Username": username,
"ClientEphemeral" : base64.b64encode(A).decode('utf8'),
"ClientProof" : base64.b64encode(M).decode('utf8'),
"SRPSession": session,
}
if self.__clientsecret:
payload['ClientSecret'] = self.__clientsecret
auth_response = self.api_request("/auth", payload)
if "ServerProof" not in auth_response:
raise ValueError("Invalid password")
usr.verify_session( base64.b64decode(auth_response["ServerProof"]))
if not usr.authenticated():
raise ValueError('Invalid server proof')
self._session_data = {
'UID': auth_response["UID"],
'AccessToken': auth_response["AccessToken"],
'RefreshToken': auth_response["RefreshToken"],
'Scope': auth_response["Scope"].split(),
}
if self.UID is not None:
self.s.headers['x-pm-uid'] = self.UID
self.s.headers['Authorization'] = 'Bearer ' + self.AccessToken
return self.Scope
def provide_2fa(self, code):
ret = self.api_request('/auth/2fa', {
"TwoFactorCode": code
})
self._session_data['Scope'] = ret['Scope']
return self.Scope
def logout(self):
if self._session_data:
self.api_request('/auth',method='DELETE')
del self.s.headers['Authorization']
del self.s.headers['x-pm-uid']
self._session_data = {}
def refresh(self):
refresh_response = self.api_request('/auth/refresh', {
"ResponseType": "token",
"GrantType": "refresh_token",
"RefreshToken": self.RefreshToken,
"RedirectURI": "http://protonmail.ch"
})
self._session_data['AccessToken'] = refresh_response["AccessToken"]
self._session_data['RefreshToken'] = refresh_response["RefreshToken"]
self.s.headers['Authorization'] = 'Bearer ' + self.AccessToken
@property
def UID(self):
return self._session_data.get('UID', None)
@property
def AccessToken(self):
return self._session_data.get('AccessToken', None)
@property
def RefreshToken(self):
return self._session_data.get('RefreshToken', None)
@property
def Scope(self):
return self._session_data.get('Scope', [])

187
proton/cert_pinning.py Normal file
View file

@ -0,0 +1,187 @@
import base64
import hashlib
from ssl import DER_cert_to_PEM_cert
import requests
from OpenSSL import crypto
from requests.adapters import HTTPAdapter
from urllib3.connectionpool import HTTPSConnectionPool
from urllib3.poolmanager import PoolManager
from urllib3.util.timeout import Timeout
from .constants import PUBKEY_HASH_DICT
class TLSPinningError(requests.exceptions.SSLError):
def __init__(self, strerror):
self.strerror = strerror
super(TLSPinningError, self).__init__(strerror)
class TLSPinningHTTPSConnectionPool(HTTPSConnectionPool):
"""Verify the certificate upon each connection"""
def __init__(
self,
host,
hash_dict,
port=None,
strict=False,
timeout=Timeout.DEFAULT_TIMEOUT,
maxsize=1,
block=False,
headers=None,
retries=None,
_proxy=None,
_proxy_headers=None,
key_file=None,
cert_file=None,
cert_reqs=None,
key_password=None,
ca_certs=None,
ssl_version=None,
assert_hostname=None,
assert_fingerprint=None,
ca_cert_dir=None,
**conn_kw
):
self.hash_dict = hash_dict
try:
super(TLSPinningHTTPSConnectionPool, self).__init__(
host,
port,
strict,
timeout,
maxsize,
block,
headers,
retries,
_proxy,
_proxy_headers,
key_file,
cert_file,
cert_reqs,
key_password,
ca_certs,
ssl_version,
assert_hostname,
assert_fingerprint,
ca_cert_dir,
**conn_kw
)
except TypeError:
super(TLSPinningHTTPSConnectionPool, self).__init__(
host,
port,
strict,
timeout,
maxsize,
block,
headers,
retries,
_proxy,
_proxy_headers,
key_file,
cert_file,
cert_reqs,
ca_certs,
ssl_version,
assert_hostname,
assert_fingerprint,
ca_cert_dir,
**conn_kw
)
def _validate_conn(self, conn):
r = super(TLSPinningHTTPSConnectionPool, self)._validate_conn(conn)
sock = conn.sock
pem_certificate = self.get_certificate(sock)
if self.is_session_secure(pem_certificate, conn, self.hash_dict):
return r
def is_session_secure(self, cert, conn, hash_dict):
"""Check if connection is secure"""
cert_hash = self.extract_hash(cert)
if not self.is_hash_valid(cert_hash, hash_dict):
# Also generate a report
conn.close()
raise TLSPinningError("Insecure connection")
return True
def is_hash_valid(self, cert_hash, hash_dict):
"""Validate the hash against a known list of hashes/pins"""
try:
hash_dict[self.host].index(cert_hash)
except (ValueError, KeyError, TypeError):
return False
else:
return True
def get_certificate(self, sock):
"""Extract and convert certificate to PEM format"""
certificate_binary_form = sock.getpeercert(True)
return DER_cert_to_PEM_cert(certificate_binary_form)
def extract_hash(self, cert):
"""Extract encrypted hash from the certificate"""
cert_obj = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
pubkey_obj = cert_obj.get_pubkey()
pubkey = crypto.dump_publickey(crypto.FILETYPE_ASN1, pubkey_obj)
spki_hash = hashlib.sha256(pubkey).digest()
cert_hash = base64.b64encode(spki_hash).decode('utf-8')
return cert_hash
class TLSPinningPoolManager(PoolManager):
"""Attach TLSPinningHTTPSConnectionPool to TLSPinningPoolManager"""
def __init__(
self,
hash_dict,
num_pools=10,
headers=None,
**connection_pool_kw
):
self.hash_dict = hash_dict
super(TLSPinningPoolManager, self).__init__(
num_pools=10, headers=None, **connection_pool_kw
)
def _new_pool(self, scheme, host, port, request_context):
if scheme != 'https':
return super(TLSPinningPoolManager, self)._new_pool(
scheme, host, port, request_context
)
kwargs = self.connection_pool_kw
pool = TLSPinningHTTPSConnectionPool(
host=host, port=port,
hash_dict=self.hash_dict, **kwargs
)
return pool
class TLSPinningAdapter(HTTPAdapter):
"""Attach TLSPinningPoolManager to TLSPinningAdapter"""
def __init__(self, hash_dict=PUBKEY_HASH_DICT):
self.hash_dict = hash_dict
super(TLSPinningAdapter, self).__init__()
def init_poolmanager(
self,
connections,
maxsize,
block=False,
**pool_kwargs
):
self.poolmanager = TLSPinningPoolManager(
num_pools=connections, maxsize=maxsize, block=block,
strict=True, hash_dict=self.hash_dict, **pool_kwargs
)

15
proton/constants.py Normal file
View file

@ -0,0 +1,15 @@
PUBKEY_HASH_DICT = {
"api.protonvpn.ch": [
"IEwk65VSaxv3s1/88vF/rM8PauJoIun3rzVCX5mLS3M=",
"drtmcR2kFkM8qJClsuWgUzxgBkePfRCkRpqUesyDmeE=",
"YRGlaY0jyJ4Jw2/4M8FIftwbDIQfh8Sdro96CeEel54=",
"AfMENBVvOS8MnISprtvyPsjKlPooqh8nMB/pvCrpJpw="
],
"protonvpn.com": [
"+0dMG0qG2Ga+dNE8uktwMm7dv6RFEXwBoBjQ43GqsQ0=",
"8joiNBdqaYiQpKskgtkJsqRxF7zN0C0aqfi8DacknnI=",
"JMI8yrbc6jB1FYGyyWRLFTmDNgIszrNEMGlgy972e7w=",
"Iu44zU84EOCZ9vx/vz67/MRVrxF1IO4i4NIa8ETwiIY="
]
}

216
proton/doc/conf.py Normal file
View file

@ -0,0 +1,216 @@
# -*- coding: utf-8 -*-
#
# Secure Remote Password documentation build configuration file, created by
# sphinx-quickstart on Fri Mar 25 10:20:52 2011.
#
# This file is execfile()d with the current directory set to its containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
import sys, os
# 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.
#sys.path.insert(0, os.path.abspath('.'))
# -- General configuration -----------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = []
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = '.rst'
# The encoding of source files.
#source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = u'Secure Remote Password'
copyright = u'2011, Tom Cocagne'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '1.0'
# The full version, including alpha/beta/rc tags.
release = '1.0'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['_build']
# The reST default role (used for this markup: `text`) to use for all documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
# -- 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 = 'default'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
#html_theme_path = []
# The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> documentation".
#html_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
#html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#html_logo = None
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#html_favicon = None
# 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']
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
#html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#html_additional_pages = {}
# If false, no module index is generated.
#html_domain_indices = True
# If false, no index is generated.
#html_use_index = True
# If true, the index is split into individual pages for each letter.
#html_split_index = False
# If true, links to the reST sources are added to the pages.
#html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
#html_file_suffix = None
# Output file base name for HTML help builder.
htmlhelp_basename = 'SecureRemotePassworddoc'
# -- Options for LaTeX output --------------------------------------------------
# The paper size ('letter' or 'a4').
#latex_paper_size = 'letter'
# The font size ('10pt', '11pt' or '12pt').
#latex_font_size = '10pt'
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
('index', 'SecureRemotePassword.tex', u'Secure Remote Password Documentation',
u'Tom Cocagne', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#latex_use_parts = False
# If true, show page references after internal links.
#latex_show_pagerefs = False
# If true, show URL addresses after external links.
#latex_show_urls = False
# Additional stuff for the LaTeX preamble.
#latex_preamble = ''
# Documents to append as an appendix to all manuals.
#latex_appendices = []
# If false, no module index is generated.
#latex_domain_indices = True
# -- Options for manual page output --------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'secureremotepassword', u'Secure Remote Password Documentation',
[u'Tom Cocagne'], 1)
]

14
proton/doc/index.rst Normal file
View file

@ -0,0 +1,14 @@
Welcome to Python Proton Client module's documentation!
=======================================================
Contents:
.. toctree::
:maxdepth: 2
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

37
proton/srp/README.md Normal file
View file

@ -0,0 +1,37 @@
# 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)
```

13
proton/srp/__init__.py Normal file
View file

@ -0,0 +1,13 @@
from . import _pysrp
_mod = None
try:
from . import _ctsrp
_mod = _ctsrp
except (ImportError, OSError):
pass
if not _mod:
_mod = _pysrp
User = _mod.User

304
proton/srp/_ctsrp.py Normal file
View file

@ -0,0 +1,304 @@
# 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 sys
import ctypes
from .pmhash import pmhash
from .util import *
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:
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:
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_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 bn_num_bytes(a):
return ((BN_num_bits(a) + 7) // 8)
def bn_mod(rem, m, d, ctx):
return BN_div(None, rem, m, d, ctx)
def bn_is_zero(n):
return n[0].top == 0
def bn_to_bytes(n):
b = ctypes.create_string_buffer(bn_num_bytes(n))
BN_bn2bin(n, b)
return b.raw[::-1]
def bytes_to_bn(dest_bn, bytes):
BN_bin2bn(bytes[::-1], len(bytes), dest_bn)
def bn_hash(hash_class, dest, n1, n2):
h = hash_class()
h.update(bn_to_bytes(n1))
h.update(bn_to_bytes(n2))
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)
BN_bn2bin(N, bin2)
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, bn_to_bytes(salt), bn_to_bytes(modulus), version)
bytes_to_bn(dest, exp)
def update_hash(h, n):
h.update(bn_to_bytes(n))
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 = BN_new()
g = BN_new()
k = BN_new()
bytes_to_bn(N, n_bin)
BN_hex2bn(g, g_hex)
bn_hash_k(hash_class, k, g, N, width=bn_num_bytes(N))
return N, g, k
class User(object):
def __init__(self, password, n_bin, g_hex=b"2", bytes_a=None, bytes_A=None):
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 = BN_new()
self.A = BN_new()
self.B = BN_new()
self.s = BN_new()
self.S = BN_new()
self.u = BN_new()
self.x = BN_new()
self.v = BN_new()
self.tmp1 = BN_new()
self.tmp2 = BN_new()
self.tmp3 = BN_new()
self.ctx = BN_CTX_new()
self.M = None
self.K = None
self.expected_server_proof = None
self._authenticated = False
self.hash_class = pmhash
self.N, self.g, self.k = get_ngk(self.hash_class, n_bin, g_hex, self.ctx)
if bytes_a:
bytes_to_bn(self.a, bytes_a)
else:
BN_rand(self.a, 256, 0, 0)
if bytes_A:
bytes_to_bn(self.A, bytes_A)
else:
BN_mod_exp(self.A, self.g, self.a, self.N, self.ctx)
def __del__(self):
if not hasattr(self, 'a'):
return # __init__ threw exception. no clean up required
BN_free(self.a)
BN_free(self.A)
BN_free(self.B)
BN_free(self.s)
BN_free(self.S)
BN_free(self.u)
BN_free(self.x)
BN_free(self.v)
BN_free(self.N)
BN_free(self.g)
BN_free(self.k)
BN_free(self.tmp1)
BN_free(self.tmp2)
BN_free(self.tmp3)
BN_CTX_free(self.ctx)
def authenticated(self):
return self._authenticated
def get_ephemeral_secret(self):
return bn_to_bytes(self.a)
def get_session_key(self):
return self.K if self._authenticated else None
def get_challenge(self):
return bn_to_bytes(self.A)
# Returns M or None if SRP-6a safety check is violated
def process_challenge(self, bytes_s, bytes_server_challenge, version=PM_VERSION):
bytes_to_bn(self.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.s, self.password, self.N, version)
BN_mod_exp(self.v, self.g, self.x, self.N, self.ctx)
# S = (B - k*(g^x)) ^ (a + ux)
BN_mul(self.tmp1, self.u, self.x, self.ctx)
BN_add(self.tmp2, self.a, self.tmp1) # tmp2 = (a + ux)
BN_mod_exp(self.tmp1, self.g, self.x, self.N, self.ctx)
BN_mul(self.tmp3, self.k, self.tmp1, self.ctx) # tmp3 = k*(g^x)
BN_sub(self.tmp1, self.B, self.tmp3) # tmp1 = (B - K*(g^x))
BN_mod_exp(self.S, self.tmp1, self.tmp2, self.N, self.ctx)
self.K = bn_to_bytes(self.S)
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:
BN_rand(self.s, 10*8, 0, 0)
else:
bytes_to_bn(self.s, bytes_s)
calculate_x(self.hash_class, self.x, self.s, self.password, self.N, version)
BN_mod_exp(self.v, self.g, self.x, self.N, self.ctx)
return bn_to_bytes(self.s), bn_to_bytes(self.v)
# ---------------------------------------------------------
# Init
#
RAND_seed(os.urandom(32), 32)

130
proton/srp/_pysrp.py Normal file
View file

@ -0,0 +1,130 @@
# 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 *
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, long_to_bytes(salt), long_to_bytes(modulus), version)
return bytes_to_long(exp)
def calculate_client_proof(hash_class, A, B, K):
h = hash_class()
h.update(long_to_bytes(A))
h.update(long_to_bytes(B))
h.update(K)
return h.digest()
def calculate_server_proof(hash_class, A, M, K):
h = hash_class()
h.update(long_to_bytes(A))
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):
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, width=long_length(self.N))
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.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)
def get_session_key(self):
return self.K if self._authenticated else None
def get_challenge(self):
return long_to_bytes(self.A)
# Returns M or None if SRP-6a safety check is violated
def process_challenge(self, bytes_s, bytes_server_challenge, version=PM_VERSION):
self.s = bytes_to_long(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.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)
self.M = calculate_client_proof(self.hash_class, self.A, self.B, self.K)
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.s = get_random_of_length(10) if bytes_s is None else bytes_to_long(bytes_s)
self.x = calculate_x(self.hash_class, self.s, self.p, self.N, version)
return long_to_bytes(self.s), long_to_bytes(pow(self.g, self.x, self.N))

27
proton/srp/pmhash.py Normal file
View file

@ -0,0 +1,27 @@
# 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)

57
proton/srp/util.py Normal file
View file

@ -0,0 +1,57 @@
import base64
import bcrypt
import os
PM_VERSION = 4
def bcrypt_b64_encode(s): # The joy of bcrypt
bcrypt_base64 = b"./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
std_base64chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
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)
raise ValueError('Unsupported auth version')
def long_length(n):
return (n.bit_length() + 7) // 8
def bytes_to_long(s):
return int.from_bytes(s, 'little')
def long_to_bytes(n):
return n.to_bytes(long_length(n), '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) if isinstance(s, int) else s
h.update(data)
return bytes_to_long(h.digest())

32
setup.py Executable file
View file

@ -0,0 +1,32 @@
#!/usr/bin/env python
from setuptools import setup, find_packages
long_description = '''
This package, originally forked from python-srp module implements a simple
wrapper to the Proton Technologies API, abstracting from the SRP authentication.
'''
setup(name = 'proton-client',
version = '0.0.9',
description = 'Proton Technologies API wrapper',
author = 'Proton Technologies',
author_email = 'contact@protonmail.com',
url = 'https://github.com/ProtonMail/proton-python-client',
long_description = long_description,
install_requires = ['requests', 'bcrypt', 'python-gnupg', 'pyopenssl'],
packages = find_packages(),
include_package_data = True,
license = "MIT",
platforms = "OS Independent",
classifiers = [
'Development Status :: 3 - Alpha',
'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent',
'Programming Language :: Python :: 3',
'Programming Language :: Python',
'Topic :: Security',
],)

122
tests/test_api.py Normal file
View file

@ -0,0 +1,122 @@
import unittest
from testdata import instances
from testserver import TestServer
from proton.srp.util import *
from proton.srp._ctsrp import User as CTUser
from proton.srp._pysrp import User as PYUser
class SRPTestCases:
class SRPTestBase(unittest.TestCase):
def test_invalid_version(self):
modulus = bytes.fromhex(instances[0]['Modulus'])
salt = base64.b64decode(instances[0]['Salt'])
with self.assertRaises(ValueError):
usr = self.user('pass', modulus)
salt, usr.compute_v(salt, 2)
with self.assertRaises(ValueError):
usr = self.user('pass', modulus)
salt, usr.compute_v(salt, 5)
def test_compute_v(self):
for instance in 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: " + str(instance)[:30] + "..."
)
self.assertEqual(
instance["Verifier"],
base64.b64encode(v).decode('utf8'),
"Wrong verifier while generating v, instance: " + str(instance)[:30] + "..."
)
def test_generate_v(self):
for instance in 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: " + str(instance)[:30] + "..."
)
self.assertEqual(
generated_v,
computed_v,
"Wrong verifier while generating v, instance: " + str(instance)[:30] + "..."
)
def test_srp(self):
for instance in 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: " + str(instance)[:30] + "..."
)
self.assertEqual(
server.get_session_key(),
usr.get_session_key(),
"Secrets do not match, instance: " + str(instance)[:30] + "..."
)
self.assertTrue(
server.get_authenticated(),
"Server is not correctly authenticated, instance:: " + str(instance)[:30] + "..."
)
self.assertTrue(
usr.authenticated(),
"User is not correctly authenticated, instance:: " + str(instance)[:30] + "..."
)
class TestCTSRPClass(SRPTestCases.SRPTestBase):
def setUp(self):
self.user = CTUser
class TestPYSRPClass(SRPTestCases.SRPTestBase):
def setUp(self):
self.user = PYUser
if __name__ == '__main__':
unittest.main()

49
tests/test_tlspinning.py Normal file
View file

@ -0,0 +1,49 @@
import pytest
import requests
from proton import cert_pinning
fake_hashes = {
"api.protonvpn.ch": [
"aIEwk65VSaxv3s1/88vF/rM8PauJoIun3rzVCX5mLS3M=",
"adrtmcR2kFkM8qJClsuWgUzxgBkePfRCkRpqUesyDmeE=",
"aYRGlaY0jyJ4Jw2/4M8FIftwbDIQfh8Sdro96CeEel54=",
"aAfMENBVvOS8MnISprtvyPsjKlPooqh8nMB/pvCrpJpw="
],
"protonvpn.com": [
"a+0dMG0qG2Ga+dNE8uktwMm7dv6RFEXwBoBjQ43GqsQ0=",
"a8joiNBdqaYiQpKskgtkJsqRxF7zN0C0aqfi8DacknnI=",
"aJMI8yrbc6jB1FYGyyWRLFTmDNgIszrNEMGlgy972e7w=",
"aIu44zU84EOCZ9vx/vz67/MRVrxF1IO4i4NIa8ETwiIY="
]
}
class TestCertificatePinning():
s = requests.Session()
def test_api_url_real_hash(self):
url = 'https://api.protonvpn.ch/tests/ping'
self.s.mount(url, cert_pinning.TLSPinningAdapter())
r = self.s.get(url)
assert int(r.status_code) == 200
def test_non_api_url_real_hash(self):
url = 'https://protonvpn.com'
self.s.mount(url, cert_pinning.TLSPinningAdapter())
r = self.s.get(url)
assert int(r.status_code) == 200
def test_api_url_fake_hash(self):
url = 'https://api.protonvpn.ch/tests/ping'
self.s.mount(url, cert_pinning.TLSPinningAdapter(fake_hashes))
with pytest.raises(requests.exceptions.ConnectionError):
self.s.get(url)
def test_non_api_url_fake_hash(self):
url = 'https://protonvpn.com'
self.s.mount(url, cert_pinning.TLSPinningAdapter(fake_hashes))
with pytest.raises(requests.exceptions.ConnectionError):
self.s.get(url)

81
tests/testdata.py Normal file
View file

@ -0,0 +1,81 @@
instances = [
{
'Username': 'test',
'Password': 'test',
'Modulus': '1B64DF29DEDD8656245DB7EEE751442AD9CF1DAFC5A71A94076385C2FBF9FA7AD63E94CB365EC94EBA5BE131CF63D3930CAC4755DE6D0625C24DD9A906551D216601222EBA94FF50C78B8B26DBF27636F4019F1700BA091287462CFFAD4F88B22D66BBF8993090865E46D077ECF1DB78CB2AB0D036AD786B046B5D93BD473C95779914CB93F607FD7EFB9D34161951263CE794BF181FB301EE444D170999EAFF9427CC4151BD91A755F1A184009C1418B16EEC7BFC2D5F88D42B38A4CC176B73EAB132FE37DD7E1162DCA1D13E81A6F10F090DE77EB8CC492CD0B19BB6FC151F5B4AD56B14308D582D86471390C4223400AEE3D5E94C973FB997D59F8A9F309F',
'Verifier': 'rhmWw1f4YsGq/cVgIePVSaot9Zj2kGNLxIgPytirz9/Nd8X8a28ZvFnWMQD0Jj8IgJPfO4EsI2nU5NuFqIbPI4OKs6s0nWvEHXkdtzxT1n451MGThvZ6o7I+0Ofi1Dh6Rgkv3MxVL/6cNev3EDyhcvh5w9hUIOk5OfRDcFMKv8ht5OYI5a/++m1++x58LQyrrTsRMMIcvDFXsjmMj4Ch3leP02S56cxDl6IQTosU+JcXGHsgNDf90aDnlsDFMGt6As1FSQm8bw0Yat+P02IZrXURQKsMLOKxdwb8xWySVymXAyjcJ7Z9ZabGjCX6Lelo9Wiz0hIEGE7nnOeVwzJKaA==',
'Salt': 'Jl54BOeNTVl8Ng==',
'Exception': None
},
{
'Username': 'abc123',
'Password': 'LongerPassword',
'Modulus': 'FB6443D98DA170445C9795DC351398F1DE1518FB2827F757E8805C5F43DC2927499060A929171245B20FAED4F0EF5611276430A1943F6FD8E7999D8F40407494EE2FD147B31ECC1D59AC7F63E9266CE6EE58FA9B54D3FF3F712F1F210353E7714730A7A787D36D7B7D0940F16A30263EAD448C09BE1EA9F322FE8A844A30C4B900747F30057F33CF850BD717D0AB8008BE6EB333D30F02C1575601F8077307FCA6DA0DBE0156C485E30343A371E9083B58F8F57DB049F46A9ACEE00C1A702E99D04C0777543B3A25B8B33BF35C6E95332E0C907FB357A46A28DB073510DC7903F0E14B5B6DD11945F0D19B7E3939D942E8808D8BFFF2A4AE35E4EECE4AB069BD',
'Verifier': '9U87bD4BYK3aDRQntSh0pR4AneEQ97Dd3rfysHfShFatlZGjntcJqAheiz6BAJPNnk+ui4Ps842bBZdrXMcfI9kftWra/eByMA1uzJ16rH9UQy2gpUH2hBRqoSMWGduiwnFp168NGAfAX+Zp9ce+N4J4t9E04arJnnfUfjja6iMK9wzzpXZn6XcdpbRJ5EeY8FO8Y6I0Y7TM8sxfeeSyGLhFSZLvLoXFjALi48zXSjNNw7GJBcH+hbejeYFiIf0cSK7sPFn8O5CJFXXCmjO5wx4KdYuTH5y919guttwdS70M67iVlISyyXTHcpxH6967IsbWJms/pNoMrjXT2xauCA==',
'Salt': 'hyzJpo9GoQaQZg==',
'Exception': None
},
{
'Username': 'VeryLongUsername_123456789',
'Password': 'VeryLongAndSecurePassword',
'Modulus': 'C3358515FE673964104A3BCC015F3079F6711ACC6E1C35CA4AED5A85C345B258160B73E32A1DC1F1BD8389FE96B7A6EFD1CA8A6265186FE256BD67DB5507A81CA5BBBA1AFC6E854794343F2DF91182A4FAEC84FA6FDC3028C85DE344EF545D5A5668CFBE00D0A98E7DF9C4E3833BFE49E5E938F4658D23EB791757852A520C7908471C249324FD87B382A17973CBB962E20FCD598051CC43E792013412603294ACAFE51185E6D57AFBAC13F83E8B1ACCC296094B2B6D76B8DF9210FF00FBAFEB9ED333C123AF5E0E8607E1EE01AC80A7FBEC194952050C100D1CF0C58740509DC82A0EE6F82536F8AD0E651284E8277407BB625DDA23A5CD906B00B76D21DADD',
'Verifier': 'AuYwtTLuT5coocpDHcZCQMqMQbFevrcxnqoijrOveXe7+h4XqdzpakUnKxJ8n+IrHt5wo2Nhsgdipw7woeb1lskt3WBdJ6KCTHCwjOcBUlHpMnJbea7ere7qCl+ar7LQOE/hh1xu2uotSBFMCDrPxoVLX00/jwtfFnr3e9he0tZ2+lZgUgPqKWo3OT4SthM9N9ILMQ4GSW5s5HgPw1QVhTXXQ6Nr6fwW5ID+ViyPS2n66WdCV2JcVwTza90/pYT3ZEvw/DOzD91v09lIdarp3a4c2/lbLORbmUiip0wHu4gLa5jv/J73xZEOGg54REwHrlx6eOgl31TdFjexC1n/EA==',
'Salt': 'cujclj9P6zluIQ==',
'Exception': None
},
{
'Username': 'TestUsername',
'Password': 'Test Password With Spaces',
'Modulus': '530B86096FE2BA84CBD4518EE26386BFF353127BC9545537B55148374619392CB90C05EA35F0032583D9928EB6309642236DF0D2EB00736BD8ACC349AE8094C97F330AEE0F493E76D1C092C98C61B880C942C54D9BA0AE2483AAD6A125E601685ECDD8BD984C4F15EDDEC8D21F873C5CE2F47B16069D95023F6CAF2E4C25703DCF2E09C66E3927E19D4C6377D5C31F9EB2D881E820BCB97747274C3F465DF3C17BFAC9C37DF6B255209697755AC068517A191364BF7D7A3A3B4321606A540590D70A1A0AEEA580B7731B89D2D2FD4EC5D0AB65ED91757041C1E088ABC8C4BAC2B9B586DB036F035F04BC9ED6ADE0713CFCB85BAE556C4D3FA2A00B9CE55D04C4',
'Verifier': '9TcpTx2TZ2oyPXMtQ+jn+rTIGxl6MpOMnazV5tGMlcNg00VujDzqsEi3O0pwB2r1x0T9H4UMi7/l+1qy0HlsdG53OTO4ij+z+gRuA5xQAb/521g34q+/zJobBYuHISfV+LaHRgn1Z9VMYURkTNF66WyHMn1nd7swzGE+zWxBa0gN1d4HFW1h9VjLUOeOGGp4UwWIR2+pSO50hJO6yGC8pQgEZhmyM0VfliWWzKsLMxRdfS4eHArvKsVOLN/IChSRKjeiDSSkOVGQZyNUoZDpg4AsGBLeXJovlbdjQoM/8pByJJpX71Ze6WZIltTRyjTYirYORDrLNCQr8DHpE6hLKA==',
'Salt': 'vSds4CNJcRFDog==',
'Exception': None
},
{
'Username': 'Test1',
'Password': '',
'Salt': 'vSds4CNJcRFDog==',
'Modulus': '6B4DDFC843BE2777F709F7E3379E308581D3976CF4A85F37C44302816BAE75F0E99C038A763E864A2367AFDA5C06875DF0990D1121196809B0D8D44423DBF43BC66164341E0CCC3A09637D04DE96146492935827372197B3B470CBFCFDEF5B9245DF5761D0E9267DF9293E32D9F5A503F827AE90E39414C90F19A6D4CCDE664227268D6C8164E9C570A0E79968CDF5597260502FAC9FDB9C585F2BD37597FD7149AE066EE0ED1B3958958DCB697DCD878E097FB8543AD0CAA99407A6F991DC70F137DABE97E8D07CE3D58B922F8BEF3C862C8CE224501E953B17E94B8F79EDD1B8E287087B172EC217FA5183CD1D4C3C66B950A06BAD64DA1C0DDCAF91BB5FBF',
'Exception': ValueError
},
{
'Username': 'Test2',
'Password': 'PasswordThatIsLongerThan72CharsAndContainsSomeRandomStuffThatNoOneCaresAbout',
'Modulus': 'C376377AC6C62697E211C0BE80DA3C5F7B7381AB92D94536B406594AD0C1BFE90A424E36085CD2F553F370ED863E72597210DD94A19B0DA4257CD18EEDAF33A71E5BD7657DB25BE602F0430FE4762D9F6100DD319D7E5870DC0583D0782832E68E8A12C1AA0B8018FB71D5F3C9AEB80208DE62CAB066FCC80E274F32199AA2193882E256A86E2B8993C7278CE470BA4A9B315AF33C029967EB96C470987F440F7CD4688072B98DC0B57686B580DE76BF10F3D277D24CAD012A83A98A834F90A22A29D113F272A38E750DF3188ACFCADC8642B7F9847CE4F721EBF1D9105BE33CDC19A01080A9427F4F76B27D3FCF8926A5C4884C42D1B6D052C2F744452283DB',
'Verifier': 'Ft8p5GMgowgwSRb2jJ7uoTkvptOQmNqH+Aov/Lawsc5vbaBIIoIwnF03jiGVEEO9kVVTZG/+5zQorvY5voZJrgJev+I/LR0WbzIY+Tqm7BbVRSARc7jkTFHXYFFiasuOR5myTDPyctxyfTQzAwGX7MccC82nWUuPn/yWsAgqppvVatC+QUilEKALvHqjnupJoLay0ZfmLOkV8eeXUQFkOcRzGFYtkCSD5aCQYBMZYkuLm/4rUEQsYQ9GyluGDNfYm0kDUy9oq0ujJdDciSvFAw3arIANsuEDmGg/eo0/1iZLywcZPzS20Cu07KIgQ+Ct2HemCmgFDJKQ2CBnW+HvFQ==',
'Salt': 'lSIG/btGTkKS4Q==',
'Exception': None
},
{
'Username': 'Test3',
'Password': 'Test3',
'Modulus': '6BF8F261CD7B1C9125CCB16F6FDCB59E1E7835E2E1C83D43B51DD319CCB6EFDC56F775448538F40B2259DCF464829E9E2E8CFE9E8A5658A5429BBCF65928EBF91276896148B920E1A3719BFC07ABDE69265B9296079E539F3B20B4FD88457DFD7776F300F79A6B01F5438F80C05A2E27D1903C2AED087C8D1566919FDA443D61E61BF5095CCD5F59E9B7C12E6210138A2B48EC39311A12442E298BA94D994E0038725706084EF993F5D10884E9ED1235A2C72E5E8A26F5FF0BF77B1F98D84F93CB9A3598DE647CF2ACD85E91541593834E7253DC417262488E02D4BA53873B1F7FBA17AE90A189AB7562E74691F396CD8C99311C9B6D4810528FC3698895BEE6',
'Verifier': 'drKnuCh+aADaBMQdO+CM3USFXb5s4qAOgKyk2vxGLi6Gu8Z+SLskGYwx25djGgLqDlo6OncjlS1KPkc+euklc9CoPKDn0ZI3qGKSE4JK7LytiqC2IbpNa3j6jFQu020suyLYtAPJ+9IW6mvgiRi1dKuBlEArSAHz4aAO/ThKxjSArombm8F+tIQG4dznQWe2l7XzAmB9sFsYplDnnAtcydWtBiM1lnYqPJ3APWB/J1+r7YUNw/GPt8PuCz3tVDxyUJoe061iLQmPBsKfNpuSKBgwmMidjwN6UBbuLRhOyYhNO+sk6ER479NuYg6O7lvbnRdS4ELJs84Q2LmKLa3wGg==',
'Salt': 'rPelup77AgUCQg==',
'Exception': None
},
{
'Username': 'Test4',
'Password': 'Test4',
'Modulus': 'F3FC06AB4FCED0A9E73D85826EA1C21AEEED978C3C938524265E32DBFA0287979278ED80990E854421BF186116E538F8302E749A683B9272795F70352CD98554E6FBA7714BA0AB5C02A1CC641BB931F50E0A8F8FB31446C21950A3620E514F1ABEEE102BB8B225DE5EF34B0064010EE70CF7371D2D0FC154586E42F99701BEAAE1EBFC041A7975A782A3455AA4EDEA9ED0B126E4DEC746E5CCC696B9511E61DC0C26A7C39438F99C71CAA47F1BEF3FF25FB8C61D20A9D191E5B56273FA90C0D310A0296E2B86156EEEC27536A3252AE4ED3AEF6708E6C51D464E0EE15EC4B50FAA22095790D4FE93A6BDCF572DA36850015B3DE882407DCFB37EA45176E230E0',
'Verifier': 'ED24sqbunfInO0jSyO+x2nbva3Uc4jetsw0oGZhYwo31azz8vU6lI1t6B8+a0fNR9yUxPU/Fr03wx8MH0JkIWulqDCYkU4THrnL6Uu6xOJEX4RRReT/l5SJA4zHbT0Zk7XFBvwUXtRa3mJRedJkR/bqpp7QUOobdKQKdm1n2l+ktgAq4hHuxLS1BUSvMVJh2B3bFc4BLgTAj9EQA8VEZVetSniMejNBdcEPFDnEXgFNddVPoWuuTpd0jWikeFXSyVT7q5D+Jg1UvO3wXWarYbGnn2lYubv9WY6spgLwbJv796YuFcso9/7dtPRpT/TJDqmHTYsoPbNYl017uODCjSw==',
'Salt': 'dRuDue2lP/mG2w==',
'Exception': None
},
{
'Username': 'Test5',
'Password': 'Test5',
'Modulus': '434EAA334EFC1225D376B2D38DC6FDF4E1E1182AB4B47803AE9DAC05865D1A91D95E7F3018B9893EBCFDED5EA6CAD29F953175EE25ED35AE4D9B37999360503D9D3EB8B31A2A64139A9928C7CD4600C433012CE52D105AB4718EC2F525FA4F2F4E5B4376BD3A8698E3C6CE60E98646E71EB26B18565EB90C3367FFB9CD4FCF8FDF75F6E9DE30D252DAED835BEF13D21EDFFB8163E56EBCFB2D884AE2C7778E28279F61252F5E24C79103B16078A980E8F6ED9A62A51187B7166A7AE335BE785A4DE8C5E5BE4EF1B0B9DBF8C3C42C445BD236B279DB53F28F7685ABC440A09B8CCC4D6B1723866ABBDEE916CC5F68EEF5F902A2E7A15805788DBE74FA1F20AEEC',
'Verifier': 'F1MlhQI02Ek1HSGe1ow3wYbzjUJicjbMePOqBZynRpLDd3cD6meWe8coXKS9z63jOPORh9c9hSi/lgwDS+lhYPD50/xTRrKoq4rMdo0gHOazBCRbjTddN8NW3jPYIPpdNSuxr4ReJCc9rNizvjIpqNYb0q02KD3CewEtvZDhmeIl0Y/b82yahlB5Dkx0sEJ4KPCqPYru8BhnOl1/CTxz/CCIyQhHNt700vFGRLoewxCKhIteb+sGHFRerC35OOJIsuJc3Snf1o800YQATpxVxnqEzrYUs2ofXIRNGOFX8WU9PP+zFx7mAbp6KvSfoWVQB+a263p9D/3TzAN0kflm0A==',
'Salt': 'JNmIaPOOZEzaWg==',
'Exception': None
},
{
'Username': 'Test6',
'Password': 'Test6',
'Modulus': '5BA65673D3C677FF42CD223C6130CE991DB345B2647DD4ED19881FEB6D695AC5FB33B402E813CF6B829372E13C611740A37B231F3034F1142950F8B48A55250FAAAC29FBE12B18BE3852258FC8DE37F53238EE68DF8BAF41B4BF4ECE551D7DE91B428A2EB78DDB86C8F154B2593D18EEE3331441D98F86AFCCCAF51424E8E20ACB86EC478FA7F596805DE700532070EB8F90E66FB050385ACFE7ED45F044144962A97327237EB77F2A9A810D18E9FB366070E88315852F597D991B291484F17E2BC011528A0E9F7551667C6740A41397E229EB24C9735F814546AFA17E5A999B4CFE65A08EBFF5EB58F90BF2BDACD04BD00967AF01CCA06608B15716BE0F05AA',
'Verifier': 'H9/tTIo5U2GClndXSJW7JHQdw3XmiZfPztBJvM7UNbrL851hJdXCpypXRvS3NCdt5AIr3ic48ll3NKFDmA6ORdPsE9vII0IdOWMxWIU0enpPi2cb8AbvVXS8RF6+LPOwOF8wDfro+/gDoj7ofk2hnbUdnbf9bjKxVau+V+NQJPL7m1P8XTxAhP3IYf3flfWjhmNisasPIXkBxwMM/rVg/9Wqkzrzpoo2eaaMolQirmBZe8gdJlsURSR1v7PPaCYP8rRY7RZMV2RWUm/W8o56n2iKm2F9ldRFveMnI9W+aUrnI7lAT/H7PvP1hPDXPwVnhhPlYHk8ovj9q1KGl12MBA==',
'Salt': 'ZLpMUnhDn1QjkQ==',
'Exception': None
}
]

62
tests/testserver.py Normal file
View file

@ -0,0 +1,62 @@
from proton.srp.util import *
from proton.srp.pmhash import pmhash
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.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))
h.update(client_proof)
h.update(long_to_bytes(self.secret))
return h.digest()
def calculate_client_proof(self):
h = self.hash_class()
h.update(long_to_bytes(self.A))
h.update(long_to_bytes(self.B))
h.update(long_to_bytes(self.secret))
return h.digest()
def calculate_k(self):
width = long_length(self.modulus)
h = self.hash_class()
h.update(self.generator.to_bytes(width, 'little'))
h.update(long_to_bytes(self.modulus))
return bytes_to_long(h.digest())
def get_challenge(self):
return long_to_bytes(self.B)
def get_session_key(self):
return long_to_bytes(self.secret) #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)