add support for initializing and resetting crypto identity

Fixes #137
This commit is contained in:
Henry Hiles 2026-10-16 10:49:04 -04:00
commit 23046f08a9
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
8 changed files with 448 additions and 36 deletions

View file

@ -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"];

View file

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

View file

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

View file

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

View 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);
}

View file

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

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

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