parent
784e5254e2
commit
23046f08a9
8 changed files with 448 additions and 36 deletions
|
|
@ -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<int> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<RecoveryKey> generateRecoveryKey({String passphrase = ""}) async =>
|
||||
.fromJson(
|
||||
await _sendCommand("generate_recovery_key", {"passphrase": passphrase}),
|
||||
);
|
||||
|
||||
Future<void> resetEncryption(RecoveryKey key) =>
|
||||
_sendCommand("reset_encryption", key.toJson());
|
||||
|
||||
Future<Uri?> 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<String> joinRoom(JoinRoomRequest request) async =>
|
||||
(await _sendCommand("join_room", request.toJson()))["room_id"];
|
||||
|
||||
|
|
|
|||
|
|
@ -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<IMap<String, IList<SettingsCategory>>> {
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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<String, Object?> toJson() => _$ClientStateToJson(this);
|
||||
|
||||
factory ClientState.fromJson(Map<String, Object?> 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<String, Object?> toJson() => _$VerificationStateToJson(this);
|
||||
|
||||
factory VerificationState.fromJson(Map<String, Object?> json) =>
|
||||
_$VerificationStateFromJson(json);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
19
lib/models/recovery_key.dart
Normal file
19
lib/models/recovery_key.dart
Normal file
|
|
@ -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<String, dynamic> crossSigningKeys,
|
||||
final IMap<String, dynamic>? passphraseMeta,
|
||||
}) with _$RecoveryKey {
|
||||
Map<String, Object?> toJson() => _$RecoveryKeyToJson(this);
|
||||
|
||||
factory RecoveryKey.fromJson(Map<String, Object?> json) =>
|
||||
_$RecoveryKeyFromJson(json);
|
||||
}
|
||||
|
|
@ -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<String?>(null);
|
||||
final formKey = useRef(GlobalKey<FormState>());
|
||||
final verificationState = ref.watch(
|
||||
ClientStateController.provider.select(
|
||||
(state) => state?.verificationState,
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> 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"),
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
145
lib/widgets/recovery_key_sheet.dart
Normal file
145
lib/widgets/recovery_key_sheet.dart
Normal file
|
|
@ -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<String?>(null);
|
||||
final needsApproval = useState(false);
|
||||
final approvalUrl = useState<Uri?>(null);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
Future<void> 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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
153
lib/widgets/reset_encryption_sheet.dart
Normal file
153
lib/widgets/reset_encryption_sheet.dart
Normal file
|
|
@ -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<String?>(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<bool>(
|
||||
(_) => 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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue