diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 4291cfa..3927a34 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -31,6 +31,7 @@ import "package:nexus/models/requests/get_room_state.dart"; import "package:nexus/models/requests/get_space_hierarchy.dart"; import "package:nexus/models/requests/join_room.dart"; import "package:nexus/models/profile_response.dart"; +import "package:nexus/models/recovery_key.dart"; import "package:nexus/models/requests/oauth/exchange_token.dart"; import "package:nexus/models/requests/oauth/get_auth_url.dart"; import "package:nexus/models/requests/oauth/register_client.dart"; @@ -190,6 +191,33 @@ class ClientController extends AsyncNotifier { } } + Future generateRecoveryKey({String passphrase = ""}) async => + .fromJson( + await _sendCommand("generate_recovery_key", {"passphrase": passphrase}), + ); + + Future resetEncryption(RecoveryKey key) => + _sendCommand("reset_encryption", key.toJson()); + + Future getCrossSigningResetUrl(Uri homeserver) async { + const action = "org.matrix.cross_signing_reset"; + final oauth = (await _sendCommand("get_login_flows", { + "homeserver_url": homeserver.toString(), + }))["oauth"]; + + if (oauth?["account_management_uri"] case final String uri + when (oauth["account_management_actions_supported"] as List?)?.contains( + action, + ) ?? + false) { + final parsed = Uri.parse(uri); + return parsed.replace( + queryParameters: {...parsed.queryParameters, "action": action}, + ); + } + return null; + } + Future joinRoom(JoinRoomRequest request) async => (await _sendCommand("join_room", request.toJson()))["room_id"]; diff --git a/lib/controllers/settings_sections.dart b/lib/controllers/settings_sections.dart index f782b86..bf84252 100644 --- a/lib/controllers/settings_sections.dart +++ b/lib/controllers/settings_sections.dart @@ -22,6 +22,8 @@ import "package:nexus/models/room_order.dart"; import "package:nexus/models/settings_category.dart"; import "package:nexus/main.dart"; import "package:nexus/widgets/settings/dialog_list_tile.dart"; +import "package:nexus/widgets/reset_encryption_sheet.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; class SettingsSectionsController extends AsyncNotifier>> { @@ -312,6 +314,21 @@ class SettingsSectionsController title: "Other", icon: Icons.key, settings: .new([ + .new( + title: "Reset Encryption Identity", + description: "Replace your encryption identity and recovery key with new ones, if you've lost your recovery key or your encryption is broken.", + builder: (title, description, icon) => Builder( + builder: (context) => M3EButton.icon( + onPressed: () => context.showAdaptiveSheet( + (_) => const ResetEncryptionSheet(), + ), + label: Text(title), + icon: Icon(icon), + tooltip: description, + ), + ), + icon: Icons.key_off, + ), .new( title: "Log Out", description: diff --git a/lib/models/client_state.dart b/lib/models/client_state.dart index 262203d..38f1e5b 100644 --- a/lib/models/client_state.dart +++ b/lib/models/client_state.dart @@ -12,9 +12,23 @@ class const ClientState({ required final String? userId, required final String? deviceId, required final String? homeserverUrl, + final VerificationState verificationState = const .new(), }) with _$ClientState { Map toJson() => _$ClientStateToJson(this); factory ClientState.fromJson(Map json) => _$ClientStateFromJson(json); } + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable() +class const VerificationState({ + final bool stateChecked = false, + final bool hasCrossSigning = false, + @JsonKey(name: "has_ssss") final bool hasSecretStorage = false, +}) with _$VerificationState { + Map toJson() => _$VerificationStateToJson(this); + + factory VerificationState.fromJson(Map json) => + _$VerificationStateFromJson(json); +} diff --git a/lib/models/gomuks_exception.dart b/lib/models/gomuks_exception.dart index dbd3f61..0ae33be 100644 --- a/lib/models/gomuks_exception.dart +++ b/lib/models/gomuks_exception.dart @@ -6,6 +6,12 @@ class GomuksException implements Exception { String? get errorCode => RegExp(r"^(M_[A-Z_]+)").firstMatch(message)?.group(1); + /// The HTTP status code of the failed request + int? get statusCode => switch (RegExp(r"HTTP (\d{3})").firstMatch(message)) { + final match? => int.parse(match.group(1)!), + null => null, + }; + @override String toString() => message; } diff --git a/lib/models/recovery_key.dart b/lib/models/recovery_key.dart new file mode 100644 index 0000000..e6d6aa4 --- /dev/null +++ b/lib/models/recovery_key.dart @@ -0,0 +1,19 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; + +part "recovery_key.freezed.dart"; +part "recovery_key.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(includeIfNull: false) +class const RecoveryKey({ + required final String recoveryKey, + + required final IMap crossSigningKeys, + final IMap? passphraseMeta, +}) with _$RecoveryKey { + Map toJson() => _$RecoveryKeyToJson(this); + + factory RecoveryKey.fromJson(Map json) => + _$RecoveryKeyFromJson(json); +} diff --git a/lib/widgets/pages/verify.dart b/lib/widgets/pages/verify.dart index 7e85b10..76bd94d 100644 --- a/lib/widgets/pages/verify.dart +++ b/lib/widgets/pages/verify.dart @@ -2,9 +2,14 @@ import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/models/client_state.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/pages/settings.dart"; import "package:nexus/widgets/appbar.dart"; import "package:nexus/helpers/required_validator_helper.dart"; +import "package:nexus/widgets/reset_encryption_sheet.dart"; class const VerifyPage({super.key}) extends HookConsumerWidget { @override @@ -13,6 +18,11 @@ class const VerifyPage({super.key}) extends HookConsumerWidget { final isLoading = useState(false); final inputError = useState(null); final formKey = useRef(GlobalKey()); + final verificationState = ref.watch( + ClientStateController.provider.select( + (state) => state?.verificationState, + ), + ); Future verify() async { isLoading.value = true; @@ -40,45 +50,65 @@ class const VerifyPage({super.key}) extends HookConsumerWidget { ), ]), ), - body: AlertDialog( - insetPadding: .symmetric(vertical: 12, horizontal: 32), - scrollable: true, - title: Text("Verify"), - content: Form( - key: formKey.value, - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - Text( - "Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.", - ), - SizedBox(height: 12), - TextFormField( - autofocus: true, - controller: passphraseController, - textInputAction: .done, - autovalidateMode: .onUserInteraction, - validator: requiredValidator, - obscureText: true, - decoration: .new( - label: Text("Recovery Key or Passphrase"), - errorText: inputError.value, - ), - onFieldSubmitted: (_) => verify(), - // Don't defocus on submit - onEditingComplete: () {}, - ), - ], + body: switch (verificationState) { + null || VerificationState(stateChecked: false) => const Loading(), + // Nothing to verify with yet, so go straight to setting it up. + VerificationState(hasSecretStorage: false) => Dialog( + insetPadding: const .symmetric(vertical: 12, horizontal: 32), + child: ConstrainedBox( + constraints: const .new(maxWidth: 450), + child: const ResetEncryptionSheet(isPage: true), ), ), - actions: [ - TextButton( - onPressed: isLoading.value ? null : verify, - child: Text("Verify"), + _ => AlertDialog( + insetPadding: .symmetric(vertical: 12, horizontal: 32), + scrollable: true, + title: Text("Verify"), + content: Form( + key: formKey.value, + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text( + "Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.", + ), + SizedBox(height: 12), + TextFormField( + autofocus: true, + controller: passphraseController, + textInputAction: .done, + autovalidateMode: .onUserInteraction, + validator: requiredValidator, + obscureText: true, + decoration: .new( + label: Text("Recovery Key or Passphrase"), + errorText: inputError.value, + ), + onFieldSubmitted: (_) => verify(), + // Don't defocus on submit + onEditingComplete: () {}, + ), + ], + ), ), - ], - ), + actions: [ + TextButton( + onPressed: () => context.showAdaptiveSheet( + (_) => const ResetEncryptionSheet(), + ), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text("Reset Identity"), + ), + TextButton( + onPressed: isLoading.value ? null : verify, + child: Text("Verify"), + ), + ], + ), + }, ); } } diff --git a/lib/widgets/recovery_key_sheet.dart b/lib/widgets/recovery_key_sheet.dart new file mode 100644 index 0000000..68ec682 --- /dev/null +++ b/lib/widgets/recovery_key_sheet.dart @@ -0,0 +1,145 @@ +import "dart:async"; + +import "package:flutter/services.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/helpers/launch_helper.dart"; +import "package:nexus/models/gomuks_exception.dart"; +import "package:nexus/models/recovery_key.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +/// Shows a newly generated recovery key, and once the user confirms they've +/// saved it, resets encryption with it. Pops with true if that succeeded. +class const RecoveryKeySheet( + final RecoveryKey recoveryKey, { + required final bool isReset, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final saved = useState(false); + final isLoading = useState(false); + final error = useState(null); + final needsApproval = useState(false); + final approvalUrl = useState(null); + final colorScheme = Theme.of(context).colorScheme; + + Future reset() async { + if (isLoading.value) return; + isLoading.value = true; + error.value = null; + try { + await ref + .read(ClientController.provider.notifier) + .resetEncryption(recoveryKey); + if (context.mounted) Navigator.of(context).pop(true); + } catch (resetError) { + if (resetError case GomuksException(statusCode: 401)) { + // Open the approval page straight away the first time. + if (!needsApproval.value) { + needsApproval.value = true; + final homeserver = ref + .read(ClientStateController.provider) + ?.homeserverUrl; + final url = homeserver == null + ? null + : await ref + .read(ClientController.provider.notifier) + .getCrossSigningResetUrl(.parse(homeserver)) + .onError((_, _) => null); + if (!context.mounted || url == null) return; + + approvalUrl.value = url; + await ref.read(LaunchHelper.provider).launchUrl(url); + } + } else { + error.value = resetError.toString(); + } + } finally { + if (context.mounted) isLoading.value = false; + } + } + + // Approval happens on a web page that can't redirect back, so keep retrying + // in the background until it goes through, or the sheet is closed. + useEffect(() { + if (!needsApproval.value) return null; + return Timer.periodic(const .new(seconds: 3), (_) => reset()).cancel; + }, [needsApproval.value]); + + return SheetLayout( + title: const Text("Save Your Recovery Key"), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text("Cancel"), + ), + TextButton( + onPressed: !saved.value || isLoading.value ? null : reset, + style: isReset + ? TextButton.styleFrom(foregroundColor: colorScheme.error) + : null, + child: Text( + needsApproval.value + ? "Try Again" + : isReset + ? "Reset" + : "Set Up", + ), + ), + ], + children: [ + const Text( + "This is your new recovery key. Store it somewhere safe, like a password manager. You'll need it to verify new sessions.", + ), + Card.filled( + child: ListTile( + title: SelectableText( + recoveryKey.recoveryKey, + style: const .new(fontFamily: "bundled-mono"), + ), + trailing: IconButton( + tooltip: "Copy recovery key", + onPressed: () => + Clipboard.setData(.new(text: recoveryKey.recoveryKey)), + icon: const Icon(Icons.copy), + ), + ), + ), + CheckboxListTile( + value: saved.value, + onChanged: (value) => saved.value = value ?? false, + title: const Text("I've saved my recovery key"), + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: needsApproval.value + ? Column( + crossAxisAlignment: .stretch, + spacing: 8, + children: [ + const Text( + "Your homeserver needs you to approve resetting your identity in your account settings. Once you've approved it, come back here and the reset will finish automatically.", + ), + if (approvalUrl.value case final url?) + OutlinedButton.icon( + onPressed: () => + ref.read(LaunchHelper.provider).launchUrl(url), + icon: const Icon(Icons.open_in_new), + label: const Text("Approve in account settings"), + ), + ], + ) + : const SizedBox(width: .infinity), + ), + if (error.value case final message?) + Text(message, style: .new(color: colorScheme.error)), + ], + ); + } +} diff --git a/lib/widgets/reset_encryption_sheet.dart b/lib/widgets/reset_encryption_sheet.dart new file mode 100644 index 0000000..52a10b4 --- /dev/null +++ b/lib/widgets/reset_encryption_sheet.dart @@ -0,0 +1,153 @@ +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/main.dart"; +import "package:nexus/widgets/recovery_key_sheet.dart"; +import "package:nexus/widgets/sheet_layout.dart"; + +class const ResetEncryptionSheet({ + /// Whether this is shown as a page rather than in a sheet, in which case it + /// can't be cancelled, and is replaced once encryption is set up. + final bool isPage = false, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final isReset = ref.watch( + ClientStateController.provider.select( + (state) => state?.verificationState.hasCrossSigning ?? false, + ), + ); + + final usePassphrase = useState(false); + final passphraseController = useTextEditingController(); + final confirmationController = useTextEditingController(); + final passphrase = useValueListenable(passphraseController).text; + final confirmation = useValueListenable(confirmationController).text; + final mismatch = confirmation.isNotEmpty && confirmation != passphrase; + final isLoading = useState(false); + final error = useState(null); + final colorScheme = Theme.of(context).colorScheme; + + return SheetLayout( + title: Text(isReset ? "Reset Encryption Identity" : "Set Up Encryption"), + actions: [ + if (!isPage) + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Cancel"), + ), + TextButton( + onPressed: + isLoading.value || + (usePassphrase.value && + (passphrase.isEmpty || passphrase != confirmation)) + ? null + : () async { + isLoading.value = true; + error.value = null; + try { + final recoveryKey = await ref + .read(ClientController.provider.notifier) + .generateRecoveryKey( + passphrase: usePassphrase.value ? passphrase : "", + ); + if (!context.mounted) return; + + final done = await context.showAdaptiveSheet( + (_) => RecoveryKeySheet(recoveryKey, isReset: isReset), + ); + if (done != true) return; + if (context.mounted && !isPage) Navigator.of(context).pop(); + + // Shown from the root navigator, because on the verify page, + // this is replaced by the chat once the session is verified. + navigatorKey.currentContext?.showAdaptiveSheet( + (context) => SheetLayout( + title: Text( + isReset ? "Identity Reset" : "Encryption Set Up", + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text("Done"), + ), + ], + children: [ + Text( + isReset + ? "Your encryption identity has been reset, and this session is verified. You'll need to verify your other sessions again." + : "Encryption is set up, and this session is verified. Use your recovery key to verify your other sessions.", + ), + ], + ), + ); + } catch (generateError) { + error.value = generateError.toString(); + } finally { + if (context.mounted) isLoading.value = false; + } + }, + child: Text(isReset ? "Continue" : "Set Up"), + ), + ], + children: [ + Text( + isReset + ? "This replaces your encryption identity with a new one. Your other sessions will need to be verified again, and any messages you can't currently decrypt may become unreadable." + : "Set up encryption so you can verify your other sessions, and get back your encrypted messages if you lose access to this one.", + ), + Column( + children: [ + SwitchListTile( + secondary: const Icon(Icons.password), + title: const Text("Use a passphrase"), + subtitle: const Text("Optional, as well as your recovery key"), + value: usePassphrase.value, + onChanged: (value) => usePassphrase.value = value, + ), + AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOut, + alignment: .topCenter, + child: usePassphrase.value + ? Padding( + padding: const .only(top: 12), + child: Column( + crossAxisAlignment: .stretch, + spacing: 12, + children: [ + const Text( + "Your passphrase can be used to gain access to your E2EE messages, so make it long and unique. Your passphrase should ideally not be the same as your password.", + ), + TextField( + controller: passphraseController, + obscureText: true, + decoration: const .new(labelText: "Passphrase"), + ), + TextField( + controller: confirmationController, + obscureText: true, + decoration: .new( + labelText: "Confirm passphrase", + errorText: mismatch + ? "Passphrases don't match" + : null, + ), + ), + ], + ), + ) + : const SizedBox(width: .infinity), + ), + ], + ), + if (error.value case final message?) + Text(message, style: .new(color: colorScheme.error)), + ], + ); + } +}