diff --git a/auth/lib/l10n/arb/app_en.arb b/auth/lib/l10n/arb/app_en.arb index d67473d82..f540fc717 100644 --- a/auth/lib/l10n/arb/app_en.arb +++ b/auth/lib/l10n/arb/app_en.arb @@ -144,7 +144,8 @@ "enterCodeHint": "Enter the 6-digit code from\nyour authenticator app", "lostDeviceTitle": "Lost device?", "twoFactorAuthTitle": "Two-factor authentication", - "passkeyAuthTitle": "Passkey authentication", + "passkeyAuthTitle": "Passkey verification", + "verifyPasskey": "Verify passkey", "recoverAccount": "Recover account", "enterRecoveryKeyHint": "Enter your recovery key", "recover": "Recover", @@ -407,7 +408,7 @@ "hearUsWhereTitle": "How did you hear about Ente? (optional)", "hearUsExplanation": "We don't track app installs. It'd help if you told us where you found us!", "waitingForBrowserRequest": "Waiting for browser request...", - "launchPasskeyUrlAgain": "Launch passkey URL again", + "waitingForVerification": "Waiting for verification...", "passkey": "Passkey", "developerSettingsWarning":"Are you sure that you want to modify Developer settings?", "developerSettings": "Developer settings", diff --git a/auth/lib/models/account/two_factor.dart b/auth/lib/models/account/two_factor.dart new file mode 100644 index 000000000..6a18f4277 --- /dev/null +++ b/auth/lib/models/account/two_factor.dart @@ -0,0 +1,13 @@ +enum TwoFactorType { totp, passkey } + +// ToString for TwoFactorType +String twoFactorTypeToString(TwoFactorType type) { + switch (type) { + case TwoFactorType.totp: + return "totp"; + case TwoFactorType.passkey: + return "passkey"; + default: + return type.name; + } +} diff --git a/auth/lib/services/passkey_service.dart b/auth/lib/services/passkey_service.dart index fdc9719dc..825a19729 100644 --- a/auth/lib/services/passkey_service.dart +++ b/auth/lib/services/passkey_service.dart @@ -17,6 +17,28 @@ class PasskeyService { return response.data!["accountsToken"] as String; } + Future isPasskeyRecoveryEnabled() async { + final response = await _enteDio.get( + "/users/two-factor/recovery-status", + ); + return response.data!["isPasskeyRecoveryEnabled"] as bool; + } + + Future configurePasskeyRecovery( + String secret, + String userEncryptedSecret, + String userSecretNonce, + ) async { + await _enteDio.post( + "/users/two-factor/passkeys/configure-recovery", + data: { + "secret": secret, + "userSecretCipher": userEncryptedSecret, + "userSecretNonce": userSecretNonce, + }, + ); + } + Future openPasskeyPage(BuildContext context) async { try { final jwtToken = await getJwtToken(); diff --git a/auth/lib/services/user_service.dart b/auth/lib/services/user_service.dart index c5436b361..b870aa647 100644 --- a/auth/lib/services/user_service.dart +++ b/auth/lib/services/user_service.dart @@ -11,6 +11,7 @@ import 'package:ente_auth/core/event_bus.dart'; import 'package:ente_auth/core/network.dart'; import 'package:ente_auth/events/user_details_changed_event.dart'; import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/models/account/two_factor.dart'; import 'package:ente_auth/models/api/user/srp.dart'; import 'package:ente_auth/models/delete_account.dart'; import 'package:ente_auth/models/key_attributes.dart'; @@ -762,7 +763,11 @@ class UserService { } } - Future recoverTwoFactor(BuildContext context, String sessionID) async { + Future recoverTwoFactor( + BuildContext context, + String sessionID, + TwoFactorType type, + ) async { final dialog = createProgressDialog(context, context.l10n.pleaseWait); await dialog.show(); try { @@ -770,6 +775,7 @@ class UserService { _config.getHttpEndpoint() + "/users/two-factor/recover", queryParameters: { "sessionID": sessionID, + "twoFactorType": twoFactorTypeToString(type), }, ); if (response.statusCode == 200) { @@ -778,6 +784,7 @@ class UserService { MaterialPageRoute( builder: (BuildContext context) { return TwoFactorRecoveryPage( + type, sessionID, response.data["encryptedSecret"], response.data["secretDecryptionNonce"], @@ -788,6 +795,7 @@ class UserService { ); } } on DioError catch (e) { + await dialog.hide(); _logger.severe(e); if (e.response != null && e.response!.statusCode == 404) { showToast(context, context.l10n.sessionExpired); @@ -809,6 +817,7 @@ class UserService { ); } } catch (e) { + await dialog.hide(); _logger.severe(e); // ignore: unawaited_futures showErrorDialog( @@ -823,6 +832,7 @@ class UserService { Future removeTwoFactor( BuildContext context, + TwoFactorType type, String sessionID, String recoveryKey, String encryptedSecret, @@ -862,6 +872,7 @@ class UserService { data: { "sessionID": sessionID, "secret": secret, + "twoFactorType": twoFactorTypeToString(type), }, ); if (response.statusCode == 200) { @@ -881,6 +892,7 @@ class UserService { ); } } on DioError catch (e) { + await dialog.hide(); _logger.severe(e); if (e.response != null && e.response!.statusCode == 404) { showToast(context, "Session expired"); @@ -902,6 +914,7 @@ class UserService { ); } } catch (e) { + await dialog.hide(); _logger.severe(e); // ignore: unawaited_futures showErrorDialog( diff --git a/auth/lib/ui/passkey_page.dart b/auth/lib/ui/passkey_page.dart index e02a7fd82..3c60af36e 100644 --- a/auth/lib/ui/passkey_page.dart +++ b/auth/lib/ui/passkey_page.dart @@ -1,9 +1,11 @@ import 'dart:convert'; import 'package:ente_auth/core/configuration.dart'; -import 'package:ente_auth/ente_theme_data.dart'; import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/models/account/two_factor.dart'; import 'package:ente_auth/services/user_service.dart'; +import 'package:ente_auth/ui/components/buttons/button_widget.dart'; +import 'package:ente_auth/ui/components/models/button_type.dart'; import 'package:ente_auth/utils/dialog_util.dart'; import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; @@ -99,30 +101,50 @@ class _PasskeyPageState extends State { } Widget _getBody() { - final l10n = context.l10n; - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - l10n.waitingForBrowserRequest, - style: const TextStyle( - height: 1.4, - fontSize: 16, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + context.l10n.waitingForVerification, + style: const TextStyle( + height: 1.4, + fontSize: 16, + ), ), - ), - const SizedBox(height: 16), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 32), - child: ElevatedButton( - style: Theme.of(context).colorScheme.optionalActionButtonStyle, - onPressed: launchPasskey, - child: Text(l10n.launchPasskeyUrlAgain), + const SizedBox(height: 16), + ButtonWidget( + buttonType: ButtonType.primary, + labelText: context.l10n.verifyPasskey, + onTap: () => launchPasskey(), ), - ), - ], + const Padding(padding: EdgeInsets.all(30)), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + UserService.instance.recoverTwoFactor( + context, + widget.sessionID, + TwoFactorType.passkey, + ); + }, + child: Container( + padding: const EdgeInsets.all(10), + child: Center( + child: Text( + context.l10n.recoverAccount, + style: const TextStyle( + decoration: TextDecoration.underline, + fontSize: 12, + ), + ), + ), + ), + ), + ], + ), ), ); } diff --git a/auth/lib/ui/settings/security_section_widget.dart b/auth/lib/ui/settings/security_section_widget.dart index 636692dcf..f4cc3324f 100644 --- a/auth/lib/ui/settings/security_section_widget.dart +++ b/auth/lib/ui/settings/security_section_widget.dart @@ -21,6 +21,7 @@ import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/navigation_util.dart'; import 'package:ente_auth/utils/toast_util.dart'; import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; class SecuritySectionWidget extends StatefulWidget { const SecuritySectionWidget({Key? key}) : super(key: key); @@ -32,6 +33,7 @@ class SecuritySectionWidget extends StatefulWidget { class _SecuritySectionWidgetState extends State { final _config = Configuration.instance; late bool _hasLoggedIn; + final Logger _logger = Logger('SecuritySectionWidget'); @override void initState() { @@ -75,7 +77,7 @@ class _SecuritySectionWidgetState extends State { pressedColor: getEnteColorScheme(context).fillFaint, trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, - onTap: () => PasskeyService.instance.openPasskeyPage(context), + onTap: () async => await onPasskeyClick(context), ), sectionOptionSpacing, MenuItemWidget( @@ -159,6 +161,31 @@ class _SecuritySectionWidgetState extends State { ); } + Future onPasskeyClick(BuildContext buildContext) async { + try { + final isPassKeyResetEnabled = + await PasskeyService.instance.isPasskeyRecoveryEnabled(); + if (!isPassKeyResetEnabled) { + final Uint8List recoveryKey = Configuration.instance.getRecoveryKey(); + final resetKey = CryptoUtil.generateKey(); + final resetKeyBase64 = CryptoUtil.bin2base64(resetKey); + final encryptionResult = CryptoUtil.encryptSync( + resetKey, + recoveryKey, + ); + await PasskeyService.instance.configurePasskeyRecovery( + resetKeyBase64, + CryptoUtil.bin2base64(encryptionResult.encryptedData!), + CryptoUtil.bin2base64(encryptionResult.nonce!), + ); + } + PasskeyService.instance.openPasskeyPage(buildContext).ignore(); + } catch (e, s) { + _logger.severe("failed to open passkey page", e, s); + await showGenericErrorDialog(context: context); + } + } + Future updateEmailMFA(bool enableEmailMFA) async { try { final UserDetails details = diff --git a/auth/lib/ui/two_factor_authentication_page.dart b/auth/lib/ui/two_factor_authentication_page.dart index 3abd01cde..43b8f967e 100644 --- a/auth/lib/ui/two_factor_authentication_page.dart +++ b/auth/lib/ui/two_factor_authentication_page.dart @@ -1,4 +1,5 @@ import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/models/account/two_factor.dart'; import 'package:ente_auth/services/user_service.dart'; import 'package:ente_auth/ui/lifecycle_event_handler.dart'; import 'package:flutter/material.dart'; @@ -129,7 +130,11 @@ class _TwoFactorAuthenticationPageState GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { - UserService.instance.recoverTwoFactor(context, widget.sessionID); + UserService.instance.recoverTwoFactor( + context, + widget.sessionID, + TwoFactorType.totp, + ); }, child: Container( padding: const EdgeInsets.all(10), diff --git a/auth/lib/ui/two_factor_recovery_page.dart b/auth/lib/ui/two_factor_recovery_page.dart index e007a9c40..4eae7ff2d 100644 --- a/auth/lib/ui/two_factor_recovery_page.dart +++ b/auth/lib/ui/two_factor_recovery_page.dart @@ -1,6 +1,7 @@ import 'dart:ui'; import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/models/account/two_factor.dart'; import 'package:ente_auth/services/user_service.dart'; import 'package:ente_auth/utils/dialog_util.dart'; import 'package:flutter/material.dart'; @@ -9,8 +10,10 @@ class TwoFactorRecoveryPage extends StatefulWidget { final String sessionID; final String encryptedSecret; final String secretDecryptionNonce; + final TwoFactorType type; const TwoFactorRecoveryPage( + this.type, this.sessionID, this.encryptedSecret, this.secretDecryptionNonce, { @@ -72,6 +75,7 @@ class _TwoFactorRecoveryPageState extends State { ? () async { await UserService.instance.removeTwoFactor( context, + widget.type, widget.sessionID, _recoveryKey.text, widget.encryptedSecret, diff --git a/mobile/lib/generated/intl/messages_en.dart b/mobile/lib/generated/intl/messages_en.dart index ea5df0b51..0ca7d3024 100644 --- a/mobile/lib/generated/intl/messages_en.dart +++ b/mobile/lib/generated/intl/messages_en.dart @@ -800,8 +800,6 @@ class MessageLookup extends MessageLookupByLibrary { "Kindly help us with this information"), "language": MessageLookupByLibrary.simpleMessage("Language"), "lastUpdated": MessageLookupByLibrary.simpleMessage("Last updated"), - "launchPasskeyUrlAgain": - MessageLookupByLibrary.simpleMessage("Launch passkey URL again"), "leave": MessageLookupByLibrary.simpleMessage("Leave"), "leaveAlbum": MessageLookupByLibrary.simpleMessage("Leave album"), "leaveFamily": MessageLookupByLibrary.simpleMessage("Leave family"), diff --git a/mobile/lib/generated/intl/messages_pt.dart b/mobile/lib/generated/intl/messages_pt.dart index b6a33539d..4a2bfa26d 100644 --- a/mobile/lib/generated/intl/messages_pt.dart +++ b/mobile/lib/generated/intl/messages_pt.dart @@ -821,8 +821,6 @@ class MessageLookup extends MessageLookupByLibrary { "language": MessageLookupByLibrary.simpleMessage("Idioma"), "lastUpdated": MessageLookupByLibrary.simpleMessage("Última atualização"), - "launchPasskeyUrlAgain": MessageLookupByLibrary.simpleMessage( - "Iniciar a URL de chave de acesso novamente"), "leave": MessageLookupByLibrary.simpleMessage("Sair"), "leaveAlbum": MessageLookupByLibrary.simpleMessage("Sair do álbum"), "leaveFamily": MessageLookupByLibrary.simpleMessage("Sair da família"), diff --git a/mobile/lib/generated/intl/messages_zh.dart b/mobile/lib/generated/intl/messages_zh.dart index 6bb1c25f6..4a056efe8 100644 --- a/mobile/lib/generated/intl/messages_zh.dart +++ b/mobile/lib/generated/intl/messages_zh.dart @@ -674,8 +674,6 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("请帮助我们了解这个信息"), "language": MessageLookupByLibrary.simpleMessage("语言"), "lastUpdated": MessageLookupByLibrary.simpleMessage("最后更新"), - "launchPasskeyUrlAgain": - MessageLookupByLibrary.simpleMessage("再次启动 通行密钥 URL"), "leave": MessageLookupByLibrary.simpleMessage("离开"), "leaveAlbum": MessageLookupByLibrary.simpleMessage("离开相册"), "leaveFamily": MessageLookupByLibrary.simpleMessage("离开家庭计划"), diff --git a/mobile/lib/generated/l10n.dart b/mobile/lib/generated/l10n.dart index 36073334d..03b753399 100644 --- a/mobile/lib/generated/l10n.dart +++ b/mobile/lib/generated/l10n.dart @@ -8318,16 +8318,6 @@ class S { ); } - /// `Launch passkey URL again` - String get launchPasskeyUrlAgain { - return Intl.message( - 'Launch passkey URL again', - name: 'launchPasskeyUrlAgain', - desc: '', - args: [], - ); - } - /// `Passkey` String get passkey { return Intl.message( diff --git a/mobile/lib/l10n/intl_en.arb b/mobile/lib/l10n/intl_en.arb index c9028ec18..16b203128 100644 --- a/mobile/lib/l10n/intl_en.arb +++ b/mobile/lib/l10n/intl_en.arb @@ -1189,7 +1189,6 @@ "cleanUncategorized": "Clean Uncategorized", "cleanUncategorizedDescription": "Remove all files from Uncategorized that are present in other albums", "waitingForVerification": "Waiting for verification...", - "launchPasskeyUrlAgain": "Launch passkey URL again", "passkey": "Passkey", "passkeyAuthTitle": "Passkey verification", "verifyPasskey": "Verify passkey", diff --git a/mobile/lib/services/user_service.dart b/mobile/lib/services/user_service.dart index 6d0408855..44e098567 100644 --- a/mobile/lib/services/user_service.dart +++ b/mobile/lib/services/user_service.dart @@ -916,7 +916,7 @@ class UserService { _config.getHttpEndpoint() + "/users/two-factor/remove", data: { "sessionID": sessionID, - "secret": utf8.decode(base64.decode(secret)), + "secret": secret, "twoFactorType": twoFactorTypeToString(type), }, ); diff --git a/mobile/lib/ui/settings/security_section_widget.dart b/mobile/lib/ui/settings/security_section_widget.dart index 5099bc5d3..dce7e97ec 100644 --- a/mobile/lib/ui/settings/security_section_widget.dart +++ b/mobile/lib/ui/settings/security_section_widget.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import "dart:convert"; import "dart:typed_data"; import 'package:flutter/material.dart'; @@ -27,7 +26,6 @@ import "package:photos/utils/crypto_util.dart"; import "package:photos/utils/dialog_util.dart"; import "package:photos/utils/navigation_util.dart"; import "package:photos/utils/toast_util.dart"; -import "package:uuid/uuid.dart"; class SecuritySectionWidget extends StatefulWidget { const SecuritySectionWidget({Key? key}) : super(key: key); @@ -243,15 +241,14 @@ class _SecuritySectionWidgetState extends State { if (!isPassKeyResetEnabled) { final Uint8List recoveryKey = await UserService.instance.getOrCreateRecoveryKey(context); - final resetSecret = const Uuid().v4().toString(); - final bytes = utf8.encode(resetSecret); - final base64Str = base64.encode(bytes); + final resetKey = CryptoUtil.generateKey(); + final resetKeyBase64 = CryptoUtil.bin2base64(resetKey); final encryptionResult = CryptoUtil.encryptSync( - CryptoUtil.base642bin(base64Str), + resetKey, recoveryKey, ); await PasskeyService.instance.configurePasskeyRecovery( - resetSecret, + resetKeyBase64, CryptoUtil.bin2base64(encryptionResult.encryptedData!), CryptoUtil.bin2base64(encryptionResult.nonce!), );