mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-07-17 22:14:08 +00:00
poc for recovery works
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
This commit is contained in:
parent
7ea311236d
commit
991376e1d9
21 changed files with 726 additions and 102 deletions
|
|
@ -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(() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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_,
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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!(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue