diff --git a/mobile/lib/core/constants.dart b/mobile/lib/core/constants.dart index df706784f..fd94f259a 100644 --- a/mobile/lib/core/constants.dart +++ b/mobile/lib/core/constants.dart @@ -8,7 +8,8 @@ const String sentryDSN = const String sentryDebugDSN = "https://ca5e686dd7f149d9bf94e620564cceba@sentry.ente.io/3"; const String sentryTunnel = "https://sentry-reporter.ente.io"; -const String githubDiscussionsUrl = "https://github.com/ente-io/ente/discussions"; +const String githubDiscussionsUrl = + "https://github.com/ente-io/ente/discussions"; const int microSecondsInDay = 86400000000; const int android11SDKINT = 30; const int jan011981Time = 347155200000000; @@ -41,6 +42,7 @@ const supportEmail = 'support@ente.io'; class FFDefault { static const bool enableStripe = true; static const bool disableCFWorker = false; + static const bool enablePasskey = false; } const kDefaultProductionEndpoint = 'https://api.ente.io'; diff --git a/mobile/lib/generated/intl/messages_en.dart b/mobile/lib/generated/intl/messages_en.dart index 7675ae171..8889b63c5 100644 --- a/mobile/lib/generated/intl/messages_en.dart +++ b/mobile/lib/generated/intl/messages_en.dart @@ -797,6 +797,8 @@ 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"), @@ -954,6 +956,9 @@ class MessageLookup extends MessageLookupByLibrary { "orPickAnExistingOne": MessageLookupByLibrary.simpleMessage("Or pick an existing one"), "pair": MessageLookupByLibrary.simpleMessage("Pair"), + "passkey": MessageLookupByLibrary.simpleMessage("Passkey"), + "passkeyAuthTitle": + MessageLookupByLibrary.simpleMessage("Passkey authentication"), "password": MessageLookupByLibrary.simpleMessage("Password"), "passwordChangedSuccessfully": MessageLookupByLibrary.simpleMessage( "Password changed successfully"), @@ -1460,6 +1465,8 @@ class MessageLookup extends MessageLookupByLibrary { "viewer": MessageLookupByLibrary.simpleMessage("Viewer"), "visitWebToManage": MessageLookupByLibrary.simpleMessage( "Please visit web.ente.io to manage your subscription"), + "waitingForBrowserRequest": MessageLookupByLibrary.simpleMessage( + "Waiting for browser request..."), "waitingForWifi": MessageLookupByLibrary.simpleMessage("Waiting for WiFi..."), "weAreOpenSource": diff --git a/mobile/lib/generated/l10n.dart b/mobile/lib/generated/l10n.dart index 701a01164..b19a95551 100644 --- a/mobile/lib/generated/l10n.dart +++ b/mobile/lib/generated/l10n.dart @@ -8308,6 +8308,46 @@ class S { ); } + /// `Waiting for browser request...` + String get waitingForBrowserRequest { + return Intl.message( + 'Waiting for browser request...', + name: 'waitingForBrowserRequest', + desc: '', + args: [], + ); + } + + /// `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( + 'Passkey', + name: 'passkey', + desc: '', + args: [], + ); + } + + /// `Passkey authentication` + String get passkeyAuthTitle { + return Intl.message( + 'Passkey authentication', + name: 'passkeyAuthTitle', + desc: '', + args: [], + ); + } + /// `Play album on TV` String get playOnTv { return Intl.message( diff --git a/mobile/lib/l10n/intl_en.arb b/mobile/lib/l10n/intl_en.arb index fee87d311..e4ad661aa 100644 --- a/mobile/lib/l10n/intl_en.arb +++ b/mobile/lib/l10n/intl_en.arb @@ -1188,6 +1188,10 @@ "changeLocationOfSelectedItems": "Change location of selected items?", "editsToLocationWillOnlyBeSeenWithinEnte": "Edits to location will only be seen within Ente", "cleanUncategorized": "Clean Uncategorized", + "waitingForBrowserRequest": "Waiting for browser request...", + "launchPasskeyUrlAgain": "Launch passkey URL again", + "passkey": "Passkey", + "passkeyAuthTitle": "Passkey authentication", "playOnTv": "Play album on TV", "pair": "Pair", "deviceNotFound": "Device not found", diff --git a/mobile/lib/services/feature_flag_service.dart b/mobile/lib/services/feature_flag_service.dart index 689c7cc40..2891b03f6 100644 --- a/mobile/lib/services/feature_flag_service.dart +++ b/mobile/lib/services/feature_flag_service.dart @@ -68,6 +68,18 @@ class FeatureFlagService { } } + bool enablePasskey() { + try { + if (isInternalUserOrDebugBuild()) { + return true; + } + return _getFeatureFlags().enablePasskey; + } catch (e) { + _logger.info('error in enablePasskey check', e); + return FFDefault.enablePasskey; + } + } + bool isInternalUserOrDebugBuild() { final String? email = Configuration.instance.getEmail(); final userID = Configuration.instance.getUserID(); @@ -94,20 +106,24 @@ class FeatureFlags { static FeatureFlags defaultFlags = FeatureFlags( disableCFWorker: FFDefault.disableCFWorker, enableStripe: FFDefault.enableStripe, + enablePasskey: FFDefault.enablePasskey, ); final bool disableCFWorker; final bool enableStripe; + final bool enablePasskey; FeatureFlags({ required this.disableCFWorker, required this.enableStripe, + required this.enablePasskey, }); Map toMap() { return { "disableCFWorker": disableCFWorker, "enableStripe": enableStripe, + "enablePasskey": enablePasskey, }; } @@ -120,6 +136,7 @@ class FeatureFlags { return FeatureFlags( disableCFWorker: json["disableCFWorker"] ?? FFDefault.disableCFWorker, enableStripe: json["enableStripe"] ?? FFDefault.enableStripe, + enablePasskey: json["enablePasskey"] ?? FFDefault.enablePasskey, ); } } diff --git a/mobile/lib/services/passkey_service.dart b/mobile/lib/services/passkey_service.dart new file mode 100644 index 000000000..e704edccf --- /dev/null +++ b/mobile/lib/services/passkey_service.dart @@ -0,0 +1,33 @@ +import "package:flutter/cupertino.dart"; +import "package:logging/logging.dart"; +import "package:photos/core/network/network.dart"; +import "package:photos/utils/dialog_util.dart"; +import 'package:url_launcher/url_launcher_string.dart'; + +class PasskeyService { + PasskeyService._privateConstructor(); + static final PasskeyService instance = PasskeyService._privateConstructor(); + + final _enteDio = NetworkClient.instance.enteDio; + + Future getJwtToken() async { + final response = await _enteDio.get( + "/users/accounts-token", + ); + return response.data!["accountsToken"] as String; + } + + Future openPasskeyPage(BuildContext context) async { + try { + final jwtToken = await getJwtToken(); + final url = "https://accounts.ente.io/account-handoff?token=$jwtToken"; + await launchUrlString( + url, + mode: LaunchMode.externalApplication, + ); + } catch (e) { + Logger('PasskeyService').severe("failed to open passkey page", e); + showGenericErrorDialog(context: context, error: e).ignore(); + } + } +} diff --git a/mobile/lib/services/user_service.dart b/mobile/lib/services/user_service.dart index 2ae079554..ab5c815d0 100644 --- a/mobile/lib/services/user_service.dart +++ b/mobile/lib/services/user_service.dart @@ -28,6 +28,7 @@ import 'package:photos/models/set_recovery_key_request.dart'; import 'package:photos/models/user_details.dart'; import 'package:photos/ui/account/login_page.dart'; import 'package:photos/ui/account/ott_verification_page.dart'; +import "package:photos/ui/account/passkey_page.dart"; import 'package:photos/ui/account/password_entry_page.dart'; import 'package:photos/ui/account/password_reentry_page.dart'; import "package:photos/ui/account/recovery_page.dart"; @@ -314,6 +315,30 @@ class UserService { } } + Future acceptPasskey( + BuildContext context, + Map response, + Uint8List keyEncryptionKey, + ) async { + final userPassword = Configuration.instance.getVolatilePassword(); + if (userPassword == null) throw Exception("volatile password is null"); + + await _saveConfiguration(response); + + if (Configuration.instance.getEncryptedToken() != null) { + await Configuration.instance.decryptSecretsAndGetKeyEncKey( + userPassword, + Configuration.instance.getKeyAttributes()!, + keyEncryptionKey: keyEncryptionKey, + ); + } else { + throw Exception("unexpected response during passkey verification"); + } + + Navigator.of(context).popUntil((route) => route.isFirst); + Bus.instance.fire(AccountConfiguredEvent()); + } + Future verifyEmail( BuildContext context, String ott, { @@ -648,10 +673,17 @@ class UserService { if (response.statusCode == 200) { Widget page; final String twoFASessionID = response.data["twoFactorSessionID"]; + final String passkeySessionID = response.data["passkeySessionID"]; + Configuration.instance.setVolatilePassword(userPassword); if (twoFASessionID.isNotEmpty) { await setTwoFactor(value: true); page = TwoFactorAuthenticationPage(twoFASessionID); + } else if (passkeySessionID.isNotEmpty) { + page = PasskeyPage( + passkeySessionID, + keyEncryptionKey: keyEncryptionKey, + ); } else { await _saveConfiguration(response); if (Configuration.instance.getEncryptedToken() != null) { @@ -1108,16 +1140,19 @@ class UserService { } } - Future _saveConfiguration(Response response) async { - await Configuration.instance.setUserID(response.data["id"]); - if (response.data["encryptedToken"] != null) { + Future _saveConfiguration(dynamic response) async { + final responseData = response is Map ? response : response.data as Map?; + if (responseData == null) return; + + await Configuration.instance.setUserID(responseData["id"]); + if (responseData["encryptedToken"] != null) { await Configuration.instance - .setEncryptedToken(response.data["encryptedToken"]); + .setEncryptedToken(responseData["encryptedToken"]); await Configuration.instance.setKeyAttributes( - KeyAttributes.fromMap(response.data["keyAttributes"]), + KeyAttributes.fromMap(responseData["keyAttributes"]), ); } else { - await Configuration.instance.setToken(response.data["token"]); + await Configuration.instance.setToken(responseData["token"]); } } diff --git a/mobile/lib/ui/account/passkey_page.dart b/mobile/lib/ui/account/passkey_page.dart new file mode 100644 index 000000000..31ee3faa6 --- /dev/null +++ b/mobile/lib/ui/account/passkey_page.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; +import 'package:photos/core/configuration.dart'; +import 'package:photos/ente_theme_data.dart'; +import "package:photos/generated/l10n.dart"; +import 'package:photos/services/user_service.dart'; +import 'package:uni_links/uni_links.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class PasskeyPage extends StatefulWidget { + final String sessionID; + final Uint8List keyEncryptionKey; + + const PasskeyPage( + this.sessionID, { + Key? key, + required this.keyEncryptionKey, + }) : super(key: key); + + @override + State createState() => _PasskeyPageState(); +} + +class _PasskeyPageState extends State { + final Logger _logger = Logger("PasskeyPage"); + + @override + void initState() { + launchPasskey(); + _initDeepLinks(); + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + Future launchPasskey() async { + await launchUrlString( + "https://accounts.ente.io/passkeys/flow?" + "passkeySessionID=${widget.sessionID}" + "&redirect=ente://passkey", + mode: LaunchMode.externalApplication, + ); + } + + Future _handleDeeplink(String? link) async { + if (!context.mounted || + Configuration.instance.hasConfiguredAccount() || + link == null) { + return; + } + if (mounted && link.toLowerCase().startsWith("ente://passkey")) { + final uri = Uri.parse(link).queryParameters['response']; + + // response to json + final res = utf8.decode(base64.decode(uri!)); + final json = jsonDecode(res) as Map; + + try { + await UserService.instance.acceptPasskey( + context, + json, + widget.keyEncryptionKey, + ); + } catch (e) { + _logger.severe(e); + } + } + } + + Future _initDeepLinks() async { + // Attach a listener to the stream + linkStream.listen( + _handleDeeplink, + onError: (err) { + _logger.severe(err); + }, + ); + return false; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + S.of(context).passkeyAuthTitle, + ), + ), + body: _getBody(), + ); + } + + Widget _getBody() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + S.of(context).waitingForBrowserRequest, + 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(S.of(context).launchPasskeyUrlAgain), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/ui/settings/account_section_widget.dart b/mobile/lib/ui/settings/account_section_widget.dart index c184d4b89..8b9c7bbef 100644 --- a/mobile/lib/ui/settings/account_section_widget.dart +++ b/mobile/lib/ui/settings/account_section_widget.dart @@ -8,12 +8,15 @@ import 'package:photos/theme/ente_theme.dart'; import 'package:photos/ui/account/change_email_dialog.dart'; import 'package:photos/ui/account/delete_account_page.dart'; import 'package:photos/ui/account/password_entry_page.dart'; +import "package:photos/ui/account/recovery_key_page.dart"; import 'package:photos/ui/components/captioned_text_widget.dart'; import 'package:photos/ui/components/expandable_menu_item_widget.dart'; import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import "package:photos/ui/payment/subscription.dart"; import 'package:photos/ui/settings/common_settings.dart'; +import "package:photos/utils/crypto_util.dart"; import 'package:photos/utils/dialog_util.dart'; +import "package:photos/utils/navigation_util.dart"; import "package:url_launcher/url_launcher_string.dart"; class AccountSectionWidget extends StatelessWidget { @@ -101,6 +104,43 @@ class AccountSectionWidget extends StatelessWidget { }, ), sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: S.of(context).recoveryKey, + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + showOnlyLoadingState: true, + onTap: () async { + final hasAuthenticated = await LocalAuthenticationService.instance + .requestLocalAuthentication( + context, + S.of(context).authToViewYourRecoveryKey, + ); + if (hasAuthenticated) { + String recoveryKey; + try { + recoveryKey = await _getOrCreateRecoveryKey(context); + } catch (e) { + await showGenericErrorDialog(context: context, error: e); + return; + } + unawaited( + routeToPage( + context, + RecoveryKeyPage( + recoveryKey, + S.of(context).ok, + showAppBar: true, + onDone: () {}, + ), + ), + ); + } + }, + ), + sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: S.of(context).exportYourData, @@ -157,6 +197,12 @@ class AccountSectionWidget extends StatelessWidget { ); } + Future _getOrCreateRecoveryKey(BuildContext context) async { + return CryptoUtil.bin2hex( + await UserService.instance.getOrCreateRecoveryKey(context), + ); + } + void _onLogoutTapped(BuildContext context) { showChoiceActionSheet( context, diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index fe0ff653f..7b02d7416 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -181,7 +181,7 @@ dependency_overrides: # current fork of tfite_flutter_helper depends on ffi: ^1.x.x # but we need ffi: ^2.0.1 for newer packages. The original tfite_flutter_helper # - ffi: ^2.0.0 + ffi: ^2.1.0 video_player: git: url: https://github.com/ente-io/packages.git