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 : }
|