summaryrefslogtreecommitdiff
path: root/src/leap/soledad/backends/sqlcipher.py
blob: ab74bab1e1d260dcd2ab627b2e4a805d244e3901 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
"""A U1DB backend that uses SQLCipher as its persistence layer."""

import os
from pysqlcipher import dbapi2
import time

# TODO: uncomment imports below after solving circular dependency issue
# between leap_client and soledad.
#from leap import util
from u1db.backends import sqlite_backend
#util.logger.debug(
#    "Monkey-patching u1db.backends.sqlite_backend with pysqlcipher.dbapi2..."
#)
sqlite_backend.dbapi2 = dbapi2

from u1db import (
    errors,
)

from leap.soledad.backends.leap_backend import LeapDocument


def open(path, password, create=True, document_factory=None, soledad=None):
    """Open a database at the given location.

    Will raise u1db.errors.DatabaseDoesNotExist if create=False and the
    database does not already exist.

    :param path: The filesystem path for the database to open.
    :param create: True/False, should the database be created if it doesn't
        already exist?
    :param document_factory: A function that will be called with the same
        parameters as Document.__init__.
    :return: An instance of Database.
    """
    return SQLCipherDatabase.open_database(
        path, password, create=create, document_factory=document_factory,
        soledad=soledad)


class DatabaseIsNotEncrypted(Exception):
    """
    Exception raised when trying to open non-encrypted databases.
    """
    pass


class SQLCipherDatabase(sqlite_backend.SQLitePartialExpandDatabase):
    """A U1DB implementation that uses SQLCipher as its persistence layer."""

    _index_storage_value = 'expand referenced encrypted'

    @classmethod
    def set_pragma_key(cls, db_handle, key):
        db_handle.cursor().execute("PRAGMA key = '%s'" % key)

    def __init__(self, sqlite_file, password, document_factory=None,
                 soledad=None):
        """Create a new sqlcipher file."""
        self._check_if_db_is_encrypted(sqlite_file)
        self._db_handle = dbapi2.connect(sqlite_file)
        SQLCipherDatabase.set_pragma_key(self._db_handle, password)
        self._real_replica_uid = None
        self._ensure_schema()
        self._soledad = soledad

        def factory(doc_id=None, rev=None, json='{}', has_conflicts=False,
                    encrypted_json=None, syncable=True):
            return LeapDocument(doc_id=doc_id, rev=rev, json=json,
                                has_conflicts=has_conflicts,
                                encrypted_json=encrypted_json,
                                syncable=syncable, soledad=self._soledad)
        self.set_document_factory(factory)

    def _check_if_db_is_encrypted(self, sqlite_file):
        if not os.path.exists(sqlite_file):
            return
        else:
            try:
                # try to open an encrypted database with the regular u1db
                # backend should raise a DatabaseError exception.
                sqlite_backend.SQLitePartialExpandDatabase(sqlite_file)
                raise DatabaseIsNotEncrypted()
            except dbapi2.DatabaseError:
                pass

    @classmethod
    def _open_database(cls, sqlite_file, password, document_factory=None,
                       soledad=None):
        if not os.path.isfile(sqlite_file):
            raise errors.DatabaseDoesNotExist()
        tries = 2
        while True:
            # Note: There seems to be a bug in sqlite 3.5.9 (with python2.6)
            #       where without re-opening the database on Windows, it
            #       doesn't see the transaction that was just committed
            db_handle = dbapi2.connect(sqlite_file)
            SQLCipherDatabase.set_pragma_key(db_handle, password)
            c = db_handle.cursor()
            v, err = cls._which_index_storage(c)
            db_handle.close()
            if v is not None:
                break
            # possibly another process is initializing it, wait for it to be
            # done
            if tries == 0:
                raise err  # go for the richest error?
            tries -= 1
            time.sleep(cls.WAIT_FOR_PARALLEL_INIT_HALF_INTERVAL)
        return SQLCipherDatabase._sqlite_registry[v](
            sqlite_file, password, document_factory=document_factory,
            soledad=soledad)

    @classmethod
    def open_database(cls, sqlite_file, password, create, backend_cls=None,
                      document_factory=None, soledad=None):
        """Open U1DB database using SQLCipher as backend."""
        try:
            return cls._open_database(sqlite_file, password,
                                      document_factory=document_factory,
                                      soledad=soledad)
        except errors.DatabaseDoesNotExist:
            if not create:
                raise
            if backend_cls is None:
                # default is SQLCipherPartialExpandDatabase
                backend_cls = SQLCipherDatabase
            return backend_cls(sqlite_file, password,
                               document_factory=document_factory,
                               soledad=soledad)

    def sync(self, url, creds=None, autocreate=True):
        """
        Synchronize encrypted documents with remote replica exposed at url.
        """
        from u1db.sync import Synchronizer
        from leap.soledad.backends.leap_backend import LeapSyncTarget
        return Synchronizer(
            self,
            LeapSyncTarget(url,
                           creds=creds,
                           soledad=self._soledad)).sync(autocreate=autocreate)

    def _extra_schema_init(self, c):
        c.execute(
            'ALTER TABLE document '
            'ADD COLUMN syncable BOOL NOT NULL DEFAULT TRUE')

    def _put_and_update_indexes(self, old_doc, doc):
        super(SQLCipherDatabase, self)._put_and_update_indexes(old_doc, doc)
        c = self._db_handle.cursor()
        c.execute('UPDATE document SET syncable=? WHERE doc_id=?',
                  (doc.syncable, doc.doc_id))

    def _get_doc(self, doc_id, check_for_conflicts=False):
        doc = super(SQLCipherDatabase, self)._get_doc(doc_id,
                                                      check_for_conflicts)
        if doc:
            c = self._db_handle.cursor()
            c.execute('SELECT syncable FROM document WHERE doc_id=?',
                      (doc.doc_id,))
            doc.syncable = bool(c.fetchone()[0])
        return doc

sqlite_backend.SQLiteDatabase.register_implementation(SQLCipherDatabase)