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 : 'sqlcipher_flutter_libs.framework/Versions/Current/' 85 : 'sqlcipher_flutter_libs', 86 : ); 87 : } 88 0 : if (Platform.isWindows) { 89 0 : return DynamicLibrary.open('libsqlcipher.dll'); 90 : } 91 : 92 0 : throw UnsupportedError('Unsupported platform: ${Platform.operatingSystem}'); 93 : } 94 : 95 : /// checks whether the database exists and is encrypted 96 : /// 97 : /// In case it is not encrypted, the file is being migrated 98 : /// to SQLCipher and encrypted using the given cipher and checks 99 : /// whether that operation was successful 100 0 : Future<void> ensureDatabaseFileEncrypted() async { 101 0 : final file = File(path); 102 : 103 : // in case the file does not exist there is no need to migrate 104 0 : if (!await file.exists()) { 105 : return; 106 : } 107 : 108 : // no work to do in case the DB is already encrypted 109 0 : if (!await _isPlainText(file)) { 110 : return; 111 : } 112 : 113 0 : Logs().d( 114 : 'Warning: Found unencrypted sqlite database. Encrypting using SQLCipher.', 115 : ); 116 : 117 : // hell, it's unencrypted. This should not happen. Time to encrypt it. 118 0 : final plainDb = await factory.openDatabase(path); 119 : 120 0 : final encryptedPath = '$path.encrypted'; 121 : 122 0 : await plainDb.execute( 123 0 : "ATTACH DATABASE '$encryptedPath' AS encrypted KEY '$cipher';", 124 : ); 125 0 : await plainDb.execute("SELECT sqlcipher_export('encrypted');"); 126 : // ignore: prefer_single_quotes 127 0 : await plainDb.execute("DETACH DATABASE encrypted;"); 128 0 : await plainDb.close(); 129 : 130 0 : Logs().d('Migrated data to temporary database. Checking integrity.'); 131 : 132 0 : final encryptedFile = File(encryptedPath); 133 : // we should now have a second file - which is encrypted 134 0 : assert(await encryptedFile.exists()); 135 0 : assert(!await _isPlainText(encryptedFile)); 136 : 137 0 : Logs().d('New file encrypted. Deleting plain text database.'); 138 : 139 : // deleting the plain file and replacing it with the new one 140 0 : await file.delete(); 141 0 : await encryptedFile.copy(path); 142 : // delete the temporary encrypted file 143 0 : await encryptedFile.delete(); 144 : 145 0 : Logs().d('Migration done.'); 146 : } 147 : 148 : /// safely applies the PRAGMA key to a [Database] 149 : /// 150 : /// To be directly used as [OpenDatabaseOptions.onConfigure]. 151 : /// 152 : /// * ensures PRAGMA is supported by the given [database] 153 : /// * applies [cipher] as PRAGMA key 154 : /// * checks whether this operation was successful 155 0 : Future<void> applyPragmaKey(Database database) async { 156 0 : final cipherVersion = await database.rawQuery('PRAGMA cipher_version;'); 157 0 : if (cipherVersion.isEmpty) { 158 : // Make sure that we're actually using SQLCipher, since the pragma 159 : // used to encrypt databases just fails silently with regular 160 : // sqlite3 161 : // (meaning that we'd accidentally use plaintext databases). 162 0 : throw StateError( 163 : 'SQLCipher library is not available, ' 164 : 'please check your dependencies!', 165 : ); 166 : } else { 167 0 : final version = cipherVersion.singleOrNull?['cipher_version']; 168 0 : Logs().d( 169 0 : 'PRAGMA supported by bundled SQLite. Encryption supported. SQLCipher version: $version.', 170 : ); 171 : } 172 : 173 0 : final result = await database.rawQuery("PRAGMA KEY='$cipher';"); 174 0 : assert(result.single['ok'] == 'ok'); 175 : } 176 : 177 : /// checks whether a File has a plain text SQLite header 178 0 : Future<bool> _isPlainText(File file) async { 179 0 : final raf = await file.open(); 180 0 : final bytes = await raf.read(15); 181 0 : await raf.close(); 182 : 183 : const header = [ 184 : 83, 185 : 81, 186 : 76, 187 : 105, 188 : 116, 189 : 101, 190 : 32, 191 : 102, 192 : 111, 193 : 114, 194 : 109, 195 : 97, 196 : 116, 197 : 32, 198 : 51, 199 : ]; 200 : 201 0 : return _listEquals(bytes, header); 202 : } 203 : 204 : /// Taken from `package:flutter/foundation.dart`; 205 : /// 206 : /// Compares two lists for element-by-element equality. 207 0 : bool _listEquals<T>(List<T>? a, List<T>? b) { 208 : if (a == null) { 209 : return b == null; 210 : } 211 0 : if (b == null || a.length != b.length) { 212 : return false; 213 : } 214 : if (identical(a, b)) { 215 : return true; 216 : } 217 0 : for (int index = 0; index < a.length; index += 1) { 218 0 : if (a[index] != b[index]) { 219 : return false; 220 : } 221 : } 222 : return true; 223 : } 224 : }