recovery does work #121

This commit is contained in:
otsmr 2025-06-19 10:22:16 +02:00
parent 78ca650166
commit 4108d9a798
24 changed files with 598 additions and 56 deletions

View file

@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/localization/generated/app_localizations.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/api/media_upload.dart';
import 'package:twonly/src/services/notification.service.dart'; import 'package:twonly/src/services/notification.service.dart';
import 'package:twonly/src/utils/storage.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/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/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'dart:async'; 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 // these two callbacks are called on updated to the corresponding database
/// The Widget that configures your application. /// The Widget that configures your application.
@ -161,7 +153,7 @@ class AppMainWidget extends StatefulWidget {
class _AppMainWidgetState extends State<AppMainWidget> { class _AppMainWidgetState extends State<AppMainWidget> {
Future<bool> userCreated = isUserCreated(); Future<bool> userCreated = isUserCreated();
bool showOnboarding = true; bool showOnboarding = kReleaseMode;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View file

@ -9,3 +9,13 @@ late TwonlyDatabase twonlyDB;
List<CameraDescription> gCameras = <CameraDescription>[]; List<CameraDescription> gCameras = <CameraDescription>[];
bool gIsDemoUser = false; 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;

View file

@ -12,7 +12,7 @@ import 'package:twonly/src/providers/connection.provider.dart';
import 'package:twonly/src/providers/settings.provider.dart'; import 'package:twonly/src/providers/settings.provider.dart';
import 'package:twonly/src/services/fcm.service.dart'; import 'package:twonly/src/services/fcm.service.dart';
import 'package:twonly/src/services/notification.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/log.dart';
import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/utils/storage.dart';
import 'app.dart'; import 'app.dart';

View file

@ -15,6 +15,7 @@ enum MessageKind {
ack, ack,
pushKey, pushKey,
receiveMediaError, receiveMediaError,
signalDecryptError
} }
enum DownloadState { enum DownloadState {

View file

@ -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.", "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", "backupInsecurePasswordOk": "Trotzdem fortfahren",
"backupInsecurePasswordCancel": "Erneut versuchen", "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.", "backupSelectStrongPassword": "Wähle ein sicheres Passwort. Dies ist erforderlich, wenn du dein twonly Safe-Backup wiederherstellen möchtest.",
"password": "Passwort", "password": "Passwort",
"passwordRepeated": "Passwort wiederholen", "passwordRepeated": "Passwort wiederholen",

View file

@ -449,5 +449,8 @@
"backupOwnServerDesc": "Save your twonly safe backups at twonly or on any server of your choice.", "backupOwnServerDesc": "Save your twonly safe backups at twonly or on any server of your choice.",
"backupUseOwnServer": "Use server", "backupUseOwnServer": "Use server",
"backupResetServer": "Use standard 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"
} }

View file

@ -1795,6 +1795,24 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Save now'** /// **'Save now'**
String get backupTwonlySaveNow; 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 class _AppLocalizationsDelegate

View file

@ -916,7 +916,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get backupTwonlySafeLongDesc => 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 @override
String get backupSelectStrongPassword => String get backupSelectStrongPassword =>
@ -953,4 +953,14 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get backupTwonlySaveNow => 'Jetzt speichern'; 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';
} }

View file

@ -947,4 +947,14 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get backupTwonlySaveNow => 'Save now'; 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';
} }

View file

@ -11,7 +11,6 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:mutex/mutex.dart'; import 'package:mutex/mutex.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/app.dart';
import 'package:twonly/src/constants/secure_storage_keys.dart'; import 'package:twonly/src/constants/secure_storage_keys.dart';
import 'package:twonly/src/database/twonly_database.dart'; import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/model/protobuf/api/websocket/client_to_server.pbserver.dart'; import 'package:twonly/src/model/protobuf/api/websocket/client_to_server.pbserver.dart';

View file

@ -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/api/media_download.dart';
import 'package:twonly/src/services/notification.service.dart'; import 'package:twonly/src/services/notification.service.dart';
import 'package:twonly/src/services/signal/encryption.signal.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/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/utils/storage.dart';

