diff --git a/commet/lib/client/client.dart b/commet/lib/client/client.dart index ee2c8dd07..c87c252f1 100644 --- a/commet/lib/client/client.dart +++ b/commet/lib/client/client.dart @@ -150,6 +150,9 @@ abstract class Client { Future executeLoginFlow(LoginFlow flow); + // returns true if the homeserver is configured in a way to disable end to end encryption + Future hasServerDisabledEncryption(); + /// Logout and invalidate the current session Future logout(); diff --git a/commet/lib/client/matrix/matrix_client.dart b/commet/lib/client/matrix/matrix_client.dart index 1330a465a..c059857e0 100644 --- a/commet/lib/client/matrix/matrix_client.dart +++ b/commet/lib/client/matrix/matrix_client.dart @@ -898,6 +898,23 @@ class MatrixClient extends Client { }); } + @override + Future hasServerDisabledEncryption() async { + var data = await matrixClient.getWellknown(); + Log.i(data); + + var prop = + data.additionalProperties.tryGetMap("io.element.e2ee"); + + var value = prop?.tryGet("force_disable"); + + if (value == true) { + return true; + } + + return false; + } + @override Future joinRoomFromPreview(RoomPreview preview) { String roomId = preview.roomId; diff --git a/commet/lib/client/matrix_background/matrix_background_client.dart b/commet/lib/client/matrix_background/matrix_background_client.dart index 5353305cd..45d7f9f63 100644 --- a/commet/lib/client/matrix_background/matrix_background_client.dart +++ b/commet/lib/client/matrix_background/matrix_background_client.dart @@ -271,4 +271,10 @@ class MatrixBackgroundClient implements Client { @override // TODO: implement favoriteRooms NotifyingListFilter get favoriteRooms => throw UnimplementedError(); + + @override + Future hasServerDisabledEncryption() { + // TODO: implement hasServerDisabledEncryption + throw UnimplementedError(); + } } diff --git a/commet/lib/ui/pages/matrix/authentication/matrix_uia_request.dart b/commet/lib/ui/pages/matrix/authentication/matrix_uia_request.dart index 1bd762131..f430ae985 100644 --- a/commet/lib/ui/pages/matrix/authentication/matrix_uia_request.dart +++ b/commet/lib/ui/pages/matrix/authentication/matrix_uia_request.dart @@ -1,4 +1,6 @@ import 'package:commet/ui/pages/matrix/authentication/matrix_uia_request_view.dart'; +import 'package:commet/utils/error_utils.dart'; +import 'package:commet/utils/links/link_utils.dart'; import 'package:flutter/widgets.dart'; import 'package:matrix/matrix.dart'; @@ -15,6 +17,7 @@ class MatrixUIARequest extends StatefulWidget { class _MatrixUIARequestState extends State { void Function(UiaRequestState)? originalOnUpdate; late UiaRequestState state; + late Set steps; @override void initState() { @@ -22,6 +25,7 @@ class _MatrixUIARequestState extends State { originalOnUpdate = widget.request.onUpdate; widget.request.onUpdate = onUpdate; + steps = widget.request.nextStages; super.initState(); } @@ -29,6 +33,7 @@ class _MatrixUIARequestState extends State { void onUpdate(UiaRequestState state) { setState(() { this.state = state; + this.steps = widget.request.nextStages; }); originalOnUpdate?.call(state); @@ -38,15 +43,34 @@ class _MatrixUIARequestState extends State { Widget build(BuildContext context) { return MatrixUIARequestView( state, + nextSteps: steps, onSubmitAuthentication: submitAuthentication, + onSubmitSso: submitSso, onSuccess: () => Navigator.of(context).pop(), onFail: () => Navigator.of(context).pop(), ); } void submitAuthentication(String password) { - widget.request.completeStage(AuthenticationPassword( - password: password, - identifier: AuthenticationUserIdentifier(user: "alice"))); + ErrorUtils.tryRun(context, () async { + await widget.request.completeStage(AuthenticationPassword( + password: password, + identifier: AuthenticationUserIdentifier( + user: widget.client.matrixClient.userID!))); + }); + } + + submitSso() { + // https://spec.matrix.org/v1.15/client-server-api/#client-behaviour-21 + var hs = widget.client.matrixClient.homeserver!; + var url = hs.replace( + path: "/_matrix/client/v3/auth/m.login.sso/fallback/web", + queryParameters: {"session": widget.request.session}); + + LinkUtils.open(url, + bypassConfirmation: true, + filterTrackingParameters: false, + context: context); + Navigator.of(context).pop(); } } diff --git a/commet/lib/ui/pages/matrix/authentication/matrix_uia_request_view.dart b/commet/lib/ui/pages/matrix/authentication/matrix_uia_request_view.dart index 2fe25f03f..3b31273d1 100644 --- a/commet/lib/ui/pages/matrix/authentication/matrix_uia_request_view.dart +++ b/commet/lib/ui/pages/matrix/authentication/matrix_uia_request_view.dart @@ -1,5 +1,5 @@ import 'package:commet/utils/common_strings.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; // have to do it this way to avoid some widgetbook codegen issue // ignore: implementation_imports import 'package:matrix/src/utils/uia_request.dart'; @@ -9,9 +9,16 @@ import 'package:flutter/material.dart' as material; class MatrixUIARequestView extends StatefulWidget { const MatrixUIARequestView(this.state, - {this.onSubmitAuthentication, super.key, this.onFail, this.onSuccess}); + {this.onSubmitAuthentication, + required this.nextSteps, + super.key, + this.onFail, + this.onSubmitSso, + this.onSuccess}); final UiaRequestState state; + final Set nextSteps; final Function(String password)? onSubmitAuthentication; + final Function()? onSubmitSso; final Function()? onSuccess; final Function()? onFail; @@ -19,14 +26,37 @@ class MatrixUIARequestView extends StatefulWidget { State createState() => _MatrixUIARequestViewState(); } +enum UIAStep { + password, + sso, +} + class _MatrixUIARequestViewState extends State { TextEditingController passwordFieldController = TextEditingController(); + bool get canUsePassword => widget.nextSteps.contains("m.login.password"); + bool get canUseSso => widget.nextSteps.contains("m.login.sso"); + + bool get canUseAnyNextStep => canUsePassword || canUseSso; + + UIAStep? pickedStep; + + @override + void initState() { + if (canUsePassword && !canUseSso) { + pickedStep = UIAStep.password; + } + + super.initState(); + } + @override Widget build(BuildContext context) { return SizedBox( width: 500, - height: 200, - child: buildView(), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: buildView(), + ), ); } @@ -39,31 +69,83 @@ class _MatrixUIARequestViewState extends State { case UiaRequestState.loading: return loading(); case UiaRequestState.waitForUser: + return pickedStep == null ? showAvailableSteps() : showPickedStep(); + } + } + + Widget showAvailableSteps() { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 12, + children: [ + if (canUsePassword) + tiamat.Button( + text: "Continue with password", + onTap: () => setState(() { + pickedStep = UIAStep.password; + }), + ), + if (canUseSso) + tiamat.Button( + text: "Continue with SSO", + onTap: () { + widget.onSubmitSso?.call(); + setState(() { + pickedStep = UIAStep.sso; + }); + }), + if (canUseAnyNextStep == false) ...[ + tiamat.Text.labelLow( + "Sorry, none of the authentication methods provided by the server are supported."), + tiamat.Text.labelLow(widget.nextSteps.toString()), + ] + ], + ), + ); + } + + Widget showPickedStep() { + if (pickedStep == UIAStep.password) { + return userPasswordInput(); + } + + switch (pickedStep!) { + case UIAStep.password: return userPasswordInput(); + case UIAStep.sso: + return showSsoStep(); } } + Widget showSsoStep() { + return SizedBox( + height: 300, + width: 300, + child: Center( + child: CircularProgressIndicator(), + )); + } + Widget userPasswordInput() { return Column( mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 12, children: [ TextInput( placeholder: "Account Password", obscureText: true, controller: passwordFieldController, ), - Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: SizedBox( - width: 100, - height: 40, - child: Button( - text: CommonStrings.promptSubmit, - onTap: () => widget.onSubmitAuthentication - ?.call(passwordFieldController.text), - )), + SizedBox( + height: 40, + child: Button( + text: CommonStrings.promptSubmit, + onTap: () => widget.onSubmitAuthentication + ?.call(passwordFieldController.text), )) ], ); diff --git a/commet/lib/ui/pages/settings/categories/account/security/matrix/cross_signing/cross_signing_view.dart b/commet/lib/ui/pages/settings/categories/account/security/matrix/cross_signing/cross_signing_view.dart index 2b2693b21..2a81ffb8a 100644 --- a/commet/lib/ui/pages/settings/categories/account/security/matrix/cross_signing/cross_signing_view.dart +++ b/commet/lib/ui/pages/settings/categories/account/security/matrix/cross_signing/cross_signing_view.dart @@ -1,5 +1,4 @@ import 'package:commet/main.dart'; -import 'package:commet/ui/atoms/code_block.dart'; import 'package:commet/utils/common_strings.dart'; import 'package:commet/utils/error_utils.dart'; import 'package:commet/utils/text_utils.dart'; @@ -279,19 +278,33 @@ class _MatrixCrossSigningViewState extends State { return m.Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, + spacing: 12, children: [ m.Column( crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 12, children: [ tiamat.Text.label(labelMatrixRecoveryKeyExplanation), - const SizedBox(height: 8), - m.SelectionArea( - child: Codeblock( - text: widget.recoveryKey!, + m.Container( + decoration: BoxDecoration( + color: m.ColorScheme.of(context).surfaceContainerLowest, + borderRadius: BorderRadiusGeometry.circular(8), + ), + child: m.SelectionArea( + child: m.Padding( + padding: const EdgeInsets.fromLTRB(0, 12, 0, 12), + child: m.Center( + child: Text( + widget.recoveryKey!, + style: const TextStyle( + fontSize: 12, + fontFamily: "Code", + fontFeatures: [FontFeature.disable("calt")]), + )), + ), ), ), - const SizedBox(height: 8), - tiamat.Button.secondary( + tiamat.Button( text: copyBackupCodeText, onTap: () { services.Clipboard.setData( @@ -302,10 +315,7 @@ class _MatrixCrossSigningViewState extends State { }), ], ), - const SizedBox( - height: 50, - ), - tiamat.Button( + tiamat.Button.secondary( text: CommonStrings.promptConfirm, isLoading: isActionLoading, onTap: () async { diff --git a/commet/lib/ui/pages/settings/categories/account/security/matrix/matrix_security_tab.dart b/commet/lib/ui/pages/settings/categories/account/security/matrix/matrix_security_tab.dart index ce48dd168..863a63e65 100644 --- a/commet/lib/ui/pages/settings/categories/account/security/matrix/matrix_security_tab.dart +++ b/commet/lib/ui/pages/settings/categories/account/security/matrix/matrix_security_tab.dart @@ -1,7 +1,11 @@ +import 'package:commet/client/alert.dart'; import 'package:commet/client/matrix/matrix_client.dart'; +import 'package:commet/debug/log.dart'; +import 'package:commet/ui/molecules/alert_view.dart'; import 'package:commet/ui/navigation/adaptive_dialog.dart'; import 'package:commet/ui/pages/settings/categories/account/security/matrix/session/matrix_session.dart'; import 'package:commet/utils/common_strings.dart'; +import 'package:commet/utils/links/link_utils.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:matrix/matrix.dart'; @@ -20,7 +24,27 @@ class MatrixSecurityTab extends StatefulWidget { class _MatrixSecurityTabState extends State { bool crossSigningEnabled = false; bool? messageBackupEnabled; + bool isVerified = false; + bool encryptionDisabled = false; List? devices; + late DateTime time; + + Map? authMetadata; + + bool? supportsLegacyUIA; + + Uri? get accountManagementUri { + var str = authMetadata?.tryGet("account_management_uri"); + if (str == null) return null; + return Uri.tryParse(str); + } + + List? get supportedActions => + authMetadata?.tryGetList("account_management_actions_supported"); + + bool get canRemoveDeviceOAuth => + supportedActions?.contains("org.matrix.device_delete") == true && + accountManagementUri != null; String get labelMatrixCrossSigning => Intl.message("Cross signing", desc: "Title label for matrix cross signing", @@ -64,8 +88,14 @@ class _MatrixSecurityTabState extends State { @override void initState() { + time = DateTime.now(); checkState(); getDevices(); + + widget.client.hasServerDisabledEncryption().then((v) => setState(() { + encryptionDisabled = v; + })); + super.initState(); } @@ -74,12 +104,43 @@ class _MatrixSecurityTabState extends State { var encryption = widget.client.getMatrixClient().encryption; crossSigningEnabled = encryption?.crossSigning.enabled ?? false; messageBackupEnabled = encryption?.keyManager.enabled ?? false; + isVerified = widget.client.matrixClient.isUnknownSession == false; }); } void getDevices() async { var gotDevices = await widget.client.getMatrixClient().getDevices(); + if (supportsLegacyUIA == null) { + try { + await widget.client.matrixClient + .request(RequestType.DELETE, "/client/v3/devices/_", data: { + "auth": { + "type": "m.login.dummy", + } + }); + } catch (e) { + if (e case MatrixException mx) { + if (mx.error == MatrixError.M_FORBIDDEN) { + supportsLegacyUIA = true; + } else { + supportsLegacyUIA = false; + } + } + } + } + + if (supportsLegacyUIA != true) { + if (authMetadata == null) { + try { + authMetadata = await widget.client.matrixClient + .request(RequestType.GET, "/client/v1/auth_metadata"); + } catch (e, s) { + Log.onError(e, s); + } + } + } + gotDevices ?.sort((a, b) => (b.lastSeenTs ?? 0).compareTo(a.lastSeenTs ?? 0)); @@ -95,47 +156,132 @@ class _MatrixSecurityTabState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.max, - children: [ - const SizedBox( - height: 4, - ), - crossSigningPanel(), - const SizedBox( - height: 4, - ), - sessionsPanel() - ], + spacing: 4, + children: [crossSigningPanel(), sessionsPanel()], ); } + Iterable get inactiveDevices => + devices?.where((i) => isDeviceInactive(i)) ?? []; + Panel sessionsPanel() { return Panel( header: labelMatrixAccountSessions, mode: TileType.surfaceContainerLow, child: devices == null - ? const CircularProgressIndicator() - : ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.all(2.0), - child: MatrixSession( - devices![index], - widget.client.getMatrixClient(), - onUpdated: () { - setState(() { - getDevices(); - }); - }, + ? SizedBox( + height: 300, + child: Center(child: const CircularProgressIndicator())) + : Column( + children: [ + if (supportsLegacyUIA == true && inactiveDevices.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 8), + child: Container( + decoration: BoxDecoration( + color: ColorScheme.of(context).surfaceContainerLowest, + borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Flexible( + child: AlertView( + Alert( + AlertType.warning, + messageGetter: () => + "You have ${inactiveDevices.length} sessions which have not been used recently", + titleGetter: () => "Inactive Devices", + ), + ), + ), + SizedBox( + width: 40, + height: 40, + child: tiamat.IconButton( + size: 15, + icon: Icons.logout, + onPressed: () { + removeInactiveDevices(); + }, + ), + ) + ], + ), + ), + ), ), - ); - }, - itemCount: devices!.length, + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + var device = devices![index]; + return Padding( + padding: const EdgeInsets.all(2.0), + child: MatrixSession( + device, + widget.client.getMatrixClient(), + inactive: isDeviceInactive(device), + removeSession: supportsLegacyUIA == true + ? () => removeDeviceUIA(device.deviceId) + : canRemoveDeviceOAuth + ? () => removeDeviceOAuth(device.deviceId) + : null, + ), + ); + }, + itemCount: devices!.length, + ), + ], ), ); } + void removeDeviceUIA(String deviceID) async { + await widget.client.matrixClient.uiaRequestBackground((auth) async { + await widget.client.matrixClient.deleteDevice(deviceID, auth: auth); + + getDevices(); + }); + } + + void removeInactiveDevices() async { + var confirm = await AdaptiveDialog.confirmation(context, + dangerous: true, + prompt: + "Are you sure you want to log out of ${inactiveDevices.length} sessions?"); + + if (confirm != true) return; + + await widget.client.matrixClient.uiaRequestBackground((auth) async { + await widget.client.matrixClient.deleteDevices( + inactiveDevices.map((i) => i.deviceId).toList(), + auth: auth); + }); + + getDevices(); + } + + void removeDeviceOAuth(String deviceID) { + LinkUtils.open( + accountManagementUri!.replace(queryParameters: { + "action": "org.matrix.device_delete", + "device_id": deviceID + }), + context: context, + filterTrackingParameters: false, + bypassConfirmation: false); + } + + void onUpdated() { + setState(() { + getDevices(); + }); + } + Panel crossSigningPanel() { return Panel( header: labelMatrixCrossSigningAndBackup, @@ -143,7 +289,40 @@ class _MatrixSecurityTabState extends State { child: Padding( padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), child: Column( + spacing: 4, children: [ + if (encryptionDisabled) + Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 8), + child: Container( + decoration: BoxDecoration( + color: ColorScheme.of(context).surfaceContainerLowest, + borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: AlertView(Alert(AlertType.info, + messageGetter: () => + "Your homeserver has force-disabled encryption", + titleGetter: () => "Encryption Disabled")), + ), + ), + ), + if (!isVerified) + Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 8), + child: Container( + decoration: BoxDecoration( + color: ColorScheme.of(context).surfaceContainerLowest, + borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: AlertView(Alert(AlertType.warning, + messageGetter: () => + "Your current session is not verified. You will not be able to participate in encrypted chats.", + titleGetter: () => "Unverified")), + ), + ), + ), crossSigning(), const tiamat.Seperator(), messageBackup() @@ -238,4 +417,13 @@ class _MatrixSecurityTabState extends State { ], ); } + + bool isDeviceInactive(Device device) { + if (device.lastSeenTs == null) return false; + + var date = DateTime.fromMillisecondsSinceEpoch(device.lastSeenTs!); + var diff = time.difference(date); + + return diff.inDays > 60; + } } diff --git a/commet/lib/ui/pages/settings/categories/account/security/matrix/session/matrix_session.dart b/commet/lib/ui/pages/settings/categories/account/security/matrix/session/matrix_session.dart index b8f14ce73..5ed6f7906 100644 --- a/commet/lib/ui/pages/settings/categories/account/security/matrix/session/matrix_session.dart +++ b/commet/lib/ui/pages/settings/categories/account/security/matrix/session/matrix_session.dart @@ -8,11 +8,12 @@ import '../../../../../../matrix/verification/matrix_verification_page.dart'; class MatrixSession extends StatefulWidget { const MatrixSession(this.device, this.matrixClient, - {super.key, this.onUpdated}); + {super.key, this.onUpdated, this.inactive = false, this.removeSession}); final Device device; final Client matrixClient; final Function? onUpdated; - + final Function? removeSession; + final bool inactive; @override State createState() => _MatrixSessionState(); } @@ -29,8 +30,9 @@ class _MatrixSessionState extends State { lastSeenTimestamp: widget.device.lastSeenTs, verified: isVerified(), isThisDevice: isCurrentDevice(), + inactive: widget.inactive, beginVerification: beginVerification, - removeSession: removeSession, + removeSession: widget.removeSession, ); } @@ -61,12 +63,4 @@ class _MatrixSessionState extends State { previousOnUpdate?.call(); setState(() {}); } - - void removeSession() async { - await widget.matrixClient.uiaRequestBackground((auth) async { - await widget.matrixClient - .deleteDevice(widget.device.deviceId, auth: auth); - widget.onUpdated?.call(); - }); - } } diff --git a/commet/lib/ui/pages/settings/categories/account/security/matrix/session/matrix_session_view.dart b/commet/lib/ui/pages/settings/categories/account/security/matrix/session/matrix_session_view.dart index 2e37121fa..9eb2000ce 100644 --- a/commet/lib/ui/pages/settings/categories/account/security/matrix/session/matrix_session_view.dart +++ b/commet/lib/ui/pages/settings/categories/account/security/matrix/session/matrix_session_view.dart @@ -13,6 +13,7 @@ class MatrixSessionView extends StatelessWidget { super.key, this.verified = false, this.isThisDevice = false, + this.inactive = false, this.beginVerification, this.removeSession}); @@ -22,6 +23,7 @@ class MatrixSessionView extends StatelessWidget { final int? lastSeenTimestamp; final bool verified; final bool isThisDevice; + final bool inactive; final Function? beginVerification; final Function? removeSession; @@ -64,6 +66,15 @@ class MatrixSessionView extends StatelessWidget { padding: EdgeInsets.fromLTRB(0, 0, 8, 0), child: TinyPill("This Device"), ), + if (inactive) + Padding( + padding: EdgeInsets.fromLTRB(0, 0, 8, 0), + child: TinyPill( + "Inactive", + background: ColorScheme.of(context).error, + foreground: ColorScheme.of(context).onError, + ), + ), tiamat.Text.labelLow(deviceId), ], ), @@ -104,7 +115,7 @@ class MatrixSessionView extends StatelessWidget { text: promptMatrixVerifySession, onTap: () => beginVerification?.call(), ), - if (!isThisDevice) + if (!isThisDevice && removeSession != null) Padding( padding: const EdgeInsets.fromLTRB(8, 0, 4, 0), child: tiamat.IconButton( diff --git a/commet/pubspec.yaml b/commet/pubspec.yaml index 5db1e96b3..e9529d93d 100644 --- a/commet/pubspec.yaml +++ b/commet/pubspec.yaml @@ -72,7 +72,7 @@ dependencies: matrix_dart_sdk_drift_db: git: url: https://github.com/commetchat/matrix-dart-sdk-drift-db.git - ref: upstream-v6.1.1+patch.1 + ref: upstream-v6.1.1+patch.3 signal_sticker_api: git: diff --git a/pubspec.lock b/pubspec.lock index 8b57a4921..0c6c4103a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1292,8 +1292,8 @@ packages: dependency: transitive description: path: "." - ref: "upstream-v6.1.1+patch.1" - resolved-ref: bb1abba161bd0e7438aa50e4ba8fb46408b69e67 + ref: "upstream-v6.1.1+patch.3" + resolved-ref: "95f8198557b73000d72642875a6d1c429b816c0b" url: "https://github.com/commetchat/matrix-dart-sdk-drift-db.git" source: git version: "0.0.1"