diff options
-rw-r--r-- | service/pixelated/bitmask_libraries/certs.py | 9 | ||||
-rw-r--r-- | service/pixelated/bitmask_libraries/provider.py | 31 | ||||
-rw-r--r-- | service/pixelated/config/__init__.py | 1 | ||||
-rw-r--r-- | service/pixelated/config/args.py | 1 | ||||
-rw-r--r-- | service/pixelated/config/leap_cert.py | 7 | ||||
-rw-r--r-- | service/pixelated/support/ext_requests_urllib3.py | 79 | ||||
-rw-r--r-- | service/pixelated/support/tls_adapter.py | 47 | ||||
-rw-r--r-- | service/test/unit/bitmask_libraries/test_provider.py | 20 |
8 files changed, 174 insertions, 21 deletions
diff --git a/service/pixelated/bitmask_libraries/certs.py b/service/pixelated/bitmask_libraries/certs.py index 6b12bce4..ed09e4a3 100644 --- a/service/pixelated/bitmask_libraries/certs.py +++ b/service/pixelated/bitmask_libraries/certs.py @@ -22,16 +22,19 @@ from leap.common import ca_bundle from .config import AUTO_DETECT_CA_BUNDLE LEAP_CERT = None +LEAP_FINGERPRINT = None def which_bundle(provider): - if LEAP_CERT: - return LEAP_CERT return str(LeapCertificate(provider).provider_ca_bundle()) +def which_bootstrap_fingerprint(provider): + return LEAP_FINGERPRINT + + def which_bootstrap_bundle(provider): - if LEAP_CERT: + if LEAP_CERT is not None: return LEAP_CERT return str(LeapCertificate(provider).auto_detect_bootstrap_ca_bundle()) diff --git a/service/pixelated/bitmask_libraries/provider.py b/service/pixelated/bitmask_libraries/provider.py index 5304e662..34e426d7 100644 --- a/service/pixelated/bitmask_libraries/provider.py +++ b/service/pixelated/bitmask_libraries/provider.py @@ -17,7 +17,8 @@ import json from leap.common.certs import get_digest import requests -from .certs import which_bootstrap_bundle, which_bundle +from .certs import which_bootstrap_bundle, which_bundle, which_bootstrap_fingerprint +from pixelated.support.tls_adapter import EnforceTLSv1Adapter class LeapProvider(object): @@ -75,16 +76,10 @@ class LeapProvider(object): return cert def _fetch_certificate(self): - session = requests.session() - try: - cert_url = '%s/ca.crt' % self._provider_base_url() - response = session.get(cert_url, verify=which_bootstrap_bundle(self), timeout=self.config.timeout_in_s) - response.raise_for_status() - - cert_data = response.content - return cert_data - finally: - session.close() + cert_url = '%s/ca.crt' % self._provider_base_url() + response = self._validated_get(cert_url) + cert_data = response.content + return cert_data def validate_certificate(self, cert_data=None): if cert_data is None: @@ -99,11 +94,19 @@ class LeapProvider(object): if fingerprint.strip() != digest: raise Exception('Certificate fingerprints don\'t match') + def _validated_get(self, url): + session = requests.session() + try: + session.mount('https://', EnforceTLSv1Adapter(assert_fingerprint=which_bootstrap_fingerprint(self))) + response = session.get(url, verify=which_bootstrap_bundle(self), timeout=self.config.timeout_in_s) + response.raise_for_status() + return response + finally: + session.close() + def fetch_provider_json(self): url = '%s/provider.json' % self._provider_base_url() - response = requests.get(url, verify=which_bootstrap_bundle(self), timeout=self.config.timeout_in_s) - response.raise_for_status() - + response = self._validated_get(url) json_data = json.loads(response.content) return json_data diff --git a/service/pixelated/config/__init__.py b/service/pixelated/config/__init__.py index 2045354e..af264c77 100644 --- a/service/pixelated/config/__init__.py +++ b/service/pixelated/config/__init__.py @@ -37,6 +37,7 @@ import pixelated.support.ext_sqlcipher import pixelated.support.ext_esmtp_sender_factory import pixelated.support.ext_fetch import pixelated.support.ext_keymanager_fetch_key +import pixelated.support.ext_requests_urllib3 def initialize(): diff --git a/service/pixelated/config/args.py b/service/pixelated/config/args.py index 48f7b6df..d3284fab 100644 --- a/service/pixelated/config/args.py +++ b/service/pixelated/config/args.py @@ -30,6 +30,7 @@ def parse(): parser.add_argument('-sk', '--sslkey', metavar='<server.key>', default=None, help='use specified file as web server\'s SSL key (when using the user-agent together with the pixelated-dispatcher)') parser.add_argument('-sc', '--sslcert', metavar='<server.crt>', default=None, help='use specified file as web server\'s SSL certificate (when using the user-agent together with the pixelated-dispatcher)') parser.add_argument('-lc', '--leap-cert', metavar='<leap.crt>', default=None, help='use specified file for LEAP cert authority certificate (url https://<provider-domain>/ca.crt)') + parser.add_argument('--leap-cert-fingerprint', metavar='<leap certificate fingerprint>', default=None, help='use specified fingerprint to validate connection with leap provider', dest='leap_cert_fingerprint') parser.add_argument('--register', metavar=('provider', 'username'), nargs=2, help='register a new username on the desired provider') args = parser.parse_args() diff --git a/service/pixelated/config/leap_cert.py b/service/pixelated/config/leap_cert.py index 9e6dfc01..3172c953 100644 --- a/service/pixelated/config/leap_cert.py +++ b/service/pixelated/config/leap_cert.py @@ -18,4 +18,9 @@ import pixelated.bitmask_libraries.certs as certs def init_leap_cert(args): - certs.LEAP_CERT = args.leap_cert + if args.leap_cert_fingerprint is None: + certs.LEAP_CERT = args.leap_cert + certs.LEAP_FINGERPRINT = None + else: + certs.LEAP_FINGERPRINT = args.leap_cert_fingerprint + certs.LEAP_CERT = False diff --git a/service/pixelated/support/ext_requests_urllib3.py b/service/pixelated/support/ext_requests_urllib3.py new file mode 100644 index 00000000..a836d6fd --- /dev/null +++ b/service/pixelated/support/ext_requests_urllib3.py @@ -0,0 +1,79 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + +import requests +import requests.packages.urllib3.connectionpool +from socket import error as SocketError, timeout as SocketTimeout +from requests.packages.urllib3.packages.ssl_match_hostname import CertificateError, match_hostname +import socket +import ssl + +from requests.packages.urllib3.exceptions import ( + ClosedPoolError, + ConnectTimeoutError, + EmptyPoolError, + HostChangedError, + MaxRetryError, + SSLError, + ReadTimeoutError, + ProxyError, +) + +from requests.packages.urllib3.util import ( + assert_fingerprint, + get_host, + is_connection_dropped, + resolve_cert_reqs, + resolve_ssl_version, + ssl_wrap_socket, + Timeout, +) + + +def patched_connect(self): + # Add certificate verification + try: + sock = socket.create_connection(address=(self.host, self.port), timeout=self.timeout) + except SocketTimeout: + raise ConnectTimeoutError(self, "Connection to %s timed out. (connect timeout=%s)" % (self.host, self.timeout)) + + resolved_cert_reqs = resolve_cert_reqs(self.cert_reqs) + resolved_ssl_version = resolve_ssl_version(self.ssl_version) + + if self._tunnel_host: + self.sock = sock + # Calls self._set_hostport(), so self.host is + # self._tunnel_host below. + self._tunnel() + + # Wrap socket using verification with the root certs in + # trusted_root_certs + self.sock = ssl_wrap_socket(sock, self.key_file, self.cert_file, + cert_reqs=resolved_cert_reqs, + ca_certs=self.ca_certs, + server_hostname=self.host, + ssl_version=resolved_ssl_version) + + if self.assert_fingerprint: + assert_fingerprint(self.sock.getpeercert(binary_form=True), + self.assert_fingerprint) + elif resolved_cert_reqs != ssl.CERT_NONE and self.assert_hostname is not False: + match_hostname(self.sock.getpeercert(), + self.assert_hostname or self.host) + + +if requests.__version__ == '2.0.0': + requests.packages.urllib3.connectionpool.VerifiedHTTPSConnection.connect = patched_connect diff --git a/service/pixelated/support/tls_adapter.py b/service/pixelated/support/tls_adapter.py new file mode 100644 index 00000000..f543bf4d --- /dev/null +++ b/service/pixelated/support/tls_adapter.py @@ -0,0 +1,47 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + +import ssl +from requests.adapters import HTTPAdapter +try: + from urllib3.poolmanager import PoolManager +except: + from requests.packages.urllib3.poolmanager import PoolManager + +VERIFY_HOSTNAME = None + + +def latest_available_ssl_version(): + try: + return ssl.PROTOCOL_TLSv1_2 + except AttributeError: + return ssl.PROTOCOL_TLSv1 + + +class EnforceTLSv1Adapter(HTTPAdapter): + __slots__ = ('_assert_hostname', '_assert_fingerprint') + + def __init__(self, assert_hostname=VERIFY_HOSTNAME, assert_fingerprint=None): + self._assert_hostname = assert_hostname + self._assert_fingerprint = assert_fingerprint + super(EnforceTLSv1Adapter, self).__init__() + + def init_poolmanager(self, connections, maxsize, block=False): + self.poolmanager = PoolManager(num_pools=connections, maxsize=maxsize, + block=block, ssl_version=latest_available_ssl_version(), + assert_hostname=self._assert_hostname, + assert_fingerprint=self._assert_fingerprint, + cert_reqs=ssl.CERT_REQUIRED) diff --git a/service/test/unit/bitmask_libraries/test_provider.py b/service/test/unit/bitmask_libraries/test_provider.py index 8c0cf97e..a1e69543 100644 --- a/service/test/unit/bitmask_libraries/test_provider.py +++ b/service/test/unit/bitmask_libraries/test_provider.py @@ -15,7 +15,7 @@ # along with Pixelated. If not, see <http://www.gnu.org/licenses/>. import json -from mock import patch, MagicMock +from mock import patch, MagicMock, ANY from httmock import all_requests, HTTMock, urlmatch from requests import HTTPError from pixelated.bitmask_libraries.config import LeapConfig @@ -202,8 +202,8 @@ class LeapProviderTest(AbstractLeapTest): provider = LeapProvider('some-provider.test', self.config) provider.fetch_valid_certificate() - get_func.assert_called_once_with('https://some-provider.test/provider.json', verify=BOOTSTRAP_CA_CERT, timeout=15) - session.get.assert_called_once_with('https://some-provider.test/ca.crt', verify=BOOTSTRAP_CA_CERT, timeout=15) + session.get.assert_any_call('https://some-provider.test/ca.crt', verify=BOOTSTRAP_CA_CERT, timeout=15) + session.get.assert_any_call('https://some-provider.test/provider.json', verify=BOOTSTRAP_CA_CERT, timeout=15) def test_that_provider_cert_is_used_to_fetch_soledad_json(self): get_func = MagicMock(wraps=requests.get) @@ -214,3 +214,17 @@ class LeapProviderTest(AbstractLeapTest): provider.fetch_soledad_json() get_func.assert_called_with('https://api.some-provider.test:4430/1/config/soledad-service.json', verify=CA_CERT, timeout=15) + + def test_that_leap_fingerprint_is_validated(self): + session = MagicMock(wraps=requests.session()) + session_func = MagicMock(return_value=session) + + with patch('pixelated.bitmask_libraries.provider.which_bootstrap_fingerprint', return_value='some fingerprint'): + with patch('pixelated.bitmask_libraries.provider.which_bootstrap_bundle', return_value=False): + with patch('pixelated.bitmask_libraries.provider.requests.session', new=session_func): + with HTTMock(provider_json_mock, ca_cert_mock, not_found_mock): + provider = LeapProvider('some-provider.test', self.config) + provider.fetch_valid_certificate() + + session.get.assert_any_call('https://some-provider.test/ca.crt', verify=False, timeout=15) + session.mount.assert_called_with('https://', ANY) |