diff --git a/lib/app.dart b/lib/app.dart index 732065a..d55289f 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:provider/provider.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/localization/generated/app_localizations.dart'; @@ -6,22 +7,13 @@ import 'package:twonly/src/providers/settings.provider.dart'; import 'package:twonly/src/services/api/media_upload.dart'; import 'package:twonly/src/services/notification.service.dart'; import 'package:twonly/src/utils/storage.dart'; -import 'package:twonly/src/views/onboarding.view.dart'; +import 'package:twonly/src/views/onboarding/onboarding.view.dart'; import 'package:twonly/src/views/home.view.dart'; -import 'package:twonly/src/views/register.view.dart'; +import 'package:twonly/src/views/onboarding/register.view.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'dart:async'; -// these global function can be called from anywhere to update -// the ui when something changed. The callbacks will be set by -// App widget. - -// this callback is called by the apiProvider -Function(bool) globalCallbackConnectionState = (a) {}; -bool globalIsAppInBackground = true; -int globalBestFriendUserId = -1; - // these two callbacks are called on updated to the corresponding database /// The Widget that configures your application. @@ -161,7 +153,7 @@ class AppMainWidget extends StatefulWidget { class _AppMainWidgetState extends State { Future userCreated = isUserCreated(); - bool showOnboarding = true; + bool showOnboarding = kReleaseMode; @override Widget build(BuildContext context) { diff --git a/lib/globals.dart b/lib/globals.dart index 3b8e486..69f362e 100644 --- a/lib/globals.dart +++ b/lib/globals.dart @@ -9,3 +9,13 @@ late TwonlyDatabase twonlyDB; List gCameras = []; bool gIsDemoUser = false; + +// The following global function can be called from anywhere to update +// the UI when something changed. The callbacks will be set by +// App widget. + +// This callback called by the apiProvider +Function(bool) globalCallbackConnectionState = (a) {}; + +bool globalIsAppInBackground = true; +int globalBestFriendUserId = -1; diff --git a/lib/main.dart b/lib/main.dart index ca13af3..07e36f4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,7 +12,7 @@ import 'package:twonly/src/providers/connection.provider.dart'; import 'package:twonly/src/providers/settings.provider.dart'; import 'package:twonly/src/services/fcm.service.dart'; import 'package:twonly/src/services/notification.service.dart'; -import 'package:twonly/src/services/twonly_safe/create_backup.service.dart'; +import 'package:twonly/src/services/twonly_safe/create_backup.twonly_safe.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/storage.dart'; import 'app.dart'; diff --git a/lib/src/database/tables/messages_table.dart b/lib/src/database/tables/messages_table.dart index eb3584f..8876920 100644 --- a/lib/src/database/tables/messages_table.dart +++ b/lib/src/database/tables/messages_table.dart @@ -15,6 +15,7 @@ enum MessageKind { ack, pushKey, receiveMediaError, + signalDecryptError } enum DownloadState { diff --git a/lib/src/localization/app_de.arb b/lib/src/localization/app_de.arb index 3a9cb1f..240795d 100644 --- a/lib/src/localization/app_de.arb +++ b/lib/src/localization/app_de.arb @@ -284,7 +284,7 @@ "backupInsecurePasswordDesc": "Das gewählte Passwort ist sehr unsicher und kann daher leicht von Angreifern erraten werden. Bitte wähle ein sicheres Passwort.", "backupInsecurePasswordOk": "Trotzdem fortfahren", "backupInsecurePasswordCancel": "Erneut versuchen", - "backupTwonlySafeLongDesc": "twonly hat keine zentralen Benutzerkonten. Ein Schlüsselpaar wird während der Installation erstellt, das aus einem öffentlichen und einem privaten Schlüssel besteht. Der private Schlüssel wird nur auf deinem Gerät gespeichert, um ihn vor unbefugtem Zugriff zu schützen. Der öffentliche Schlüssel wird auf den Server hochgeladen und mit deinem gewählten Benutzernamen verknüpft, damit andere dich finden können.\n\ntwonly Safe erstellt regelmäßig ein verschlüsseltes, anonymes Backup deines privaten Schlüssels zusammen mit deinen Kontakten und Einstellungen. Dein Benutzername und das gewählte Passwort reichen aus, um diese Daten auf einem anderen Gerät wiederherzustellen.", + "backupTwonlySafeLongDesc": "twonly hat keine zentralen Benutzerkonten. Während der Installation wird ein Schlüsselpaar erstellt, das aus einem öffentlichen und einem privaten Schlüssel besteht. Der private Schlüssel wird nur auf deinem Gerät gespeichert, um ihn vor unbefugtem Zugriff zu schützen. Der öffentliche Schlüssel wird auf den Server hochgeladen und mit deinem gewählten Benutzernamen verknüpft, damit andere dich finden können.\n\ntwonly Safe erstellt regelmäßig ein verschlüsseltes, anonymes Backup deines privaten Schlüssels zusammen mit deinen Kontakten und Einstellungen. Dein Benutzername und das gewählte Passwort reichen aus, um diese Daten auf einem anderen Gerät wiederherzustellen.", "backupSelectStrongPassword": "Wähle ein sicheres Passwort. Dies ist erforderlich, wenn du dein twonly Safe-Backup wiederherstellen möchtest.", "password": "Passwort", "passwordRepeated": "Passwort wiederholen", diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb index d33c173..222d724 100644 --- a/lib/src/localization/app_en.arb +++ b/lib/src/localization/app_en.arb @@ -449,5 +449,8 @@ "backupOwnServerDesc": "Save your twonly safe backups at twonly or on any server of your choice.", "backupUseOwnServer": "Use server", "backupResetServer": "Use standard server", - "backupTwonlySaveNow": "Save now" + "backupTwonlySaveNow": "Save now", + "twonlySafeRecoverTitle": "Recovery", + "twonlySafeRecoverDesc": "If you have created a backup with twonly Safe, you can restore it here.", + "twonlySafeRecoverBtn": "Restore backup" } \ No newline at end of file diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 9bea1fa..e1363bf 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -1795,6 +1795,24 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Save now'** String get backupTwonlySaveNow; + + /// No description provided for @twonlySafeRecoverTitle. + /// + /// In en, this message translates to: + /// **'Recovery'** + String get twonlySafeRecoverTitle; + + /// No description provided for @twonlySafeRecoverDesc. + /// + /// In en, this message translates to: + /// **'If you have created a backup with twonly Safe, you can restore it here.'** + String get twonlySafeRecoverDesc; + + /// No description provided for @twonlySafeRecoverBtn. + /// + /// In en, this message translates to: + /// **'Restore backup'** + String get twonlySafeRecoverBtn; } class _AppLocalizationsDelegate diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 2bfa3ef..c10af71 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -916,7 +916,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get backupTwonlySafeLongDesc => - 'twonly hat keine zentralen Benutzerkonten. Ein Schlüsselpaar wird während der Installation erstellt, das aus einem öffentlichen und einem privaten Schlüssel besteht. Der private Schlüssel wird nur auf deinem Gerät gespeichert, um ihn vor unbefugtem Zugriff zu schützen. Der öffentliche Schlüssel wird auf den Server hochgeladen und mit deinem gewählten Benutzernamen verknüpft, damit andere dich finden können.\n\ntwonly Safe erstellt regelmäßig ein verschlüsseltes, anonymes Backup deines privaten Schlüssels zusammen mit deinen Kontakten und Einstellungen. Dein Benutzername und das gewählte Passwort reichen aus, um diese Daten auf einem anderen Gerät wiederherzustellen.'; + 'twonly hat keine zentralen Benutzerkonten. Während der Installation wird ein Schlüsselpaar erstellt, das aus einem öffentlichen und einem privaten Schlüssel besteht. Der private Schlüssel wird nur auf deinem Gerät gespeichert, um ihn vor unbefugtem Zugriff zu schützen. Der öffentliche Schlüssel wird auf den Server hochgeladen und mit deinem gewählten Benutzernamen verknüpft, damit andere dich finden können.\n\ntwonly Safe erstellt regelmäßig ein verschlüsseltes, anonymes Backup deines privaten Schlüssels zusammen mit deinen Kontakten und Einstellungen. Dein Benutzername und das gewählte Passwort reichen aus, um diese Daten auf einem anderen Gerät wiederherzustellen.'; @override String get backupSelectStrongPassword => @@ -953,4 +953,14 @@ class AppLocalizationsDe extends AppLocalizations { @override String get backupTwonlySaveNow => 'Jetzt speichern'; + + @override + String get twonlySafeRecoverTitle => 'Recovery'; + + @override + String get twonlySafeRecoverDesc => + 'If you have created a backup with twonly Safe, you can restore it here.'; + + @override + String get twonlySafeRecoverBtn => 'Restore backup'; } diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index f9173e5..39f2f36 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -947,4 +947,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get backupTwonlySaveNow => 'Save now'; + + @override + String get twonlySafeRecoverTitle => 'Recovery'; + + @override + String get twonlySafeRecoverDesc => + 'If you have created a backup with twonly Safe, you can restore it here.'; + + @override + String get twonlySafeRecoverBtn => 'Restore backup'; } diff --git a/lib/src/services/api.service.dart b/lib/src/services/api.service.dart index 60ba797..847da60 100644 --- a/lib/src/services/api.service.dart +++ b/lib/src/services/api.service.dart @@ -11,7 +11,6 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:mutex/mutex.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:twonly/globals.dart'; -import 'package:twonly/app.dart'; import 'package:twonly/src/constants/secure_storage_keys.dart'; import 'package:twonly/src/database/twonly_database.dart'; import 'package:twonly/src/model/protobuf/api/websocket/client_to_server.pbserver.dart'; diff --git a/lib/src/services/api/media_upload.dart b/lib/src/services/api/media_upload.dart index eefa597..5f6e07b 100644 --- a/lib/src/services/api/media_upload.dart +++ b/lib/src/services/api/media_upload.dart @@ -23,7 +23,7 @@ import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart'; import 'package:twonly/src/services/api/media_download.dart'; import 'package:twonly/src/services/notification.service.dart'; import 'package:twonly/src/services/signal/encryption.signal.dart'; -import 'package:twonly/src/services/twonly_safe/create_backup.service.dart'; +import 'package:twonly/src/services/twonly_safe/create_backup.twonly_safe.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/storage.dart'; diff --git a/lib/src/services/api/messages.dart b/lib/src/services/api/messages.dart index 10b72bb..31fd522 100644 --- a/lib/src/services/api/messages.dart +++ b/lib/src/services/api/messages.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:cryptography_plus/cryptography_plus.dart'; import 'package:drift/drift.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/twonly_database.dart'; @@ -8,6 +9,7 @@ import 'package:twonly/src/database/tables/messages_table.dart'; import 'package:twonly/src/model/json/message.dart'; import 'package:twonly/src/model/json/userdata.dart'; import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart'; +import 'package:twonly/src/services/api/media_upload.dart'; import 'package:twonly/src/services/api/utils.dart'; import 'package:twonly/src/services/signal/encryption.signal.dart'; import 'package:twonly/src/services/notification.service.dart'; @@ -98,6 +100,10 @@ Future sendRetransmitMessage(int retransId) async { return; } + var hash = uint8ListToHex( + Uint8List.fromList((await Sha256().hash(encryptedBytes)).bytes)); + Log.info("Sending message: ${hash.substring(0, 10)}"); + Result resp = await apiService.sendTextMessage( retrans.contactId, encryptedBytes, diff --git a/lib/src/services/fcm.service.dart b/lib/src/services/fcm.service.dart index 75f697d..b15c498 100644 --- a/lib/src/services/fcm.service.dart +++ b/lib/src/services/fcm.service.dart @@ -2,7 +2,6 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:twonly/globals.dart'; -import 'package:twonly/app.dart'; import 'package:twonly/src/constants/secure_storage_keys.dart'; import 'package:twonly/src/database/twonly_database.dart'; import 'package:twonly/src/services/notification.service.dart'; diff --git a/lib/src/services/twonly_safe/common.service.dart b/lib/src/services/twonly_safe/common.twonly_safe.dart similarity index 76% rename from lib/src/services/twonly_safe/common.service.dart rename to lib/src/services/twonly_safe/common.twonly_safe.dart index 87d2952..169e2b9 100644 --- a/lib/src/services/twonly_safe/common.service.dart +++ b/lib/src/services/twonly_safe/common.twonly_safe.dart @@ -4,26 +4,10 @@ import 'package:hashlib/hashlib.dart'; import 'package:http/http.dart' as http; import 'package:twonly/src/model/json/userdata.dart'; import 'package:twonly/src/services/api/media_upload.dart'; -import 'package:twonly/src/services/twonly_safe/create_backup.service.dart'; +import 'package:twonly/src/services/twonly_safe/create_backup.twonly_safe.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/storage.dart'; -Future getTwonlySafeBackupUrl() async { - final user = await getUser(); - if (user == null || user.twonlySafeBackup == null) return null; - - String backupServerUrl = "https://safe.twonly.eu/"; - - if (user.backupServer != null) { - backupServerUrl = user.backupServer!.serverUrl; - } - - String backupId = - uint8ListToHex(user.twonlySafeBackup!.backupId).toLowerCase(); - - return "${backupServerUrl}backups/$backupId"; -} - Future enableTwonlySafe(String password) async { final user = await getUser(); if (user == null) return; @@ -69,9 +53,9 @@ Future<(Uint8List, Uint8List)> getMasterKey( List passwordBytes = utf8.encode(password); List saltBytes = utf8.encode(username); - // Parameters for scrypt + // Values are derived from the Threema Whitepaper + // https://threema.com/assets/documents/cryptography_whitepaper.pdf - // Create an instance of Scrypt final scrypt = Scrypt( cost: 65536, blockSize: 8, @@ -80,8 +64,30 @@ Future<(Uint8List, Uint8List)> getMasterKey( salt: saltBytes, ); - // Derive the key - // final key = (await compute(scrypt.convert, passwordBytes)).bytes; final key = (scrypt.convert(passwordBytes)).bytes; return (key.sublist(0, 32), key.sublist(32, 64)); } + +Future getTwonlySafeBackupUrl() async { + final user = await getUser(); + if (user == null || user.twonlySafeBackup == null) return null; + return getTwonlySafeBackupUrlFromServer( + user.twonlySafeBackup!.backupId, + user.backupServer, + ); +} + +Future getTwonlySafeBackupUrlFromServer( + List backupId, + BackupServer? backupServer, +) async { + String backupServerUrl = "https://safe.twonly.eu/"; + + if (backupServer != null) { + backupServerUrl = backupServer.serverUrl; + } + + String backupIdHex = uint8ListToHex(backupId).toLowerCase(); + + return "${backupServerUrl}backups/$backupIdHex"; +} diff --git a/lib/src/services/twonly_safe/create_backup.service.dart b/lib/src/services/twonly_safe/create_backup.service.dart index 37ccbe3..c25cf56 100644 --- a/lib/src/services/twonly_safe/create_backup.service.dart +++ b/lib/src/services/twonly_safe/create_backup.service.dart @@ -12,7 +12,7 @@ import 'package:twonly/src/database/twonly_database.dart'; import 'package:twonly/src/model/json/userdata.dart'; import 'package:twonly/src/model/protobuf/backup/backup.pb.dart'; import 'package:twonly/src/services/api/media_upload.dart'; -import 'package:twonly/src/services/twonly_safe/common.service.dart'; +import 'package:twonly/src/services/twonly_safe/common.twonly_safe.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/views/settings/backup/backup.view.dart'; diff --git a/lib/src/services/twonly_safe/create_backup.twonly_safe.dart b/lib/src/services/twonly_safe/create_backup.twonly_safe.dart new file mode 100644 index 0000000..5e1819c --- /dev/null +++ b/lib/src/services/twonly_safe/create_backup.twonly_safe.dart @@ -0,0 +1,213 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:background_downloader/background_downloader.dart'; +import 'package:cryptography_plus/cryptography_plus.dart'; +import 'package:drift/drift.dart'; +import 'package:drift_flutter/drift_flutter.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:twonly/src/constants/secure_storage_keys.dart'; +import 'package:twonly/src/database/twonly_database.dart'; +import 'package:twonly/src/model/json/userdata.dart'; +import 'package:twonly/src/model/protobuf/backup/backup.pb.dart'; +import 'package:twonly/src/services/api/media_upload.dart'; +import 'package:twonly/src/services/twonly_safe/common.twonly_safe.dart'; +import 'package:twonly/src/utils/log.dart'; +import 'package:twonly/src/utils/storage.dart'; +import 'package:twonly/src/views/settings/backup/backup.view.dart'; + +Future performTwonlySafeBackup({bool force = false}) async { + final user = await getUser(); + + if (user == null || user.twonlySafeBackup == null || user.isDemoUser) { + // Log.warn("perform twonly safe backup was called while it is disabled"); + return; + } + + if (user.twonlySafeBackup!.backupUploadState == + LastBackupUploadState.pending) { + Log.warn("Backup upload is already pending."); + return; + } + + DateTime? lastUpdateTime = user.twonlySafeBackup!.lastBackupDone; + if (!force && lastUpdateTime != null) { + if (lastUpdateTime.isAfter(DateTime.now().subtract(Duration(days: 1)))) { + return; + } + } + + Log.info("Starting new twonly Safe-Backup."); + + final baseDir = (await getApplicationSupportDirectory()).path; + + final backupDir = Directory(join(baseDir, "backup_twonly_safe/")); + await backupDir.create(recursive: true); + + final backupDatabaseFile = + File(join(backupDir.path, "twonly_database.backup.sqlite")); + + // copy database + final originalDatabase = File(join(baseDir, "twonly_database.sqlite")); + await originalDatabase.copy(backupDatabaseFile.path); + + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + final backupDB = TwonlyDatabase( + driftDatabase( + name: "twonly_database.backup", + native: DriftNativeOptions( + databaseDirectory: () async { + return backupDir; + }, + ), + ), + ); + + await backupDB.deleteDataForTwonlySafe(); + + var secureStorageBackup = {}; + final storage = FlutterSecureStorage(); + secureStorageBackup[SecureStorageKeys.signalIdentity] = + await storage.read(key: SecureStorageKeys.signalIdentity); + secureStorageBackup[SecureStorageKeys.signalSignedPreKey] = + await storage.read(key: SecureStorageKeys.signalSignedPreKey); + + var userBackup = await getUser(); + if (userBackup == null) return; + // FILTER settings which should not be in the backup + userBackup.twonlySafeBackup = null; + userBackup.lastImageSend = null; + userBackup.todaysImageCounter = null; + userBackup.lastPlanBallance = ""; + userBackup.additionalUserInvites = ""; + userBackup.signalLastSignedPreKeyUpdated = null; + + secureStorageBackup[SecureStorageKeys.userData] = jsonEncode(userBackup); + + // Compress and convert backup data + + final twonlyDatabaseBytes = await backupDatabaseFile.readAsBytes(); + await backupDatabaseFile.delete(); + + final backupProto = TwonlySafeBackupContent( + secureStorageJson: jsonEncode(secureStorageBackup), + twonlyDatabase: twonlyDatabaseBytes, + ); + + final backupBytes = gzip.encode(backupProto.writeToBuffer()); + + final backupHash = uint8ListToHex((await Sha256().hash(backupBytes)).bytes); + + if (user.twonlySafeBackup!.lastBackupDone == null || + user.twonlySafeBackup!.lastBackupDone! + .isAfter(DateTime.now().subtract(Duration(days: 90)))) { + force = true; + } + + final lastHash = + await storage.read(key: SecureStorageKeys.twonlySafeLastBackupHash); + + if (lastHash != null && !force) { + if (backupHash == lastHash) { + Log.info("Since last backup nothing has changed."); + return; + } + } + await storage.write( + key: SecureStorageKeys.twonlySafeLastBackupHash, + value: backupHash, + ); + + // Encrypt backup data + + final xchacha20 = Xchacha20.poly1305Aead(); + final nonce = xchacha20.newNonce(); + + final secretBox = await xchacha20.encrypt( + backupBytes, + secretKey: SecretKey(user.twonlySafeBackup!.encryptionKey), + nonce: nonce, + ); + + final encryptedBackupBytes = (TwonlySafeBackupEncrypted( + mac: secretBox.mac.bytes, + nonce: nonce, + cipherText: secretBox.cipherText, + )).writeToBuffer(); + + Log.info("Backup files created."); + + var encryptedBackupBytesFile = + File(join(backupDir.path, "twonly_safe.backup")); + + await encryptedBackupBytesFile.writeAsBytes(encryptedBackupBytes); + + Log.info( + "Create twonly Safe backup with a size of ${encryptedBackupBytes.length} bytes."); + + if (user.backupServer != null) { + if (encryptedBackupBytes.length > user.backupServer!.maxBackupBytes) { + Log.error("Backup is to big for the alternative backup server."); + await updateUserdata((user) { + user.twonlySafeBackup!.backupUploadState = LastBackupUploadState.failed; + return user; + }); + return; + } + } + + final task = UploadTask.fromFile( + taskId: "backup", + file: encryptedBackupBytesFile, + httpRequestMethod: "PUT", + url: (await getTwonlySafeBackupUrl())!, + requiresWiFi: true, + post: 'binary', + priority: 5, + retries: 2, + headers: { + "Content-Type": "application/octet-stream", + }, + ); + if (await FileDownloader().enqueue(task)) { + Log.info("Starting upload from twonly Safe backup."); + await updateUserdata((user) { + user.twonlySafeBackup!.backupUploadState = LastBackupUploadState.pending; + user.twonlySafeBackup!.lastBackupDone = DateTime.now(); + user.twonlySafeBackup!.lastBackupSize = encryptedBackupBytes.length; + return user; + }); + gUpdateBackupView(); + } else { + Log.error("Error starting UploadTask for twonly Safe."); + } +} + +Future handleBackupStatusUpdate(TaskStatusUpdate update) async { + if (update.status == TaskStatus.failed || + update.status == TaskStatus.canceled) { + Log.error( + "twonly Safe upload failed. ${update.responseStatusCode} ${update.responseBody} ${update.responseHeaders} ${update.exception}"); + await updateUserdata((user) { + if (user.twonlySafeBackup != null) { + user.twonlySafeBackup!.backupUploadState = LastBackupUploadState.failed; + } + return user; + }); + } else if (update.status == TaskStatus.complete) { + Log.error( + "twonly Safe uploaded with status code ${update.responseStatusCode}"); + await updateUserdata((user) { + if (user.twonlySafeBackup != null) { + user.twonlySafeBackup!.backupUploadState = + LastBackupUploadState.success; + } + return user; + }); + } else { + Log.info("Backup is in state: ${update.status}"); + return; + } + gUpdateBackupView(); +} diff --git a/lib/src/services/twonly_safe/restore.twonly_safe.dart b/lib/src/services/twonly_safe/restore.twonly_safe.dart new file mode 100644 index 0000000..6cb8c71 --- /dev/null +++ b/lib/src/services/twonly_safe/restore.twonly_safe.dart @@ -0,0 +1,101 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:cryptography_plus/cryptography_plus.dart'; +import 'package:drift/drift.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:twonly/src/constants/secure_storage_keys.dart'; +import 'package:twonly/src/model/json/userdata.dart'; +import 'package:twonly/src/model/protobuf/backup/backup.pb.dart'; +import 'package:twonly/src/services/twonly_safe/common.twonly_safe.dart'; +import 'package:twonly/src/utils/log.dart'; + +Future recoverTwonlySafe( + String username, + String password, + BackupServer? server, +) async { + final (backupId, encryptionKey) = await getMasterKey(password, username); + + String? backupServerUrl = + await getTwonlySafeBackupUrlFromServer(backupId, server); + + if (backupServerUrl == null) { + Log.error("Could not create backup url"); + throw Exception("Could not create backup server url"); + } + + late Uint8List backupData; + late http.Response response; + + try { + response = await http.get(Uri.parse(backupServerUrl), headers: { + HttpHeaders.acceptHeader: 'application/octet-stream', + }); + } catch (e) { + Log.error('Error fetching backup: $e'); + throw Exception("Backup server could not be reached. ($e)"); + } + + switch (response.statusCode) { + case 200: + backupData = response.bodyBytes; + case 400: + throw Exception('Bad Request: Validation failed.'); + case 404: + throw Exception('No backup was found.'); + case 429: + throw Exception('Too Many Requests: Rate limit reached.'); + default: + throw Exception('Unexpected error: ${response.statusCode}'); + } + + return await handleBackupData(encryptionKey, backupData); +} + +Future handleBackupData( + Uint8List encryptionKey, + Uint8List backupData, +) async { + TwonlySafeBackupEncrypted encryptedBackup = + TwonlySafeBackupEncrypted.fromBuffer( + backupData, + ); + + SecretBox secretBox = SecretBox( + encryptedBackup.cipherText, + nonce: encryptedBackup.nonce, + mac: Mac(encryptedBackup.mac), + ); + + final compressedBytes = await Xchacha20.poly1305Aead().decrypt( + secretBox, + secretKey: SecretKeyData(encryptionKey), + ); + + final plaintextBytes = gzip.decode(compressedBytes); + + TwonlySafeBackupContent backupContent = TwonlySafeBackupContent.fromBuffer( + plaintextBytes, + ); + + final baseDir = (await getApplicationSupportDirectory()).path; + final originalDatabase = File(join(baseDir, "twonly_database.sqlite")); + await originalDatabase.writeAsBytes(backupContent.twonlyDatabase); + + final storage = FlutterSecureStorage(); + + final secureStorage = jsonDecode(backupContent.secureStorageJson); + + await storage.write( + key: SecureStorageKeys.signalIdentity, + value: secureStorage[SecureStorageKeys.signalIdentity]); + await storage.write( + key: SecureStorageKeys.signalSignedPreKey, + value: secureStorage[SecureStorageKeys.signalSignedPreKey]); + await storage.write( + key: SecureStorageKeys.userData, + value: secureStorage[SecureStorageKeys.userData]); +} diff --git a/lib/src/views/components/flame.dart b/lib/src/views/components/flame.dart index 0aa408e..19af603 100644 --- a/lib/src/views/components/flame.dart +++ b/lib/src/views/components/flame.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:twonly/app.dart'; +import 'package:twonly/globals.dart'; import 'package:twonly/src/views/components/animate_icon.dart'; import 'package:twonly/src/database/twonly_database.dart'; diff --git a/lib/src/views/onboarding.view.dart b/lib/src/views/onboarding/onboarding.view.dart similarity index 100% rename from lib/src/views/onboarding.view.dart rename to lib/src/views/onboarding/onboarding.view.dart diff --git a/lib/src/views/onboarding/recover.view.dart b/lib/src/views/onboarding/recover.view.dart new file mode 100644 index 0000000..35711cb --- /dev/null +++ b/lib/src/views/onboarding/recover.view.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:restart_app/restart_app.dart'; +import 'package:twonly/src/model/json/userdata.dart'; +import 'package:twonly/src/services/twonly_safe/restore.twonly_safe.dart'; +import 'package:twonly/src/utils/log.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/views/components/alert_dialog.dart'; +import 'package:twonly/src/views/settings/backup/twonly_safe_server.view.dart'; + +class BackupRecoveryView extends StatefulWidget { + const BackupRecoveryView({super.key}); + + @override + State createState() => _BackupRecoveryViewState(); +} + +class _BackupRecoveryViewState extends State { + bool obscureText = true; + bool isLoading = false; + BackupServer? backupServer; + final TextEditingController usernameCtrl = TextEditingController(); + final TextEditingController passwordCtrl = TextEditingController(); + + Future _recoverTwonlySafe() async { + setState(() { + isLoading = true; + }); + + try { + await recoverTwonlySafe( + usernameCtrl.text, + passwordCtrl.text, + backupServer, + ); + + Restart.restartApp( + notificationTitle: 'Backup successfully recovered.', + notificationBody: 'Click here to open the app again', + ); + } catch (e) { + // in case something was already written from the backup... + Log.error("$e"); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$e'), + duration: Duration(seconds: 3), + ), + ); + } + } + + setState(() { + isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("twonly Safe ${context.lang.twonlySafeRecoverTitle}"), + actions: [ + IconButton( + onPressed: () { + showAlertDialog( + context, + "twonly Safe", + context.lang.backupTwonlySafeLongDesc, + ); + }, + icon: FaIcon(FontAwesomeIcons.circleInfo), + iconSize: 18, + ) + ], + ), + body: Padding( + padding: EdgeInsetsGeometry.symmetric(vertical: 40, horizontal: 40), + child: ListView( + children: [ + Text( + context.lang.twonlySafeRecoverDesc, + textAlign: TextAlign.center, + ), + const SizedBox(height: 30), + TextField( + controller: usernameCtrl, + onChanged: (value) { + setState(() {}); + }, + style: TextStyle(fontSize: 17), + decoration: getInputDecoration( + context, + context.lang.registerUsernameDecoration, + ), + ), + SizedBox(height: 10), + Stack( + children: [ + TextField( + controller: passwordCtrl, + onChanged: (value) { + setState(() {}); + }, + style: TextStyle(fontSize: 17), + obscureText: obscureText, + decoration: getInputDecoration( + context, + context.lang.password, + ), + ), + Positioned( + right: 0, + top: 0, + bottom: 0, + child: IconButton( + onPressed: () { + setState(() { + obscureText = !obscureText; + }); + }, + icon: FaIcon( + obscureText + ? FontAwesomeIcons.eye + : FontAwesomeIcons.eyeSlash, + size: 16, + ), + ), + ) + ], + ), + SizedBox(height: 30), + Center( + child: OutlinedButton( + onPressed: () async { + backupServer = await Navigator.push(context, + MaterialPageRoute(builder: (context) { + return TwonlySafeServerView(); + })); + setState(() {}); + }, + child: Text(context.lang.backupExpertSettings), + ), + ), + SizedBox(height: 10), + Center( + child: FilledButton.icon( + onPressed: (!isLoading) ? _recoverTwonlySafe : null, + icon: isLoading + ? SizedBox( + height: 12, + width: 12, + child: CircularProgressIndicator(strokeWidth: 1), + ) + : Icon(Icons.lock_clock_rounded), + label: Text(context.lang.twonlySafeRecoverBtn), + )) + ], + ), + ), + ); + } +} diff --git a/lib/src/views/register.view.dart b/lib/src/views/onboarding/register.view.dart similarity index 93% rename from lib/src/views/register.view.dart rename to lib/src/views/onboarding/register.view.dart index 1ee3a1c..1e92352 100644 --- a/lib/src/views/register.view.dart +++ b/lib/src/views/onboarding/register.view.dart @@ -12,6 +12,7 @@ import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/views/components/alert_dialog.dart'; import 'package:twonly/src/model/json/userdata.dart'; import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/views/onboarding/recover.view.dart'; class RegisterView extends StatefulWidget { const RegisterView({super.key, required this.callbackOnSuccess}); @@ -26,6 +27,7 @@ class _RegisterViewState extends State { final TextEditingController inviteCodeController = TextEditingController(); bool _isTryingToRegister = false; + bool _isValidUserName = false; Future createNewUser({bool isDemoAccount = false}) async { String username = (isDemoAccount) ? "" : usernameController.text; @@ -134,6 +136,9 @@ class _RegisterViewState extends State { usernameController.selection = TextSelection.fromPosition( TextPosition(offset: usernameController.text.length), ); + setState(() { + _isValidUserName = usernameController.text.length >= 3; + }); }, inputFormatters: [ LengthLimitingTextInputFormatter(12), @@ -181,9 +186,7 @@ class _RegisterViewState extends State { ), ) : Icon(Icons.group), - onPressed: () async { - createNewUser(); - }, + onPressed: _isValidUserName ? createNewUser : null, style: ButtonStyle( padding: WidgetStateProperty.all( EdgeInsets.symmetric(vertical: 10, horizontal: 30), @@ -209,8 +212,11 @@ class _RegisterViewState extends State { ), OutlinedButton.icon( onPressed: () { - showAlertDialog(context, "Coming soon", - "This feature is not yet implemented! Just create a new account :/"); + Navigator.push(context, MaterialPageRoute( + builder: (context) { + return BackupRecoveryView(); + }, + )); }, label: Text("Restore identity"), ), diff --git a/lib/src/views/settings/backup/backup.view.dart b/lib/src/views/settings/backup/backup.view.dart index 4b8bb63..06323e2 100644 --- a/lib/src/views/settings/backup/backup.view.dart +++ b/lib/src/views/settings/backup/backup.view.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/src/model/json/userdata.dart'; -import 'package:twonly/src/services/twonly_safe/common.service.dart'; -import 'package:twonly/src/services/twonly_safe/create_backup.service.dart'; +import 'package:twonly/src/services/twonly_safe/common.twonly_safe.dart'; +import 'package:twonly/src/services/twonly_safe/create_backup.twonly_safe.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/views/components/alert_dialog.dart'; diff --git a/lib/src/views/settings/backup/twonly_safe_backup.view.dart b/lib/src/views/settings/backup/twonly_safe_backup.view.dart index bd1041a..34e66d1 100644 --- a/lib/src/views/settings/backup/twonly_safe_backup.view.dart +++ b/lib/src/views/settings/backup/twonly_safe_backup.view.dart @@ -2,7 +2,7 @@ import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:twonly/src/services/twonly_safe/common.service.dart'; +import 'package:twonly/src/services/twonly_safe/common.twonly_safe.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/components/alert_dialog.dart'; import 'package:twonly/src/views/settings/backup/twonly_safe_server.view.dart'; @@ -60,8 +60,11 @@ class _TwonlyIdentityBackupViewState extends State { actions: [ IconButton( onPressed: () { - showAlertDialog(context, "twonly Safe", - context.lang.backupTwonlySafeLongDesc); + showAlertDialog( + context, + "twonly Safe", + context.lang.backupTwonlySafeLongDesc, + ); }, icon: FaIcon(FontAwesomeIcons.circleInfo), iconSize: 18, diff --git a/lib/src/views/settings/backup/twonly_safe_server.view.dart b/lib/src/views/settings/backup/twonly_safe_server.view.dart index 714e703..2db1522 100644 --- a/lib/src/views/settings/backup/twonly_safe_server.view.dart +++ b/lib/src/views/settings/backup/twonly_safe_server.view.dart @@ -72,14 +72,15 @@ class _TwonlySafeServerViewState extends State { final data = jsonDecode(response.body); final backupServer = BackupServer( - serverUrl: serverUrl, - retentionDays: data["retentionDays"]!, - maxBackupBytes: data["maxBackupBytes"]!); + serverUrl: serverUrl, + retentionDays: data["retentionDays"]!, + maxBackupBytes: data["maxBackupBytes"]!, + ); await updateUserdata((user) { user.backupServer = backupServer; return user; }); - if (mounted) Navigator.pop(context); + if (mounted) Navigator.pop(context, backupServer); } else { // If the server did not return a 200 OK response, throw an exception. throw Exception(