diff options
| -rw-r--r-- | client/changes/feature_add-pool-of-http-https-connections | 2 | ||||
| -rw-r--r-- | client/src/leap/soledad/client/api.py | 4 | ||||
| -rw-r--r-- | client/src/leap/soledad/client/http_client.py | 194 | ||||
| -rw-r--r-- | client/src/leap/soledad/client/http_target.py | 53 | ||||
| -rw-r--r-- | client/src/leap/soledad/client/sqlcipher.py | 13 | 
5 files changed, 232 insertions, 34 deletions
| diff --git a/client/changes/feature_add-pool-of-http-https-connections b/client/changes/feature_add-pool-of-http-https-connections new file mode 100644 index 00000000..7ff2a4ee --- /dev/null +++ b/client/changes/feature_add-pool-of-http-https-connections @@ -0,0 +1,2 @@ +  o Add a pool of HTTP/HTTPS connections that is able to verify the server +    certificate against a given CA certificate. diff --git a/client/src/leap/soledad/client/api.py b/client/src/leap/soledad/client/api.py index ffd95f6c..91e0a4a0 100644 --- a/client/src/leap/soledad/client/api.py +++ b/client/src/leap/soledad/client/api.py @@ -272,7 +272,8 @@ class Soledad(object):          replica_uid = self._dbpool.replica_uid          self._dbsyncer = SQLCipherU1DBSync(              self._sqlcipher_opts, self._crypto, replica_uid, -            self._defer_encryption) +            SOLEDAD_CERT, +            defer_encryption=self._defer_encryption)      #      # Closing methods @@ -630,6 +631,7 @@ class Soledad(object):              Whether to defer decryption of documents, or do it inline while              syncing.          :type defer_decryption: bool +          :return: A deferred whose callback will be invoked with the local              generation before the synchronization was performed.          :rtype: twisted.internet.defer.Deferred diff --git a/client/src/leap/soledad/client/http_client.py b/client/src/leap/soledad/client/http_client.py new file mode 100644 index 00000000..b08d199e --- /dev/null +++ b/client/src/leap/soledad/client/http_client.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +# http_client.py +# Copyright (C) 2015 LEAP +# +# 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 <http://www.gnu.org/licenses/>. + + +""" +Twisted HTTP/HTTPS client. +""" + +import os + +from zope.interface import implements + +from OpenSSL.crypto import load_certificate +from OpenSSL.crypto import FILETYPE_PEM + +from twisted.internet import reactor +from twisted.internet.ssl import ClientContextFactory +from twisted.internet.ssl import CertificateOptions +from twisted.internet.defer import succeed + +from twisted.web.client import Agent +from twisted.web.client import HTTPConnectionPool +from twisted.web.client import readBody +from twisted.web.http_headers import Headers +from twisted.web.error import Error +from twisted.web.iweb import IBodyProducer + + +from leap.soledad.common.errors import InvalidAuthTokenError + + +# +# Setup a pool of connections +# + +_pool = HTTPConnectionPool(reactor, persistent=True) +_pool.maxPersistentPerHost = 10 +_agent = None + +# if we ever want to trust the system's CAs, we should use an agent like this: +# from twisted.web.client import BrowserLikePolicyForHTTPS +# _agent = Agent(reactor, BrowserLikePolicyForHTTPS(), pool=_pool) + + +# +# SSL/TLS certificate configuration +# + +def configure_certificate(cert_file): +    """ +    Configure an agent that verifies server certificates against a CA cert +    file. + +    :param cert_file: The path to the certificate file. +    :type cert_file: str +    """ +    global _agent +    cert = _load_cert(cert_file) +    _agent = Agent( +        reactor, +        SoledadClientContextFactory(cert), +        pool=_pool) + + +def _load_cert(cert_file): +    """ +    Load a X509 certificate from a file. + +    :param cert_file: The path to the certificate file. +    :type cert_file: str + +    :return: The X509 certificate. +    :rtype: OpenSSL.crypto.X509 +    """ +    if os.path.exists(cert_file): +        with open(cert_file) as f: +            data = f.read() +            return load_certificate(FILETYPE_PEM, data) + + +class SoledadClientContextFactory(ClientContextFactory): +    """ +    A context factory that will verify the server's certificate against a +    given CA certificate. +    """ + +    def __init__(self, cacert): +        """ +        Initialize the context factory. + +        :param cacert: The CA certificate. +        :type cacert: OpenSSL.crypto.X509 +        """ +        self._cacert = cacert + +    def getContext(self, hostname, port): +        opts = CertificateOptions(verify=True, caCerts=[self._cacert]) +        return opts.getContext() + + +# +# HTTP request facilities +# + +def _unauth_to_invalid_token_error(failure): +    """ +    An errback to translate unauthorized errors to our own invalid token +    class. + +    :param failure: The original failure. +    :type failure: twisted.python.failure.Failure + +    :return: Either the original failure or an invalid auth token error. +    :rtype: twisted.python.failure.Failure +    """ +    failure.trap(Error) +    if failure.getErrorMessage() == "401 Unauthorized": +        raise InvalidAuthTokenError +    return failure + + +class StringBodyProducer(object): +    """ +    A producer that writes the body of a request to a consumer. +    """ + +    implements(IBodyProducer) + +    def __init__(self, body): +        """ +        Initialize the string produer. + +        :param body: The body of the request. +        :type body: str +        """ +        self.body = body +        self.length = len(body) + +    def startProducing(self, consumer): +        """ +        Write the body to the consumer. + +        :param consumer: Any IConsumer provider. +        :type consumer: twisted.internet.interfaces.IConsumer + +        :return: A successful deferred. +        :rtype: twisted.internet.defer.Deferred +        """ +        consumer.write(self.body) +        return succeed(None) + +    def pauseProducing(self): +        pass + +    def stopProducing(self): +        pass + + +def httpRequest(url, method='GET', body=None, headers={}): +    """ +    Perform an HTTP request. + +    :param url: The URL for the request. +    :type url: str +    :param method: The HTTP method of the request. +    :type method: str +    :param body: The body of the request, if any. +    :type body: str +    :param headers: The headers of the request. +    :type headers: dict + +    :return: A deferred that fires with the body of the request. +    :rtype: twisted.internet.defer.Deferred +    """ +    if body: +        body = StringBodyProducer(body) +    d = _agent.request( +        method, url, headers=Headers(headers), bodyProducer=body) +    d.addCallbacks(readBody, _unauth_to_invalid_token_error) +    return d diff --git a/client/src/leap/soledad/client/http_target.py b/client/src/leap/soledad/client/http_target.py index 75af9cf7..dc6c0e0a 100644 --- a/client/src/leap/soledad/client/http_target.py +++ b/client/src/leap/soledad/client/http_target.py @@ -1,5 +1,5 @@  # -*- coding: utf-8 -*- -# target.py +# http_target.py  # Copyright (C) 2015 LEAP  #  # This program is free software: you can redistribute it and/or modify @@ -21,6 +21,7 @@ A U1DB backend for encrypting data before sending to server and decrypting  after receiving.  """ +  import json  import base64  import logging @@ -30,15 +31,12 @@ from functools import partial  from twisted.internet import defer  from twisted.internet import reactor -from twisted.web.client import getPage -from twisted.web.error import Error  from u1db import errors  from u1db import SyncTarget  from u1db.remote import utils  from leap.soledad.common.document import SoledadDocument -from leap.soledad.common.errors import InvalidAuthTokenError  from leap.soledad.client.crypto import is_symmetrically_encrypted  from leap.soledad.client.crypto import encrypt_doc @@ -47,24 +45,13 @@ from leap.soledad.client.events import SOLEDAD_SYNC_SEND_STATUS  from leap.soledad.client.events import SOLEDAD_SYNC_RECEIVE_STATUS  from leap.soledad.client.events import signal  from leap.soledad.client.encdecpool import SyncDecrypterPool +from leap.soledad.client.http_client import httpRequest +from leap.soledad.client.http_client import configure_certificate  logger = logging.getLogger(__name__) -def _unauth_to_invalid_token_error(failure): -    failure.trap(Error) -    if failure.getErrorMessage() == "401 Unauthorized": -        raise InvalidAuthTokenError -    return failure - - -def getSoledadPage(*args, **kwargs): -    d = getPage(*args, **kwargs) -    d.addErrback(_unauth_to_invalid_token_error) -    return d - -  class SoledadHTTPSyncTarget(SyncTarget):      """      A SyncTarget that encrypts data before sending and decrypts data after @@ -76,7 +63,7 @@ class SoledadHTTPSyncTarget(SyncTarget):      written to the main database.      """ -    def __init__(self, url, source_replica_uid, creds, crypto, +    def __init__(self, url, source_replica_uid, creds, crypto, cert_file,                   sync_db=None, sync_enc_pool=None):          """          Initialize the sync target. @@ -93,12 +80,19 @@ class SoledadHTTPSyncTarget(SyncTarget):          :param crypto: An instance of SoledadCrypto so we can encrypt/decrypt                          document contents when syncing.          :type crypto: soledad.crypto.SoledadCrypto +        :param cert_file: Path to the certificate of the ca used to validate +                          the SSL certificate used by the remote soledad +                          server. +        :type cert_file: str          :param sync_db: Optional. handler for the db with the symmetric                          encryption of the syncing documents. If                          None, encryption will be done in-place,                          instead of retreiving it from the dedicated                          database.          :type sync_db: Sqlite handler +        :param verify_ssl: Whether we should perform SSL server certificate +                           verification. +        :type verify_ssl: bool          """          if url.endswith("/"):              url = url[:-1] @@ -113,6 +107,7 @@ class SoledadHTTPSyncTarget(SyncTarget):          # asynchronous encryption/decryption attributes          self._decryption_callback = None          self._sync_decr_pool = None +        configure_certificate(cert_file)      def set_creds(self, creds):          """ @@ -125,7 +120,7 @@ class SoledadHTTPSyncTarget(SyncTarget):          token = creds['token']['token']          auth = '%s:%s' % (uuid, token)          b64_token = base64.b64encode(auth) -        self._auth_header = {'Authorization': 'Token %s' % b64_token} +        self._auth_header = {'Authorization': ['Token %s' % b64_token]}      @property      def _defer_encryption(self): @@ -153,7 +148,7 @@ class SoledadHTTPSyncTarget(SyncTarget):                   source_replica_last_known_transaction_id)          :rtype: twisted.internet.defer.Deferred          """ -        raw = yield getSoledadPage(self._url, headers=self._auth_header) +        raw = yield httpRequest(self._url, headers=self._auth_header)          res = json.loads(raw)          defer.returnValue([              res['target_replica_uid'], @@ -197,12 +192,12 @@ class SoledadHTTPSyncTarget(SyncTarget):              'transaction_id': source_replica_transaction_id          })          headers = self._auth_header.copy() -        headers.update({'content-type': 'application/json'}) -        return getSoledadPage( +        headers.update({'content-type': ['application/json']}) +        return httpRequest(              self._url,              method='PUT',              headers=headers, -            postdata=data) +            body=data)      @defer.inlineCallbacks      def sync_exchange(self, docs_by_generation, source_replica_uid, @@ -295,7 +290,7 @@ class SoledadHTTPSyncTarget(SyncTarget):              defer.returnValue([None, None])          headers = self._auth_header.copy() -        headers.update({'content-type': 'application/x-soledad-sync-put'}) +        headers.update({'content-type': ['application/x-soledad-sync-put']})          # add remote replica metadata to the request          first_entries = ['[']          self._prepare( @@ -335,11 +330,11 @@ class SoledadHTTPSyncTarget(SyncTarget):              doc_idx=doc_idx)          entries.append('\r\n]')          data = ''.join(entries) -        result = yield getSoledadPage( +        result = yield httpRequest(              self._url,              method='POST',              headers=headers, -            postdata=data) +            body=data)          defer.returnValue(result)      def _encrypt_doc(self, doc): @@ -385,7 +380,7 @@ class SoledadHTTPSyncTarget(SyncTarget):              self._setup_sync_decr_pool()          headers = self._auth_header.copy() -        headers.update({'content-type': 'application/x-soledad-sync-get'}) +        headers.update({'content-type': ['application/x-soledad-sync-get']})          #---------------------------------------------------------------------          # maybe receive the first document @@ -486,11 +481,11 @@ class SoledadHTTPSyncTarget(SyncTarget):              ',', entries, received=received)          entries.append('\r\n]')          # send headers -        return getSoledadPage( +        return httpRequest(              self._url,              method='POST',              headers=headers, -            postdata=''.join(entries)) +            body=''.join(entries))      def _insert_received_doc(self, idx, total, response):          """ diff --git a/client/src/leap/soledad/client/sqlcipher.py b/client/src/leap/soledad/client/sqlcipher.py index 96732325..ed9e95dc 100644 --- a/client/src/leap/soledad/client/sqlcipher.py +++ b/client/src/leap/soledad/client/sqlcipher.py @@ -434,13 +434,14 @@ class SQLCipherU1DBSync(SQLCipherDatabase):      """      syncing_lock = defaultdict(threading.Lock) -    def __init__(self, opts, soledad_crypto, replica_uid, +    def __init__(self, opts, soledad_crypto, replica_uid, cert_file,                   defer_encryption=False):          self._opts = opts          self._path = opts.path          self._crypto = soledad_crypto          self.__replica_uid = replica_uid +        self._cert_file = cert_file          self._sync_db_key = opts.sync_db_key          self._sync_db = None @@ -570,9 +571,8 @@ class SQLCipherU1DBSync(SQLCipherDatabase):          :param url: The url of the target replica to sync with.          :type url: str -        :param creds: -            optional dictionary giving credentials. -            to authorize the operation with the server. +        :param creds: optional dictionary giving credentials to authorize the +                      operation with the server.          :type creds: dict          :param defer_decryption:              Whether to defer the decryption process using the intermediate @@ -599,6 +599,10 @@ class SQLCipherU1DBSync(SQLCipherDatabase):          one instance synchronizing the same database replica at the same time.          Because of that, this method blocks until the syncing lock can be          acquired. + +        :param creds: optional dictionary giving credentials to authorize the +                      operation with the server. +        :type creds: dict          """          with self.syncing_lock[self._path]:              syncer = self._get_syncer(url, creds=creds) @@ -640,6 +644,7 @@ class SQLCipherU1DBSync(SQLCipherDatabase):                      self._replica_uid,                      creds=creds,                      crypto=self._crypto, +                    cert_file=self._cert_file,                      sync_db=self._sync_db,                      sync_enc_pool=self._sync_enc_pool))              self._syncers[url] = (h, syncer) | 
