diff --git a/lib/utils/client_manager.dart b/lib/utils/client_manager.dart index 279fc0cb6..0715c7225 100644 --- a/lib/utils/client_manager.dart +++ b/lib/utils/client_manager.dart @@ -5,13 +5,13 @@ import 'package:flutter/foundation.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:matrix/encryption/utils/key_verification.dart'; import 'package:matrix/matrix.dart'; -import 'package:sembast/sembast.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:sembast/sembast.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'famedlysdk_store.dart'; -import 'matrix_sdk_extensions.dart/flutter_matrix_hive_database.dart'; import 'matrix_sdk_extensions.dart/flutter_matrix_sembast_database.dart'; +import 'matrix_sdk_extensions.dart/flutter_matrix_sembast_database_old.dart'; abstract class ClientManager { static const String clientNamespace = 'im.fluffychat.store.clients'; @@ -81,7 +81,8 @@ abstract class ClientManager { }, importantStateEvents: {'im.ponies.room_emotes'}, databaseBuilder: FlutterMatrixSembastDatabase.databaseBuilder, - legacyDatabaseBuilder: FlutterMatrixHiveStore.hiveDatabaseBuilder, + legacyDatabaseBuilder: FlutterMatrixSembastDatabaseOld.databaseBuilder, + //legacyDatabaseBuilder: FlutterMatrixHiveStore.hiveDatabaseBuilder, supportedLoginTypes: { AuthenticationTypes.password, if (PlatformInfos.isMobile || PlatformInfos.isWeb) diff --git a/lib/utils/matrix_sdk_extensions.dart/codec.dart b/lib/utils/matrix_sdk_extensions.dart/codec.dart new file mode 100644 index 000000000..8f93b2230 --- /dev/null +++ b/lib/utils/matrix_sdk_extensions.dart/codec.dart @@ -0,0 +1,120 @@ +//@dart=2.12 + +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:encrypt/encrypt.dart'; +import 'package:sembast/sembast.dart'; + +var _random = Random.secure(); + +/// Random bytes generator +Uint8List _randBytes(int length) { + return Uint8List.fromList( + List.generate(length, (i) => _random.nextInt(256))); +} + +/// Generate an encryption password based on a user input password +/// +/// It uses MD5 which generates a 16 bytes blob, size needed for Salsa20 +Uint8List _generateEncryptPassword(String password) { + final blob = Uint8List.fromList(md5.convert(utf8.encode(password)).bytes); + assert(blob.length == 16); + return blob; +} + +/// Salsa20 based encoder +class _EncryptEncoder extends Converter { + final Salsa20 salsa20; + + _EncryptEncoder(this.salsa20); + + @override + String convert(dynamic input) { + // Generate random initial value + final iv = _randBytes(8); + final ivEncoded = base64.encode(iv); + assert(ivEncoded.length == 12); + + // Encode the input value + final encoded = + Encrypter(salsa20).encrypt(json.encode(input), iv: IV(iv)).base64; + + // Prepend the initial value + return '$ivEncoded$encoded'; + } +} + +/// Salsa20 based decoder +class _EncryptDecoder extends Converter { + final Salsa20 salsa20; + + _EncryptDecoder(this.salsa20); + + @override + dynamic convert(String input) { + // Read the initial value that was prepended + assert(input.length >= 12); + final iv = base64.decode(input.substring(0, 12)); + + // Extract the real input + input = input.substring(12); + + // Decode the input + final decoded = + json.decode(Encrypter(salsa20).decrypt64(input, iv: IV(iv))); + if (decoded is Map) { + return decoded.cast(); + } + return decoded; + } +} + +/// Salsa20 based Codec +class _EncryptCodec extends Codec { + late _EncryptEncoder _encoder; + late _EncryptDecoder _decoder; + + _EncryptCodec(Uint8List passwordBytes) { + final salsa20 = Salsa20(Key(passwordBytes)); + _encoder = _EncryptEncoder(salsa20); + _decoder = _EncryptDecoder(salsa20); + } + + @override + Converter get decoder => _decoder; + + @override + Converter get encoder => _encoder; +} + +/// Our plain text signature +const _encryptCodecSignature = 'encrypt'; + +/// Create a codec to use to open a database with encrypted stored data. +/// +/// Hash (md5) of the password is used (but never stored) as a key to encrypt +/// the data using the Salsa20 algorithm with a random (8 bytes) initial value +/// +/// This is just used as a demonstration and should not be considered as a +/// reference since its implementation (and storage format) might change. +/// +/// No performance metrics has been made to check whether this is a viable +/// solution for big databases. +/// +/// The usage is then +/// +/// ```dart +/// // Initialize the encryption codec with a user password +/// var codec = getEncryptSembastCodec(password: '[your_user_password]'); +/// // Open the database with the codec +/// Database db = await factory.openDatabase(dbPath, codec: codec); +/// +/// // ...your database is ready to use +/// ``` +SembastCodec getEncryptSembastCodec({required String password}) => SembastCodec( + signature: _encryptCodecSignature, + codec: _EncryptCodec(_generateEncryptPassword(password)), + ); diff --git a/lib/utils/matrix_sdk_extensions.dart/flutter_matrix_sembast_database.dart b/lib/utils/matrix_sdk_extensions.dart/flutter_matrix_sembast_database.dart index c3953f5c1..7307d5c7d 100644 --- a/lib/utils/matrix_sdk_extensions.dart/flutter_matrix_sembast_database.dart +++ b/lib/utils/matrix_sdk_extensions.dart/flutter_matrix_sembast_database.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; @@ -11,9 +10,13 @@ import 'package:matrix/matrix.dart'; import 'package:path_provider/path_provider.dart'; import 'package:sembast/sembast.dart'; import 'package:sembast/sembast_io.dart'; +import 'package:sembast_sqflite/sembast_sqflite.dart'; import 'package:sembast_web/sembast_web.dart'; +import 'package:sqflite/sqflite.dart' as sqflite; +import 'package:sqflite_common_ffi/sqflite_ffi.dart' as sqflite_ffi; import '../platform_infos.dart'; +import 'codec.dart'; class FlutterMatrixSembastDatabase extends MatrixSembastDatabase { FlutterMatrixSembastDatabase( @@ -29,7 +32,7 @@ class FlutterMatrixSembastDatabase extends MatrixSembastDatabase { ); static const String _cipherStorageKey = 'sembast_encryption_key'; - static const int _cipherStorageKeyLength = 512; + static const int _cipherStorageKeyLength = 1024; static Future databaseBuilder( Client client) async { @@ -53,7 +56,6 @@ class FlutterMatrixSembastDatabase extends MatrixSembastDatabase { // workaround for if we just wrote to the key and it still doesn't exist final rawEncryptionKey = await secureStorage.read(key: _cipherStorageKey); if (rawEncryptionKey == null) throw MissingPluginException(); - codec = getEncryptSembastCodec(password: rawEncryptionKey); } on MissingPluginException catch (_) { Logs().i('Sembast encryption is not supported on this platform'); @@ -63,13 +65,26 @@ class FlutterMatrixSembastDatabase extends MatrixSembastDatabase { client.clientName, codec: codec, path: await _findDatabasePath(client), - dbFactory: kIsWeb ? databaseFactoryWeb : databaseFactoryIo, + dbFactory: kIsWeb + ? databaseFactoryWeb + : getDatabaseFactorySqflite(sqflite.databaseFactory), ); await db.open(); Logs().d('Sembast is ready'); return db; } + static DatabaseFactory get factory { + if (kIsWeb) return databaseFactoryWeb; + if (Platform.isAndroid || Platform.isIOS) { + return getDatabaseFactorySqflite(sqflite.databaseFactory); + } + if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) { + return getDatabaseFactorySqflite(sqflite_ffi.databaseFactoryFfi); + } + return databaseFactoryIo; + } + static Future _findDatabasePath(Client client) async { String path = client.clientName; if (!kIsWeb) { @@ -83,7 +98,7 @@ class FlutterMatrixSembastDatabase extends MatrixSembastDatabase { directory = Directory.current; } } - path = '${directory.path}${client.clientName}.db'; + path = '${directory.path}${client.clientName}.sqflite'; } return path; } @@ -129,79 +144,3 @@ class FlutterMatrixSembastDatabase extends MatrixSembastDatabase { return; } } - -class _EncryptEncoder extends Converter, String> { - final String key; - final String signature; - _EncryptEncoder(this.key, this.signature); - - @override - String convert(Map input) { - String encoded; - switch (signature) { - case "Salsa20": - encoded = Encrypter(Salsa20(Key.fromUtf8(key))) - .encrypt(json.encode(input), iv: IV.fromLength(8)) - .base64; - break; - case "AES": - encoded = Encrypter(AES(Key.fromUtf8(key))) - .encrypt(json.encode(input), iv: IV.fromLength(16)) - .base64; - break; - default: - throw FormatException('invalid $signature'); - break; - } - return encoded; - } -} - -class _EncryptDecoder extends Converter> { - final String key; - final String signature; - _EncryptDecoder(this.key, this.signature); - - @override - Map convert(String input) { - dynamic decoded; - switch (signature) { - case "Salsa20": - decoded = json.decode(Encrypter(Salsa20(Key.fromUtf8(key))) - .decrypt64(input, iv: IV.fromLength(8))); - break; - case "AES": - decoded = json.decode(Encrypter(AES(Key.fromUtf8(key))) - .decrypt64(input, iv: IV.fromLength(16))); - break; - default: - break; - } - if (decoded is Map) { - return decoded.cast(); - } - throw FormatException('invalid input $input'); - } -} - -class _EncryptCodec extends Codec, String> { - final String signature; - _EncryptEncoder _encoder; - _EncryptDecoder _decoder; - _EncryptCodec(String password, this.signature) { - _encoder = _EncryptEncoder(password, signature); - _decoder = _EncryptDecoder(password, signature); - } - - @override - Converter> get decoder => _decoder; - - @override - Converter, String> get encoder => _encoder; -} - -// Salsa20 (16 length key required) or AES (32 length key required) -SembastCodec getEncryptSembastCodec( - {@required String password, String signature = "Salsa20"}) => - SembastCodec( - signature: signature, codec: _EncryptCodec(password, signature)); diff --git a/lib/utils/matrix_sdk_extensions.dart/flutter_matrix_sembast_database_old.dart b/lib/utils/matrix_sdk_extensions.dart/flutter_matrix_sembast_database_old.dart new file mode 100644 index 000000000..31e09289f --- /dev/null +++ b/lib/utils/matrix_sdk_extensions.dart/flutter_matrix_sembast_database_old.dart @@ -0,0 +1,131 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart' hide Key; +import 'package:flutter/services.dart'; + +import 'package:encrypt/encrypt.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:matrix/matrix.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:sembast/sembast.dart'; +import 'package:sembast/sembast_io.dart'; +import 'package:sembast_web/sembast_web.dart'; + +import '../platform_infos.dart'; +import 'codec.dart'; + +class FlutterMatrixSembastDatabaseOld extends MatrixSembastDatabase { + FlutterMatrixSembastDatabaseOld( + String name, { + SembastCodec codec, + String path, + DatabaseFactory dbFactory, + }) : super( + name, + codec: codec, + path: path, + dbFactory: dbFactory, + ); + + static const String _cipherStorageKey = 'sembast_encryption_key'; + static const int _cipherStorageKeyLength = 512; + + static Future databaseBuilder( + Client client) async { + Logs().d('Open Sembast...'); + SembastCodec codec; + try { + // Workaround for secure storage is calling Platform.operatingSystem on web + if (kIsWeb) throw MissingPluginException(); + + const secureStorage = FlutterSecureStorage(); + final containsEncryptionKey = + await secureStorage.containsKey(key: _cipherStorageKey); + if (!containsEncryptionKey) { + final key = SecureRandom(_cipherStorageKeyLength).base64; + await secureStorage.write( + key: _cipherStorageKey, + value: key, + ); + } + + // workaround for if we just wrote to the key and it still doesn't exist + final rawEncryptionKey = await secureStorage.read(key: _cipherStorageKey); + if (rawEncryptionKey == null) throw MissingPluginException(); + + codec = getEncryptSembastCodec(password: rawEncryptionKey); + } on MissingPluginException catch (_) { + Logs().i('Sembast encryption is not supported on this platform'); + } + + final db = FlutterMatrixSembastDatabaseOld( + client.clientName, + codec: codec, + path: await _findDatabasePath(client), + dbFactory: kIsWeb ? databaseFactoryWeb : databaseFactoryIo, + ); + await db.open(); + Logs().d('Sembast is ready'); + return db; + } + + static Future _findDatabasePath(Client client) async { + String path = client.clientName; + if (!kIsWeb) { + Directory directory; + try { + directory = await getApplicationSupportDirectory(); + } catch (_) { + try { + directory = await getLibraryDirectory(); + } catch (_) { + directory = Directory.current; + } + } + path = '${directory.path}${client.clientName}.db'; + } + return path; + } + + @override + int get maxFileSize => supportsFileStoring ? 100 * 1024 * 1024 : 0; + @override + bool get supportsFileStoring => (PlatformInfos.isIOS || + PlatformInfos.isAndroid || + PlatformInfos.isDesktop); + + Future _getFileStoreDirectory() async { + try { + try { + return (await getApplicationSupportDirectory()).path; + } catch (_) { + return (await getApplicationDocumentsDirectory()).path; + } + } catch (_) { + return (await getDownloadsDirectory()).path; + } + } + + @override + Future getFile(Uri mxcUri) async { + if (!supportsFileStoring) return null; + final tempDirectory = await _getFileStoreDirectory(); + final file = + File('$tempDirectory/${Uri.encodeComponent(mxcUri.toString())}'); + if (await file.exists() == false) return null; + final bytes = await file.readAsBytes(); + return bytes; + } + + @override + Future storeFile(Uri mxcUri, Uint8List bytes, int time) async { + if (!supportsFileStoring) return null; + final tempDirectory = await _getFileStoreDirectory(); + final file = + File('$tempDirectory/${Uri.encodeComponent(mxcUri.toString())}'); + if (await file.exists()) return; + await file.writeAsBytes(bytes); + return; + } +} diff --git a/pubspec.lock b/pubspec.lock index 0567662ec..023fef59f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1171,6 +1171,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.1.1" + sembast_sqflite: + dependency: "direct main" + description: + name: sembast_sqflite + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0+1" sembast_web: dependency: "direct main" description: @@ -1308,7 +1315,21 @@ packages: name: sqflite_common url: "https://pub.dartlang.org" source: hosted - version: "2.0.0+2" + version: "2.0.1+1" + sqflite_common_ffi: + dependency: "direct main" + description: + name: sqflite_common_ffi + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.1" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 59bd7d1e8..137b5eea7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,10 +63,12 @@ dependencies: record: ^3.0.0 salomon_bottom_bar: ^3.1.0 scroll_to_index: ^2.1.0 + sembast_sqflite: ^2.0.0+1 sembast_web: ^2.0.1+1 sentry: ^6.0.1 share: ^2.0.4 slugify: ^2.0.0 + sqflite_common_ffi: ^2.1.0 swipe_to_action: ^0.2.0 uni_links: ^0.5.1 unifiedpush: ^1.0.6