View file

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:cryptography_plus/cryptography_plus.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/database/twonly_database.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/message.dart';
import 'package:twonly/src/model/json/userdata.dart'; import 'package:twonly/src/model/json/userdata.dart';
import 'package:twonly/src/model/protobuf/api/websocket/error.pb.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/api/utils.dart';
import 'package:twonly/src/services/signal/encryption.signal.dart'; import 'package:twonly/src/services/signal/encryption.signal.dart';
import 'package:twonly/src/services/notification.service.dart'; import 'package:twonly/src/services/notification.service.dart';
@ -98,6 +100,10 @@ Future sendRetransmitMessage(int retransId) async {
return; return;
} }
var hash = uint8ListToHex(
Uint8List.fromList((await Sha256().hash(encryptedBytes)).bytes));
Log.info("Sending message: ${hash.substring(0, 10)}");
Result resp = await apiService.sendTextMessage( Result resp = await apiService.sendTextMessage(
retrans.contactId, retrans.contactId,
encryptedBytes, encryptedBytes,

View file

@ -2,7 +2,6 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/app.dart';
import 'package:twonly/src/constants/secure_storage_keys.dart'; import 'package:twonly/src/constants/secure_storage_keys.dart';
import 'package:twonly/src/database/twonly_database.dart'; import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/services/notification.service.dart'; import 'package:twonly/src/services/notification.service.dart';

View file

@ -4,26 +4,10 @@ import 'package:hashlib/hashlib.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:twonly/src/model/json/userdata.dart'; import 'package:twonly/src/model/json/userdata.dart';
import 'package:twonly/src/services/api/media_upload.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/log.dart';
import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/utils/storage.dart';
Future<String?> 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 { Future enableTwonlySafe(String password) async {
final user = await getUser(); final user = await getUser();
if (user == null) return; if (user == null) return;
@ -69,9 +53,9 @@ Future<(Uint8List, Uint8List)> getMasterKey(
List<int> passwordBytes = utf8.encode(password); List<int> passwordBytes = utf8.encode(password);
List<int> saltBytes = utf8.encode(username); List<int> 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( final scrypt = Scrypt(
cost: 65536, cost: 65536,
blockSize: 8, blockSize: 8,
@ -80,8 +64,30 @@ Future<(Uint8List, Uint8List)> getMasterKey(
salt: saltBytes, salt: saltBytes,
); );
// Derive the key
// final key = (await compute(scrypt.convert, passwordBytes)).bytes;
final key = (scrypt.convert(passwordBytes)).bytes; final key = (scrypt.convert(passwordBytes)).bytes;
return (key.sublist(0, 32), key.sublist(32, 64)); return (key.sublist(0, 32), key.sublist(32, 64));
} }
Future<String?> getTwonlySafeBackupUrl() async {
final user = await getUser();
if (user == null || user.twonlySafeBackup == null) return null;
return getTwonlySafeBackupUrlFromServer(
user.twonlySafeBackup!.backupId,
user.backupServer,
);
}
Future<String?> getTwonlySafeBackupUrlFromServer(
List<int> 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";
}

View file

@ -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/json/userdata.dart';
import 'package:twonly/src/model/protobuf/backup/backup.pb.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/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/log.dart';
import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/views/settings/backup/backup.view.dart'; import 'package:twonly/src/views/settings/backup/backup.view.dart';

View file

@ -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();
}

View file

@ -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]);
}

View file

@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; 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/views/components/animate_icon.dart';
import 'package:twonly/src/database/twonly_database.dart'; import 'package:twonly/src/database/twonly_database.dart';

View file

@ -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<BackupRecoveryView> createState() => _BackupRecoveryViewState();
}
class _BackupRecoveryViewState extends State<BackupRecoveryView> {
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),
))
],
),
),
);
}
}

View file

