From 991376e1d978afaa7293c77f90ecd9fc7a23520d Mon Sep 17 00:00:00 2001 From: otsmr Date: Tue, 23 Jun 2026 21:07:26 +0200 Subject: [PATCH] poc for recovery works --- lib/app.dart | 5 +- lib/core/bridge/wrapper/key_manager.dart | 6 + lib/core/frb_generated.dart | 93 ++-- .../generated/app_localizations.dart | 12 + .../generated/app_localizations_de.dart | 7 + .../generated/app_localizations_en.dart | 7 + lib/src/localization/translations | 2 +- .../model/json/onboarding_state.model.dart | 3 + .../model/json/onboarding_state.model.g.dart | 3 + lib/src/providers/routing.provider.dart | 5 +- lib/src/services/api.service.dart | 28 ++ lib/src/services/backup.service.dart | 20 + .../passwordless_recovery.service.dart | 19 +- lib/src/utils/misc.dart | 22 +- .../onboarding/recover_password.view.dart | 15 +- .../onboarding/recover_passwordless.view.dart | 423 +++++++++++++++++- .../recovery_from_secure_storage.view.dart | 15 +- rust/src/bridge/wrapper/key_manager.rs | 9 + rust/src/frb_generated.rs | 95 ++-- test/services/backup_service_test.dart | 33 ++ .../passwordless_recovery_service_test.dart | 6 +- 21 files changed, 726 insertions(+), 102 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 5d77c850..316d621f 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -191,7 +191,8 @@ class _AppMainWidgetState extends State { _proofOfWork = (null, disabled); } - if (onboardingState.hasStartedPasswordlessRecovery) { + if (onboardingState.hasStartedPasswordlessRecovery || + onboardingState.emailRecoveryRequested) { WidgetsBinding.instance.addPostFrameCallback((_) { routerProvider.push(Routes.recoverPasswordless); }); @@ -199,7 +200,7 @@ class _AppMainWidgetState extends State { } await PasswordlessRecoveryService.handleRecoveryLink( - 'https://me.twonly.eu/r/#d0e204fa-3d1e-4e53-a6a5-53aadf1e2ee5/oHeAEPp_CivU3QO67L0AkSlM94E5klbP8XDLbNgsGmA', + 'https://me.twonly.eu/r/#7fdb8f08-0927-4e44-8761-038993414e48/1p7SKEzpxE3wSW9FQw60EUI4OpSW2U4EskdXLw8xg48', ); setState(() { diff --git a/lib/core/bridge/wrapper/key_manager.dart b/lib/core/bridge/wrapper/key_manager.dart index a3517edc..6e6e5095 100644 --- a/lib/core/bridge/wrapper/key_manager.dart +++ b/lib/core/bridge/wrapper/key_manager.dart @@ -20,6 +20,12 @@ class RustKeyManager { static Future getUserId() => RustLib.instance.api .crateBridgeWrapperKeyManagerRustKeyManagerGetUserId(); + static Future importSerialized({required List serializedBytes}) => + RustLib.instance.api + .crateBridgeWrapperKeyManagerRustKeyManagerImportSerialized( + serializedBytes: serializedBytes, + ); + static Future importSignalIdentity({ required List identityKeyPairStructure, required PlatformInt64 registrationId, diff --git a/lib/core/frb_generated.dart b/lib/core/frb_generated.dart index 376bc9ad..b0b840d8 100644 --- a/lib/core/frb_generated.dart +++ b/lib/core/frb_generated.dart @@ -76,7 +76,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => -508562557; + int get rustContentHash => 340781866; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -212,6 +212,10 @@ abstract class RustLibApi extends BaseApi { Future crateBridgeWrapperKeyManagerRustKeyManagerGetUserId(); + Future crateBridgeWrapperKeyManagerRustKeyManagerImportSerialized({ + required List serializedBytes, + }); + Future crateBridgeWrapperKeyManagerRustKeyManagerImportSignalIdentity({ required List identityKeyPairStructure, required PlatformInt64 registrationId, @@ -1234,6 +1238,41 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: [], ); + @override + Future crateBridgeWrapperKeyManagerRustKeyManagerImportSerialized({ + required List serializedBytes, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_list_prim_u_8_loose(serializedBytes, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 21, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: + kCrateBridgeWrapperKeyManagerRustKeyManagerImportSerializedConstMeta, + argValues: [serializedBytes], + apiImpl: this, + ), + ); + } + + TaskConstMeta + get kCrateBridgeWrapperKeyManagerRustKeyManagerImportSerializedConstMeta => + const TaskConstMeta( + debugName: "rust_key_manager_import_serialized", + argNames: ["serializedBytes"], + ); + @override Future crateBridgeWrapperKeyManagerRustKeyManagerImportSignalIdentity({ required List identityKeyPairStructure, @@ -1253,7 +1292,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 21, + funcId: 22, port: port_, ); }, @@ -1297,7 +1336,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 22, + funcId: 23, port: port_, ); }, @@ -1330,7 +1369,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 23, + funcId: 24, port: port_, ); }, @@ -1362,7 +1401,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 24, + funcId: 25, port: port_, ); }, @@ -1397,7 +1436,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 25, + funcId: 26, port: port_, ); }, @@ -1429,7 +1468,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 26, + funcId: 27, port: port_, ); }, @@ -1464,7 +1503,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 27, + funcId: 28, port: port_, ); }, @@ -1501,7 +1540,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 28, + funcId: 29, port: port_, ); }, @@ -1540,7 +1579,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 29, + funcId: 30, port: port_, ); }, @@ -1575,7 +1614,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 30, + funcId: 31, port: port_, ); }, @@ -1611,7 +1650,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 31, + funcId: 32, port: port_, ); }, @@ -1648,7 +1687,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 32, + funcId: 33, port: port_, ); }, @@ -1686,7 +1725,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 33, + funcId: 34, port: port_, ); }, @@ -1724,7 +1763,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 34, + funcId: 35, port: port_, ); }, @@ -1762,7 +1801,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 35, + funcId: 36, port: port_, ); }, @@ -1801,7 +1840,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 36, + funcId: 37, port: port_, ); }, @@ -1840,7 +1879,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 37, + funcId: 38, port: port_, ); }, @@ -1885,7 +1924,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 38, + funcId: 39, port: port_, ); }, @@ -1937,7 +1976,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 39, + funcId: 40, port: port_, ); }, @@ -1978,7 +2017,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 40, + funcId: 41, port: port_, ); }, @@ -2016,7 +2055,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 41, + funcId: 42, port: port_, ); }, @@ -2054,7 +2093,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 42, + funcId: 43, port: port_, ); }, @@ -2092,7 +2131,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 43, + funcId: 44, port: port_, ); }, @@ -2130,7 +2169,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 44, + funcId: 45, port: port_, ); }, @@ -2172,7 +2211,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 45, + funcId: 46, port: port_, ); }, @@ -2212,7 +2251,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 46, + funcId: 47, port: port_, ); }, diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index 73bca631..4fcaac54 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -4032,6 +4032,18 @@ abstract class AppLocalizations { /// **'Network error, please ensure you have internet'** String get passwordlessRecoveryNetworkError; + /// No description provided for @passwordlessRecoveryInvalidEmail. + /// + /// In en, this message translates to: + /// **'The email address is invalid.'** + String get passwordlessRecoveryInvalidEmail; + + /// No description provided for @passwordlessRecoveryResendEmail. + /// + /// In en, this message translates to: + /// **'Resend recovery email'** + String get passwordlessRecoveryResendEmail; + /// No description provided for @passwordlessRecoveryHelpAFriend. /// /// In en, this message translates to: diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index c9f25363..51c7913e 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -2325,6 +2325,13 @@ class AppLocalizationsDe extends AppLocalizations { String get passwordlessRecoveryNetworkError => 'Netzwerkfehler, bitte stelle sicher, dass du Internet hast'; + @override + String get passwordlessRecoveryInvalidEmail => + 'Die E-Mail-Adresse ist ungültig.'; + + @override + String get passwordlessRecoveryResendEmail => 'E-Mail erneut senden'; + @override String get passwordlessRecoveryHelpAFriend => 'Einem Freund helfen'; diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index ced3438a..80ecf519 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -2306,6 +2306,13 @@ class AppLocalizationsEn extends AppLocalizations { String get passwordlessRecoveryNetworkError => 'Network error, please ensure you have internet'; + @override + String get passwordlessRecoveryInvalidEmail => + 'The email address is invalid.'; + + @override + String get passwordlessRecoveryResendEmail => 'Resend recovery email'; + @override String get passwordlessRecoveryHelpAFriend => 'Help a Friend'; diff --git a/lib/src/localization/translations b/lib/src/localization/translations index bd641ce3..147f44e6 160000 --- a/lib/src/localization/translations +++ b/lib/src/localization/translations @@ -1 +1 @@ -Subproject commit bd641ce3a3f3b9c709dc97a279d9fe8b9b6f07e1 +Subproject commit 147f44e608f39f04af3b3be9aee1ad662e5a84e1 diff --git a/lib/src/model/json/onboarding_state.model.dart b/lib/src/model/json/onboarding_state.model.dart index 25a6d6ab..cdd4eac4 100644 --- a/lib/src/model/json/onboarding_state.model.dart +++ b/lib/src/model/json/onboarding_state.model.dart @@ -43,6 +43,7 @@ class OnboardingState { this.downloadAuthToken, this.serverRegistered = false, this.encryptionKey, + this.emailRecoveryRequested = false, }); factory OnboardingState.fromJson(Map json) => @@ -55,6 +56,8 @@ class OnboardingState { List? downloadAuthToken; bool serverRegistered; List? encryptionKey; + bool emailRecoveryRequested; + List receivedShares = []; diff --git a/lib/src/model/json/onboarding_state.model.g.dart b/lib/src/model/json/onboarding_state.model.g.dart index d6fc82d6..67ba5930 100644 --- a/lib/src/model/json/onboarding_state.model.g.dart +++ b/lib/src/model/json/onboarding_state.model.g.dart @@ -47,6 +47,8 @@ OnboardingState _$OnboardingStateFromJson(Map json) => encryptionKey: (json['encryptionKey'] as List?) ?.map((e) => (e as num).toInt()) .toList(), + emailRecoveryRequested: + json['emailRecoveryRequested'] as bool? ?? false, ) ..receivedShares = (json['receivedShares'] as List) .map((e) => ReceivedRecoveryShare.fromJson(e as Map)) @@ -60,5 +62,6 @@ Map _$OnboardingStateToJson(OnboardingState instance) => 'downloadAuthToken': instance.downloadAuthToken, 'serverRegistered': instance.serverRegistered, 'encryptionKey': instance.encryptionKey, + 'emailRecoveryRequested': instance.emailRecoveryRequested, 'receivedShares': instance.receivedShares, }; diff --git a/lib/src/providers/routing.provider.dart b/lib/src/providers/routing.provider.dart index 691b9a49..1412bec8 100644 --- a/lib/src/providers/routing.provider.dart +++ b/lib/src/providers/routing.provider.dart @@ -61,7 +61,10 @@ final routerProvider = GoRouter( ), GoRoute( path: Routes.recoverPasswordless, - builder: (context, state) => const RecoverPasswordless(), + builder: (context, state) { + final token = state.extra as String?; + return RecoverPasswordless(initialEmailToken: token); + }, ), // Chats diff --git a/lib/src/services/api.service.dart b/lib/src/services/api.service.dart index 30e131ce..b173766f 100644 --- a/lib/src/services/api.service.dart +++ b/lib/src/services/api.service.dart @@ -776,6 +776,34 @@ class ApiService { return sendRequestSync(req); } + Future getServerKeyForPasswordlessRecovery({ + required int userId, + List? encryptedServerKeyNone, + List? pinUnlockToken, + List? pinProtectionKey, + String? email, + }) async { + final get = Handshake_GetServerKeyForPasswordLessRecovery() + ..userId = Int64(userId); + if (encryptedServerKeyNone != null) { + get.encryptedServerKeyNone = encryptedServerKeyNone; + } + if (pinUnlockToken != null) { + get.pinUnlockToken = pinUnlockToken; + } + if (pinProtectionKey != null) { + get.pinProtectionKey = pinProtectionKey; + } + if (email != null) { + get.email = email; + } + + final handshake = Handshake()..getServerKeyForPasswordlessRecovery = get; + final req = createClientToServerFromHandshake(handshake); + return sendRequestSync(req, authenticated: false); + } + + Future submitRecoveryShare({ required String notificationId, required List encryptedMessage, diff --git a/lib/src/services/backup.service.dart b/lib/src/services/backup.service.dart index 454655aa..42fb4e67 100644 --- a/lib/src/services/backup.service.dart +++ b/lib/src/services/backup.service.dart @@ -325,6 +325,26 @@ class BackupService { return _nextBackupStage(); } + static Future startPasswordlessBackupRecovery( + int userId, + String username, + Uint8List keyManagerBytes, + ) async { + final state = BackupRecovery( + username: username, + password: '', + userId: userId, + )..state = BackupRecoveryState.archiveBackupStarted; + + await deleteLocalUserData(); + + // Import KeyManager keys into secure storage & in-memory key manager + await RustKeyManager.importSerialized(serializedBytes: keyManagerBytes); + + await KeyValueStore.put(KeyValueKeys.backupRecoveryState, state.toJson()); + return _nextBackupStage(); + } + static Future<(Uint8List?, RecoveryError?)> _downloadBackup( String backupServerUrl, ) async { diff --git a/lib/src/services/passwordless_recovery.service.dart b/lib/src/services/passwordless_recovery.service.dart index 4504effb..ce76c47f 100644 --- a/lib/src/services/passwordless_recovery.service.dart +++ b/lib/src/services/passwordless_recovery.service.dart @@ -8,10 +8,12 @@ import 'package:cryptography_plus/cryptography_plus.dart' show Hmac, Mac, SecretBox, SecretKey, Xchacha20; import 'package:drift/drift.dart'; import 'package:fixnum/fixnum.dart'; +import 'package:go_router/go_router.dart'; import 'package:twonly/core/bridge/wrapper.dart'; import 'package:twonly/core/bridge/wrapper/key_manager.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/keyvalue.keys.dart'; +import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart' show getContactDisplayName; import 'package:twonly/src/database/twonly.db.dart'; @@ -33,6 +35,9 @@ import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/he enum SecondFactorType { email, pin, none } class PasswordlessRecoveryService { + static final StreamController onEmailTokenReceived = + StreamController.broadcast(); + static String linkPrefix = 'https://me.twonly.eu/r/#'; static final Set _handledNotificationIds = {}; @@ -43,7 +48,16 @@ class PasswordlessRecoveryService { final fragment = link.substring(hashIndex + 1); final parts = fragment.split('/'); - if (parts.length < 2) return; + if (parts.length < 2) { + if (fragment.isNotEmpty) { + onEmailTokenReceived.add(fragment); + final context = rootNavigatorKey.currentContext; + if (context != null && context.mounted) { + unawaited(context.push(Routes.recoverPasswordless, extra: fragment)); + } + } + return; + } final notificationId = parts[0]; final base64Key = parts[1]; @@ -384,7 +398,8 @@ class PasswordlessRecoveryService { clock.now().difference(lastContactHeartbeat).inHours >= 24; if (isContactHeartbeatOlderThan24h) { - // Get all contacts where recoveryLastHeartbeat is NULL. Then for each contacts send + // Get all contacts where recoveryLastHeartbeat is NULL. Then for each contacts send. + // recoveryLastHeartbeat is ONLY updated in case the contact has responded. final pendingShares = await (twonlyDB.select(twonlyDB.contacts)..where( (t) => diff --git a/lib/src/utils/misc.dart b/lib/src/utils/misc.dart index ed71ccde..2c8428eb 100644 --- a/lib/src/utils/misc.dart +++ b/lib/src/utils/misc.dart @@ -16,6 +16,7 @@ import 'package:provider/provider.dart'; import 'package:twonly/src/localization/generated/app_localizations.dart'; import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart'; import 'package:twonly/src/providers/settings.provider.dart'; +import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; @@ -93,10 +94,10 @@ Future saveVideoToGallery( if (!hasAccess) { await Gal.requestAccess(toAlbum: true); } - + var pathToSave = videoPath; File? tempFile; - + try { if (name != null) { final file = File(videoPath); @@ -428,3 +429,20 @@ String createEmailHint(String email) { final restDomain = domainParts.skip(1).join('.'); return '$localMasked@$domainNameMasked${restDomain.isNotEmpty ? '.$restDomain' : ''}'; } + +extension RecoveryErrorLocalization on RecoveryError { + String toLocalizedString(BuildContext context) { + switch (this) { + case RecoveryError.noInternet: + return context.lang.recoverErrorNoInternet; + case RecoveryError.usernameNotValid: + return context.lang.recoverErrorUsernameNotValid; + case RecoveryError.passwordInvalid: + return context.lang.recoverErrorPasswordInvalid; + case RecoveryError.tryAgainLater: + return context.lang.recoverErrorTryAgainLater; + case RecoveryError.unkownError: + return context.lang.recoverErrorUnknown; + } + } +} diff --git a/lib/src/visual/views/onboarding/recover_password.view.dart b/lib/src/visual/views/onboarding/recover_password.view.dart index 5f17a3ed..205949f5 100644 --- a/lib/src/visual/views/onboarding/recover_password.view.dart +++ b/lib/src/visual/views/onboarding/recover_password.view.dart @@ -39,23 +39,10 @@ class _BackupRecoveryViewState extends State { if (!mounted) return; if (error != null) { - String errorMessage; - switch (error) { - case RecoveryError.noInternet: - errorMessage = context.lang.recoverErrorNoInternet; - case RecoveryError.usernameNotValid: - errorMessage = context.lang.recoverErrorUsernameNotValid; - case RecoveryError.passwordInvalid: - errorMessage = context.lang.recoverErrorPasswordInvalid; - case RecoveryError.tryAgainLater: - errorMessage = context.lang.recoverErrorTryAgainLater; - case RecoveryError.unkownError: - errorMessage = context.lang.recoverErrorUnknown; - } setState(() { isLoading = false; }); - return showSnackbar(context, errorMessage); + return showSnackbar(context, error.toLocalizedString(context)); } await Restart.restartApp( diff --git a/lib/src/visual/views/onboarding/recover_passwordless.view.dart b/lib/src/visual/views/onboarding/recover_passwordless.view.dart index 0b536839..78bb8fc9 100644 --- a/lib/src/visual/views/onboarding/recover_passwordless.view.dart +++ b/lib/src/visual/views/onboarding/recover_passwordless.view.dart @@ -1,16 +1,25 @@ import 'dart:async'; import 'dart:convert'; +import 'package:cryptography_plus/cryptography_plus.dart' + show Hmac, Mac, SecretBox, SecretKey, Xchacha20; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hashlib/random.dart'; import 'package:qr_flutter/qr_flutter.dart'; +import 'package:restart_app/restart_app.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:twonly/core/bridge/wrapper.dart' show RustUtils; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/keyvalue.keys.dart'; import 'package:twonly/src/model/json/onboarding_state.model.dart'; +import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart'; +import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart' + as server; +import 'package:twonly/src/model/protobuf/client/generated/passwordless_recovery.pb.dart'; +import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/services/passwordless_recovery.service.dart'; import 'package:twonly/src/utils/keyvalue.dart'; import 'package:twonly/src/utils/log.dart'; @@ -22,7 +31,9 @@ import 'package:twonly/src/visual/elements/my_input.element.dart'; import 'package:twonly/src/visual/views/onboarding/components/animated_bell_icon.comp.dart'; class RecoverPasswordless extends StatefulWidget { - const RecoverPasswordless({super.key}); + const RecoverPasswordless({this.initialEmailToken, super.key}); + + final String? initialEmailToken; @override State createState() => _RecoverPasswordlessState(); @@ -36,15 +47,45 @@ class _RecoverPasswordlessState extends State { OnboardingState? _onboardingState; Timer? _pollTimer; + SharedSecretData? _reconstructedSecret; + bool _isReconstructing = false; + String? _reconstructionError; + + bool _isRecovering = false; + final TextEditingController _secondFactorController = TextEditingController(); + + StreamSubscription? _emailTokenSubscription; + @override void initState() { super.initState(); + _secondFactorController.text = widget.initialEmailToken ?? ''; + _emailTokenSubscription = PasswordlessRecoveryService + .onEmailTokenReceived + .stream + .listen((token) async { + if (mounted) { + final state = _onboardingState; + if (state != null && !state.emailRecoveryRequested) { + state.emailRecoveryRequested = true; + await KeyValueStore.update( + key: KeyValueKeys.onboardingState, + update: (s) => s.emailRecoveryRequested = true, + ); + } + setState(() { + _secondFactorController.text = token; + }); + } + }); _initAsync(); } @override void dispose() { + _emailTokenSubscription?.cancel(); _pollTimer?.cancel(); + _secondFactorController.dispose(); super.dispose(); } @@ -55,6 +96,14 @@ class _RecoverPasswordlessState extends State { KeyValueKeys.onboardingState, ); + if (widget.initialEmailToken != null && !state.emailRecoveryRequested) { + state.emailRecoveryRequested = true; + await KeyValueStore.update( + key: KeyValueKeys.onboardingState, + update: (s) => s.emailRecoveryRequested = true, + ); + } + // 2. Generate fields if they are missing if (state.notificationId == null) { state @@ -94,6 +143,8 @@ class _RecoverPasswordlessState extends State { // 6. If already registered, do an immediate check and start the poll timer if (state.serverRegistered) _startPollTimer(); + + _checkAndReconstruct(); } catch (e) { Log.warn('Error during passwordless recovery initialization: $e'); } finally { @@ -116,7 +167,280 @@ class _RecoverPasswordlessState extends State { _onboardingState!, ); // _onboardingState was changed in checkAndStorePasswordlessMessages - if (didUpdate && mounted) setState(() => ()); + if (didUpdate && mounted) { + setState(() => ()); + _checkAndReconstruct(); + } + } + + void _checkAndReconstruct() { + final state = _onboardingState; + if (state != null) { + final shares = state.receivedShares; + if (shares.isNotEmpty) { + final threshold = shares.first.threshold; + if (shares.length >= threshold && + _reconstructedSecret == null && + !_isReconstructing) { + unawaited(_reconstructSecret()); + } + } + } + } + + Future _reconstructSecret() async { + final state = _onboardingState; + if (state == null) return; + final shares = state.receivedShares; + if (shares.isEmpty) return; + final threshold = shares.first.threshold; + if (shares.length < threshold) return; + + setState(() { + _isReconstructing = true; + _reconstructionError = null; + }); + + try { + final shareBytesList = shares + .map((s) => Uint8List.fromList(s.sharedSecretDataBytes)) + .toList(); + final secretBytes = await RustUtils.recoverSecret( + shares: shareBytesList, + threshold: threshold, + ); + final sharedSecretData = SharedSecretData.fromBuffer(secretBytes); + if (mounted) { + setState(() { + _reconstructedSecret = sharedSecretData; + }); + } + } catch (e) { + Log.error('Failed to reconstruct secret: $e'); + if (mounted) { + setState(() { + _reconstructionError = e.toString(); + }); + } + } finally { + if (mounted) { + setState(() { + _isReconstructing = false; + }); + } + } + } + + Future _recoverNow(List shares) async { + final reconstructed = _reconstructedSecret; + if (reconstructed == null) return; + + setState(() { + _isRecovering = true; + }); + + try { + final userId = shares.first.myUserId; + Uint8List? serverKey; + + if (reconstructed.hasPinSeed()) { + final pin = _secondFactorController.text.trim(); + if (pin.isEmpty) { + showSnackbar( + context, + context.lang.passwordlessRecoveryEnterPin, + ); + setState(() { + _isRecovering = false; + }); + return; + } + + // Calculate pinProtectionKey + final pinProtectionKey = await Hmac.sha256().calculateMac( + Uint8List.fromList(utf8.encode(pin)), + secretKey: SecretKey(reconstructed.pinSeed), + ); + + // Fetch serverKey + final res = await apiService.getServerKeyForPasswordlessRecovery( + userId: userId, + pinUnlockToken: reconstructed.pinUnlockToken, + pinProtectionKey: pinProtectionKey.bytes, + encryptedServerKeyNone: reconstructed.encryptedServerKeyNonce, + ); + + if (res.isError) { + if (mounted) { + showSnackbar( + context, + context.lang.passwordlessRecoveryTestPinIncorrect, + ); + } + setState(() { + _isRecovering = false; + }); + return; + } + + final ok = res.value as server.Response_Ok; + serverKey = Uint8List.fromList(ok.passwordlessRecoveryServerKey); + } else if (reconstructed.hasEmailHint()) { + final state = _onboardingState; + if (state == null) return; + + if (!state.emailRecoveryRequested) { + // Stage 1: Send Email + final email = _secondFactorController.text.trim(); + if (email.isEmpty) { + showSnackbar( + context, + context.lang.passwordlessRecoveryEnterEmail, + ); + setState(() { + _isRecovering = false; + }); + return; + } + + // Fetch serverKey (sends recovery email) + final res = await apiService.getServerKeyForPasswordlessRecovery( + userId: userId, + email: email, + encryptedServerKeyNone: reconstructed.encryptedServerKeyNonce, + ); + + if (res.isError) { + if (mounted) { + final isInternalError = res.error == ErrorCode.InternalError; + showSnackbar( + context, + isInternalError + ? context.lang.passwordlessRecoveryNetworkError + : context.lang.passwordlessRecoveryInvalidEmail, + ); + } + setState(() { + _isRecovering = false; + }); + return; + } + + // Success - server sent recovery email. Store in state + state.emailRecoveryRequested = true; + await KeyValueStore.update( + key: KeyValueKeys.onboardingState, + update: (s) => s.emailRecoveryRequested = true, + ); + + _secondFactorController.clear(); + if (mounted) { + showSnackbar( + context, + context.lang.passwordlessRecoveryShareSent, + level: SnackbarLevel.success, + ); + } + setState(() { + _isRecovering = false; + }); + return; + } else { + // Stage 2: Token verification + final token = _secondFactorController.text.trim(); + if (token.isEmpty) { + if (mounted) { + showSnackbar( + context, + 'Please enter the recovery token from your email.', + ); + } + setState(() { + _isRecovering = false; + }); + return; + } + + try { + serverKey = base64Url.decode(base64Url.normalize(token)); + } catch (e) { + if (mounted) { + showSnackbar( + context, + 'Invalid verification token format.', + ); + } + setState(() { + _isRecovering = false; + }); + return; + } + } + } + + // Decrypt recoveryData using serverKey if present + List recoveryDataBytes; + if (serverKey != null) { + final envelope = EncryptedEnvelope.fromBuffer( + reconstructed.recoveryData, + ); + final secretBox = SecretBox( + envelope.encryptedData, + nonce: envelope.iv, + mac: Mac(envelope.mac), + ); + final xchacha20 = Xchacha20.poly1305Aead(); + recoveryDataBytes = await xchacha20.decrypt( + secretBox, + secretKey: SecretKey(serverKey), + ); + } else { + recoveryDataBytes = reconstructed.recoveryData; + } + + final recoveryData = RecoveryData.fromBuffer(recoveryDataBytes); + + // Start full passwordless recovery + final error = await BackupService.startPasswordlessBackupRecovery( + recoveryData.userId.toInt(), + shares.first.myDisplayName, + Uint8List.fromList(recoveryData.keyManager), + ); + + if (!mounted) return; + + if (error != null) { + showSnackbar( + context, + error.toLocalizedString(context), + ); + setState(() { + _isRecovering = false; + }); + return; + } + + // Successful! Restart the app to apply restored keymanager/archive database + await Restart.restartApp( + notificationTitle: context.lang.recoverSuccessTitle, + notificationBody: context.lang.recoverSuccessBody, + forceKill: true, + ); + } catch (e) { + Log.error('Failed to recover passwordless: $e'); + if (mounted) { + showSnackbar( + context, + '${context.lang.recoverErrorUnknown}: $e', + ); + } + } finally { + if (mounted) { + setState(() { + _isRecovering = false; + }); + } + } } Future _registerPasswordlessNotification(OnboardingState state) async { @@ -233,14 +557,93 @@ class _RecoverPasswordlessState extends State { const SizedBox(height: 24), if (thresholdReached) ...[ - MyButton( - onPressed: () { - // TODO(recovery): Navigate to the actual recovery flow with the collected shares. - Log.info('Recover now pressed with ${shares.length} shares'); - }, - child: Text(context.lang.recoverPasswordlessRecoverNowBtn), - ), - const SizedBox(height: 16), + if (_isReconstructing) + const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: CircularProgressIndicator(), + ), + ) + else if (_reconstructionError != null) + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text( + 'Reconstruction failed: $_reconstructionError', + style: const TextStyle(color: Colors.red), + textAlign: TextAlign.center, + ), + ) + else if (_reconstructedSecret != null) ...[ + if (_reconstructedSecret!.hasPinSeed()) ...[ + MyInput( + controller: _secondFactorController, + hintText: context.lang.passwordlessRecoveryMethodPinHint, + keyboardType: TextInputType.number, + ), + const SizedBox(height: 16), + ] else if (_reconstructedSecret!.hasEmailHint()) ...[ + if (_onboardingState != null && + !_onboardingState!.emailRecoveryRequested) ...[ + MyInput( + controller: _secondFactorController, + hintText: _reconstructedSecret!.emailHint, + prefixIcon: const Icon(Icons.email_rounded), + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 16), + ] else ...[ + MyInput( + controller: _secondFactorController, + hintText: 'Enter recovery token', + prefixIcon: const Icon(Icons.key_rounded), + ), + const SizedBox(height: 16), + ], + ], + MyButton( + onPressed: _isRecovering ? null : () => _recoverNow(shares), + child: _isRecovering + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.black87, + ), + ) + : Text( + _reconstructedSecret!.hasEmailHint() && + _onboardingState != null && + !_onboardingState!.emailRecoveryRequested + ? 'Send recovery email' + : context.lang.recoverPasswordlessRecoverNowBtn, + ), + ), + const SizedBox(height: 16), + if (_reconstructedSecret!.hasEmailHint() && + _onboardingState != null && + _onboardingState!.emailRecoveryRequested) ...[ + MyButton( + onPressed: _isRecovering + ? null + : () async { + final state = _onboardingState; + if (state == null) return; + setState(() { + state.emailRecoveryRequested = false; + _secondFactorController.clear(); + }); + await KeyValueStore.update( + key: KeyValueKeys.onboardingState, + update: (s) => s.emailRecoveryRequested = false, + ); + }, + variant: MyButtonVariant.secondary, + child: Text(context.lang.passwordlessRecoveryResendEmail), + ), + const SizedBox(height: 16), + ], + ], ], ], ); diff --git a/lib/src/visual/views/recovery_from_secure_storage.view.dart b/lib/src/visual/views/recovery_from_secure_storage.view.dart index c9d683c2..2a1397a0 100644 --- a/lib/src/visual/views/recovery_from_secure_storage.view.dart +++ b/lib/src/visual/views/recovery_from_secure_storage.view.dart @@ -30,22 +30,9 @@ class _RecoveryViewState extends State { if (!mounted) return; if (error != null) { - String msg; - switch (error) { - case RecoveryError.noInternet: - msg = context.lang.recoverErrorNoInternet; - case RecoveryError.usernameNotValid: - msg = context.lang.recoverErrorUsernameNotValid; - case RecoveryError.passwordInvalid: - msg = context.lang.recoverErrorPasswordInvalid; - case RecoveryError.tryAgainLater: - msg = context.lang.recoverErrorTryAgainLater; - case RecoveryError.unkownError: - msg = context.lang.recoverErrorUnknown; - } setState(() { _isLoading = false; - _errorMessage = msg; + _errorMessage = error.toLocalizedString(context); _showRegisterNewPrompt = true; }); return; diff --git a/rust/src/bridge/wrapper/key_manager.rs b/rust/src/bridge/wrapper/key_manager.rs index 2bc1ad93..06ef4490 100644 --- a/rust/src/bridge/wrapper/key_manager.rs +++ b/rust/src/bridge/wrapper/key_manager.rs @@ -116,4 +116,13 @@ impl RustKeyManager { let serialized_bytes = postcard::to_allocvec(&*key_manager)?; Ok(serialized_bytes) } + + pub async fn import_serialized(serialized_bytes: Vec) -> Result<()> { + let ctx = get_twonly_flutter()?; + let key_manager: crate::keys::KeyManager = postcard::from_bytes(&serialized_bytes)?; + key_manager.store_to_keychain(&ctx.secure_storage)?; + *ctx.key_manager.lock().await = key_manager; + Ok(()) + } } + diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 1b437d7e..c95d58d1 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -40,7 +40,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -508562557; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 340781866; // Section: executor @@ -469,6 +469,46 @@ fn wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl( }, ) } +fn wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "rust_key_manager_import_serialized", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_serialized_bytes = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || async move { + let output_ok = + crate::bridge::wrapper::key_manager::RustKeyManager::import_serialized( + api_serialized_bytes, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -1829,32 +1869,33 @@ fn pde_ffi_dispatcher_primary_impl( 18 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_login_token_impl(port, ptr, rust_vec_len, data_len), 19 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_signal_identity_impl(port, ptr, rust_vec_len, data_len), 20 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_get_user_id_impl(port, ptr, rust_vec_len, data_len), -21 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len), -22 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_load_signed_prekey_impl(port, ptr, rust_vec_len, data_len), -23 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_load_signed_prekeys_impl(port, ptr, rust_vec_len, data_len), -24 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_key_manager_impl(port, ptr, rust_vec_len, data_len), -25 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_signed_prekey_impl(port, ptr, rust_vec_len, data_len), -26 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len), -27 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len), -28 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_store_signed_prekey_impl(port, ptr, rust_vec_len, data_len), -29 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len), -30 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len), -31 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_announced_user_by_public_id_impl(port, ptr, rust_vec_len, data_len), -32 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_config_impl(port, ptr, rust_vec_len, data_len), -33 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_contact_promotion_impl(port, ptr, rust_vec_len, data_len), -34 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_contact_version_impl(port, ptr, rust_vec_len, data_len), -35 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_other_promotions_by_public_id_impl(port, ptr, rust_vec_len, data_len), -36 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_own_promotions_after_version_impl(port, ptr, rust_vec_len, data_len), -37 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_share_for_contact_impl(port, ptr, rust_vec_len, data_len), -38 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_push_new_user_relation_impl(port, ptr, rust_vec_len, data_len), -39 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_push_own_promotion_and_clear_old_version_impl(port, ptr, rust_vec_len, data_len), -40 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_set_contact_version_impl(port, ptr, rust_vec_len, data_len), -41 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_set_shares_impl(port, ptr, rust_vec_len, data_len), -42 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_store_other_promotion_impl(port, ptr, rust_vec_len, data_len), -43 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_update_config_impl(port, ptr, rust_vec_len, data_len), -44 => wire__crate__bridge__callbacks__user_discovery__user_discovery_utils_flutter_sign_data_impl(port, ptr, rust_vec_len, data_len), -45 => wire__crate__bridge__callbacks__user_discovery__user_discovery_utils_flutter_verify_signature_impl(port, ptr, rust_vec_len, data_len), -46 => wire__crate__bridge__callbacks__user_discovery__user_discovery_utils_flutter_verify_stored_pubkey_impl(port, ptr, rust_vec_len, data_len), +21 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_serialized_impl(port, ptr, rust_vec_len, data_len), +22 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_import_signal_identity_impl(port, ptr, rust_vec_len, data_len), +23 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_load_signed_prekey_impl(port, ptr, rust_vec_len, data_len), +24 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_load_signed_prekeys_impl(port, ptr, rust_vec_len, data_len), +25 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_key_manager_impl(port, ptr, rust_vec_len, data_len), +26 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_remove_signed_prekey_impl(port, ptr, rust_vec_len, data_len), +27 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_serialize_impl(port, ptr, rust_vec_len, data_len), +28 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_set_user_id_impl(port, ptr, rust_vec_len, data_len), +29 => wire__crate__bridge__wrapper__key_manager__rust_key_manager_store_signed_prekey_impl(port, ptr, rust_vec_len, data_len), +30 => wire__crate__bridge__wrapper__rust_utils_generate_shares_impl(port, ptr, rust_vec_len, data_len), +31 => wire__crate__bridge__wrapper__rust_utils_recover_secret_impl(port, ptr, rust_vec_len, data_len), +32 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_announced_user_by_public_id_impl(port, ptr, rust_vec_len, data_len), +33 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_config_impl(port, ptr, rust_vec_len, data_len), +34 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_contact_promotion_impl(port, ptr, rust_vec_len, data_len), +35 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_contact_version_impl(port, ptr, rust_vec_len, data_len), +36 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_other_promotions_by_public_id_impl(port, ptr, rust_vec_len, data_len), +37 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_own_promotions_after_version_impl(port, ptr, rust_vec_len, data_len), +38 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_get_share_for_contact_impl(port, ptr, rust_vec_len, data_len), +39 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_push_new_user_relation_impl(port, ptr, rust_vec_len, data_len), +40 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_push_own_promotion_and_clear_old_version_impl(port, ptr, rust_vec_len, data_len), +41 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_set_contact_version_impl(port, ptr, rust_vec_len, data_len), +42 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_set_shares_impl(port, ptr, rust_vec_len, data_len), +43 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_store_other_promotion_impl(port, ptr, rust_vec_len, data_len), +44 => wire__crate__bridge__callbacks__user_discovery__user_discovery_store_flutter_update_config_impl(port, ptr, rust_vec_len, data_len), +45 => wire__crate__bridge__callbacks__user_discovery__user_discovery_utils_flutter_sign_data_impl(port, ptr, rust_vec_len, data_len), +46 => wire__crate__bridge__callbacks__user_discovery__user_discovery_utils_flutter_verify_signature_impl(port, ptr, rust_vec_len, data_len), +47 => wire__crate__bridge__callbacks__user_discovery__user_discovery_utils_flutter_verify_stored_pubkey_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } diff --git a/test/services/backup_service_test.dart b/test/services/backup_service_test.dart index 331ccf4a..6f3bc45a 100644 --- a/test/services/backup_service_test.dart +++ b/test/services/backup_service_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:twonly/core/bridge.dart' as bridge; import 'package:twonly/core/bridge/wrapper/backup.dart'; +import 'package:twonly/core/bridge/wrapper/key_manager.dart'; import 'package:twonly/core/frb_generated.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; @@ -20,6 +21,8 @@ import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/keyvalue.dart'; +import '../mocks/platform_channels.dart'; + void main() { if (!Platform.isMacOS) { return; @@ -40,6 +43,17 @@ void main() { return null; }); + const pathProviderChannel = MethodChannel( + 'plugins.flutter.io/path_provider', + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(pathProviderChannel, (methodCall) async { + if (methodCall.method == 'getApplicationSupportDirectory') { + return tempDir.path; + } + return null; + }); + final dylibPath = '${Directory.current.path}/rust/target/debug/librust_lib_twonly.dylib'; if (File(dylibPath).existsSync()) { @@ -66,6 +80,7 @@ void main() { }); setUp(() async { + setupPlatformChannelMocks(); await locator.reset(); final dbFile = File('${tempDir.path}/twonly.sqlite'); locator @@ -230,5 +245,23 @@ void main() { ); }, ); + + test( + 'startPasswordlessBackupRecovery restores identity and returns tryAgainLater on download', + () async { + final keyManagerBytes = await RustKeyManager.serialize(); + + final error = await BackupService.startPasswordlessBackupRecovery( + 1, + 'test_user', + keyManagerBytes, + ); + expect(error, RecoveryError.tryAgainLater); + + // Verify key manager was imported successfully + final recoveredUserId = await RustKeyManager.getUserId(); + expect(recoveredUserId, 1); + }, + ); }); } diff --git a/test/services/passwordless_recovery_service_test.dart b/test/services/passwordless_recovery_service_test.dart index 432b7cb6..02111252 100644 --- a/test/services/passwordless_recovery_service_test.dart +++ b/test/services/passwordless_recovery_service_test.dart @@ -362,11 +362,13 @@ void main() { // Verify db update final c2 = await twonlyDB.contactsDao.getContactById(2); - expect(c2?.recoveryLastHeartbeat, baseTime); + expect(c2?.recoveryLastHeartbeat, isNull); // Verify calling again does not resend mockApi.sentTextMessages.clear(); - await PasswordlessRecoveryService.performHeartbeat(); + await withClock(Clock.fixed(baseTime), () async { + await PasswordlessRecoveryService.performHeartbeat(); + }); expect(mockApi.sentTextMessages.length, 0); }, );