summaryrefslogtreecommitdiff
path: root/soledad/backends/leap_backend.py
diff options
context:
space:
mode:
Diffstat (limited to 'soledad/backends/leap_backend.py')
-rw-r--r--soledad/backends/leap_backend.py224
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']