@ -12,6 +12,7 @@ import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/views/components/alert_dialog.dart'; import 'package:twonly/src/views/components/alert_dialog.dart';
import 'package:twonly/src/model/json/userdata.dart'; import 'package:twonly/src/model/json/userdata.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/onboarding/recover.view.dart';
class RegisterView extends StatefulWidget { class RegisterView extends StatefulWidget {
const RegisterView({super.key, required this.callbackOnSuccess}); const RegisterView({super.key, required this.callbackOnSuccess});
@ -26,6 +27,7 @@ class _RegisterViewState extends State<RegisterView> {
final TextEditingController inviteCodeController = TextEditingController(); final TextEditingController inviteCodeController = TextEditingController();
bool _isTryingToRegister = false; bool _isTryingToRegister = false;
bool _isValidUserName = false;
Future createNewUser({bool isDemoAccount = false}) async { Future createNewUser({bool isDemoAccount = false}) async {
String username = (isDemoAccount) ? "<demo>" : usernameController.text; String username = (isDemoAccount) ? "<demo>" : usernameController.text;
@ -134,6 +136,9 @@ class _RegisterViewState extends State<RegisterView> {
usernameController.selection = TextSelection.fromPosition( usernameController.selection = TextSelection.fromPosition(
TextPosition(offset: usernameController.text.length), TextPosition(offset: usernameController.text.length),
); );
setState(() {
_isValidUserName = usernameController.text.length >= 3;
});
}, },
inputFormatters: [ inputFormatters: [
LengthLimitingTextInputFormatter(12), LengthLimitingTextInputFormatter(12),
@ -181,9 +186,7 @@ class _RegisterViewState extends State<RegisterView> {
), ),
) )
: Icon(Icons.group), : Icon(Icons.group),
onPressed: () async { onPressed: _isValidUserName ? createNewUser : null,
createNewUser();
},
style: ButtonStyle( style: ButtonStyle(
padding: WidgetStateProperty.all<EdgeInsets>( padding: WidgetStateProperty.all<EdgeInsets>(
EdgeInsets.symmetric(vertical: 10, horizontal: 30), EdgeInsets.symmetric(vertical: 10, horizontal: 30),
@ -209,8 +212,11 @@ class _RegisterViewState extends State<RegisterView> {
), ),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () { onPressed: () {
showAlertDialog(context, "Coming soon", Navigator.push(context, MaterialPageRoute(
"This feature is not yet implemented! Just create a new account :/"); builder: (context) {
return BackupRecoveryView();
},
));
}, },
label: Text("Restore identity"), label: Text("Restore identity"),
), ),

View file

@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/model/json/userdata.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/common.twonly_safe.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/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/views/components/alert_dialog.dart'; import 'package:twonly/src/views/components/alert_dialog.dart';

View file

@ -2,7 +2,7 @@ import 'package:flutter/services.dart' show rootBundle;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.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/utils/misc.dart';
import 'package:twonly/src/views/components/alert_dialog.dart'; import 'package:twonly/src/views/components/alert_dialog.dart';
import 'package:twonly/src/views/settings/backup/twonly_safe_server.view.dart'; import 'package:twonly/src/views/settings/backup/twonly_safe_server.view.dart';
@ -60,8 +60,11 @@ class _TwonlyIdentityBackupViewState extends State<TwonlyIdentityBackupView> {
actions: [ actions: [
IconButton( IconButton(
onPressed: () { onPressed: () {
showAlertDialog(context, "twonly Safe", showAlertDialog(
context.lang.backupTwonlySafeLongDesc); context,
"twonly Safe",
context.lang.backupTwonlySafeLongDesc,
);
}, },
icon: FaIcon(FontAwesomeIcons.circleInfo), icon: FaIcon(FontAwesomeIcons.circleInfo),
iconSize: 18, iconSize: 18,

View file

@ -74,12 +74,13 @@ class _TwonlySafeServerViewState extends State<TwonlySafeServerView> {
final backupServer = BackupServer( final backupServer = BackupServer(
serverUrl: serverUrl, serverUrl: serverUrl,
retentionDays: data["retentionDays"]!, retentionDays: data["retentionDays"]!,
maxBackupBytes: data["maxBackupBytes"]!); maxBackupBytes: data["maxBackupBytes"]!,
);
await updateUserdata((user) { await updateUserdata((user) {
user.backupServer = backupServer; user.backupServer = backupServer;
return user; return user;
}); });
if (mounted) Navigator.pop(context); if (mounted) Navigator.pop(context, backupServer);
} else { } else {
// If the server did not return a 200 OK response, throw an exception. // If the server did not return a 200 OK response, throw an exception.
throw Exception( throw Exception(