diff options
Diffstat (limited to 'soledad/backends/leap_backend.py')
| -rw-r--r-- | soledad/backends/leap_backend.py | 224 | 
1 files changed, 224 insertions, 0 deletions
| diff --git a/soledad/backends/leap_backend.py b/soledad/backends/leap_backend.py new file mode 100644 index 00000000..a37f9d25 --- /dev/null +++ b/soledad/backends/leap_backend.py @@ -0,0 +1,224 @@ +""" +A U1DB backend that encrypts data before sending to server and decrypts after +receiving. +""" + +try: +    import simplejson as json +except ImportError: +    import json  # noqa + +from u1db import Document +from u1db.remote import utils +from u1db.remote.http_target import HTTPSyncTarget +from u1db.remote.http_database import HTTPDatabase +from u1db.errors import BrokenSyncStream + +import uuid + + +class NoDefaultKey(Exception): +    """ +    Exception to signal that there's no default OpenPGP key configured. +    """ +    pass + + +class NoSoledadInstance(Exception): +    """ +    Exception to signal that no Soledad instance was found. +    """ +    pass + + +class DocumentNotEncrypted(Exception): +    """ +    Exception to signal failures in document encryption. +    """ +    pass + + +class LeapDocument(Document): +    """ +    Encryptable and syncable document. + +    LEAP Documents are standard u1db documents with cabability of returning an +    encrypted version of the document json string as well as setting document +    content based on an encrypted version of json string. +    """ + +    def __init__(self, doc_id=None, rev=None, json='{}', has_conflicts=False, +                 encrypted_json=None, soledad=None, syncable=True): +        super(LeapDocument, self).__init__(doc_id, rev, json, has_conflicts) +        self._soledad = soledad +        self._syncable = syncable +        if encrypted_json: +            self.set_encrypted_json(encrypted_json) + +    def get_encrypted_content(self): +        """ +        Return an encrypted JSON serialization of document's contents. +        """ +        if not self._soledad: +            raise NoSoledadInstance() +        return self._soledad.encrypt_symmetric(self.doc_id, +                                               self.get_json()) + +    def set_encrypted_content(self, cyphertext): +        """ +        Set document's content based on an encrypted JSON serialization of +        contents. +        """ +        plaintext = self._soledad.decrypt_symmetric(self.doc_id, cyphertext) +        return self.set_json(plaintext) + +    def get_encrypted_json(self): +        """ +        Return a valid JSON string containing document's content encrypted to +        the user's public key. +        """ +        return json.dumps({'_encrypted_json': self.get_encrypted_content()}) + +    def set_encrypted_json(self, encrypted_json): +        """ +        Set document's content based on a valid JSON string containing the +        encrypted document's contents. +        """ +        if not self._soledad: +            raise NoSoledadInstance() +        cyphertext = json.loads(encrypted_json)['_encrypted_json'] +        self.set_encrypted_content(cyphertext) + +    def _get_syncable(self): +        return self._syncable + +    def _set_syncable(self, syncable=True): +        self._syncable = syncable + +    syncable = property( +        _get_syncable, +        _set_syncable, +        doc="Determine if document should be synced with server." +    ) + +    # Returning the revision as string solves the following exception in +    # Twisted web: +    #     exceptions.TypeError: Can only pass-through bytes on Python 2 +    def _get_rev(self): +        if self._rev is None: +            return None +        return str(self._rev) + +    def _set_rev(self, rev): +        self._rev = rev + +    rev = property( +        _get_rev, +        _set_rev, +        doc="Wrapper to ensure `doc.rev` is always returned as bytes.") + + +class LeapSyncTarget(HTTPSyncTarget): +    """ +    A SyncTarget that encrypts data before sending and decrypts data after +    receiving. +    """ + +    def __init__(self, url, creds=None, soledad=None): +        super(LeapSyncTarget, self).__init__(url, creds) +        self._soledad = soledad + +    def _parse_sync_stream(self, data, return_doc_cb, ensure_callback=None): +        """ +        Does the same as parent's method but ensures incoming content will be +        decrypted. +        """ +        parts = data.splitlines()  # one at a time +        if not parts or parts[0] != '[': +            raise BrokenSyncStream +        data = parts[1:-1] +        comma = False +        if data: +            line, comma = utils.check_and_strip_comma(data[0]) +            res = json.loads(line) +            if ensure_callback and 'replica_uid' in res: +                ensure_callback(res['replica_uid']) +            for entry in data[1:]: +                if not comma:  # missing in between comma +                    raise BrokenSyncStream +                line, comma = utils.check_and_strip_comma(entry) +                entry = json.loads(line) +                # decrypt after receiving from server. +                if not self._soledad: +                    raise NoSoledadInstance() +                enc_json = json.loads(entry['content'])['_encrypted_json'] +                if not self._soledad.is_encrypted_sym(enc_json): +                    raise DocumentNotEncrypted( +                        "Incoming document from sync is not encrypted.") +                doc = LeapDocument(entry['id'], entry['rev'], +                                   encrypted_json=entry['content'], +                                   soledad=self._soledad) +                return_doc_cb(doc, entry['gen'], entry['trans_id']) +        if parts[-1] != ']': +            try: +                partdic = json.loads(parts[-1]) +            except ValueError: +                pass +            else: +                if isinstance(partdic, dict): +                    self._error(partdic) +            raise BrokenSyncStream +        if not data or comma:  # no entries or bad extra comma +            raise BrokenSyncStream +        return res + +    def sync_exchange(self, docs_by_generations, source_replica_uid, +                      last_known_generation, last_known_trans_id, +                      return_doc_cb, ensure_callback=None): +        """ +        Does the same as parent's method but encrypts content before syncing. +        """ +        self._ensure_connection() +        if self._trace_hook:  # for tests +            self._trace_hook('sync_exchange') +        url = '%s/sync-from/%s' % (self._url.path, source_replica_uid) +        self._conn.putrequest('POST', url) +        self._conn.putheader('content-type', 'application/x-u1db-sync-stream') +        for header_name, header_value in self._sign_request('POST', url, {}): +            self._conn.putheader(header_name, header_value) +        entries = ['['] +        size = 1 + +        def prepare(**dic): +            entry = comma + '\r\n' + json.dumps(dic) +            entries.append(entry) +            return len(entry) + +        comma = '' +        size += prepare( +            last_known_generation=last_known_generation, +            last_known_trans_id=last_known_trans_id, +            ensure=ensure_callback is not None) +        comma = ',' +        for doc, gen, trans_id in docs_by_generations: +            if doc.syncable: +                # encrypt and verify before sending to server. +                enc_json = json.loads( +                    doc.get_encrypted_json())['_encrypted_json'] +                if not self._soledad.is_encrypted_sym(enc_json): +                    raise DocumentNotEncrypted( +                        "Could not encrypt document before sync.") +                size += prepare(id=doc.doc_id, rev=doc.rev, +                                content=doc.get_encrypted_json(), +                                gen=gen, trans_id=trans_id) +        entries.append('\r\n]') +        size += len(entries[-1]) +        self._conn.putheader('content-length', str(size)) +        self._conn.endheaders() +        for entry in entries: +            self._conn.send(entry) +        entries = None +        data, _ = self._response() +        res = self._parse_sync_stream(data, return_doc_cb, ensure_callback) +        data = None +        return res['new_generation'], res['new_transaction_id'] | 
