poc for recovery works
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-06-23 21:07:26 +02:00
parent 7ea311236d
commit 991376e1d9
21 changed files with 726 additions and 102 deletions

View file

@ -191,7 +191,8 @@ class _AppMainWidgetState extends State<AppMainWidget> {
_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<AppMainWidget> {
}
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(() {

View file

@ -20,6 +20,12 @@ class RustKeyManager {
static Future<PlatformInt64?> getUserId() => RustLib.instance.api
.crateBridgeWrapperKeyManagerRustKeyManagerGetUserId();
static Future<void> importSerialized({required List<int> serializedBytes}) =>
RustLib.instance.api
.crateBridgeWrapperKeyManagerRustKeyManagerImportSerialized(
serializedBytes: serializedBytes,
);
static Future<void> importSignalIdentity({
required List<int> identityKeyPairStructure,
required PlatformInt64 registrationId,

View file

@ -76,7 +76,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
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<PlatformInt64?> crateBridgeWrapperKeyManagerRustKeyManagerGetUserId();
Future<void> crateBridgeWrapperKeyManagerRustKeyManagerImportSerialized({
required List<int> serializedBytes,
});
Future<void> crateBridgeWrapperKeyManagerRustKeyManagerImportSignalIdentity({
required List<int> identityKeyPairStructure,
required PlatformInt64 registrationId,
@ -1234,6 +1238,41 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: [],
);
@override
Future<void> crateBridgeWrapperKeyManagerRustKeyManagerImportSerialized({
required List<int> 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<void> crateBridgeWrapperKeyManagerRustKeyManagerImportSignalIdentity({
required List<int> 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_,
);
},

View file

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

View file

@ -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';

View file

@ -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';

@ -1 +1 @@
Subproject commit bd641ce3a3f3b9c709dc97a279d9fe8b9b6f07e1
Subproject commit 147f44e608f39f04af3b3be9aee1ad662e5a84e1

View file

@ -43,6 +43,7 @@ class OnboardingState {
this.downloadAuthToken,
this.serverRegistered = false,
this.encryptionKey,
this.emailRecoveryRequested = false,
});
factory OnboardingState.fromJson(Map<String, dynamic> json) =>
@ -55,6 +56,8 @@ class OnboardingState {
List<int>? downloadAuthToken;
bool serverRegistered;
List<int>? encryptionKey;
bool emailRecoveryRequested;
List<ReceivedRecoveryShare> receivedShares = [];

View file

@ -47,6 +47,8 @@ OnboardingState _$OnboardingStateFromJson(Map<String, dynamic> json) =>
encryptionKey: (json['encryptionKey'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList(),
emailRecoveryRequested:
json['emailRecoveryRequested'] as bool? ?? false,
)
..receivedShares = (json['receivedShares'] as List<dynamic>)
.map((e) => ReceivedRecoveryShare.fromJson(e as Map<String, dynamic>))
@ -60,5 +62,6 @@ Map<String, dynamic> _$OnboardingStateToJson(OnboardingState instance) =>
'downloadAuthToken': instance.downloadAuthToken,
'serverRegistered': instance.serverRegistered,
'encryptionKey': instance.encryptionKey,
'emailRecoveryRequested': instance.emailRecoveryRequested,
'receivedShares': instance.receivedShares,
};

View file

@ -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

View file

@ -776,6 +776,34 @@ class ApiService {
return sendRequestSync(req);
}
Future<Result> getServerKeyForPasswordlessRecovery({
required int userId,
List<int>? encryptedServerKeyNone,
List<int>? pinUnlockToken,
List<int>? 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<Result> submitRecoveryShare({
required String notificationId,
required List<int> encryptedMessage,

View file

@ -325,6 +325,26 @@ class BackupService {
return _nextBackupStage();
}
static Future<RecoveryError?> 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 {

View file

@ -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<String> onEmailTokenReceived =
StreamController<String>.broadcast();
static String linkPrefix = 'https://me.twonly.eu/r/#';
static final Set<String> _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) =>

View file

@ -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<String?> 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;
}
}
}

View file

@ -39,23 +39,10 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
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(

View file

@ -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<RecoverPasswordless> createState() => _RecoverPasswordlessState();
@ -36,15 +47,45 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
OnboardingState? _onboardingState;
Timer? _pollTimer;
SharedSecretData? _reconstructedSecret;
bool _isReconstructing = false;
String? _reconstructionError;
bool _isRecovering = false;
final TextEditingController _secondFactorController = TextEditingController();
StreamSubscription<String>? _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<OnboardingState>(
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<RecoverPasswordless> {
KeyValueKeys.onboardingState,
);
if (widget.initialEmailToken != null && !state.emailRecoveryRequested) {
state.emailRecoveryRequested = true;
await KeyValueStore.update<OnboardingState>(
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<RecoverPasswordless> {
// 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<RecoverPasswordless> {
_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<void> _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<void> _recoverNow(List<ReceivedRecoveryShare> 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<OnboardingState>(
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<int> 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<void> _registerPasswordlessNotification(OnboardingState state) async {
@ -233,14 +557,93 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
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<OnboardingState>(
key: KeyValueKeys.onboardingState,
update: (s) => s.emailRecoveryRequested = false,
);
},
variant: MyButtonVariant.secondary,
child: Text(context.lang.passwordlessRecoveryResendEmail),
),
const SizedBox(height: 16),
],
],
],
],
);

View file

@ -30,22 +30,9 @@ class _RecoveryViewState extends State<RecoveryView> {
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;

View file

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

View file

@ -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::SseCodec, _, _, _>(
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 = <Vec<u8>>::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!(),
}
}

View file

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

View file

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