Line data Source code
1 : import 'dart:ffi'; 2 : import 'dart:io'; 3 : import 'dart:math' show max; 4 : 5 : import 'package:sqflite_common/sqlite_api.dart'; 6 : import 'package:sqlite3/open.dart'; 7 : 8 : import 'package:matrix/matrix.dart'; 9 : 10 : /// A helper utility for SQfLite related encryption operations 11 : /// 12 : /// * helps loading the required dynamic libraries - even on cursed systems 13 : /// * migrates unencrypted SQLite databases to SQLCipher 14 : /// * applies the PRAGMA key to a database and ensure it is properly loading 15 : class SQfLiteEncryptionHelper { 16 : /// the factory to use for all SQfLite operations 17 : final DatabaseFactory factory; 18 : 19 : /// the path of the database 20 : final String path; 21 : 22 : /// the (supposed) PRAGMA key of the database 23 : final String cipher; 24 : 25 0 : const SQfLiteEncryptionHelper({ 26 : required this.factory, 27 : required this.path, 28 : required this.cipher, 29 : }); 30 : 31 : /// Loads the correct [DynamicLibrary] required for SQLCipher 32 : /// 33 : /// To be used with `package:sqlite3/open.dart`: 34 : /// ```dart 35 : /// void main() { 36 : /// final factory = createDatabaseFactoryFfi( 37 : /// ffiInit: SQfLiteEncryptionHelper.ffiInit, 38 : /// ); 39 : /// } 40 : /// ``` 41 0 : static void ffiInit() => open.overrideForAll(_loadSQLCipherDynamicLibrary); 42 : 43 0 : static DynamicLibrary _loadSQLCipherDynamicLibrary() { 44 : // Taken from https://github.com/simolus3/sqlite3.dart/blob/e66702c5bec7faec2bf71d374c008d5273ef2b3b/sqlite3/lib/src/load_library.dart#L24 45 0 : if (Platform.isAndroid) { 46 : try { 47 0 : return DynamicLibrary.open('libsqlcipher.so'); 48 : } catch (_) { 49 : // On some (especially old) Android devices, we somehow can't dlopen 50 : // libraries shipped with the apk. We need to find the full path of the 51 : // library (/data/data/<id>/lib/libsqlcipher.so) and open that one. 52 : // For details, see https://github.com/simolus3/moor/issues/420 53 0 : final appIdAsBytes = File('/proc/self/cmdline').readAsBytesSync(); 54 : 55 : // app id ends with the first \0 character in here. 56 0 : final endOfAppId = max(appIdAsBytes.indexOf(0), 0); 57 0 : final appId = String.fromCharCodes(appIdAsBytes.sublist(0, endOfAppId)); 58 : 59 0 : return DynamicLibrary.open('/data/data/$appId/lib/libsqlcipher.so'); 60 : } 61 : } 62 0 : if (Platform.isLinux) { 63 : // *not my fault grumble* 64 : // 65 : // On many Linux systems, I encountered issues opening the system provided 66 : // libsqlcipher.so. I hence decided to ship an own one - statically linked 67 : // against a patched version of OpenSSL compiled with the correct options. 68 : // 69 : // This was the only way I reached to run on particular Fedora and Arch 70 : // systems. 71 : // 72 : // Hours wasted : 12 73 : try { 74 0 : return DynamicLibrary.open('libsqlcipher_flutter_libs_plugin.so'); 75 : } catch (_) { 76 0 : return DynamicLibrary.open('libsqlcipher.so'); 77 : } 78 : } 79 0 : if (Platform.isIOS) { 80 0 : return DynamicLibrary.process(); 81 : } 82 0 : if (Platform.isMacOS) { 83 0 : return DynamicLibrary.open( 84 : '/usr/lib/libsqlcipher_flutter_libs_plugin.dylib'); 85 : } 86 0 : if (Platform.isWindows) { 87 0 : return DynamicLibrary.open('libsqlcipher.dll'); 88 : } 89 : 90 0 : throw UnsupportedError('Unsupported platform: ${Platform.operatingSystem}'); 91 : } 92 : 93 : /// checks whether the database exists and is encrypted 94 : /// 95 : /// In case it is not encrypted, the file is being migrated 96 : /// to SQLCipher and encrypted using the given cipher and checks 97 : /// whether that operation was successful 98 0 : Future<void> ensureDatabaseFileEncrypted() async { 99 0 : final file = File(path); 100 : 101 : // in case the file does not exist there is no need to migrate 102 0 : if (!await file.exists()) { 103 : return; 104 : } 105 : 106 : // no work to do in case the DB is already encrypted 107 0 : if (!await _isPlainText(file)) { 108 : return; 109 : } 110 : 111 0 : Logs().d( 112 : 'Warning: Found unencrypted sqlite database. Encrypting using SQLCipher.'); 113 : 114 : // hell, it's unencrypted. This should not happen. Time to encrypt it. 115 0 : final plainDb = await factory.openDatabase(path); 116 : 117 0 : final encryptedPath = '$path.encrypted'; 118 : 119 0 : await plainDb.execute( 120 0 : "ATTACH DATABASE '$encryptedPath' AS encrypted KEY '$cipher';"); 121 0 : await plainDb.execute("SELECT sqlcipher_export('encrypted');"); 122 : // ignore: prefer_single_quotes 123 0 : await plainDb.execute("DETACH DATABASE encrypted;"); 124 0 : await plainDb.close(); 125 : 126 0 : Logs().d('Migrated data to temporary database. Checking integrity.'); 127 : 128 0 : final encryptedFile = File(encryptedPath); 129 : // we should now have a second file - which is encrypted 130 0 : assert(await encryptedFile.exists()); 131 0 : assert(!await _isPlainText(encryptedFile)); 132 : 133 0 : Logs().d('New file encrypted. Deleting plain text database.'); 134 : 135 : // deleting the plain file and replacing it with the new one 136 0 : await file.delete(); 137 0 : await encryptedFile.copy(path); 138 : // delete the temporary encrypted file 139 0 : await encryptedFile.delete(); 140 : 141 0 : Logs().d('Migration done.'); 142 : } 143 : 144 : /// safely applies the PRAGMA key to a [Database] 145 : /// 146 : /// To be directly used as [OpenDatabaseOptions.onConfigure]. 147 : /// 148 : /// * ensures PRAGMA is supported by the given [database] 149 : /// * applies [cipher] as PRAGMA key 150 : /// * checks whether this operation was successful 151 0 : Future<void> applyPragmaKey(Database database) async { 152 0 : final cipherVersion = await database.rawQuery('PRAGMA cipher_version;'); 153 0 : if (cipherVersion.isEmpty) { 154 : // Make sure that we're actually using SQLCipher, since the pragma 155 : // used to encrypt databases just fails silently with regular 156 : // sqlite3 157 : // (meaning that we'd accidentally use plaintext databases). 158 0 : throw StateError( 159 : 'SQLCipher library is not available, ' 160 : 'please check your dependencies!', 161 : ); 162 : } else { 163 0 : final version = cipherVersion.singleOrNull?['cipher_version']; 164 0 : Logs().d( 165 0 : 'PRAGMA supported by bundled SQLite. Encryption supported. SQLCipher version: $version.'); 166 : } 167 : 168 0 : final result = await database.rawQuery("PRAGMA KEY='$cipher';"); 169 0 : assert(result.single['ok'] == 'ok'); 170 : } 171 : 172 : /// checks whether a File has a plain text SQLite header 173 0 : Future<bool> _isPlainText(File file) async { 174 0 : final raf = await file.open(); 175 0 : final bytes = await raf.read(15); 176 0 : await raf.close(); 177 : 178 : const header = [ 179 : 83, 180 : 81, 181 : 76, 182 : 105, 183 : 116, 184 : 101, 185 : 32, 186 : 102, 187 : 111, 188 : 114, 189 : 109, 190 : 97, 191 : 116, 192 : 32, 193 : 51, 194 : ]; 195 : 196 0 : return _listEquals(bytes, header); 197 : } 198 : 199 : /// Taken from `package:flutter/foundation.dart`; 200 : /// 201 : /// Compares two lists for element-by-element equality. 202 0 : bool _listEquals<T>(List<T>? a, List<T>? b) { 203 : if (a == null) { 204 : return b == null; 205 : } 206 0 : if (b == null || a.length != b.length) { 207 : return false; 208 : } 209 : if (identical(a, b)) { 210 : return true; 211 : } 212 0 : for (int index = 0; index < a.length; index += 1) { 213 0 : if (a[index] != b[index]) { 214 : return false; 215 : } 216 : } 217 : return true; 218 : } 219 : }