Repository: rotki/rotki

Issue:
Make rotkehlchen compatible with sqlcipher 4
## Problem Definition

So far rotkehlchen was supporting sqlcipher 3. Recently sqlcipher 4.0 was [released](https://www.zetetic.net/blog/2018/11/30/sqlcipher-400-release) and is unfortunately incompatible by default.

## Task

Make rotkehlchen compatible with sqlcipher 4. 

 - [ ] If system sqlcipher version is 4 then follow the [compatibility guide](https://www.zetetic.net/blog/2018/11/30/sqlcipher-400-release/#compatibility) to migrate the database.
- [ ] Add a test for the above


Discussion:

Resolving patch:
diff --git a/rotkehlchen/db/dbhandler.py b/rotkehlchen/db/dbhandler.py
--- a/rotkehlchen/db/dbhandler.py
+++ b/rotkehlchen/db/dbhandler.py
@@ -53,6 +53,19 @@ def str_to_bool(s):
     return True if s == 'True' else False
 
 
+def detect_sqlcipher_version() -> int:
+    conn = sqlcipher.connect(':memory:')  # pylint: disable=no-member
+    query = conn.execute('PRAGMA cipher_version;')
+    version = query.fetchall()[0][0]
+    # TODO: proper version comparison please -- don't be lazy
+    if '4.0.0' in version:
+        sqlcipher_version = 4
+    else:
+        sqlcipher_version = 3
+    conn.close()
+    return sqlcipher_version
+
+
 ROTKEHLCHEN_DB_VERSION = 2
 
 
@@ -62,14 +75,29 @@ class DBHandler(object):
 
     def __init__(self, user_data_dir: typing.FilePath, username: str, password: str):
         self.user_data_dir = user_data_dir
+        self.sqlcipher_version = detect_sqlcipher_version()
         self.connect(password)
         try:
             self.conn.executescript(DB_SCRIPT_CREATE_TABLES)
         except sqlcipher.DatabaseError as e:  # pylint: disable=no-member
+            migrated = False
             errstr = str(e)
-            if errstr == 'file is not a database':
-                errstr = 'Wrong password while decrypting the database or not a database'
-            raise AuthenticationError(errstr)
+            if self.sqlcipher_version == 4:
+                migrated = True
+                # if we are at version 4 perhaps we are just upgrading from an
+                # sqlcipher3 database
+                script = f'PRAGMA KEY="{password}";PRAGMA cipher_migrate;COMMIT;'
+                try:
+                    self.conn.executescript(script)
+                    self.conn.executescript(DB_SCRIPT_CREATE_TABLES)
+                except sqlcipher.DatabaseError as e:  # pylint: disable=no-member
+                    errstr = str(e)
+                    migrated = False
+
+            if self.sqlcipher_version != 4 or not migrated:
+                if errstr == 'file is not a database':
+                    errstr = 'Wrong password while decrypting the database or not a database'
+                raise AuthenticationError(errstr)
 
         self.run_updates()
         cursor = self.conn.cursor()
@@ -113,7 +141,10 @@ def connect(self, password: str) -> None:
             os.path.join(self.user_data_dir, 'rotkehlchen.db'),
         )
         self.conn.text_factory = str
-        self.conn.executescript('PRAGMA key="{}"; PRAGMA kdf_iter={};'.format(password, KDF_ITER))
+        self.conn.executescript('PRAGMA key="{}";'.format(password))
+        if self.sqlcipher_version == 3:
+            script = f'PRAGMA key="{password}"; PRAGMA kdf_iter={KDF_ITER};'
+            self.conn.executescript(script)
         self.conn.execute('PRAGMA foreign_keys=ON')
 
     def disconnect(self):
@@ -150,12 +181,11 @@ def import_unencrypted(self, unencrypted_db_data: bytes, password: str) -> None:
 
             # Now attach to the unencrypted DB and copy it to our DB and encrypt it
             self.conn = sqlcipher.connect(tempdbpath)  # pylint: disable=no-member
-            self.conn.executescript(
-                'ATTACH DATABASE "{}" AS encrypted KEY "{}";'
-                'PRAGMA encrypted.kdf_iter={};'
-                'SELECT sqlcipher_export("encrypted");'
-                'DETACH DATABASE encrypted;'.format(rdbpath, password, KDF_ITER)
-            )
+            script = f'ATTACH DATABASE "{rdbpath}" AS encrypted KEY "{password}";'
+            if self.sqlcipher_version == 3:
+                script += f'PRAGMA encrypted.kdf_iter={KDF_ITER};'
+            script += 'SELECT sqlcipher_export("encrypted");DETACH DATABASE encrypted;'
+            self.conn.executescript(script)
             self.disconnect()
 
         self.connect(password)


Test patch:
diff --git a/rotkehlchen/tests/data/sqlcipher_v3_rotkehlchen.db b/rotkehlchen/tests/data/sqlcipher_v3_rotkehlchen.db
new file mode 100644
Binary files /dev/null and b/rotkehlchen/tests/data/sqlcipher_v3_rotkehlchen.db differ
diff --git a/rotkehlchen/tests/test_db.py b/rotkehlchen/tests/test_db.py
--- a/rotkehlchen/tests/test_db.py
+++ b/rotkehlchen/tests/test_db.py
@@ -1,5 +1,6 @@
 import os
 import time
+from shutil import copyfile
 
 import pytest
 from eth_utils.address import to_checksum_address
@@ -10,14 +11,16 @@
 from rotkehlchen.db.dbhandler import (
     DEFAULT_ANONYMIZED_LOGS,
     DEFAULT_BALANCE_SAVE_FREQUENCY,
+    DEFAULT_INCLUDE_CRYPTO2CRYPTO,
+    DEFAULT_INCLUDE_GAS_COSTS,
     DEFAULT_MAIN_CURRENCY,
     DEFAULT_START_DATE,
     DEFAULT_UI_FLOATING_PRECISION,
-    DEFAULT_INCLUDE_CRYPTO2CRYPTO,
-    DEFAULT_INCLUDE_GAS_COSTS,
     ROTKEHLCHEN_DB_VERSION,
     BlockchainAccounts,
+    DBHandler,
     LocationData,
+    detect_sqlcipher_version,
 )
 from rotkehlchen.errors import AuthenticationError, InputError
 from rotkehlchen.utils import createTimeStamp, ts_now
@@ -339,3 +342,27 @@ def test_balance_save_frequency_check(data_dir, username):
 
     last_save_ts = data.db.get_last_balance_save_time()
     assert last_save_ts == data_save_ts
+
+
+def test_upgrade_sqlcipher_v3_to_v4(data_dir):
+    """Test that we can upgrade from an sqlcipher v3 to v4 rotkehlchen database
+    Issue: https://github.com/rotkehlchenio/rotkehlchen/issues/229
+    """
+    sqlcipher_version = detect_sqlcipher_version()
+    if sqlcipher_version == 3:
+        # nothing to test
+        return
+
+    username = 'foo'
+    userdata_dir = os.path.join(data_dir, username)
+    os.mkdir(userdata_dir)
+    # get the v3 database file and copy it into the user's data directory
+    dir_path = os.path.dirname(os.path.realpath(__file__))
+    copyfile(
+        os.path.join(dir_path, 'data', 'sqlcipher_v3_rotkehlchen.db'),
+        os.path.join(userdata_dir, 'rotkehlchen.db'),
+    )
+
+    # the constructor should migrate it in-place and we should have a working DB
+    db = DBHandler(userdata_dir, username, '123')
+    assert db.get_version() == ROTKEHLCHEN_DB_VERSION