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