From 7fcce4be1d91bc593b63c05866073a2a16658cd8 Mon Sep 17 00:00:00 2001 From: Ice Hades <119406114+ice-hades@users.noreply.github.com> Date: Fri, 20 Dec 2024 17:17:52 +0400 Subject: [PATCH 1/6] feat: biometrics entoll --- android/app/src/main/AndroidManifest.xml | 1 + .../main/kotlin/io/ion/app/MainActivity.kt | 29 +- assets/svg/action_wallet_faceid.svg | 13 + ios/Runner/Info.plist | 2 + .../auth/providers/auth_provider.c.dart | 9 +- .../sign_up_password/sign_up_password.dart | 10 +- .../hooks/use_on_suggest_biometrics.dart | 25 + .../suggest_to_add_biometrics_popup.dart | 87 +++ .../user/providers/biometrics_provider.c.dart | 51 ++ .../user_verify_identity_provider.c.dart | 19 +- lib/l10n/app_en.arb | 2 + lib/l10n/app_en.arb.orig | 684 ++++++++++++++++++ .../ion_identity_client/lib/ion_identity.dart | 1 + .../src/auth/dtos/authentication.freezed.dart | 186 ----- .../lib/src/auth/ion_identity_auth.dart | 16 +- .../auth/services/logout/logout_service.dart | 4 + .../core/identity_storage/data_storage.dart | 94 --- .../identity_storage/identity_storage.dart | 126 ---- .../auth_client_service_locator.dart | 2 + .../ion_identity_service_locator.dart | 4 + .../network_service_locator.dart | 34 +- .../storage/biometrics_state_storage.dart | 31 + .../lib/src/core/storage/data_storage.dart | 36 +- .../lib/src/core/storage/token_storage.dart | 42 +- .../lib/src/core/types/biometrics_state.dart | 3 + .../src/core/types/user_token.freezed.dart | 203 ------ .../lib/src/ion_identity.dart | 23 +- .../dtos/simple_message_response.freezed.dart | 170 ----- ...r_action_signing_init_request.freezed.dart | 247 ------- .../lib/src/signer/identity_signer.dart | 16 +- .../lib/src/signer/passkey_signer.dart | 43 +- .../lib/src/signer/password_signer.dart | 97 ++- .../models/user_details.freezed.dart | 392 ---------- 33 files changed, 1157 insertions(+), 1545 deletions(-) create mode 100644 assets/svg/action_wallet_faceid.svg create mode 100644 lib/app/features/components/biometrics/hooks/use_on_suggest_biometrics.dart create mode 100644 lib/app/features/components/biometrics/suggest_to_add_biometrics_popup.dart create mode 100644 lib/app/features/user/providers/biometrics_provider.c.dart create mode 100644 lib/l10n/app_en.arb.orig delete mode 100644 packages/ion_identity_client/lib/src/auth/dtos/authentication.freezed.dart delete mode 100644 packages/ion_identity_client/lib/src/core/identity_storage/data_storage.dart delete mode 100644 packages/ion_identity_client/lib/src/core/identity_storage/identity_storage.dart create mode 100644 packages/ion_identity_client/lib/src/core/storage/biometrics_state_storage.dart create mode 100644 packages/ion_identity_client/lib/src/core/types/biometrics_state.dart delete mode 100644 packages/ion_identity_client/lib/src/core/types/user_token.freezed.dart delete mode 100644 packages/ion_identity_client/lib/src/signer/dtos/simple_message_response.freezed.dart delete mode 100644 packages/ion_identity_client/lib/src/signer/dtos/user_action_signing_init_request.freezed.dart delete mode 100644 packages/ion_identity_client/lib/src/users/user_details/models/user_details.freezed.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ae66abb69..0f11b90a7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ + diff --git a/android/app/src/main/kotlin/io/ion/app/MainActivity.kt b/android/app/src/main/kotlin/io/ion/app/MainActivity.kt index 6d1f8fe9f..0d2b7930e 100644 --- a/android/app/src/main/kotlin/io/ion/app/MainActivity.kt +++ b/android/app/src/main/kotlin/io/ion/app/MainActivity.kt @@ -4,16 +4,17 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.KeyEvent -import com.banuba.sdk.pe.BanubaPhotoEditor import com.banuba.sdk.pe.PhotoCreationActivity +import com.banuba.sdk.pe.BanubaPhotoEditor import com.banuba.sdk.pe.data.PhotoEditorConfig import dev.fluttercommunity.shake_gesture_android.ShakeGesturePlugin -import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import io.flutter.plugins.GeneratedPluginRegistrant import java.io.File -class MainActivity : FlutterActivity() { +class MainActivity : FlutterFragmentActivity() { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { if (keyCode == KeyEvent.KEYCODE_MENU) { this.flutterEngine?.plugins?.get(ShakeGesturePlugin::class.java).let { plugin -> @@ -39,15 +40,14 @@ class MainActivity : FlutterActivity() { } private var exportResult: MethodChannel.Result? = null - private var photoEditorSDK: BanubaPhotoEditor? = null - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val appFlutterEngine = requireNotNull(flutterEngine) - GeneratedPluginRegistrant.registerWith(appFlutterEngine) + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + // Set up your MethodChannel here after registration MethodChannel( - appFlutterEngine.dartExecutor.binaryMessenger, + flutterEngine.dartExecutor.binaryMessenger, "banubaSdkChannel" ).setMethodCallHandler { call, result -> // Initialize export result callback to deliver the results back to Flutter @@ -61,8 +61,9 @@ class MainActivity : FlutterActivity() { if (photoEditorSDK == null) { // The SDK token is incorrect - empty or truncated result.error(ERR_CODE_SDK_NOT_INITIALIZED, "", null) + } else { + result.success(null) } - result.success(null) } METHOD_START_PHOTO_EDITOR -> { @@ -71,7 +72,8 @@ class MainActivity : FlutterActivity() { result.error(ERR_CODE_SDK_NOT_INITIALIZED, "", null) } else { // ✅ The license is active - val imageUrl = call.argument("imagePath") // Get the image URL from Flutter + val imageUrl = + call.argument("imagePath") // Get the image URL from Flutter if (imageUrl.isNullOrEmpty()) { result.error("INVALID_ARGUMENT", "Image URL is required", null) return@setMethodCallHandler @@ -79,7 +81,7 @@ class MainActivity : FlutterActivity() { val imageUri = Uri.fromFile(File(imageUrl)) val config = PhotoEditorConfig.Builder(this).build(); startActivityForResult( - PhotoCreationActivity.startFromEditor(this, config, imageUri ), + PhotoCreationActivity.startFromEditor(this, config, imageUri), PHOTO_EDITOR_REQUEST_CODE ) } @@ -103,9 +105,8 @@ class MainActivity : FlutterActivity() { // You can use Map or JSON to pass custom data for your app. private fun preparePhotoExportData(result: Intent?): Map { val photoUri = result?.getParcelableExtra(PhotoCreationActivity.EXTRA_EXPORTED) as? Uri - val data = mapOf( + return mapOf( ARG_EXPORTED_PHOTO_FILE to photoUri?.toString() ) - return data } } diff --git a/assets/svg/action_wallet_faceid.svg b/assets/svg/action_wallet_faceid.svg new file mode 100644 index 000000000..90446d724 --- /dev/null +++ b/assets/svg/action_wallet_faceid.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 6418c9cfd..5cc077ba8 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -69,6 +69,8 @@ This app needs access to your photo library to save images. NSMicrophoneUsageDescription This app requires microphone access for recording audio. + NSFaceIDUsageDescription + This app uses Face ID to authenticate the user. diff --git a/lib/app/features/auth/providers/auth_provider.c.dart b/lib/app/features/auth/providers/auth_provider.c.dart index 9b8d35f8d..b11a2672e 100644 --- a/lib/app/features/auth/providers/auth_provider.c.dart +++ b/lib/app/features/auth/providers/auth_provider.c.dart @@ -2,9 +2,11 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/features/user/providers/biometrics_provider.c.dart'; import 'package:ion/app/features/wallets/providers/main_wallet_provider.c.dart'; import 'package:ion/app/services/ion_identity/ion_identity_provider.c.dart'; import 'package:ion/app/services/storage/local_storage.c.dart'; +import 'package:ion_identity_client/ion_identity.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'auth_provider.c.freezed.dart'; @@ -15,12 +17,13 @@ class AuthState with _$AuthState { const factory AuthState({ required List authenticatedIdentityKeyNames, required String? currentIdentityKeyName, + required bool suggestToAddBiometrics, }) = _AuthState; const AuthState._(); bool get hasAuthenticated { - return authenticatedIdentityKeyNames.isNotEmpty; + return authenticatedIdentityKeyNames.isNotEmpty && suggestToAddBiometrics == false; } } @@ -35,10 +38,14 @@ class Auth extends _$Auth { final currentIdentityKeyName = authenticatedIdentityKeyNames.contains(savedIdentityKeyName) ? savedIdentityKeyName : authenticatedIdentityKeyNames.lastOrNull; + final biometricsStates = await ref.watch(biometricsStatesStreamProvider.future); + final userBiometricsState = + (currentIdentityKeyName != null) ? biometricsStates[currentIdentityKeyName] : null; return AuthState( authenticatedIdentityKeyNames: authenticatedIdentityKeyNames.toList(), currentIdentityKeyName: currentIdentityKeyName, + suggestToAddBiometrics: userBiometricsState == BiometricsState.canSuggest, ); } diff --git a/lib/app/features/auth/views/pages/sign_up_password/sign_up_password.dart b/lib/app/features/auth/views/pages/sign_up_password/sign_up_password.dart index 51ccd4413..ddae41267 100644 --- a/lib/app/features/auth/views/pages/sign_up_password/sign_up_password.dart +++ b/lib/app/features/auth/views/pages/sign_up_password/sign_up_password.dart @@ -13,6 +13,7 @@ import 'package:ion/app/features/auth/views/components/auth_scrolled_body/auth_s import 'package:ion/app/features/auth/views/components/identity_key_name_input/identity_key_name_input.dart'; import 'package:ion/app/features/auth/views/pages/sign_up_password/password_validation.dart'; import 'package:ion/app/features/auth/views/pages/sign_up_password/sign_up_password_button.dart'; +import 'package:ion/app/features/components/biometrics/hooks/use_on_suggest_biometrics.dart'; import 'package:ion/app/features/components/verify_identity/components/password_input.dart'; import 'package:ion/app/router/components/sheet_content/sheet_content.dart'; import 'package:ion/generated/assets.gen.dart'; @@ -62,6 +63,8 @@ class SignUpPasswordPage extends HookConsumerWidget { ], ); + final onSuggestToAddBiometrics = useOnSuggestToAddBiometrics(ref); + return SheetContent( body: KeyboardDismissOnTap( child: AuthScrollContainer( @@ -104,13 +107,16 @@ class SignUpPasswordPage extends HookConsumerWidget { ), SizedBox(height: 22.0.s), SignUpPasswordButton( - onPressed: () { + onPressed: () async { if (passwordController.text == passwordConfirmationController.text) { if (formKey.value.currentState!.validate()) { - ref.read(registerActionNotifierProvider.notifier).signUpWithPassword( + await ref + .read(registerActionNotifierProvider.notifier) + .signUpWithPassword( keyName: identityKeyNameController.text, password: passwordController.text, ); + await onSuggestToAddBiometrics(identityKeyNameController.text); } } else { passwordsError.value = context.i18n.error_passwords_are_not_equal; diff --git a/lib/app/features/components/biometrics/hooks/use_on_suggest_biometrics.dart b/lib/app/features/components/biometrics/hooks/use_on_suggest_biometrics.dart new file mode 100644 index 000000000..5f99bf78c --- /dev/null +++ b/lib/app/features/components/biometrics/hooks/use_on_suggest_biometrics.dart @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/features/components/biometrics/suggest_to_add_biometrics_popup.dart'; +import 'package:ion/app/features/user/providers/biometrics_provider.c.dart'; +import 'package:ion/app/router/utils/show_simple_bottom_sheet.dart'; +import 'package:ion_identity_client/ion_identity.dart'; + +Future Function(String username) useOnSuggestToAddBiometrics(WidgetRef ref) { + final context = useContext(); + return useCallback( + (String username) async { + final userBiometricsState = + await ref.read(userBiometricsStateProvider(username: username).future); + if (userBiometricsState == BiometricsState.canSuggest && context.mounted) { + await showSimpleBottomSheet( + context: context, + child: SuggestToAddBiometricsPopup(username: username), + ); + } + }, + [context], + ); +} diff --git a/lib/app/features/components/biometrics/suggest_to_add_biometrics_popup.dart b/lib/app/features/components/biometrics/suggest_to_add_biometrics_popup.dart new file mode 100644 index 000000000..4ebec2a69 --- /dev/null +++ b/lib/app/features/components/biometrics/suggest_to_add_biometrics_popup.dart @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/components/button/button.dart'; +import 'package:ion/app/components/card/info_card.dart'; +import 'package:ion/app/components/progress_bar/ion_loading_indicator.dart'; +import 'package:ion/app/components/screen_offset/screen_bottom_offset.dart'; +import 'package:ion/app/components/screen_offset/screen_side_offset.dart'; +import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/user/providers/biometrics_provider.c.dart'; +import 'package:ion/generated/assets.gen.dart'; + +class SuggestToAddBiometricsPopup extends ConsumerWidget { + const SuggestToAddBiometricsPopup({ + required this.username, + super.key, + }); + + final String username; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final minSize = Size(56.0.s, 56.0.s); + final biometricsActionsState = ref.watch(biometricsActionsNotifierProvider); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.only(left: 30.0.s, right: 30.0.s, top: 30.0.s), + child: InfoCard( + iconAsset: Assets.svg.actionWalletFaceid, + title: context.i18n.biometrics_suggestion_title, + description: context.i18n.biometrics_suggestion_desc, + ), + ), + SizedBox(height: 38.0.s), + ScreenSideOffset.small( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Button.compact( + type: ButtonType.outlined, + label: Text(context.i18n.button_cancel), + minimumSize: minSize, + onPressed: () { + ref + .read(biometricsActionsNotifierProvider.notifier) + .rejectToUseBiometrics(username: username); + Navigator.of(context).pop(); + }, + ), + ), + SizedBox( + width: 16.0.s, + ), + Expanded( + child: Button.compact( + label: Text(context.i18n.button_continue), + minimumSize: minSize, + disabled: biometricsActionsState.isLoading, + trailingIcon: biometricsActionsState.isLoading + ? const IONLoadingIndicator() + : const SizedBox.shrink(), + onPressed: () async { + await ref + .read(biometricsActionsNotifierProvider.notifier) + .enrollToUseBiometrics( + username: username, + localisedReason: context.i18n.biometrics_suggestion_title, + ); + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + ), + ], + ), + ), + ScreenBottomOffset(), + ], + ); + } +} diff --git a/lib/app/features/user/providers/biometrics_provider.c.dart b/lib/app/features/user/providers/biometrics_provider.c.dart new file mode 100644 index 000000000..6869405cf --- /dev/null +++ b/lib/app/features/user/providers/biometrics_provider.c.dart @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/services/ion_identity/ion_identity_provider.c.dart'; +import 'package:ion_identity_client/ion_identity.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'biometrics_provider.c.g.dart'; + +@riverpod +Future userBiometricsState( + Ref ref, { + required String username, +}) async { + final ionIdentity = await ref.read(ionIdentityProvider.future); + return ionIdentity(username: username).auth.getBiometricsState(); +} + +@Riverpod(keepAlive: true) +Stream> biometricsStatesStream(Ref ref) async* { + final ionIdentity = await ref.watch(ionIdentityProvider.future); + + yield* ionIdentity.biometricsStatesStream; +} + +@riverpod +class BiometricsActionsNotifier extends _$BiometricsActionsNotifier { + @override + FutureOr build() {} + + Future rejectToUseBiometrics({required String username}) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final ionIdentity = await ref.read(ionIdentityProvider.future); + await ionIdentity(username: username).auth.rejectToUseBiometrics(); + }); + } + + Future enrollToUseBiometrics({ + required String username, + required String localisedReason, + }) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final ionIdentity = await ref.read(ionIdentityProvider.future); + await ionIdentity(username: username).auth.enrollToUseBiometrics( + localisedReason: localisedReason, + ); + }); + } +} diff --git a/lib/app/features/user/providers/user_verify_identity_provider.c.dart b/lib/app/features/user/providers/user_verify_identity_provider.c.dart index aa1f61eda..d44274eaa 100644 --- a/lib/app/features/user/providers/user_verify_identity_provider.c.dart +++ b/lib/app/features/user/providers/user_verify_identity_provider.c.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:ion/app/exceptions/exceptions.dart'; import 'package:ion/app/features/auth/providers/auth_provider.c.dart'; import 'package:ion/app/features/user/model/verify_identity_type.dart'; +import 'package:ion/app/features/user/providers/biometrics_provider.c.dart'; import 'package:ion/app/services/ion_identity/ion_identity_provider.c.dart'; import 'package:ion_identity_client/ion_identity.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -37,14 +38,20 @@ AutoDisposeFutureProvider verifyUserIdentityProvider({ Future verifyIdentityType(Ref ref) async { final username = ref.read(currentIdentityKeyNameSelectorProvider); final ionIdentity = await ref.read(ionIdentityProvider.future); + if (username != null) { - return ionIdentity(username: username).auth.isPasswordFlowUser() - ? VerifyIdentityType.password - : VerifyIdentityType.passkey; + if (ionIdentity(username: username).auth.isPasswordFlowUser()) { + final userBiometricsState = + await ref.read(userBiometricsStateProvider(username: username).future); + return userBiometricsState == BiometricsState.enabled + ? VerifyIdentityType.biometrics + : VerifyIdentityType.password; + } + return VerifyIdentityType.passkey; } - return (await ionIdentity(username: '').auth.isPasskeyAvailable()) - ? VerifyIdentityType.passkey - : VerifyIdentityType.password; + + final isPasskeyAvailable = await ref.read(isPasskeyAvailableProvider.future); + return isPasskeyAvailable ? VerifyIdentityType.passkey : VerifyIdentityType.password; } @riverpod diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 79a93230d..e81d08a2e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -692,6 +692,8 @@ "verify_with_password_desc": "Please confirm your password to continue.", "verify_with_password_prompt_desc": "Your device will ask your password to confirm", "verify_with_biometrics_title": "Verify with biometrics", + "biometrics_suggestion_title": "Add biometric authentication", + "biometrics_suggestion_desc": "Do you want to use your device biometric data for a faster authentication?", "members_count": "{count, plural, =1 {{count} member} other {{count} members}}", "all_chains_item": "All chains" } diff --git a/lib/l10n/app_en.arb.orig b/lib/l10n/app_en.arb.orig new file mode 100644 index 000000000..248b139eb --- /dev/null +++ b/lib/l10n/app_en.arb.orig @@ -0,0 +1,684 @@ +{ + "@@locale": "en", + "day": "{count, plural, =1{day} other{days}}", + "hour": "{count, plural, =1{hour} other{hours}}", + "button_continue": "Continue", + "button_follow": "Follow", + "button_follow_back": "Follow back", + "button_following": "Following", + "button_save": "Save", + "button_cancel": "Cancel", + "button_log_out": "Log out", + "button_turn_off": "Turn off", + "button_unfollow": "Unfollow", + "button_delete": "Delete", + "button_edit": "Edit", + "button_close": "Close", + "button_register": "Register", + "button_send": "Send", + "button_request": "Request", + "button_login": "Login", + "button_try_again": "Try again", + "button_retry": "Retry", + "button_confirm": "Confirm", + "button_restore": "Restore", + "button_reset": "Reset", + "button_back": "Back", + "button_back_to_security": "Back to Security", + "button_lets_start": "Let's start", + "button_next": "Next", + "button_add": "Add", + "button_apply": "Apply", + "button_schedule": "Schedule", + "button_go_to_settings": "Go to Settings", + "button_allow": "Allow", + "button_dont_allow": "Don't Allow", + "button_share_story": "Share story", + "button_add_answer": "Add answer", + "button_link": "Link", + "button_share": "Share", + "button_mute": "Mute", + "button_unmute": "Unmute", + "button_block": "Block", + "button_report": "Report", + "button_publish": "Publish", + "button_learn_more": "Learn More", + "button_forward": "Forward", + "button_reply": "Reply", + "button_copy": "Copy", + "button_bookmark": "Bookmark", + "button_save_changes": "Save Changes", + "dropdown_select_category": "Select category", + "dropdown_category_hate": "Hate", + "dropdown_category_violence": "Violent speech", + "dropdown_category_child_safety": "Child safety", + "common_show_more": "Show more", + "common_show_less": "Show less", + "common_identity_key_name": "Identity key name", + "common_password": "Password", + "common_confirm_password": "Confirm password", + "common_seconds": "{seconds}s", + "common_congratulations": "Congratulations", + "common_successfully": "Successfully", + "common_select_languages": "Select languages", + "common_no_access_permission": "To grant access, go to settings and enable the appropriate settings", + "common_crop_image": "Crop Image", + "common_photo": "Photo", + "common_video": "Video", + "common_voice_message": "Voice message", + "common_poll": "Poll", + "common_archive": "Archive", + "common_select_option": "Select option", + "common_select_coin": "Select coin", + "common_email_address": "Email address", + "common_information": "Information", + "common_photos": "Photos", + "common_camera": "Camera", + "common_ion_pay": "ION Pay", + "common_profile": "Profile", + "common_document": "Document", + "common_you": "You", + "common_add_video": "Add video", + "common_forwarded": "Forwarded", + "common_forwarded_from": "Forwarded from", + "common_title": "Title", + "common_desc": "Description", + "common_public": "Public", + "common_private": "Private", + "common_invitation_link": "Invitation link", + "common_share_link": "Share link", + "common_copied": "Copied", + "auth_secured_by": "Secured by", + "auth_privacy": "By continuing, you are agreeing to our [[:link]]terms_of_service[[/:link]] & [[:link]]privacy_policy[[/:link]]", + "auth_terms_of_service": "Terms of Service", + "auth_privacy_policy": "Privacy Policy", + "auth_identity_io": "Identity.io", + "two_fa_title": "2FA Verification", + "two_fa_desc": "Please enter your confirmation code below", + "two_fa_select": "Select option {number}", + "two_fa_email": "Email code", + "two_fa_sms": "SMS code", + "two_fa_auth": "Authenticator code", + "two_fa_code_confirmation": "Please enter the code sent to", + "two_fa_delete_email_button": "Delete email", + "two_fa_edit_email_button": "Edit email", + "two_fa_deleting_email_title": "Deleting email", + "two_fa_deleting_email_description": "To delete the email verification, you must confirm by selecting the options first", + "two_fa_deleting_phone_title": "Phone number verification", + "two_fa_deleting_phone_description": "To delete verification by phone, you must confirm by selecting the options first", + "two_fa_account_linked_to": "Your account is linked to", + "two_fa_option_backup": "Backup", + "two_fa_option_email": "Email", + "two_fa_option_authenticator": "Authenticator", + "two_fa_option_phone": "Phone", + "two_fa_delete_email_success": "The email address has been successfully deleted", + "two_fa_delete_phone_success": "Verification by phone was successfully deleted", + "two_fa_success_desc": "Your identity key has been restored. You can now access your account securely", + "two_fa_failure_title": "2FA Verification error", + "two_fa_failure_desc": "Please ensure all codes are correct and try again", + "two_fa_failure_authenticator_title": "Authenticator not available", + "two_fa_failure_authenticator_description": "To set up an Authenticator app, please first link an email address or phone number to your account", + "two_fa_warning": "If you delete two-step authentication, it may put the security of your data at risk.", + "sign_up_passkey_title": "Register with passkey", + "sign_up_passkey_advantage_1_title": "No password to remember", + "sign_up_passkey_advantage_1_description": "With passkey, you can use things like your fingerprint or face to login", + "sign_up_passkey_advantage_2_title": "Works on all of your devices", + "sign_up_passkey_advantage_2_description": "Passkey will automatically be available across your synced devices", + "sign_up_passkey_advantage_3_title": "Keep your account safer", + "sign_up_passkey_advantage_3_description": "Passkey offer state-of-the-art phishing resistance", + "sign_up_passkey_use_password": "Use password instead", + "sign_up_password_title": "Register", + "sign_up_passkey_identity_key_name_taken": "Identity key name is taken", + "sign_up_password_description": "Choose a strong password to create an account", + "restore_identity_title": "Restore identity key", + "restore_identity_type_description": "Select the type of identity key recovery", + "restore_identity_type_google_drive_title": "Restore from Google Drive", + "restore_identity_type_icloud_title": "Restore from iCloud", + "restore_identity_type_icloud_description": "Restore your identity key from an iCloud backup", + "restore_identity_type_credentials_title": "Restore using recovery credentials", + "restore_identity_type_credentials_description": "Restore with Recovery code and Recovery key ID", + "discover_creators_title": "Discover creators", + "restore_identity_creds_description": "Please enter your recovery credentials below", + "restore_identity_creds_recovery_code": "Recovery code", + "restore_identity_creds_recovery_key": "Recovery key ID", + "restore_identity_creds_action": "Recovery key ID", + "discover_creators_description": "Connect with visionaries and inspiring voices", + "fill_profile_title": "Your profile", + "fill_profile_description": "Customize your account", + "fill_profile_input_name": "Name", + "fill_profile_input_nickname": "Nickname", + "get_started_title": "Get started", + "get_started_description": "Enter your identity key name to log in into your account", + "get_started_method_divider": "or", + "get_started_restore_button": "Restore identity key", + "identity_key_name_description": "Think of your identity key name as a unique identifier of your account. You’ll need it to log in and recover your account, so keep it safe and don’t forget it", + "identity_key_name_usage": "Use it to log in on any app secured by", + "select_languages_description": "You’ll be shown content in the selected language", + "dapps_section_title_highest_ranked": "Highest ranked", + "dapps_section_title_recently_added": "Recently added", + "dapps_section_title_favourites": "Favorites", + "dapps_section_title_featured": "Featured", + "dapps_section_title_categories": "Categories", + "dapps_category_defi": "DeFi", + "dapps_category_marketplaces": "Marketplaces", + "dapps_category_games": "Games", + "dapps_category_social": "Social", + "dapps_category_utilities": "Utilities", + "dapps_category_other": "Other", + "dapps_favourites_added": "{count} added dApps", + "dapps_favourites_empty_title": "You have no favourites dApps yet", + "dapps_search_empty": "Search here for dApps, categories…", + "dapp_details_launch_dapp_button_title": "Launch dApp", + "dapp_details_tips": "Tips", + "dapp_details_tips_games": "Games", + "dapp_details_tips_global_rank": "Global Rank", + "dapp_details_tips_vote": "Vote", + "dapp_details_tips_voted": "Voted", + "search_placeholder": "Search", + "search_nothing_found": "Nothing was found for your query", + "profile_following": "Following", + "profile_followers": "Followers", + "profile_following_with_counter": "Following ({counter})", + "profile_followers_with_counter": "Followers ({counter})", + "profile_privacy": "Settings & Privacy", + "profile_help": "Help Center", + "profile_profile_desc": "Your personal space", + "profile_feed_desc": "Discover and engage", + "profile_videos_desc": "Social streaming", + "profile_articles_desc": "Trending stories", + "profile_bookmarks": "Bookmarks", + "profile_bookmarks_desc": "Your saved posts", + "profile_switch_user_header": "User accounts", + "profile_create_new_account": "Create a new account", + "profile_log_out": "Log out {nickname}", + "profile_followed_by": "Followed by ", + "profile_followed_by_and": " and ", + "profile_followed_by_and_others": "others", + "profile_follows_you": "Follows you", + "profile_none": "None", + "profile_posts": "Posts", + "profile_stories": "Stories", + "profile_replies": "Replies", + "profile_videos": "Videos", + "profile_articles": "Articles", + "profile_popup_block_user_desc": "Are you sure you want to block this user?", + "profile_popup_report_title": "Report @{nickname}", + "profile_popup_report_desc": "Select a category from the list below", + "profile_popup_report_success_title": "Successfully sent", + "profile_popup_report_success_desc": "Your report has been successfully sent", + "profile_popup_unfollow": "Unfollow @{nickname}?", + "profile_popup_unfollow_desc": "Once you unfollow, you will no longer see their updates or content in your feed.", + "profile_notifications_popup_title": "Account notifications", + "profile_edit": "Edit profile", + "profile_save": "Save profile", + "profile_bio": "Bio", + "profile_location": "Location", + "profile_website": "Website", + "profile_send_option_title": "Send payment", + "profile_send_option_desc": "Send funds quickly and securely", + "profile_request_option_title": "Request payment", + "profile_request_option_desc": "Securely receive funds with one tap", + "profile_send_coin": "Send coin", + "profile_request_funds": "Request funds", + "profile_empty_state": "There's nothing here yet", + "profile_creation_date": "December 2024", + "notifications_title": "Notifications", + "notifications_type_comments": "Comments", + "notifications_type_followers": "Followers", + "notifications_type_likes": "Likes", + "notifications_followed_one": "[:username] followed you", + "notifications_followed_two": "[:username] and [:username] followed you", + "notifications_followed_many": "[:username] and {number} others followed you", + "notifications_liked_one": "[:username] liked your post", + "notifications_liked_two": "[:username] and [:username] liked your post", + "notifications_liked_many": "[:username] and {number} others liked your post", + "notifications_liked_reply_one": "[:username] liked your reply", + "notifications_liked_reply_two": "[:username] and [:username] liked your reply", + "notifications_liked_reply_many": "[:username] and {number} others liked your reply", + "notifications_reply": "[:username] replied to your post", + "notifications_share": "[:username] shared your post", + "notifications_repost": "[:username] reposted your post", + "notifications_empty_state": "You don't have any notifications", + "settings_title": "Settings", + "settings_security": "Security", + "settings_privacy": "Privacy", + "settings_push_notifications": "Push notifications", + "settings_privacy_policy": "Privacy policy", + "settings_terms_conditions": "Terms & conditions", + "settings_logout": "Logout", + "settings_app_version": "dApp version {version}", + "settings_profile_edit": "Edit profile", + "settings_app_language": "dApp language", + "settings_content_language": "Content language", + "settings_remaining_content_languages_number": "+ {number}", + "privacy_group_wallet_address_title": "Wallet address", + "privacy_group_who_can_message_you_title": "Who can message you", + "privacy_group_who_can_invite_you_title": "Who can invite you in groups", + "privacy_option_wallet_public": "Public", + "privacy_option_wallet_private": "Private", + "privacy_option_user_visibility_for_everyone": "Everyone", + "privacy_option_user_visibility_for_followed_people": "People I follow", + "privacy_option_user_visibility_for_friends": "Friends", + "push_notification_device_permission": "Device permission", + "push_notification_social_group_title": "Social", + "push_notification_chat_group_title": "Chat", + "push_notification_wallet_group_title": "Wallet", + "push_notification_system_group_title": "System", + "push_notification_social_option_posts": "Posts", + "push_notification_social_option_mentions_replies": "Mentions and replies", + "push_notification_social_option_reposts": "Reposts", + "push_notification_social_option_likes": "Likes", + "push_notification_social_option_new_followers": "New followers", + "push_notification_chat_option_direct_messages": "Direct messages", + "push_notification_chat_option_group_chats": "Group chats", + "push_notification_chat_option_channels": "Channels", + "push_notification_wallet_option_payment_request": "Payment request", + "push_notification_wallet_option_payment_received": "Payment received", + "push_notification_system_option_update": "Update", + "app_language_title": "App language", + "app_language_description": "You’ll be shown the app in the selected language", + "confirm_logout_title": "Log out {username}?", + "confirm_logout_description": "Are you sure you want to log out from the app?", + "content_language_title": "Content languages", + "content_language_description": "You’ll be shown content in the selected language", + "category_aviation": "Aviation", + "category_blockchain": "Blockchain", + "category_business": "Business", + "category_cars": "Cars", + "category_cryptocurrency": "Cryptocurrency", + "category_data_science": "Data Science", + "category_education": "Education", + "category_finance": "Finance", + "category_gamer": "Gamer", + "category_style": "Style", + "category_restaurant": "Restaurant", + "category_trading": "Trading", + "category_technology": "Technology", + "category_traveler": "Traveler", + "category_news": "News", + "wallet_balance": "Balance", + "wallet_send": "Send", + "wallet_send_coins": "Send coins", + "wallet_receive_info": "When sending funds, the networks must match! otherwise, you may permanently lose your funds", + "wallet_choose_network": "Choose network", + "wallet_receive": "Receive", + "wallet_receive_coins": "Receive coins", + "wallet_share_address": "Share address", + "wallet_scan": "Scan the QR code", + "wallet_scan_hint": "Scan the QR code to send cryptocurrency", + "wallet_sent": "Sent", + "wallet_received": "Received", + "wallet_hide": "Hide 0.00", + "wallet_empty_coins": "You have no coins yet", + "wallet_empty_nfts": "You don't have any NFT's", + "wallet_manage_coins": "Manage coins", + "wallet_manage_nfts": "Select chain", + "wallet_buy_nfts": "Buy NFTs", + "wallet_coin_address": "{coin} address", + "wallet_network": "Network", + "wallet_change": "change", + "wallet_wallets": "Wallets", + "wallet_manage_wallets": "Manage wallets", + "wallet_create_new": "Create a new wallet", + "wallet_create": "Create wallet", + "wallet_name": "Wallet name", + "wallet_edit": "Edit wallet", + "wallet_delete": "Delete wallet", + "wallet_delete_q": "Delete wallet?", + "wallet_delete_message": "All coins on this wallet will be lost. Are you sure you want to delete this wallet?", + "wallet_enter_address": "Enter address", + "wallet_usdt_amount": "USDT amount", + "wallet_arrival_time": "Arrival time", + "wallet_arrival_time_type_normal": "Normal", + "wallet_arrival_time_minutes": "min", + "wallet_network_fee": "Network fee", + "wallet_max": "max", + "wallet_send_to": "Send to", + "wallet_asset": "Asset", + "wallet_title": "Wallet", + "wallet_transaction_successful": "Transfer successful", + "wallet_transaction_details": "Details", + "wallet_transaction_details_arrival_time_format": "dd.MM.yyyy HH:mm:ss", + "wallet_explore_transaction_details_title": "View on explorer", + "wallet_invite_friends": "Invite friend", + "wallet_friends_does_not_have_account": "Your friend doesn’t have an Ice account.", + "wallet_approximate_in_usd": "~ ${amount}", + "wallet_coin_amount": "{coin} amount", + "sorting_price_asc": "Price: low to high", + "sorting_price_desc": "Price: high to low", + "wallet_sorting_title": "Sorting the list", + "contacts_title": "Contacts", + "contacts_select_title": "Select contact", + "contacts_allow_pop_up_title": "Ice is better with friends", + "contacts_allow_pop_up_desc": "Sync your contacts, see who is already on Ice, and send and receive Ice payments from any of your contacts.", + "contacts_allow_pop_up_action": "Allow contacts access", + "core_view_all": "view all", + "core_all": "All", + "core_chain": "Chain", + "core_nfts": "NFTs", + "core_coins": "Coins", + "core_dapps": "dApps", + "core_done": "Done", + "core_empty_search": "No results found", + "core_empty_transactions_history": "You don't have any transactions yet", + "date_today": "Today", + "date_yesterday": "Yesterday", + "general_feed": "Feed", + "general_videos": "Videos", + "general_articles": "Articles", + "feed_for_you": "For you", + "feed_following": "Following", + "feed_read_time_in_mins": "{mins} min read", + "feed_trending_videos": "Trending Videos", + "feed_modal_title": "Create value", + "feed_modal_post": "Post", + "feed_modal_post_description": "Voice your ideas", + "feed_modal_story": "Story", + "feed_modal_story_description": "Express the moment", + "feed_modal_video": "Video", + "feed_modal_video_description": "Show the world in motion", + "feed_modal_article": "Article", + "feed_repost_type": "Type", + "feed_repost": "Repost", + "feed_someone_reposted": "{someone} reposted", + "feed_quote_post": "Quote post", + "feed_write_comment": "Write comment", + "feed_comment_hint": "Write your comment!", + "feed_add_story": "Add to story", + "feed_copy_link": "Copy link", + "feed_whatsapp": "WhatsApp", + "feed_telegram": "Telegram", + "feed_x": "X", + "feed_more": "More", + "feed_send": "Send", + "feed_modal_article_description": "Share your wisdom", + "feed_share_via": "Share via message", + "feed_search_empty": "Search here for users, hashtags, channels...", + "feed_search_history_title": "Recent searches", + "feed_search_history_delete_title": "Delete search history?", + "feed_search_history_delete_message": "Are you sure you want to delete all search history?", + "feed_search_filter_title": "Filters", + "feed_search_filter_anyone": "From anyone", + "feed_search_filter_following": "People you follow", + "feed_search_filter_people": "People", + "feed_search_filter_languages": "Languages", + "feed_advanced_search_category_top": "Top", + "feed_advanced_search_category_latest": "Latest", + "feed_advanced_search_category_people": "People", + "feed_advanced_search_category_photos": "Photos", + "feed_advanced_search_category_videos": "Videos", + "feed_advanced_search_category_groups": "Groups", + "feed_advanced_search_category_channels": "Channels", + "video_not_found": "Video not found", + "turn_notifications_title": "Turn on notifications", + "turn_notifications_description": "Receive notifications when you transfer and receive funds", + "turn_notifications_receive": "Receive notifications when your sending or receiving assets", + "turn_notifications_stay_up": "Stay up to date with the latest news", + "turn_notifications_chat": "Chat and receive notifications even if the application is closed", + "turn_notifications_sent_title": "Sent ICE", + "turn_notifications_sent_description": "You sent 12.43 ICE to @james", + "turn_notifications_sent_time": "15m ago", + "turn_notifications_new_follower_title": "New follower", + "turn_notifications_new_follower_description": "@curtis has started following you", + "turn_notifications_new_follower_time": "24m ago", + "turn_notifications_new_message_title": "New message", + "turn_notifications_new_message_description": "@marie has sent you a message", + "turn_notifications_new_message_time": "31m ago", + "send_nft_navigation_title": "Send NFT", + "send_nft_description": "Description", + "send_nft_token_id": "Token ID", + "send_nft_token_network": "Network", + "send_nft_token_standard": "Token standard", + "send_nft_token_contract_address": "Contract address", + "send_nft_title": "Send NFT", + "post_page_title": "Post", + "post_show_replies": "Show replies", + "post_hide_replies": "Hide replies", + "post_reply_hint": "Write your reply", + "post_replying_to": "Replying to", + "post_reply_sent": "Your reply was sent", + "send_nft_confirm_asset": "Asset", + "send_nft_confirm_network": "Network", + "send_nft_confirm_arrival_time": "Arrival time", + "send_nft_confirm_network_fee": "Network fee", + "transaction_details_title": "Transaction details", + "transaction_details_view_on_explorer": "View on explorer", + "post_menu_not_interested": "Not interested", + "post_menu_follow_nickname": "Follow @{nickname}", + "post_menu_unfollow_nickname": "Unfollow @{nickname}", + "post_menu_block_nickname": "Block @{nickname}", + "post_menu_report_post": "Report post", + "protect_account_header_security": "Security", + "protect_account_title_secure_account": "Secure your account", + "protect_account_description_secure_account": "Securing your account ensures you never lose access to your data and funds", + "protect_account_button": "Protect account", + "protect_account_description_secure_account_2fa": "To secure your account, back it up and enable at least one 2FA option", + "protect_account_create_recovery_error": "An error occurred while fetching the data.", + "backup_title": "Select backup", + "backup_description": "Backups enable you to restore your data and wallet if something goes wrong", + "backup_option_with_icloud_title": "Backup with iCloud", + "backup_option_with_google_drive_title": "Backup with Google Drive", + "backup_option_with_google_drive_description": "Safe and simple way to protect your account", + "backup_option_with_recovery_keys_title": "Recovery keys", + "backup_option_with_recovery_keys_description": "Write down and store your keys on paper for secure account recovery", + "secure_your_recovery_keys_title": "Secure your recovery keys", + "secure_your_recovery_keys_description": "Please complete this process in a private place to ensure your account's safety", + "recovery_keys_successfully_protected_title": "Successfully protected", + "recovery_keys_successfully_protected_description": "Your recovery keys have been securely backed up. Please keep them safe for future account recovery", + "error_recovery_keys_title": "Recovery keys error", + "error_recovery_keys_description": "You have entered incorrect data", + "error_screenshots_arent_secure_title": "Screenshots aren’t secure", + "error_screenshots_arent_secure_description": "Anyone who has access to your keys can use your assets. We recommend writing with your hands.", + "error_nickname_invalid": "Only letters, numbers, and dots are allowed", + "error_passwords_are_not_equal": "Passwords are not equal", + "error_website_invalid": "Invalid website url", + "error_general_title": "Something went wrong", + "error_general_description": "An unexpected error occurred. Please try again later. {info}", + "error_general_error_code": "Error code: {error}", + "error_input_length": "Must be over {amount} characters", + "error_input_numbers": "Must contain 1 number", + "error_input_all_cases": "Uppercase and lowercase letters", + "error_input_special_character": "Must contain 1 special character", + "error_identity_name_invalid": "Only lowercase letters, numbers, dots and hyphens are allowed", + "warning_avoid_storing_keys": "Avoid storing keys on any device to prevent losing access to funds in case of a hack", + "warning_authenticator_setup": "Keep this key safe to restore access if you lose your device.", + "authenticator_setup_title": "Authenticator setup", + "authenticator_setup_description": "In order to link the key, you need one of the following Authenticator applications", + "authenticator_setup_key": "Setup key", + "authenticator_is_linked_to_account": "Your account is linked to the authentication application", + "authenticator_delete_title": "Deleting an authenticator", + "authenticator_delete_description": "To delete the authenticator, you must confirm by selecting the options first", + "authenticator_has_deleted": "The authenticator was successfully deleted", + "follow_instructions_title": "Follow instructions", + "follow_instructions_description": "Copy the installation key and paste it into your Authentication application", + "confirm_the_code_title": "Confirm the code", + "authenticator_protected_description": "Your authenticator was successfully configured and you are now protected", + "email_verification_title": "Email verification", + "email_verification_description": "Enter your email address for verification", + "email_confirmation_title": "Confirm email", + "email_success_description": "The email address has been successfully verified and added for 2FA", + "phone_verification_title": "Phone number verification", + "phone_verification_description": "Enter your phone number for verification", + "phone_confirmation_title": "Confirm phone number", + "phone_success_description": "The phone number has been successfully verified and added for 2FA", + "phone_number": "Phone number", + "phone_number_invalid": "Invalid phone number", + "select_countries_nav_title": "Select country", + "create_post_modal_title": "New post", + "create_post_modal_placeholder": "What’s happening?", + "create_article_nav_title": "New article", + "create_article_title_placeholder": "Title", + "create_article_add_cover": "Add cover image", + "create_article_story_placeholder": "Write your story...", + "create_video_edit_cover": "Edit cover", + "create_video_new_video": "New video", + "create_video_input_placeholder": "Add a description (optional)", + "gallery_add_photo_title": "Add photo", + "gallery_add_media_title": "Add media", + "camera": "Camera", + "visibility_settings_title_video": "Who can view this video", + "visibility_settings_title_story": "Who can view this story", + "visibility_settings_everyone": "Everyone", + "visibility_settings_followed_accounts": "Accounts you follow", + "visibility_settings_verified_accounts": "Verified accounts", + "visibility_settings_mentioned_accounts": "Only accounts you mention", + "schedule_modal_nav_title": "Set date and time", + "cancel_creation_post_title": "Cancel post?", + "cancel_creation_description": "Are you sure you want to cancel your progress?", + "cancel_creation_article_title": "Cancel article?", + "photo_library_require_access_title": "\"{appName} app\" would like to access your photo library", + "photo_library_require_access_description": "This lets you share photos from your library and save photos to your camera roll.", + "camera_require_access_title": "\"{appName} app\" would like to access your camera", + "camera_require_access_description": "{appName} application would like to access the camera", + "push_notifications_require_access_title": "\"{appName} app\" would like to Send You Notifications", + "push_notifications_require_access_description": "Notifications may include alerts, sounds, and icon badges. These can be configured is Settings.", + "gallery_permission_headline": "Gallery permission", + "gallery_no_access_title": "There is no access to your gallery", + "camera_permission_headline": "Camera permission", + "camera_no_access_title": "There is no access to your camera", + "camera_no_access_description": "To grant access, go to settings and enable the appropriate settings", + "push_notifications_permission_headline": "Notifications permission", + "push_notifications_no_access_title": "Permission Not Available", + "push_notifications_no_access_description": "You have previously denied notification permission. Please go to settings to enable notifications.", + "story_preview_title": "Story preview", + "story_settings_title": "Who can reply this story", + "article_settings_title": "Who can reply this article", + "chat_title": "Chats", + "chat_empty_description": "You have no conversations yet", + "chat_new_message_button": "New message", + "poll_length_modal_title": "Pool length time", + "poll_length_button_title": "Poll length", + "poll_add_answer_button_title": "Add answer", + "poll_choice_placeholder": "Choice {number}", + "poll_title_placeholder": "Write a question for the poll", + "chat_recents_money_request_message": "Money requested", + "toolbar_link_title": "Add a link", + "toolbar_link_placeholder": "http://", + "messaging_empty_description": "Messages and voices are end-to-end encrypted.", + "chat_modal_title": "Start conversation", + "chat_modal_private_description": "Start a private, one-on-one chat", + "chat_modal_group_description": "Chat with multiple people together", + "chat_modal_channel_description": "Share updates with a wide audience", + "chat_profile_share_modal_title": "Share profile", + "new_chat_modal_title": "New chat", + "new_chat_modal_description": "Search above for users, groups, and channels...", + "new_chat_modal_new_group_button": "New group", + "new_chat_modal_new_channel_button": "New channel", + "chat_read_all": "Read All", + "chat_delete_modal_title": "Delete chat?", + "chat_delete_modal_description": "Are you sure you want to delete all selected chats?", + "chat_search_empty": "Search here for users, chats, groups, and channels...", + "chat_money_request_title": "Money requested", + "chat_money_received_title": "Money received", + "chat_money_received_button": "View transaction", + "chat_profile_share_button": "Write a message", + "chat_learn_more_modal_title": "Privacy First, Always", + "chat_learn_more_modal_description": "Your chats are private and encrypted by default. We prioritize your privacy, and your conversations are protected with end-to-end encryption.", + "chat_add_poll_title": "New poll", + "channel_create_title": "Create a new channel", + "channel_create_type": "Channel type", + "channel_create_admins": "Channel admins", + "channel_create_action": "Create channel", + "channel_create_add_photo": "Add channel photo", + "channel_create_type_select_title": "Choose channel type", + "channel_create_type_public_desc": "Public channels are searchable and open to all users.", + "channel_create_type_private_desc": "Private channels can't be found in search and aren't end-to-end encrypted.", + "channel_create_admins_title": "Admin management", + "channel_create_admins_action": "Add administrator", + "channel_create_admin_type_title": "Choose admin type", + "channel_create_admin_type_owner": "Owner", + "channel_create_admin_type_admin": "Admin", + "channel_create_admin_type_moderator": "Moderator", + "channel_create_admin_type_remove": "Remove role", + "channel_create_admin_type_remove_title": "Remove role?", + "channel_create_admin_type_remove_desc": "Are you sure you want to remove the role?", + "channel_created_message": "The channel has been created", + "group_create_title": "New Group", + "group_create_name_label": "Group Name", + "group_create_type": "Group type", + "group_create_members_number": "Group members ({members})", + "group_create_create_button": "Create group", + "group_create_type_title": "Choose group type", + "group_create_type_public_desc": "Public channels are searchable and open to all users.", + "group_create_type_private_desc": "Private channels can't be found in search and aren't end-to-end encrypted.", + "group_create_type_encrypted": "Encrypted", + "group_create_type_encrypted_desc": "End-to-end encryption is used. Only you and the people you communicate with can see the messages.", + "group_created_message": "The group has been created", + "notification_video_loading": "Your video is loading...", + "notification_story_loading": "Your story is loading...", + "notification_post_loading": "Your post is loading...", + "notification_article_loading": "Your article is loading...", + "notification_reply_loading": "Your reply is loading...", + "notification_repost_loading": "Your repost is loading...", + "notification_video_published": "Your video has been published", + "notification_story_published": "Your story has been published", + "notification_post_published": "Your post has been published", + "notification_article_published": "Your article has been published", + "notification_reply_published": "Your reply has been published", + "notification_repost_successful": "Successfully reposted", + "chat_groups_joined": "Joined", + "chat_groups_explore": "Explore", + "chat_groups_subscribed": "Subscribed", + "code_block_type_plain_text": "Plain text", + "code_block_type_swift": "Swift", + "code_block_type_c": "C", + "code_block_type_c_plus_plus": "C++", + "code_block_type_c_sharp": "C#", + "code_block_type_css": "CSS", + "code_block_type_java": "Java", + "code_block_type_javascript": "JavaScript", + "code_block_type_python": "Python", + "code_block_type_dart": "Dart", + "write_a_message": "Write a message...", + "reaction_was_sent": "Reaction was sent", + "share_via": "Share via...", + "topic_blockchain": "Blockchain", + "topic_business": "Business", + "topic_cryptocurrency": "Cryptocurrency", + "topic_data_science": "Data Science", + "topic_finance": "Finance", + "topic_games": "Games", + "topic_style": "Style", + "topic_lifechange": "Life Change", + "topic_life": "Life", + "topic_trading": "Trading", + "topic_technology": "Technology", + "topic_travel": "Travel", + "topic_news": "News", + "topic_people": "People", + "topic_world": "World", + "topics_add": "Add", + "topics_title": "Topics", + "article_preview_title": "Article preview", + "article_page_title": "Article", + "article_page_from_author": "from {name}", + "update_update_title": "Please update", + "update_update_desc": "You are using an old app version and need to update it in order to use it further", + "update_update_action": "Update now", + "update_uptodate_title": "You are up to date", + "update_uptodate_desc": "You are now using the latest version. Check all the changes below", + "update_uptodate_action": "View changelog", + "emoji_category_smileys_people": "Smileys & People", + "emoji_category_animals_nature": "Animals & Nature", + "emoji_category_food_drink": "Food & Drink", + "emoji_category_activities": "Activities", + "emoji_category_travel_places": "Travel & Places", + "emoji_category_objects": "Objects", + "emoji_category_symbols": "Symbols", + "emoji_category_flags": "Flags", + "recent_emoji_reactions": "Recent reactions", + "passkeys_prompt_title": "Verify with passkey", + "passkeys_prompt_description": "Your device will ask your fingerprint, face or screen lock to confirm", + "verify_with_password_title": "Verify with password", + "verify_with_password_desc": "Please confirm your password to continue.", + "verify_with_password_prompt_desc": "Your device will ask your password to confirm", + "verify_with_biometrics_title": "Verify with biometrics", +<<<<<<< Updated upstream + "members_count": "{count, plural, =1 {{count} member} other {{count} members}}", + "all_chains_item": "All chains" +======= + "biometrics_suggestion_title": "Add biometric authentication", + "biometrics_suggestion_desc": "Do you want to use your device biometric data for a faster authentication?", + "members_count": "{count, plural, =1 {{count} member} other {{count} members}}" +>>>>>>> Stashed changes +} diff --git a/packages/ion_identity_client/lib/ion_identity.dart b/packages/ion_identity_client/lib/ion_identity.dart index e3c0afa5c..2d00162ab 100644 --- a/packages/ion_identity_client/lib/ion_identity.dart +++ b/packages/ion_identity_client/lib/ion_identity.dart @@ -7,6 +7,7 @@ export 'src/auth/ion_identity_auth.dart'; export 'src/auth/services/create_credentials/models/create_recovery_credentials_success.dart'; export 'src/auth/services/twofa/models/twofa_type.c.dart'; export 'src/coins/models/coin.c.dart'; +export 'src/core/types/biometrics_state.dart'; export 'src/core/types/ion_exception.dart'; export 'src/core/types/types.dart'; export 'src/core/types/user_token.c.dart'; diff --git a/packages/ion_identity_client/lib/src/auth/dtos/authentication.freezed.dart b/packages/ion_identity_client/lib/src/auth/dtos/authentication.freezed.dart deleted file mode 100644 index 4ccffb2db..000000000 --- a/packages/ion_identity_client/lib/src/auth/dtos/authentication.freezed.dart +++ /dev/null @@ -1,186 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'authentication.c.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -Authentication _$AuthenticationFromJson(Map json) { - return _Authentication.fromJson(json); -} - -/// @nodoc -mixin _$Authentication { - String get token => throw _privateConstructorUsedError; - String get refreshToken => throw _privateConstructorUsedError; - - /// Serializes this Authentication to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of Authentication - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $AuthenticationCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $AuthenticationCopyWith<$Res> { - factory $AuthenticationCopyWith( - Authentication value, $Res Function(Authentication) then) = - _$AuthenticationCopyWithImpl<$Res, Authentication>; - @useResult - $Res call({String token, String refreshToken}); -} - -/// @nodoc -class _$AuthenticationCopyWithImpl<$Res, $Val extends Authentication> - implements $AuthenticationCopyWith<$Res> { - _$AuthenticationCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of Authentication - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? token = null, - Object? refreshToken = null, - }) { - return _then(_value.copyWith( - token: null == token - ? _value.token - : token // ignore: cast_nullable_to_non_nullable - as String, - refreshToken: null == refreshToken - ? _value.refreshToken - : refreshToken // ignore: cast_nullable_to_non_nullable - as String, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$AuthenticationImplCopyWith<$Res> - implements $AuthenticationCopyWith<$Res> { - factory _$$AuthenticationImplCopyWith(_$AuthenticationImpl value, - $Res Function(_$AuthenticationImpl) then) = - __$$AuthenticationImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({String token, String refreshToken}); -} - -/// @nodoc -class __$$AuthenticationImplCopyWithImpl<$Res> - extends _$AuthenticationCopyWithImpl<$Res, _$AuthenticationImpl> - implements _$$AuthenticationImplCopyWith<$Res> { - __$$AuthenticationImplCopyWithImpl( - _$AuthenticationImpl _value, $Res Function(_$AuthenticationImpl) _then) - : super(_value, _then); - - /// Create a copy of Authentication - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? token = null, - Object? refreshToken = null, - }) { - return _then(_$AuthenticationImpl( - token: null == token - ? _value.token - : token // ignore: cast_nullable_to_non_nullable - as String, - refreshToken: null == refreshToken - ? _value.refreshToken - : refreshToken // ignore: cast_nullable_to_non_nullable - as String, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$AuthenticationImpl extends _Authentication { - const _$AuthenticationImpl({required this.token, required this.refreshToken}) - : super._(); - - factory _$AuthenticationImpl.fromJson(Map json) => - _$$AuthenticationImplFromJson(json); - - @override - final String token; - @override - final String refreshToken; - - @override - String toString() { - return 'Authentication(token: $token, refreshToken: $refreshToken)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$AuthenticationImpl && - (identical(other.token, token) || other.token == token) && - (identical(other.refreshToken, refreshToken) || - other.refreshToken == refreshToken)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, token, refreshToken); - - /// Create a copy of Authentication - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$AuthenticationImplCopyWith<_$AuthenticationImpl> get copyWith => - __$$AuthenticationImplCopyWithImpl<_$AuthenticationImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$AuthenticationImplToJson( - this, - ); - } -} - -abstract class _Authentication extends Authentication { - const factory _Authentication( - {required final String token, - required final String refreshToken}) = _$AuthenticationImpl; - const _Authentication._() : super._(); - - factory _Authentication.fromJson(Map json) = - _$AuthenticationImpl.fromJson; - - @override - String get token; - @override - String get refreshToken; - - /// Create a copy of Authentication - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$AuthenticationImplCopyWith<_$AuthenticationImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/packages/ion_identity_client/lib/src/auth/ion_identity_auth.dart b/packages/ion_identity_client/lib/src/auth/ion_identity_auth.dart index 82315d4e8..5d93eb92c 100644 --- a/packages/ion_identity_client/lib/src/auth/ion_identity_auth.dart +++ b/packages/ion_identity_client/lib/src/auth/ion_identity_auth.dart @@ -9,6 +9,7 @@ import 'package:ion_identity_client/src/auth/services/logout/logout_service.dart import 'package:ion_identity_client/src/auth/services/recover_user/recover_user_service.dart'; import 'package:ion_identity_client/src/auth/services/register/register_service.dart'; import 'package:ion_identity_client/src/auth/services/twofa/twofa_service.dart'; +import 'package:ion_identity_client/src/core/storage/biometrics_state_storage.dart'; import 'package:ion_identity_client/src/core/storage/private_key_storage.dart'; import 'package:ion_identity_client/src/signer/identity_signer.dart'; @@ -29,6 +30,7 @@ class IONIdentityAuth { required this.loginService, required this.logoutService, required this.privateKeyStorage, + required this.biometricsStateStorage, required this.createRecoveryCredentialsService, required this.createNewCredentialsService, required this.recoverUserService, @@ -45,9 +47,10 @@ class IONIdentityAuth { final RecoverUserService recoverUserService; final DelegatedLoginService delegatedLoginService; final TwoFAService twoFAService; + final PrivateKeyStorage privateKeyStorage; + final BiometricsStateStorage biometricsStateStorage; final String username; - final PrivateKeyStorage privateKeyStorage; Future registerUser() => registerService.registerUser(); @@ -69,6 +72,17 @@ class IONIdentityAuth { bool isPasswordFlowUser() => privateKeyStorage.getPrivateKey(username: username) != null; + BiometricsState? getBiometricsState() => + biometricsStateStorage.getBiometricsState(username: username); + + Future rejectToUseBiometrics() => identitySigner.rejectToUseBiometrics(username); + + Future enrollToUseBiometrics({required String localisedReason}) => + identitySigner.enrollToUseBiometrics( + username: username, + localisedReason: localisedReason, + ); + Future createRecoveryCredentials( OnVerifyIdentity onVerifyIdentity, ) => diff --git a/packages/ion_identity_client/lib/src/auth/services/logout/logout_service.dart b/packages/ion_identity_client/lib/src/auth/services/logout/logout_service.dart index 55d117c5e..8366bc124 100644 --- a/packages/ion_identity_client/lib/src/auth/services/logout/logout_service.dart +++ b/packages/ion_identity_client/lib/src/auth/services/logout/logout_service.dart @@ -2,6 +2,7 @@ import 'package:ion_identity_client/ion_identity.dart'; import 'package:ion_identity_client/src/auth/services/logout/data_sources/logout_data_source.dart'; +import 'package:ion_identity_client/src/core/storage/biometrics_state_storage.dart'; import 'package:ion_identity_client/src/core/storage/private_key_storage.dart'; import 'package:ion_identity_client/src/core/storage/token_storage.dart'; @@ -11,12 +12,14 @@ class LogoutService { required this.dataSource, required this.tokenStorage, required this.privateKeyStorage, + required this.biometricsStateStorage, }); final String username; final LogoutDataSource dataSource; final TokenStorage tokenStorage; final PrivateKeyStorage privateKeyStorage; + final BiometricsStateStorage biometricsStateStorage; Future logOut() async { final token = tokenStorage.getToken(username: username); @@ -27,6 +30,7 @@ class LogoutService { dataSource.logOut(username: username, token: token.token), tokenStorage.removeToken(username: username), privateKeyStorage.removePrivateKey(username: username), + biometricsStateStorage.removeBiometricsState(username: username), ]); } } diff --git a/packages/ion_identity_client/lib/src/core/identity_storage/data_storage.dart b/packages/ion_identity_client/lib/src/core/identity_storage/data_storage.dart deleted file mode 100644 index c01d3f29b..000000000 --- a/packages/ion_identity_client/lib/src/core/identity_storage/data_storage.dart +++ /dev/null @@ -1,94 +0,0 @@ -// SPDX-License-Identifier: ice License 1.0 - -import 'dart:convert'; - -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -/// A generic data storage class that stores a `Map` in `FlutterSecureStorage` -/// and allows for CRUD operations on that map. -/// -/// [T] is the type of the values that will be stored. It must be JSON-serializable. -class DataStorage { - DataStorage({ - required FlutterSecureStorage secureStorage, - required String storageKey, - required T Function(Map) fromJson, - required Map Function(T) toJson, - }) : _secureStorage = secureStorage, - _storageKey = storageKey, - _fromJson = fromJson, - _toJson = toJson; - - final FlutterSecureStorage _secureStorage; - final String _storageKey; - final T Function(Map) _fromJson; - final Map Function(T) _toJson; - - /// In-memory cache of items mapped by their keys. - final Map _cache = {}; - - /// Initializes the storage by loading existing data from secure storage. - /// If no data exists, the cache starts empty. - Future init() async { - await _loadFromStorage(); - } - - /// Retrieves the data associated with the specified [key]. - /// Returns `null` if no data is found. - T? getData({required String key}) { - return _cache[key]; - } - - /// Sets (adds or updates) the data for a given [key]. - Future setData({required String key, required T value}) async { - _cache[key] = value; - await _saveToStorage(); - } - - /// Removes the data associated with the specified [key]. - Future removeData({required String key}) async { - _cache.remove(key); - await _saveToStorage(); - } - - /// Clears all stored data. - Future clearAllData() async { - _cache.clear(); - await _saveToStorage(); - } - - /// Returns all data as a Map. - Map getAllData() { - return Map.unmodifiable(_cache); - } - - /// Returns all data keys. - List getAllKeys() { - return _cache.keys.toList(growable: false); - } - - /// Loads data from secure storage into the in-memory cache. - Future _loadFromStorage() async { - final jsonStr = await _secureStorage.read(key: _storageKey); - if (jsonStr == null) { - return; // No data to load, keep cache empty. - } - - final jsonMap = json.decode(jsonStr) as Map; - _cache - ..clear() - ..addAll( - jsonMap.map((key, value) { - // value should be a Map representing T - return MapEntry(key, _fromJson(value as Map)); - }), - ); - } - - /// Saves the current cache to secure storage. - Future _saveToStorage() async { - final jsonMap = _cache.map((key, value) => MapEntry(key, _toJson(value))); - final jsonStr = json.encode(jsonMap); - await _secureStorage.write(key: _storageKey, value: jsonStr); - } -} diff --git a/packages/ion_identity_client/lib/src/core/identity_storage/identity_storage.dart b/packages/ion_identity_client/lib/src/core/identity_storage/identity_storage.dart deleted file mode 100644 index 7953ba783..000000000 --- a/packages/ion_identity_client/lib/src/core/identity_storage/identity_storage.dart +++ /dev/null @@ -1,126 +0,0 @@ -// SPDX-License-Identifier: ice License 1.0 - -import 'dart:async'; - -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:ion_identity_client/src/auth/dtos/dtos.dart'; -import 'package:ion_identity_client/src/core/identity_storage/data_storage.dart'; -import 'package:ion_identity_client/src/core/types/user_token.c.dart'; -import 'package:rxdart/rxdart.dart'; - -class IdentityStorage { - IdentityStorage({ - required FlutterSecureStorage secureStorage, - }) : _tokenStorage = DataStorage( - secureStorage: secureStorage, - storageKey: 'ion_identity_client_user_tokens_key', - fromJson: Authentication.fromJson, - toJson: (auth) => auth.toJson(), - ), - _privateKeysStorage = DataStorage( - secureStorage: secureStorage, - storageKey: 'ion_identity_client_private_keys_key', - // fromJson: Assuming the stored map looks like {"value": "the_private_key"} - fromJson: (json) => json['value'] as String, - // toJson: Convert the string into a map {"value": stringValue} - toJson: (value) => {'value': value}, - ); - - final DataStorage _tokenStorage; - final DataStorage _privateKeysStorage; - - final BehaviorSubject> _userTokensSubject = BehaviorSubject>(); - - Stream> get userTokens => _userTokensSubject.stream; - - /// Initializes both underlying storages. - Future init() async { - await Future.wait([ - _tokenStorage.init(), - _privateKeysStorage.init(), - ]); - _emitCurrentTokens(); - } - - UserToken? getToken({required String username}) { - final auth = _tokenStorage.getData(key: username); - if (auth == null) return null; - return UserToken( - username: username, - token: auth.token, - refreshToken: auth.refreshToken, - ); - } - - Future setToken({ - required String username, - required String newToken, - }) async { - final currentAuth = _tokenStorage.getData(key: username) ?? Authentication.empty(); - final updatedAuth = currentAuth.copyWith(token: newToken); - await _tokenStorage.setData(key: username, value: updatedAuth); - _emitCurrentTokens(); - } - - Future setTokens({ - required String username, - required Authentication newTokens, - }) async { - await _tokenStorage.setData(key: username, value: newTokens); - _emitCurrentTokens(); - } - - Future removeToken({required String username}) async { - await _tokenStorage.removeData(key: username); - _emitCurrentTokens(); - } - - Future clearAllTokens() async { - await _tokenStorage.clearAllData(); - _emitCurrentTokens(); - } - - String? getPrivateKey({required String username}) => _privateKeysStorage.getData(key: username); - - Future setPrivateKey({required String username, required String privateKey}) => - _privateKeysStorage.setData(key: username, value: privateKey); - - Future removePrivateKey({required String username}) => - _privateKeysStorage.removeData(key: username); - - Future clearAllPrivateKeys() => _privateKeysStorage.clearAllData(); - - /// Removes both the user's token and private key. - Future clearAllUserData({required String username}) async { - await Future.wait([ - removeToken(username: username), - removePrivateKey(username: username), - ]); - } - - /// Clears all tokens and all private keys. - Future clearAll() async { - await Future.wait([ - clearAllTokens(), - clearAllPrivateKeys(), - ]); - } - - void dispose() { - _userTokensSubject.close(); - } - - void _emitCurrentTokens() { - final data = _tokenStorage.getAllData(); - final tokensList = data.entries.map((e) { - final auth = e.value; - return UserToken( - username: e.key, - token: auth.token, - refreshToken: auth.refreshToken, - ); - }).toList(); - - _userTokensSubject.add(tokensList); - } -} diff --git a/packages/ion_identity_client/lib/src/core/service_locator/ion_identity_clients/auth_client_service_locator.dart b/packages/ion_identity_client/lib/src/core/service_locator/ion_identity_clients/auth_client_service_locator.dart index 3dff6ecd6..85011bc71 100644 --- a/packages/ion_identity_client/lib/src/core/service_locator/ion_identity_clients/auth_client_service_locator.dart +++ b/packages/ion_identity_client/lib/src/core/service_locator/ion_identity_clients/auth_client_service_locator.dart @@ -65,6 +65,7 @@ class AuthClientServiceLocator { twoFAService: twoFA(username: username, config: config, identitySigner: identitySigner), delegatedLoginService: delegatedLogin(config: config), privateKeyStorage: IONIdentityServiceLocator.privateKeyStorage(), + biometricsStateStorage: IONIdentityServiceLocator.biometricsStateStorage(), ); } @@ -109,6 +110,7 @@ class AuthClientServiceLocator { ), tokenStorage: IONIdentityServiceLocator.tokenStorage(), privateKeyStorage: IONIdentityServiceLocator.privateKeyStorage(), + biometricsStateStorage: IONIdentityServiceLocator.biometricsStateStorage(), ); } diff --git a/packages/ion_identity_client/lib/src/core/service_locator/ion_identity_service_locator.dart b/packages/ion_identity_client/lib/src/core/service_locator/ion_identity_service_locator.dart index cb2069ba7..47e94950e 100644 --- a/packages/ion_identity_client/lib/src/core/service_locator/ion_identity_service_locator.dart +++ b/packages/ion_identity_client/lib/src/core/service_locator/ion_identity_service_locator.dart @@ -4,6 +4,7 @@ import 'package:ion_identity_client/ion_identity.dart'; import 'package:ion_identity_client/src/core/network/network_client.dart'; import 'package:ion_identity_client/src/core/service_locator/clients_service_locator.dart'; import 'package:ion_identity_client/src/core/service_locator/network_service_locator.dart'; +import 'package:ion_identity_client/src/core/storage/biometrics_state_storage.dart'; import 'package:ion_identity_client/src/core/storage/private_key_storage.dart'; import 'package:ion_identity_client/src/core/storage/token_storage.dart'; import 'package:ion_identity_client/src/signer/identity_signer.dart'; @@ -18,6 +19,9 @@ class IONIdentityServiceLocator { static PrivateKeyStorage privateKeyStorage() => NetworkServiceLocator().privateKeyStorage(); + static BiometricsStateStorage biometricsStateStorage() => + NetworkServiceLocator().biometricsStateStorage(); + static IONIdentityClient identityUserClient({ required String username, required IONIdentityConfig config, diff --git a/packages/ion_identity_client/lib/src/core/service_locator/network_service_locator.dart b/packages/ion_identity_client/lib/src/core/service_locator/network_service_locator.dart index 90f3eea1c..578d5dd10 100644 --- a/packages/ion_identity_client/lib/src/core/service_locator/network_service_locator.dart +++ b/packages/ion_identity_client/lib/src/core/service_locator/network_service_locator.dart @@ -6,12 +6,19 @@ import 'package:ion_identity_client/ion_identity.dart'; import 'package:ion_identity_client/src/core/network/auth_interceptor.dart'; import 'package:ion_identity_client/src/core/network/network_client.dart'; import 'package:ion_identity_client/src/core/service_locator/ion_identity_clients/auth_client_service_locator.dart'; +import 'package:ion_identity_client/src/core/storage/biometrics_state_storage.dart'; import 'package:ion_identity_client/src/core/storage/private_key_storage.dart'; import 'package:ion_identity_client/src/core/storage/token_storage.dart'; import 'package:ion_identity_client/src/core/types/request_headers.dart'; class NetworkServiceLocator - with _Dio, _Interceptors, _TokenStorage, _PrivateKeyStorage, _NetworkClient { + with + _Dio, + _Interceptors, + _TokenStorage, + _PrivateKeyStorage, + _BiometricsStateStorage, + _NetworkClient { factory NetworkServiceLocator() { return _instance; } @@ -142,6 +149,31 @@ mixin _PrivateKeyStorage { } } +mixin _BiometricsStateStorage { + BiometricsStateStorage? _biometricsStateStorageInstance; + FlutterSecureStorage? _flutterSecureStorage; + + BiometricsStateStorage biometricsStateStorage() { + if (_biometricsStateStorageInstance != null) { + return _biometricsStateStorageInstance!; + } + + _biometricsStateStorageInstance = BiometricsStateStorage( + secureStorage: flutterSecureStorage(), + ); + return _biometricsStateStorageInstance!; + } + + FlutterSecureStorage flutterSecureStorage() { + if (_flutterSecureStorage != null) { + return _flutterSecureStorage!; + } + + _flutterSecureStorage = const FlutterSecureStorage(); + return _flutterSecureStorage!; + } +} + mixin _NetworkClient { NetworkClient? _networkClient; diff --git a/packages/ion_identity_client/lib/src/core/storage/biometrics_state_storage.dart b/packages/ion_identity_client/lib/src/core/storage/biometrics_state_storage.dart new file mode 100644 index 000000000..4e35db5bd --- /dev/null +++ b/packages/ion_identity_client/lib/src/core/storage/biometrics_state_storage.dart @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'dart:async'; + +import 'package:ion_identity_client/src/core/storage/data_storage.dart'; +import 'package:ion_identity_client/src/core/types/biometrics_state.dart'; + +class BiometricsStateStorage extends DataStorage { + BiometricsStateStorage({ + required super.secureStorage, + }) : super( + storageKey: 'ion_identity_client_biometrics_state_key', + fromJson: (json) { + final value = json['value'] as String; + return BiometricsState.values.firstWhere((e) => e.name == value); + }, + toJson: (value) => {'value': value.name}, + ); + + BiometricsState? getBiometricsState({required String username}) => super.getData(key: username); + + Future updateBiometricsState({ + required String username, + required BiometricsState biometricsState, + }) => + super.setData(key: username, value: biometricsState); + + Future removeBiometricsState({required String username}) => super.removeData(key: username); + + Future clearAllPrivateKeys() => super.clearAllData(); +} diff --git a/packages/ion_identity_client/lib/src/core/storage/data_storage.dart b/packages/ion_identity_client/lib/src/core/storage/data_storage.dart index c01d3f29b..d7d30e35c 100644 --- a/packages/ion_identity_client/lib/src/core/storage/data_storage.dart +++ b/packages/ion_identity_client/lib/src/core/storage/data_storage.dart @@ -3,11 +3,11 @@ import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:rxdart/rxdart.dart'; /// A generic data storage class that stores a `Map` in `FlutterSecureStorage` -/// and allows for CRUD operations on that map. -/// -/// [T] is the type of the values that will be stored. It must be JSON-serializable. +/// and allows for CRUD operations on that map. It also exposes a stream of changes, +/// allowing clients to react to data updates over time. class DataStorage { DataStorage({ required FlutterSecureStorage secureStorage, @@ -17,7 +17,10 @@ class DataStorage { }) : _secureStorage = secureStorage, _storageKey = storageKey, _fromJson = fromJson, - _toJson = toJson; + _toJson = toJson { + // Initialize the subject with an empty map. Once init() is called, it will load actual data. + _dataSubject = BehaviorSubject.seeded(const {}); + } final FlutterSecureStorage _secureStorage; final String _storageKey; @@ -27,10 +30,18 @@ class DataStorage { /// In-memory cache of items mapped by their keys. final Map _cache = {}; + late final BehaviorSubject> _dataSubject; + + /// A stream of the current data as a `Map`. + /// Every time data changes (add/update/remove/clear), a new map is emitted. + Stream> get dataStream => _dataSubject.stream; + /// Initializes the storage by loading existing data from secure storage. /// If no data exists, the cache starts empty. + /// After loading, emits the current data state. Future init() async { await _loadFromStorage(); + _emitCurrentData(); } /// Retrieves the data associated with the specified [key]. @@ -40,21 +51,27 @@ class DataStorage { } /// Sets (adds or updates) the data for a given [key]. + /// After saving, emits the updated data map. Future setData({required String key, required T value}) async { _cache[key] = value; await _saveToStorage(); + _emitCurrentData(); } /// Removes the data associated with the specified [key]. + /// After removal, emits the updated data map. Future removeData({required String key}) async { _cache.remove(key); await _saveToStorage(); + _emitCurrentData(); } /// Clears all stored data. + /// After clearing, emits the updated (empty) data map. Future clearAllData() async { _cache.clear(); await _saveToStorage(); + _emitCurrentData(); } /// Returns all data as a Map. @@ -67,7 +84,11 @@ class DataStorage { return _cache.keys.toList(growable: false); } - /// Loads data from secure storage into the in-memory cache. + /// Closes the stream when done. + void dispose() { + _dataSubject.close(); + } + Future _loadFromStorage() async { final jsonStr = await _secureStorage.read(key: _storageKey); if (jsonStr == null) { @@ -91,4 +112,9 @@ class DataStorage { final jsonStr = json.encode(jsonMap); await _secureStorage.write(key: _storageKey, value: jsonStr); } + + void _emitCurrentData() { + // Emit an unmodifiable view of the current cache state. + _dataSubject.add(Map.unmodifiable(_cache)); + } } diff --git a/packages/ion_identity_client/lib/src/core/storage/token_storage.dart b/packages/ion_identity_client/lib/src/core/storage/token_storage.dart index e3c46868f..fcc70258c 100644 --- a/packages/ion_identity_client/lib/src/core/storage/token_storage.dart +++ b/packages/ion_identity_client/lib/src/core/storage/token_storage.dart @@ -5,7 +5,6 @@ import 'dart:async'; import 'package:ion_identity_client/src/auth/dtos/dtos.dart'; import 'package:ion_identity_client/src/core/storage/data_storage.dart'; import 'package:ion_identity_client/src/core/types/user_token.c.dart'; -import 'package:rxdart/rxdart.dart'; class TokenStorage extends DataStorage { TokenStorage({ @@ -16,15 +15,16 @@ class TokenStorage extends DataStorage { toJson: (auth) => auth.toJson(), ); - final BehaviorSubject> _userTokensSubject = BehaviorSubject>(); - - Stream> get userTokens => _userTokensSubject.stream; - - @override - Future init() async { - await super.init(); - _emitCurrentTokens(); - } + Stream> get userTokens => dataStream.map((data) { + return data.entries.map((e) { + final auth = e.value; + return UserToken( + username: e.key, + token: auth.token, + refreshToken: auth.refreshToken, + ); + }).toList(); + }); UserToken? getToken({required String username}) { final auth = super.getData(key: username); @@ -43,7 +43,6 @@ class TokenStorage extends DataStorage { final currentAuth = super.getData(key: username) ?? Authentication.empty(); final updatedAuth = currentAuth.copyWith(token: newToken); await super.setData(key: username, value: updatedAuth); - _emitCurrentTokens(); } Future setTokens({ @@ -51,34 +50,13 @@ class TokenStorage extends DataStorage { required Authentication newTokens, }) async { await super.setData(key: username, value: newTokens); - _emitCurrentTokens(); } Future removeToken({required String username}) async { await super.removeData(key: username); - _emitCurrentTokens(); } Future clearAllTokens() async { await super.clearAllData(); - _emitCurrentTokens(); - } - - void dispose() { - _userTokensSubject.close(); - } - - void _emitCurrentTokens() { - final data = super.getAllData(); - final tokensList = data.entries.map((e) { - final auth = e.value; - return UserToken( - username: e.key, - token: auth.token, - refreshToken: auth.refreshToken, - ); - }).toList(); - - _userTokensSubject.add(tokensList); } } diff --git a/packages/ion_identity_client/lib/src/core/types/biometrics_state.dart b/packages/ion_identity_client/lib/src/core/types/biometrics_state.dart new file mode 100644 index 000000000..125879b3c --- /dev/null +++ b/packages/ion_identity_client/lib/src/core/types/biometrics_state.dart @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: ice License 1.0 + +enum BiometricsState { canSuggest, enabled, rejected, failed } diff --git a/packages/ion_identity_client/lib/src/core/types/user_token.freezed.dart b/packages/ion_identity_client/lib/src/core/types/user_token.freezed.dart deleted file mode 100644 index 0bf9280dc..000000000 --- a/packages/ion_identity_client/lib/src/core/types/user_token.freezed.dart +++ /dev/null @@ -1,203 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'user_token.c.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -UserToken _$UserTokenFromJson(Map json) { - return _UserToken.fromJson(json); -} - -/// @nodoc -mixin _$UserToken { - String get username => throw _privateConstructorUsedError; - String get token => throw _privateConstructorUsedError; - String get refreshToken => throw _privateConstructorUsedError; - - /// Serializes this UserToken to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of UserToken - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $UserTokenCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $UserTokenCopyWith<$Res> { - factory $UserTokenCopyWith(UserToken value, $Res Function(UserToken) then) = - _$UserTokenCopyWithImpl<$Res, UserToken>; - @useResult - $Res call({String username, String token, String refreshToken}); -} - -/// @nodoc -class _$UserTokenCopyWithImpl<$Res, $Val extends UserToken> - implements $UserTokenCopyWith<$Res> { - _$UserTokenCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of UserToken - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? username = null, - Object? token = null, - Object? refreshToken = null, - }) { - return _then(_value.copyWith( - username: null == username - ? _value.username - : username // ignore: cast_nullable_to_non_nullable - as String, - token: null == token - ? _value.token - : token // ignore: cast_nullable_to_non_nullable - as String, - refreshToken: null == refreshToken - ? _value.refreshToken - : refreshToken // ignore: cast_nullable_to_non_nullable - as String, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$UserTokenImplCopyWith<$Res> - implements $UserTokenCopyWith<$Res> { - factory _$$UserTokenImplCopyWith( - _$UserTokenImpl value, $Res Function(_$UserTokenImpl) then) = - __$$UserTokenImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({String username, String token, String refreshToken}); -} - -/// @nodoc -class __$$UserTokenImplCopyWithImpl<$Res> - extends _$UserTokenCopyWithImpl<$Res, _$UserTokenImpl> - implements _$$UserTokenImplCopyWith<$Res> { - __$$UserTokenImplCopyWithImpl( - _$UserTokenImpl _value, $Res Function(_$UserTokenImpl) _then) - : super(_value, _then); - - /// Create a copy of UserToken - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? username = null, - Object? token = null, - Object? refreshToken = null, - }) { - return _then(_$UserTokenImpl( - username: null == username - ? _value.username - : username // ignore: cast_nullable_to_non_nullable - as String, - token: null == token - ? _value.token - : token // ignore: cast_nullable_to_non_nullable - as String, - refreshToken: null == refreshToken - ? _value.refreshToken - : refreshToken // ignore: cast_nullable_to_non_nullable - as String, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$UserTokenImpl extends _UserToken { - const _$UserTokenImpl( - {required this.username, required this.token, required this.refreshToken}) - : super._(); - - factory _$UserTokenImpl.fromJson(Map json) => - _$$UserTokenImplFromJson(json); - - @override - final String username; - @override - final String token; - @override - final String refreshToken; - - @override - String toString() { - return 'UserToken(username: $username, token: $token, refreshToken: $refreshToken)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$UserTokenImpl && - (identical(other.username, username) || - other.username == username) && - (identical(other.token, token) || other.token == token) && - (identical(other.refreshToken, refreshToken) || - other.refreshToken == refreshToken)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, username, token, refreshToken); - - /// Create a copy of UserToken - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$UserTokenImplCopyWith<_$UserTokenImpl> get copyWith => - __$$UserTokenImplCopyWithImpl<_$UserTokenImpl>(this, _$identity); - - @override - Map toJson() { - return _$$UserTokenImplToJson( - this, - ); - } -} - -abstract class _UserToken extends UserToken { - const factory _UserToken( - {required final String username, - required final String token, - required final String refreshToken}) = _$UserTokenImpl; - const _UserToken._() : super._(); - - factory _UserToken.fromJson(Map json) = - _$UserTokenImpl.fromJson; - - @override - String get username; - @override - String get token; - @override - String get refreshToken; - - /// Create a copy of UserToken - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$UserTokenImplCopyWith<_$UserTokenImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/packages/ion_identity_client/lib/src/ion_identity.dart b/packages/ion_identity_client/lib/src/ion_identity.dart index 7a6c351b0..7eb52c07f 100644 --- a/packages/ion_identity_client/lib/src/ion_identity.dart +++ b/packages/ion_identity_client/lib/src/ion_identity.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:ion_identity_client/ion_identity.dart'; import 'package:ion_identity_client/src/auth/services/key_service.dart'; import 'package:ion_identity_client/src/core/service_locator/ion_identity_service_locator.dart'; +import 'package:ion_identity_client/src/core/storage/biometrics_state_storage.dart'; import 'package:ion_identity_client/src/core/storage/private_key_storage.dart'; import 'package:ion_identity_client/src/core/storage/token_storage.dart'; import 'package:ion_identity_client/src/signer/identity_signer.dart'; @@ -23,27 +24,31 @@ class IONIdentity { required IdentitySigner identitySigner, required TokenStorage tokenStorage, required PrivateKeyStorage privateKeyStorage, + required BiometricsStateStorage biometricsStateStorage, }) : _config = config, _identitySigner = identitySigner, _tokenStorage = tokenStorage, - _privateKeyStorage = privateKeyStorage; + _privateKeyStorage = privateKeyStorage, + _biometricsStateStorage = biometricsStateStorage; /// Factory method to create a default instance of [IONIdentity] using the given [config]. factory IONIdentity.createDefault({ required IONIdentityConfig config, }) { if (!(kIsWeb || Platform.isAndroid || Platform.isIOS)) { - throw UnimplementedError('Current platform is not supproted'); + throw UnimplementedError('Current platform is not supported'); } final tokenStorage = IONIdentityServiceLocator.tokenStorage(); final privateKeyStorage = IONIdentityServiceLocator.privateKeyStorage(); + final biometricsStateStorage = IONIdentityServiceLocator.biometricsStateStorage(); final passkeySigner = PasskeysSigner(); final passwordSigner = PasswordSigner( config: config, keyService: const KeyService(), privateKeyStorage: privateKeyStorage, + biometricsStateStorage: biometricsStateStorage, ); final identitySigner = IdentitySigner(passkeySigner: passkeySigner, passwordSigner: passwordSigner); @@ -53,16 +58,22 @@ class IONIdentity { identitySigner: identitySigner, tokenStorage: tokenStorage, privateKeyStorage: privateKeyStorage, + biometricsStateStorage: biometricsStateStorage, ); } Future init() async { - await _tokenStorage.init(); - await _privateKeyStorage.init(); + await Future.wait([ + _tokenStorage.init(), + _privateKeyStorage.init(), + _biometricsStateStorage.init(), + ]); } void dispose() { _tokenStorage.dispose(); + _privateKeyStorage.dispose(); + _biometricsStateStorage.dispose(); } /// Returns a user-specific API client for the given [username]. @@ -83,10 +94,14 @@ class IONIdentity { final IdentitySigner _identitySigner; final TokenStorage _tokenStorage; final PrivateKeyStorage _privateKeyStorage; + final BiometricsStateStorage _biometricsStateStorage; /// A stream of the usernames of currently authorized users. This stream updates /// whenever the user tokens change, providing a real-time view of authenticated users. Stream> get authorizedUsers => _tokenStorage.userTokens.map( (tokens) => tokens.map((token) => token.username), ); + + Stream> get biometricsStatesStream => + _biometricsStateStorage.dataStream; } diff --git a/packages/ion_identity_client/lib/src/signer/dtos/simple_message_response.freezed.dart b/packages/ion_identity_client/lib/src/signer/dtos/simple_message_response.freezed.dart deleted file mode 100644 index 81a7da2bb..000000000 --- a/packages/ion_identity_client/lib/src/signer/dtos/simple_message_response.freezed.dart +++ /dev/null @@ -1,170 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'simple_message_response.c.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -SimpleMessageResponse _$SimpleMessageResponseFromJson( - Map json) { - return _SimpleMessageResponse.fromJson(json); -} - -/// @nodoc -mixin _$SimpleMessageResponse { - String get message => throw _privateConstructorUsedError; - - /// Serializes this SimpleMessageResponse to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of SimpleMessageResponse - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $SimpleMessageResponseCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $SimpleMessageResponseCopyWith<$Res> { - factory $SimpleMessageResponseCopyWith(SimpleMessageResponse value, - $Res Function(SimpleMessageResponse) then) = - _$SimpleMessageResponseCopyWithImpl<$Res, SimpleMessageResponse>; - @useResult - $Res call({String message}); -} - -/// @nodoc -class _$SimpleMessageResponseCopyWithImpl<$Res, - $Val extends SimpleMessageResponse> - implements $SimpleMessageResponseCopyWith<$Res> { - _$SimpleMessageResponseCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of SimpleMessageResponse - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? message = null, - }) { - return _then(_value.copyWith( - message: null == message - ? _value.message - : message // ignore: cast_nullable_to_non_nullable - as String, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$SimpleMessageResponseImplCopyWith<$Res> - implements $SimpleMessageResponseCopyWith<$Res> { - factory _$$SimpleMessageResponseImplCopyWith( - _$SimpleMessageResponseImpl value, - $Res Function(_$SimpleMessageResponseImpl) then) = - __$$SimpleMessageResponseImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({String message}); -} - -/// @nodoc -class __$$SimpleMessageResponseImplCopyWithImpl<$Res> - extends _$SimpleMessageResponseCopyWithImpl<$Res, - _$SimpleMessageResponseImpl> - implements _$$SimpleMessageResponseImplCopyWith<$Res> { - __$$SimpleMessageResponseImplCopyWithImpl(_$SimpleMessageResponseImpl _value, - $Res Function(_$SimpleMessageResponseImpl) _then) - : super(_value, _then); - - /// Create a copy of SimpleMessageResponse - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? message = null, - }) { - return _then(_$SimpleMessageResponseImpl( - message: null == message - ? _value.message - : message // ignore: cast_nullable_to_non_nullable - as String, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$SimpleMessageResponseImpl implements _SimpleMessageResponse { - const _$SimpleMessageResponseImpl({required this.message}); - - factory _$SimpleMessageResponseImpl.fromJson(Map json) => - _$$SimpleMessageResponseImplFromJson(json); - - @override - final String message; - - @override - String toString() { - return 'SimpleMessageResponse(message: $message)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$SimpleMessageResponseImpl && - (identical(other.message, message) || other.message == message)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, message); - - /// Create a copy of SimpleMessageResponse - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$SimpleMessageResponseImplCopyWith<_$SimpleMessageResponseImpl> - get copyWith => __$$SimpleMessageResponseImplCopyWithImpl< - _$SimpleMessageResponseImpl>(this, _$identity); - - @override - Map toJson() { - return _$$SimpleMessageResponseImplToJson( - this, - ); - } -} - -abstract class _SimpleMessageResponse implements SimpleMessageResponse { - const factory _SimpleMessageResponse({required final String message}) = - _$SimpleMessageResponseImpl; - - factory _SimpleMessageResponse.fromJson(Map json) = - _$SimpleMessageResponseImpl.fromJson; - - @override - String get message; - - /// Create a copy of SimpleMessageResponse - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$SimpleMessageResponseImplCopyWith<_$SimpleMessageResponseImpl> - get copyWith => throw _privateConstructorUsedError; -} diff --git a/packages/ion_identity_client/lib/src/signer/dtos/user_action_signing_init_request.freezed.dart b/packages/ion_identity_client/lib/src/signer/dtos/user_action_signing_init_request.freezed.dart deleted file mode 100644 index 0c1cb509d..000000000 --- a/packages/ion_identity_client/lib/src/signer/dtos/user_action_signing_init_request.freezed.dart +++ /dev/null @@ -1,247 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'user_action_signing_init_request.c.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -UserActionSigningInitRequest _$UserActionSigningInitRequestFromJson( - Map json) { - return _UserActionSigningInitRequest.fromJson(json); -} - -/// @nodoc -mixin _$UserActionSigningInitRequest { - String get userActionPayload => throw _privateConstructorUsedError; - String get userActionHttpMethod => throw _privateConstructorUsedError; - String get userActionHttpPath => throw _privateConstructorUsedError; - String get userActionServerKind => throw _privateConstructorUsedError; - - /// Serializes this UserActionSigningInitRequest to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of UserActionSigningInitRequest - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $UserActionSigningInitRequestCopyWith - get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $UserActionSigningInitRequestCopyWith<$Res> { - factory $UserActionSigningInitRequestCopyWith( - UserActionSigningInitRequest value, - $Res Function(UserActionSigningInitRequest) then) = - _$UserActionSigningInitRequestCopyWithImpl<$Res, - UserActionSigningInitRequest>; - @useResult - $Res call( - {String userActionPayload, - String userActionHttpMethod, - String userActionHttpPath, - String userActionServerKind}); -} - -/// @nodoc -class _$UserActionSigningInitRequestCopyWithImpl<$Res, - $Val extends UserActionSigningInitRequest> - implements $UserActionSigningInitRequestCopyWith<$Res> { - _$UserActionSigningInitRequestCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of UserActionSigningInitRequest - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? userActionPayload = null, - Object? userActionHttpMethod = null, - Object? userActionHttpPath = null, - Object? userActionServerKind = null, - }) { - return _then(_value.copyWith( - userActionPayload: null == userActionPayload - ? _value.userActionPayload - : userActionPayload // ignore: cast_nullable_to_non_nullable - as String, - userActionHttpMethod: null == userActionHttpMethod - ? _value.userActionHttpMethod - : userActionHttpMethod // ignore: cast_nullable_to_non_nullable - as String, - userActionHttpPath: null == userActionHttpPath - ? _value.userActionHttpPath - : userActionHttpPath // ignore: cast_nullable_to_non_nullable - as String, - userActionServerKind: null == userActionServerKind - ? _value.userActionServerKind - : userActionServerKind // ignore: cast_nullable_to_non_nullable - as String, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$UserActionSigningInitRequestImplCopyWith<$Res> - implements $UserActionSigningInitRequestCopyWith<$Res> { - factory _$$UserActionSigningInitRequestImplCopyWith( - _$UserActionSigningInitRequestImpl value, - $Res Function(_$UserActionSigningInitRequestImpl) then) = - __$$UserActionSigningInitRequestImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {String userActionPayload, - String userActionHttpMethod, - String userActionHttpPath, - String userActionServerKind}); -} - -/// @nodoc -class __$$UserActionSigningInitRequestImplCopyWithImpl<$Res> - extends _$UserActionSigningInitRequestCopyWithImpl<$Res, - _$UserActionSigningInitRequestImpl> - implements _$$UserActionSigningInitRequestImplCopyWith<$Res> { - __$$UserActionSigningInitRequestImplCopyWithImpl( - _$UserActionSigningInitRequestImpl _value, - $Res Function(_$UserActionSigningInitRequestImpl) _then) - : super(_value, _then); - - /// Create a copy of UserActionSigningInitRequest - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? userActionPayload = null, - Object? userActionHttpMethod = null, - Object? userActionHttpPath = null, - Object? userActionServerKind = null, - }) { - return _then(_$UserActionSigningInitRequestImpl( - userActionPayload: null == userActionPayload - ? _value.userActionPayload - : userActionPayload // ignore: cast_nullable_to_non_nullable - as String, - userActionHttpMethod: null == userActionHttpMethod - ? _value.userActionHttpMethod - : userActionHttpMethod // ignore: cast_nullable_to_non_nullable - as String, - userActionHttpPath: null == userActionHttpPath - ? _value.userActionHttpPath - : userActionHttpPath // ignore: cast_nullable_to_non_nullable - as String, - userActionServerKind: null == userActionServerKind - ? _value.userActionServerKind - : userActionServerKind // ignore: cast_nullable_to_non_nullable - as String, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$UserActionSigningInitRequestImpl - implements _UserActionSigningInitRequest { - const _$UserActionSigningInitRequestImpl( - {required this.userActionPayload, - required this.userActionHttpMethod, - required this.userActionHttpPath, - this.userActionServerKind = 'Api'}); - - factory _$UserActionSigningInitRequestImpl.fromJson( - Map json) => - _$$UserActionSigningInitRequestImplFromJson(json); - - @override - final String userActionPayload; - @override - final String userActionHttpMethod; - @override - final String userActionHttpPath; - @override - @JsonKey() - final String userActionServerKind; - - @override - String toString() { - return 'UserActionSigningInitRequest(userActionPayload: $userActionPayload, userActionHttpMethod: $userActionHttpMethod, userActionHttpPath: $userActionHttpPath, userActionServerKind: $userActionServerKind)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$UserActionSigningInitRequestImpl && - (identical(other.userActionPayload, userActionPayload) || - other.userActionPayload == userActionPayload) && - (identical(other.userActionHttpMethod, userActionHttpMethod) || - other.userActionHttpMethod == userActionHttpMethod) && - (identical(other.userActionHttpPath, userActionHttpPath) || - other.userActionHttpPath == userActionHttpPath) && - (identical(other.userActionServerKind, userActionServerKind) || - other.userActionServerKind == userActionServerKind)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash(runtimeType, userActionPayload, - userActionHttpMethod, userActionHttpPath, userActionServerKind); - - /// Create a copy of UserActionSigningInitRequest - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$UserActionSigningInitRequestImplCopyWith< - _$UserActionSigningInitRequestImpl> - get copyWith => __$$UserActionSigningInitRequestImplCopyWithImpl< - _$UserActionSigningInitRequestImpl>(this, _$identity); - - @override - Map toJson() { - return _$$UserActionSigningInitRequestImplToJson( - this, - ); - } -} - -abstract class _UserActionSigningInitRequest - implements UserActionSigningInitRequest { - const factory _UserActionSigningInitRequest( - {required final String userActionPayload, - required final String userActionHttpMethod, - required final String userActionHttpPath, - final String userActionServerKind}) = _$UserActionSigningInitRequestImpl; - - factory _UserActionSigningInitRequest.fromJson(Map json) = - _$UserActionSigningInitRequestImpl.fromJson; - - @override - String get userActionPayload; - @override - String get userActionHttpMethod; - @override - String get userActionHttpPath; - @override - String get userActionServerKind; - - /// Create a copy of UserActionSigningInitRequest - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$UserActionSigningInitRequestImplCopyWith< - _$UserActionSigningInitRequestImpl> - get copyWith => throw _privateConstructorUsedError; -} diff --git a/packages/ion_identity_client/lib/src/signer/identity_signer.dart b/packages/ion_identity_client/lib/src/signer/identity_signer.dart index ed8bb3b3d..e0d7df8a0 100644 --- a/packages/ion_identity_client/lib/src/signer/identity_signer.dart +++ b/packages/ion_identity_client/lib/src/signer/identity_signer.dart @@ -59,7 +59,21 @@ class IdentitySigner { ); } - Future isPasskeyAvailable() async { + Future isPasskeyAvailable() { return passkeySigner.canAuthenticate(); } + + Future rejectToUseBiometrics(String username) { + return passwordSigner.rejectToUseBiometrics(username); + } + + Future enrollToUseBiometrics({ + required String username, + required String localisedReason, + }) { + return passwordSigner.enrollToUseBiometrics( + username: username, + localisedReason: localisedReason, + ); + } } diff --git a/packages/ion_identity_client/lib/src/signer/passkey_signer.dart b/packages/ion_identity_client/lib/src/signer/passkey_signer.dart index 7a269553e..3154c4d8b 100644 --- a/packages/ion_identity_client/lib/src/signer/passkey_signer.dart +++ b/packages/ion_identity_client/lib/src/signer/passkey_signer.dart @@ -4,7 +4,6 @@ import 'dart:convert'; import 'package:ion_identity_client/ion_identity.dart'; import 'package:ion_identity_client/src/signer/dtos/dtos.dart'; -import 'package:local_auth/local_auth.dart'; import 'package:passkeys/authenticator.dart'; import 'package:passkeys/types.dart'; @@ -139,48 +138,8 @@ class PasskeysSigner { } } - /// Determines whether the device supports passkeys (WebAuthn/FIDO2) for authentication. - /// - /// This method performs the following checks: - /// - /// 1. **Passkey Authentication Availability**: - /// - Utilizes [PasskeyAuthenticator]'s `canAuthenticate` method to determine if the user can currently - /// authenticate using passkeys. - /// - **Note**: This may return `false` if the user hasn't set up biometrics or a device lock, even if the - /// device itself supports passkeys. - /// - /// 2. **Hardware Support for Biometrics**: - /// - Uses [LocalAuthentication]'s `canCheckBiometrics` to verify if the device has hardware support for - /// biometric authentication. - /// - /// 3. **Device-Level Authentication Support**: - /// - Checks [LocalAuthentication]'s `isDeviceSupported` to determine if device-level authentication is - /// set up. - /// - /// The method returns `true` if **either**: - /// - Passkey authentication is available (`canAuthenticate` returns `true`), **or** - /// - The device has biometric hardware support (`canCheckBiometrics` is `true`) **and** device-level - /// authentication is **not** set up (`isDeviceSupported` is `false`). - /// - /// This dual-check approach helps eliminate false negatives where the device supports passkeys, but - /// certain user configurations (like unset biometrics) might otherwise prevent successful authentication. - /// Future canAuthenticate() async { - // Ignoring because replacement is not available for all platforms // ignore: deprecated_member_use - final passkeyFuture = PasskeyAuthenticator().canAuthenticate(); - final localAuth = LocalAuthentication(); - - final results = await Future.wait([ - passkeyFuture, - localAuth.canCheckBiometrics, - localAuth.isDeviceSupported(), - ]); - - final canAuthenticateResult = results[0]; - final canCheckBiometrics = results[1]; - final isDeviceSupported = results[2]; - - return canAuthenticateResult || (canCheckBiometrics && !isDeviceSupported); + return PasskeyAuthenticator().canAuthenticate(); } } diff --git a/packages/ion_identity_client/lib/src/signer/password_signer.dart b/packages/ion_identity_client/lib/src/signer/password_signer.dart index 56365564a..b8fdcd1ac 100644 --- a/packages/ion_identity_client/lib/src/signer/password_signer.dart +++ b/packages/ion_identity_client/lib/src/signer/password_signer.dart @@ -7,8 +7,10 @@ import 'package:crypto/crypto.dart'; import 'package:cryptography/cryptography.dart' as crypto; import 'package:ion_identity_client/ion_identity.dart'; import 'package:ion_identity_client/src/auth/services/key_service.dart'; +import 'package:ion_identity_client/src/core/storage/biometrics_state_storage.dart'; import 'package:ion_identity_client/src/core/storage/private_key_storage.dart'; import 'package:ion_identity_client/src/signer/dtos/dtos.dart'; +import 'package:local_auth/local_auth.dart'; enum SignatureEncryption { hex, base64Url } @@ -17,11 +19,13 @@ class PasswordSigner { required this.config, required this.keyService, required this.privateKeyStorage, + required this.biometricsStateStorage, }); final KeyService keyService; final IONIdentityConfig config; final PrivateKeyStorage privateKeyStorage; + final BiometricsStateStorage biometricsStateStorage; Future createCredentialInfo({ required String challenge, @@ -31,26 +35,26 @@ class PasswordSigner { }) async { final keyPair = await keyService.generateKeyPair(); - final clientData = buildClientData( + final clientData = _buildClientData( challenge: challenge, origin: config.origin, clientDataType: ClientDataType.createKey, ); - final clientDataHash = sha256Hash(utf8.encode(clientData)); + final clientDataHash = _sha256Hash(utf8.encode(clientData)); - final credentialInfoFingerprint = buildCredentialInfoFingerprint( + final credentialInfoFingerprint = _buildCredentialInfoFingerprint( clientDataHash: clientDataHash, publicKeyPem: keyPair.publicKeyPem, ); - final signature = await signDataWithPrivateKey( + final signature = await _signDataWithPrivateKey( data: credentialInfoFingerprint, privateKey: keyPair.keyPair, signatureEncryption: SignatureEncryption.hex, ); - final attestationData = buildAttestationData( + final attestationData = _buildAttestationData( publicKeyPem: keyPair.publicKeyPem, signatureHex: signature, ); @@ -58,7 +62,7 @@ class PasswordSigner { final clientDataBase64Url = base64UrlEncode(utf8.encode(clientData)); final attestationDataBase64Url = base64UrlEncode(utf8.encode(attestationData)); - final credId = generateCredId(keyPair.publicKey); + final credId = _generateCredId(keyPair.publicKey); final encryptedPrivateKey = await keyService.encryptPrivateKey( keyPair.privateKeyPem, @@ -66,10 +70,13 @@ class PasswordSigner { ); if (credentialKind == CredentialKind.PasswordProtectedKey) { - await privateKeyStorage.setPrivateKey( - username: username, - privateKey: hex.encode(keyPair.privateKeyBytes), - ); + await Future.wait([ + privateKeyStorage.setPrivateKey( + username: username, + privateKey: hex.encode(keyPair.privateKeyBytes), + ), + _updateStateToCanSuggest(username), + ]); } return CredentialRequestData( @@ -92,13 +99,13 @@ class PasswordSigner { }) async { final keyPair = await keyService.reconstructKeyPairFromEncryptedPrivateKey(encryptedPrivateKey, password); - final clientData = buildClientData( + final clientData = _buildClientData( challenge: challenge, origin: config.origin, clientDataType: ClientDataType.getKey, ); - final signature = await signDataWithPrivateKey( + final signature = await _signDataWithPrivateKey( data: clientData, privateKey: keyPair.keyPair, signatureEncryption: SignatureEncryption.base64Url, @@ -114,7 +121,61 @@ class PasswordSigner { ); } - String buildClientData({ + Future rejectToUseBiometrics(String username) { + return biometricsStateStorage.updateBiometricsState( + username: username, + biometricsState: BiometricsState.rejected, + ); + } + + Future enrollToUseBiometrics({ + required String username, + required String localisedReason, + }) async { + final auth = LocalAuthentication(); + try { + final didAuthenticate = await auth.authenticate( + localizedReason: localisedReason, + options: const AuthenticationOptions(stickyAuth: true), + ); + await biometricsStateStorage.updateBiometricsState( + username: username, + biometricsState: didAuthenticate ? BiometricsState.enabled : BiometricsState.failed, + ); + } catch (_) { + await biometricsStateStorage.updateBiometricsState( + username: username, + biometricsState: BiometricsState.failed, + ); + rethrow; + } + } + + Future _updateStateToCanSuggest(String username) async { + final biometricsAvailable = await isBiometricsAvailable(); + if (biometricsAvailable) { + await biometricsStateStorage.updateBiometricsState( + username: username, + biometricsState: BiometricsState.canSuggest, + ); + } + } + + Future isBiometricsAvailable() async { + final localAuth = LocalAuthentication(); + + final results = await Future.wait([ + localAuth.canCheckBiometrics, + localAuth.isDeviceSupported(), + ]); + + final canCheckBiometrics = results[0]; + final isDeviceSupported = results[1]; + + return canCheckBiometrics && isDeviceSupported; + } + + String _buildClientData({ required String challenge, required String origin, required ClientDataType clientDataType, @@ -135,13 +196,13 @@ class PasswordSigner { } // Computes SHA256 hash - String sha256Hash(List data) { + String _sha256Hash(List data) { final hash = sha256.convert(data); return hash.toString(); } // Builds the credential info fingerprint JSON string - String buildCredentialInfoFingerprint({ + String _buildCredentialInfoFingerprint({ required String clientDataHash, required String publicKeyPem, }) { @@ -158,7 +219,7 @@ class PasswordSigner { return jsonEncode(sortedFingerprintMap); } - Future signDataWithPrivateKey({ + Future _signDataWithPrivateKey({ required String data, required crypto.SimpleKeyPairData privateKey, required SignatureEncryption signatureEncryption, @@ -178,7 +239,7 @@ class PasswordSigner { } // Builds the attestation data JSON string - String buildAttestationData({ + String _buildAttestationData({ required String publicKeyPem, required String signatureHex, }) { @@ -195,7 +256,7 @@ class PasswordSigner { return jsonEncode(sortedAttestationDataMap); } - String generateCredId(crypto.SimplePublicKey publicKey) { + String _generateCredId(crypto.SimplePublicKey publicKey) { final digest = sha256.convert(publicKey.bytes).bytes.sublist(0, 16); final base36Str = BigInt.parse(hex.encode(digest), radix: 16) diff --git a/packages/ion_identity_client/lib/src/users/user_details/models/user_details.freezed.dart b/packages/ion_identity_client/lib/src/users/user_details/models/user_details.freezed.dart deleted file mode 100644 index 0638c003f..000000000 --- a/packages/ion_identity_client/lib/src/users/user_details/models/user_details.freezed.dart +++ /dev/null @@ -1,392 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'user_details.c.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -UserDetails _$UserDetailsFromJson(Map json) { - return _UserDetails.fromJson(json); -} - -/// @nodoc -mixin _$UserDetails { - List get ionConnectIndexerRelays => - throw _privateConstructorUsedError; - String get masterPubKey => throw _privateConstructorUsedError; - String? get name => throw _privateConstructorUsedError; - String? get userId => throw _privateConstructorUsedError; - String? get username => throw _privateConstructorUsedError; - @JsonKey(name: '2faOptions') - List? get twoFaOptions => throw _privateConstructorUsedError; - List? get email => throw _privateConstructorUsedError; - List? get phoneNumber => throw _privateConstructorUsedError; - List? get ionConnectRelays => throw _privateConstructorUsedError; - - /// Serializes this UserDetails to a JSON map. - Map toJson() => throw _privateConstructorUsedError; - - /// Create a copy of UserDetails - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - $UserDetailsCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $UserDetailsCopyWith<$Res> { - factory $UserDetailsCopyWith( - UserDetails value, $Res Function(UserDetails) then) = - _$UserDetailsCopyWithImpl<$Res, UserDetails>; - @useResult - $Res call( - {List ionConnectIndexerRelays, - String masterPubKey, - String? name, - String? userId, - String? username, - @JsonKey(name: '2faOptions') List? twoFaOptions, - List? email, - List? phoneNumber, - List? ionConnectRelays}); -} - -/// @nodoc -class _$UserDetailsCopyWithImpl<$Res, $Val extends UserDetails> - implements $UserDetailsCopyWith<$Res> { - _$UserDetailsCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - /// Create a copy of UserDetails - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? ionConnectIndexerRelays = null, - Object? masterPubKey = null, - Object? name = freezed, - Object? userId = freezed, - Object? username = freezed, - Object? twoFaOptions = freezed, - Object? email = freezed, - Object? phoneNumber = freezed, - Object? ionConnectRelays = freezed, - }) { - return _then(_value.copyWith( - ionConnectIndexerRelays: null == ionConnectIndexerRelays - ? _value.ionConnectIndexerRelays - : ionConnectIndexerRelays // ignore: cast_nullable_to_non_nullable - as List, - masterPubKey: null == masterPubKey - ? _value.masterPubKey - : masterPubKey // ignore: cast_nullable_to_non_nullable - as String, - name: freezed == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String?, - userId: freezed == userId - ? _value.userId - : userId // ignore: cast_nullable_to_non_nullable - as String?, - username: freezed == username - ? _value.username - : username // ignore: cast_nullable_to_non_nullable - as String?, - twoFaOptions: freezed == twoFaOptions - ? _value.twoFaOptions - : twoFaOptions // ignore: cast_nullable_to_non_nullable - as List?, - email: freezed == email - ? _value.email - : email // ignore: cast_nullable_to_non_nullable - as List?, - phoneNumber: freezed == phoneNumber - ? _value.phoneNumber - : phoneNumber // ignore: cast_nullable_to_non_nullable - as List?, - ionConnectRelays: freezed == ionConnectRelays - ? _value.ionConnectRelays - : ionConnectRelays // ignore: cast_nullable_to_non_nullable - as List?, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$UserDetailsImplCopyWith<$Res> - implements $UserDetailsCopyWith<$Res> { - factory _$$UserDetailsImplCopyWith( - _$UserDetailsImpl value, $Res Function(_$UserDetailsImpl) then) = - __$$UserDetailsImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {List ionConnectIndexerRelays, - String masterPubKey, - String? name, - String? userId, - String? username, - @JsonKey(name: '2faOptions') List? twoFaOptions, - List? email, - List? phoneNumber, - List? ionConnectRelays}); -} - -/// @nodoc -class __$$UserDetailsImplCopyWithImpl<$Res> - extends _$UserDetailsCopyWithImpl<$Res, _$UserDetailsImpl> - implements _$$UserDetailsImplCopyWith<$Res> { - __$$UserDetailsImplCopyWithImpl( - _$UserDetailsImpl _value, $Res Function(_$UserDetailsImpl) _then) - : super(_value, _then); - - /// Create a copy of UserDetails - /// with the given fields replaced by the non-null parameter values. - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? ionConnectIndexerRelays = null, - Object? masterPubKey = null, - Object? name = freezed, - Object? userId = freezed, - Object? username = freezed, - Object? twoFaOptions = freezed, - Object? email = freezed, - Object? phoneNumber = freezed, - Object? ionConnectRelays = freezed, - }) { - return _then(_$UserDetailsImpl( - ionConnectIndexerRelays: null == ionConnectIndexerRelays - ? _value._ionConnectIndexerRelays - : ionConnectIndexerRelays // ignore: cast_nullable_to_non_nullable - as List, - masterPubKey: null == masterPubKey - ? _value.masterPubKey - : masterPubKey // ignore: cast_nullable_to_non_nullable - as String, - name: freezed == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String?, - userId: freezed == userId - ? _value.userId - : userId // ignore: cast_nullable_to_non_nullable - as String?, - username: freezed == username - ? _value.username - : username // ignore: cast_nullable_to_non_nullable - as String?, - twoFaOptions: freezed == twoFaOptions - ? _value._twoFaOptions - : twoFaOptions // ignore: cast_nullable_to_non_nullable - as List?, - email: freezed == email - ? _value._email - : email // ignore: cast_nullable_to_non_nullable - as List?, - phoneNumber: freezed == phoneNumber - ? _value._phoneNumber - : phoneNumber // ignore: cast_nullable_to_non_nullable - as List?, - ionConnectRelays: freezed == ionConnectRelays - ? _value._ionConnectRelays - : ionConnectRelays // ignore: cast_nullable_to_non_nullable - as List?, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$UserDetailsImpl implements _UserDetails { - const _$UserDetailsImpl( - {required final List ionConnectIndexerRelays, - required this.masterPubKey, - this.name, - this.userId, - this.username, - @JsonKey(name: '2faOptions') final List? twoFaOptions, - final List? email, - final List? phoneNumber, - final List? ionConnectRelays}) - : _ionConnectIndexerRelays = ionConnectIndexerRelays, - _twoFaOptions = twoFaOptions, - _email = email, - _phoneNumber = phoneNumber, - _ionConnectRelays = ionConnectRelays; - - factory _$UserDetailsImpl.fromJson(Map json) => - _$$UserDetailsImplFromJson(json); - - final List _ionConnectIndexerRelays; - @override - List get ionConnectIndexerRelays { - if (_ionConnectIndexerRelays is EqualUnmodifiableListView) - return _ionConnectIndexerRelays; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_ionConnectIndexerRelays); - } - - @override - final String masterPubKey; - @override - final String? name; - @override - final String? userId; - @override - final String? username; - final List? _twoFaOptions; - @override - @JsonKey(name: '2faOptions') - List? get twoFaOptions { - final value = _twoFaOptions; - if (value == null) return null; - if (_twoFaOptions is EqualUnmodifiableListView) return _twoFaOptions; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(value); - } - - final List? _email; - @override - List? get email { - final value = _email; - if (value == null) return null; - if (_email is EqualUnmodifiableListView) return _email; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(value); - } - - final List? _phoneNumber; - @override - List? get phoneNumber { - final value = _phoneNumber; - if (value == null) return null; - if (_phoneNumber is EqualUnmodifiableListView) return _phoneNumber; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(value); - } - - final List? _ionConnectRelays; - @override - List? get ionConnectRelays { - final value = _ionConnectRelays; - if (value == null) return null; - if (_ionConnectRelays is EqualUnmodifiableListView) - return _ionConnectRelays; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(value); - } - - @override - String toString() { - return 'UserDetails(ionConnectIndexerRelays: $ionConnectIndexerRelays, masterPubKey: $masterPubKey, name: $name, userId: $userId, username: $username, twoFaOptions: $twoFaOptions, email: $email, phoneNumber: $phoneNumber, ionConnectRelays: $ionConnectRelays)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$UserDetailsImpl && - const DeepCollectionEquality().equals( - other._ionConnectIndexerRelays, _ionConnectIndexerRelays) && - (identical(other.masterPubKey, masterPubKey) || - other.masterPubKey == masterPubKey) && - (identical(other.name, name) || other.name == name) && - (identical(other.userId, userId) || other.userId == userId) && - (identical(other.username, username) || - other.username == username) && - const DeepCollectionEquality() - .equals(other._twoFaOptions, _twoFaOptions) && - const DeepCollectionEquality().equals(other._email, _email) && - const DeepCollectionEquality() - .equals(other._phoneNumber, _phoneNumber) && - const DeepCollectionEquality() - .equals(other._ionConnectRelays, _ionConnectRelays)); - } - - @JsonKey(includeFromJson: false, includeToJson: false) - @override - int get hashCode => Object.hash( - runtimeType, - const DeepCollectionEquality().hash(_ionConnectIndexerRelays), - masterPubKey, - name, - userId, - username, - const DeepCollectionEquality().hash(_twoFaOptions), - const DeepCollectionEquality().hash(_email), - const DeepCollectionEquality().hash(_phoneNumber), - const DeepCollectionEquality().hash(_ionConnectRelays)); - - /// Create a copy of UserDetails - /// with the given fields replaced by the non-null parameter values. - @JsonKey(includeFromJson: false, includeToJson: false) - @override - @pragma('vm:prefer-inline') - _$$UserDetailsImplCopyWith<_$UserDetailsImpl> get copyWith => - __$$UserDetailsImplCopyWithImpl<_$UserDetailsImpl>(this, _$identity); - - @override - Map toJson() { - return _$$UserDetailsImplToJson( - this, - ); - } -} - -abstract class _UserDetails implements UserDetails { - const factory _UserDetails( - {required final List ionConnectIndexerRelays, - required final String masterPubKey, - final String? name, - final String? userId, - final String? username, - @JsonKey(name: '2faOptions') final List? twoFaOptions, - final List? email, - final List? phoneNumber, - final List? ionConnectRelays}) = _$UserDetailsImpl; - - factory _UserDetails.fromJson(Map json) = - _$UserDetailsImpl.fromJson; - - @override - List get ionConnectIndexerRelays; - @override - String get masterPubKey; - @override - String? get name; - @override - String? get userId; - @override - String? get username; - @override - @JsonKey(name: '2faOptions') - List? get twoFaOptions; - @override - List? get email; - @override - List? get phoneNumber; - @override - List? get ionConnectRelays; - - /// Create a copy of UserDetails - /// with the given fields replaced by the non-null parameter values. - @override - @JsonKey(includeFromJson: false, includeToJson: false) - _$$UserDetailsImplCopyWith<_$UserDetailsImpl> get copyWith => - throw _privateConstructorUsedError; -} From c0a14e67509695255e7bd0852b8249ce091e1f2c Mon Sep 17 00:00:00 2001 From: Ice Hades <119406114+ice-hades@users.noreply.github.com> Date: Thu, 26 Dec 2024 12:32:04 +0400 Subject: [PATCH 2/6] feat: verify with biometrics --- .../create_local_creds_notifier.c.dart | 3 +- .../components/twofa_input_step.dart | 5 + .../verify_identity_prompt_dialog_helper.dart | 7 + .../providers/user_delegation_provider.c.dart | 5 + .../user_verify_identity_provider.c.dart | 9 + lib/l10n/app_en.arb.orig | 684 ------------------ .../create_new_credentials_service.dart | 17 + .../create_recovery_credentials_service.dart | 7 + .../lib/src/auth/services/key_service.dart | 29 + .../auth/services/twofa/twofa_service.dart | 7 + .../lib/src/core/types/ion_exception.dart | 4 + .../lib/src/core/types/types.dart | 2 + .../lib/src/signer/identity_signer.dart | 18 +- .../lib/src/signer/password_signer.dart | 100 ++- .../lib/src/signer/user_action_signer.dart | 40 +- .../lib/src/wallets/ion_identity_wallets.dart | 11 + .../create_wallet/create_wallet_service.dart | 7 + .../generate_signature_service.dart | 18 + 18 files changed, 256 insertions(+), 717 deletions(-) delete mode 100644 lib/l10n/app_en.arb.orig diff --git a/lib/app/features/auth/providers/create_local_creds_notifier.c.dart b/lib/app/features/auth/providers/create_local_creds_notifier.c.dart index 3e966c6b4..467a3c7be 100644 --- a/lib/app/features/auth/providers/create_local_creds_notifier.c.dart +++ b/lib/app/features/auth/providers/create_local_creds_notifier.c.dart @@ -17,7 +17,8 @@ class CreateLocalCredsNotifier extends _$CreateLocalCredsNotifier { state = await AsyncValue.guard(() async { final ionIdentity = await ref.read(ionIdentityClientProvider.future); await ionIdentity.auth.createNewCredentials( - ({required onPasskeyFlow, required onPasswordFlow}) => onPasskeyFlow(), + ({required onPasskeyFlow, required onPasswordFlow, required onBiometricsFlow}) => + onPasskeyFlow(), ); }); } diff --git a/lib/app/features/auth/views/pages/recover_user_page/components/twofa_input_step.dart b/lib/app/features/auth/views/pages/recover_user_page/components/twofa_input_step.dart index e948a6a5d..269f4af6c 100644 --- a/lib/app/features/auth/views/pages/recover_user_page/components/twofa_input_step.dart +++ b/lib/app/features/auth/views/pages/recover_user_page/components/twofa_input_step.dart @@ -75,12 +75,17 @@ class TwoFAInputStep extends HookConsumerWidget { .requestRecoveryTwoFaCode(twoFaType, recoveryIdentityKeyName, ({ required OnPasswordFlow onPasswordFlow, required OnPasskeyFlow onPasskeyFlow, + required OnBiometricsFlow + onBiometricsFlow, }) { return ref.read( verifyUserIdentityProvider( onGetPassword: onGetPassword, onPasswordFlow: onPasswordFlow, onPasskeyFlow: onPasskeyFlow, + onBiometricsFlow: onBiometricsFlow, + localisedReasonForBiometricsDialog: + context.i18n.verify_with_biometrics_title, ).future, ); }); diff --git a/lib/app/features/components/verify_identity/verify_identity_prompt_dialog_helper.dart b/lib/app/features/components/verify_identity/verify_identity_prompt_dialog_helper.dart index 7e0ef45f8..4c7124bf2 100644 --- a/lib/app/features/components/verify_identity/verify_identity_prompt_dialog_helper.dart +++ b/lib/app/features/components/verify_identity/verify_identity_prompt_dialog_helper.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/extensions/extensions.dart'; import 'package:ion/app/features/components/verify_identity/hooks/use_on_get_password.dart'; import 'package:ion/app/features/components/verify_identity/verify_identity_prompt_dialog.dart'; import 'package:ion/app/features/user/providers/user_verify_identity_provider.c.dart'; @@ -46,12 +47,15 @@ class RiverpodVerifyIdentityRequestBuilder extends HookConsumerWidget { requestWithVerifyIdentity(({ required OnPasswordFlow

onPasswordFlow, required OnPasskeyFlow

onPasskeyFlow, + required OnBiometricsFlow

onBiometricsFlow, }) { return ref.read( verifyUserIdentityProvider( onGetPassword: onGetPassword, onPasswordFlow: onPasswordFlow, onPasskeyFlow: onPasskeyFlow, + onBiometricsFlow: onBiometricsFlow, + localisedReasonForBiometricsDialog: context.i18n.verify_with_biometrics_title, ).future, ); }); @@ -81,6 +85,7 @@ class HookVerifyIdentityRequestBuilder

extends HookConsumerWidget { requestWithVerifyIdentity(({ required OnPasswordFlow

onPasswordFlow, required OnPasskeyFlow

onPasskeyFlow, + required OnBiometricsFlow

onBiometricsFlow, }) async { try { return await ref.read( @@ -88,6 +93,8 @@ class HookVerifyIdentityRequestBuilder

extends HookConsumerWidget { onGetPassword: onGetPassword, onPasswordFlow: onPasswordFlow, onPasskeyFlow: onPasskeyFlow, + onBiometricsFlow: onBiometricsFlow, + localisedReasonForBiometricsDialog: context.i18n.verify_with_biometrics_title, ).future, ); } finally { diff --git a/lib/app/features/user/providers/user_delegation_provider.c.dart b/lib/app/features/user/providers/user_delegation_provider.c.dart index 0ee8f6553..51284fc19 100644 --- a/lib/app/features/user/providers/user_delegation_provider.c.dart +++ b/lib/app/features/user/providers/user_delegation_provider.c.dart @@ -103,6 +103,11 @@ class UserDelegationManager extends _$UserDelegationManager { .wallets .generateHashSignatureWithPasskey(mainWallet.id, eventId); }, + onBiometricsFlow: ({required String localisedReason}) { + return ionIdentity(username: currentIdentityKeyName) + .wallets + .generateHashSignatureWithBiometrics(mainWallet.id, eventId, localisedReason); + }, ); final curveName = switch (mainWallet.signingKey.curve) { diff --git a/lib/app/features/user/providers/user_verify_identity_provider.c.dart b/lib/app/features/user/providers/user_verify_identity_provider.c.dart index d44274eaa..035136a9d 100644 --- a/lib/app/features/user/providers/user_verify_identity_provider.c.dart +++ b/lib/app/features/user/providers/user_verify_identity_provider.c.dart @@ -16,13 +16,22 @@ AutoDisposeFutureProvider verifyUserIdentityProvider({ required Future Function() onGetPassword, required OnPasswordFlow onPasswordFlow, required OnPasskeyFlow onPasskeyFlow, + required OnBiometricsFlow onBiometricsFlow, + required String localisedReasonForBiometricsDialog, }) { return FutureProvider.autoDispose((ref) async { final username = ref.read(currentIdentityKeyNameSelectorProvider)!; final ionIdentity = await ref.read(ionIdentityProvider.future); final isPasswordFlowUser = ionIdentity(username: username).auth.isPasswordFlowUser(); + final biometricsState = ionIdentity(username: username).auth.getBiometricsState(); if (isPasswordFlowUser) { + if (biometricsState == BiometricsState.enabled) { + try { + return await onBiometricsFlow(localisedReason: localisedReasonForBiometricsDialog); + // If biometrics flow fails then fallback to password flow + } catch (_) {} + } final password = await onGetPassword(); if (password != null) { return onPasswordFlow(password: password); diff --git a/lib/l10n/app_en.arb.orig b/lib/l10n/app_en.arb.orig deleted file mode 100644 index 248b139eb..000000000 --- a/lib/l10n/app_en.arb.orig +++ /dev/null @@ -1,684 +0,0 @@ -{ - "@@locale": "en", - "day": "{count, plural, =1{day} other{days}}", - "hour": "{count, plural, =1{hour} other{hours}}", - "button_continue": "Continue", - "button_follow": "Follow", - "button_follow_back": "Follow back", - "button_following": "Following", - "button_save": "Save", - "button_cancel": "Cancel", - "button_log_out": "Log out", - "button_turn_off": "Turn off", - "button_unfollow": "Unfollow", - "button_delete": "Delete", - "button_edit": "Edit", - "button_close": "Close", - "button_register": "Register", - "button_send": "Send", - "button_request": "Request", - "button_login": "Login", - "button_try_again": "Try again", - "button_retry": "Retry", - "button_confirm": "Confirm", - "button_restore": "Restore", - "button_reset": "Reset", - "button_back": "Back", - "button_back_to_security": "Back to Security", - "button_lets_start": "Let's start", - "button_next": "Next", - "button_add": "Add", - "button_apply": "Apply", - "button_schedule": "Schedule", - "button_go_to_settings": "Go to Settings", - "button_allow": "Allow", - "button_dont_allow": "Don't Allow", - "button_share_story": "Share story", - "button_add_answer": "Add answer", - "button_link": "Link", - "button_share": "Share", - "button_mute": "Mute", - "button_unmute": "Unmute", - "button_block": "Block", - "button_report": "Report", - "button_publish": "Publish", - "button_learn_more": "Learn More", - "button_forward": "Forward", - "button_reply": "Reply", - "button_copy": "Copy", - "button_bookmark": "Bookmark", - "button_save_changes": "Save Changes", - "dropdown_select_category": "Select category", - "dropdown_category_hate": "Hate", - "dropdown_category_violence": "Violent speech", - "dropdown_category_child_safety": "Child safety", - "common_show_more": "Show more", - "common_show_less": "Show less", - "common_identity_key_name": "Identity key name", - "common_password": "Password", - "common_confirm_password": "Confirm password", - "common_seconds": "{seconds}s", - "common_congratulations": "Congratulations", - "common_successfully": "Successfully", - "common_select_languages": "Select languages", - "common_no_access_permission": "To grant access, go to settings and enable the appropriate settings", - "common_crop_image": "Crop Image", - "common_photo": "Photo", - "common_video": "Video", - "common_voice_message": "Voice message", - "common_poll": "Poll", - "common_archive": "Archive", - "common_select_option": "Select option", - "common_select_coin": "Select coin", - "common_email_address": "Email address", - "common_information": "Information", - "common_photos": "Photos", - "common_camera": "Camera", - "common_ion_pay": "ION Pay", - "common_profile": "Profile", - "common_document": "Document", - "common_you": "You", - "common_add_video": "Add video", - "common_forwarded": "Forwarded", - "common_forwarded_from": "Forwarded from", - "common_title": "Title", - "common_desc": "Description", - "common_public": "Public", - "common_private": "Private", - "common_invitation_link": "Invitation link", - "common_share_link": "Share link", - "common_copied": "Copied", - "auth_secured_by": "Secured by", - "auth_privacy": "By continuing, you are agreeing to our [[:link]]terms_of_service[[/:link]] & [[:link]]privacy_policy[[/:link]]", - "auth_terms_of_service": "Terms of Service", - "auth_privacy_policy": "Privacy Policy", - "auth_identity_io": "Identity.io", - "two_fa_title": "2FA Verification", - "two_fa_desc": "Please enter your confirmation code below", - "two_fa_select": "Select option {number}", - "two_fa_email": "Email code", - "two_fa_sms": "SMS code", - "two_fa_auth": "Authenticator code", - "two_fa_code_confirmation": "Please enter the code sent to", - "two_fa_delete_email_button": "Delete email", - "two_fa_edit_email_button": "Edit email", - "two_fa_deleting_email_title": "Deleting email", - "two_fa_deleting_email_description": "To delete the email verification, you must confirm by selecting the options first", - "two_fa_deleting_phone_title": "Phone number verification", - "two_fa_deleting_phone_description": "To delete verification by phone, you must confirm by selecting the options first", - "two_fa_account_linked_to": "Your account is linked to", - "two_fa_option_backup": "Backup", - "two_fa_option_email": "Email", - "two_fa_option_authenticator": "Authenticator", - "two_fa_option_phone": "Phone", - "two_fa_delete_email_success": "The email address has been successfully deleted", - "two_fa_delete_phone_success": "Verification by phone was successfully deleted", - "two_fa_success_desc": "Your identity key has been restored. You can now access your account securely", - "two_fa_failure_title": "2FA Verification error", - "two_fa_failure_desc": "Please ensure all codes are correct and try again", - "two_fa_failure_authenticator_title": "Authenticator not available", - "two_fa_failure_authenticator_description": "To set up an Authenticator app, please first link an email address or phone number to your account", - "two_fa_warning": "If you delete two-step authentication, it may put the security of your data at risk.", - "sign_up_passkey_title": "Register with passkey", - "sign_up_passkey_advantage_1_title": "No password to remember", - "sign_up_passkey_advantage_1_description": "With passkey, you can use things like your fingerprint or face to login", - "sign_up_passkey_advantage_2_title": "Works on all of your devices", - "sign_up_passkey_advantage_2_description": "Passkey will automatically be available across your synced devices", - "sign_up_passkey_advantage_3_title": "Keep your account safer", - "sign_up_passkey_advantage_3_description": "Passkey offer state-of-the-art phishing resistance", - "sign_up_passkey_use_password": "Use password instead", - "sign_up_password_title": "Register", - "sign_up_passkey_identity_key_name_taken": "Identity key name is taken", - "sign_up_password_description": "Choose a strong password to create an account", - "restore_identity_title": "Restore identity key", - "restore_identity_type_description": "Select the type of identity key recovery", - "restore_identity_type_google_drive_title": "Restore from Google Drive", - "restore_identity_type_icloud_title": "Restore from iCloud", - "restore_identity_type_icloud_description": "Restore your identity key from an iCloud backup", - "restore_identity_type_credentials_title": "Restore using recovery credentials", - "restore_identity_type_credentials_description": "Restore with Recovery code and Recovery key ID", - "discover_creators_title": "Discover creators", - "restore_identity_creds_description": "Please enter your recovery credentials below", - "restore_identity_creds_recovery_code": "Recovery code", - "restore_identity_creds_recovery_key": "Recovery key ID", - "restore_identity_creds_action": "Recovery key ID", - "discover_creators_description": "Connect with visionaries and inspiring voices", - "fill_profile_title": "Your profile", - "fill_profile_description": "Customize your account", - "fill_profile_input_name": "Name", - "fill_profile_input_nickname": "Nickname", - "get_started_title": "Get started", - "get_started_description": "Enter your identity key name to log in into your account", - "get_started_method_divider": "or", - "get_started_restore_button": "Restore identity key", - "identity_key_name_description": "Think of your identity key name as a unique identifier of your account. You’ll need it to log in and recover your account, so keep it safe and don’t forget it", - "identity_key_name_usage": "Use it to log in on any app secured by", - "select_languages_description": "You’ll be shown content in the selected language", - "dapps_section_title_highest_ranked": "Highest ranked", - "dapps_section_title_recently_added": "Recently added", - "dapps_section_title_favourites": "Favorites", - "dapps_section_title_featured": "Featured", - "dapps_section_title_categories": "Categories", - "dapps_category_defi": "DeFi", - "dapps_category_marketplaces": "Marketplaces", - "dapps_category_games": "Games", - "dapps_category_social": "Social", - "dapps_category_utilities": "Utilities", - "dapps_category_other": "Other", - "dapps_favourites_added": "{count} added dApps", - "dapps_favourites_empty_title": "You have no favourites dApps yet", - "dapps_search_empty": "Search here for dApps, categories…", - "dapp_details_launch_dapp_button_title": "Launch dApp", - "dapp_details_tips": "Tips", - "dapp_details_tips_games": "Games", - "dapp_details_tips_global_rank": "Global Rank", - "dapp_details_tips_vote": "Vote", - "dapp_details_tips_voted": "Voted", - "search_placeholder": "Search", - "search_nothing_found": "Nothing was found for your query", - "profile_following": "Following", - "profile_followers": "Followers", - "profile_following_with_counter": "Following ({counter})", - "profile_followers_with_counter": "Followers ({counter})", - "profile_privacy": "Settings & Privacy", - "profile_help": "Help Center", - "profile_profile_desc": "Your personal space", - "profile_feed_desc": "Discover and engage", - "profile_videos_desc": "Social streaming", - "profile_articles_desc": "Trending stories", - "profile_bookmarks": "Bookmarks", - "profile_bookmarks_desc": "Your saved posts", - "profile_switch_user_header": "User accounts", - "profile_create_new_account": "Create a new account", - "profile_log_out": "Log out {nickname}", - "profile_followed_by": "Followed by ", - "profile_followed_by_and": " and ", - "profile_followed_by_and_others": "others", - "profile_follows_you": "Follows you", - "profile_none": "None", - "profile_posts": "Posts", - "profile_stories": "Stories", - "profile_replies": "Replies", - "profile_videos": "Videos", - "profile_articles": "Articles", - "profile_popup_block_user_desc": "Are you sure you want to block this user?", - "profile_popup_report_title": "Report @{nickname}", - "profile_popup_report_desc": "Select a category from the list below", - "profile_popup_report_success_title": "Successfully sent", - "profile_popup_report_success_desc": "Your report has been successfully sent", - "profile_popup_unfollow": "Unfollow @{nickname}?", - "profile_popup_unfollow_desc": "Once you unfollow, you will no longer see their updates or content in your feed.", - "profile_notifications_popup_title": "Account notifications", - "profile_edit": "Edit profile", - "profile_save": "Save profile", - "profile_bio": "Bio", - "profile_location": "Location", - "profile_website": "Website", - "profile_send_option_title": "Send payment", - "profile_send_option_desc": "Send funds quickly and securely", - "profile_request_option_title": "Request payment", - "profile_request_option_desc": "Securely receive funds with one tap", - "profile_send_coin": "Send coin", - "profile_request_funds": "Request funds", - "profile_empty_state": "There's nothing here yet", - "profile_creation_date": "December 2024", - "notifications_title": "Notifications", - "notifications_type_comments": "Comments", - "notifications_type_followers": "Followers", - "notifications_type_likes": "Likes", - "notifications_followed_one": "[:username] followed you", - "notifications_followed_two": "[:username] and [:username] followed you", - "notifications_followed_many": "[:username] and {number} others followed you", - "notifications_liked_one": "[:username] liked your post", - "notifications_liked_two": "[:username] and [:username] liked your post", - "notifications_liked_many": "[:username] and {number} others liked your post", - "notifications_liked_reply_one": "[:username] liked your reply", - "notifications_liked_reply_two": "[:username] and [:username] liked your reply", - "notifications_liked_reply_many": "[:username] and {number} others liked your reply", - "notifications_reply": "[:username] replied to your post", - "notifications_share": "[:username] shared your post", - "notifications_repost": "[:username] reposted your post", - "notifications_empty_state": "You don't have any notifications", - "settings_title": "Settings", - "settings_security": "Security", - "settings_privacy": "Privacy", - "settings_push_notifications": "Push notifications", - "settings_privacy_policy": "Privacy policy", - "settings_terms_conditions": "Terms & conditions", - "settings_logout": "Logout", - "settings_app_version": "dApp version {version}", - "settings_profile_edit": "Edit profile", - "settings_app_language": "dApp language", - "settings_content_language": "Content language", - "settings_remaining_content_languages_number": "+ {number}", - "privacy_group_wallet_address_title": "Wallet address", - "privacy_group_who_can_message_you_title": "Who can message you", - "privacy_group_who_can_invite_you_title": "Who can invite you in groups", - "privacy_option_wallet_public": "Public", - "privacy_option_wallet_private": "Private", - "privacy_option_user_visibility_for_everyone": "Everyone", - "privacy_option_user_visibility_for_followed_people": "People I follow", - "privacy_option_user_visibility_for_friends": "Friends", - "push_notification_device_permission": "Device permission", - "push_notification_social_group_title": "Social", - "push_notification_chat_group_title": "Chat", - "push_notification_wallet_group_title": "Wallet", - "push_notification_system_group_title": "System", - "push_notification_social_option_posts": "Posts", - "push_notification_social_option_mentions_replies": "Mentions and replies", - "push_notification_social_option_reposts": "Reposts", - "push_notification_social_option_likes": "Likes", - "push_notification_social_option_new_followers": "New followers", - "push_notification_chat_option_direct_messages": "Direct messages", - "push_notification_chat_option_group_chats": "Group chats", - "push_notification_chat_option_channels": "Channels", - "push_notification_wallet_option_payment_request": "Payment request", - "push_notification_wallet_option_payment_received": "Payment received", - "push_notification_system_option_update": "Update", - "app_language_title": "App language", - "app_language_description": "You’ll be shown the app in the selected language", - "confirm_logout_title": "Log out {username}?", - "confirm_logout_description": "Are you sure you want to log out from the app?", - "content_language_title": "Content languages", - "content_language_description": "You’ll be shown content in the selected language", - "category_aviation": "Aviation", - "category_blockchain": "Blockchain", - "category_business": "Business", - "category_cars": "Cars", - "category_cryptocurrency": "Cryptocurrency", - "category_data_science": "Data Science", - "category_education": "Education", - "category_finance": "Finance", - "category_gamer": "Gamer", - "category_style": "Style", - "category_restaurant": "Restaurant", - "category_trading": "Trading", - "category_technology": "Technology", - "category_traveler": "Traveler", - "category_news": "News", - "wallet_balance": "Balance", - "wallet_send": "Send", - "wallet_send_coins": "Send coins", - "wallet_receive_info": "When sending funds, the networks must match! otherwise, you may permanently lose your funds", - "wallet_choose_network": "Choose network", - "wallet_receive": "Receive", - "wallet_receive_coins": "Receive coins", - "wallet_share_address": "Share address", - "wallet_scan": "Scan the QR code", - "wallet_scan_hint": "Scan the QR code to send cryptocurrency", - "wallet_sent": "Sent", - "wallet_received": "Received", - "wallet_hide": "Hide 0.00", - "wallet_empty_coins": "You have no coins yet", - "wallet_empty_nfts": "You don't have any NFT's", - "wallet_manage_coins": "Manage coins", - "wallet_manage_nfts": "Select chain", - "wallet_buy_nfts": "Buy NFTs", - "wallet_coin_address": "{coin} address", - "wallet_network": "Network", - "wallet_change": "change", - "wallet_wallets": "Wallets", - "wallet_manage_wallets": "Manage wallets", - "wallet_create_new": "Create a new wallet", - "wallet_create": "Create wallet", - "wallet_name": "Wallet name", - "wallet_edit": "Edit wallet", - "wallet_delete": "Delete wallet", - "wallet_delete_q": "Delete wallet?", - "wallet_delete_message": "All coins on this wallet will be lost. Are you sure you want to delete this wallet?", - "wallet_enter_address": "Enter address", - "wallet_usdt_amount": "USDT amount", - "wallet_arrival_time": "Arrival time", - "wallet_arrival_time_type_normal": "Normal", - "wallet_arrival_time_minutes": "min", - "wallet_network_fee": "Network fee", - "wallet_max": "max", - "wallet_send_to": "Send to", - "wallet_asset": "Asset", - "wallet_title": "Wallet", - "wallet_transaction_successful": "Transfer successful", - "wallet_transaction_details": "Details", - "wallet_transaction_details_arrival_time_format": "dd.MM.yyyy HH:mm:ss", - "wallet_explore_transaction_details_title": "View on explorer", - "wallet_invite_friends": "Invite friend", - "wallet_friends_does_not_have_account": "Your friend doesn’t have an Ice account.", - "wallet_approximate_in_usd": "~ ${amount}", - "wallet_coin_amount": "{coin} amount", - "sorting_price_asc": "Price: low to high", - "sorting_price_desc": "Price: high to low", - "wallet_sorting_title": "Sorting the list", - "contacts_title": "Contacts", - "contacts_select_title": "Select contact", - "contacts_allow_pop_up_title": "Ice is better with friends", - "contacts_allow_pop_up_desc": "Sync your contacts, see who is already on Ice, and send and receive Ice payments from any of your contacts.", - "contacts_allow_pop_up_action": "Allow contacts access", - "core_view_all": "view all", - "core_all": "All", - "core_chain": "Chain", - "core_nfts": "NFTs", - "core_coins": "Coins", - "core_dapps": "dApps", - "core_done": "Done", - "core_empty_search": "No results found", - "core_empty_transactions_history": "You don't have any transactions yet", - "date_today": "Today", - "date_yesterday": "Yesterday", - "general_feed": "Feed", - "general_videos": "Videos", - "general_articles": "Articles", - "feed_for_you": "For you", - "feed_following": "Following", - "feed_read_time_in_mins": "{mins} min read", - "feed_trending_videos": "Trending Videos", - "feed_modal_title": "Create value", - "feed_modal_post": "Post", - "feed_modal_post_description": "Voice your ideas", - "feed_modal_story": "Story", - "feed_modal_story_description": "Express the moment", - "feed_modal_video": "Video", - "feed_modal_video_description": "Show the world in motion", - "feed_modal_article": "Article", - "feed_repost_type": "Type", - "feed_repost": "Repost", - "feed_someone_reposted": "{someone} reposted", - "feed_quote_post": "Quote post", - "feed_write_comment": "Write comment", - "feed_comment_hint": "Write your comment!", - "feed_add_story": "Add to story", - "feed_copy_link": "Copy link", - "feed_whatsapp": "WhatsApp", - "feed_telegram": "Telegram", - "feed_x": "X", - "feed_more": "More", - "feed_send": "Send", - "feed_modal_article_description": "Share your wisdom", - "feed_share_via": "Share via message", - "feed_search_empty": "Search here for users, hashtags, channels...", - "feed_search_history_title": "Recent searches", - "feed_search_history_delete_title": "Delete search history?", - "feed_search_history_delete_message": "Are you sure you want to delete all search history?", - "feed_search_filter_title": "Filters", - "feed_search_filter_anyone": "From anyone", - "feed_search_filter_following": "People you follow", - "feed_search_filter_people": "People", - "feed_search_filter_languages": "Languages", - "feed_advanced_search_category_top": "Top", - "feed_advanced_search_category_latest": "Latest", - "feed_advanced_search_category_people": "People", - "feed_advanced_search_category_photos": "Photos", - "feed_advanced_search_category_videos": "Videos", - "feed_advanced_search_category_groups": "Groups", - "feed_advanced_search_category_channels": "Channels", - "video_not_found": "Video not found", - "turn_notifications_title": "Turn on notifications", - "turn_notifications_description": "Receive notifications when you transfer and receive funds", - "turn_notifications_receive": "Receive notifications when your sending or receiving assets", - "turn_notifications_stay_up": "Stay up to date with the latest news", - "turn_notifications_chat": "Chat and receive notifications even if the application is closed", - "turn_notifications_sent_title": "Sent ICE", - "turn_notifications_sent_description": "You sent 12.43 ICE to @james", - "turn_notifications_sent_time": "15m ago", - "turn_notifications_new_follower_title": "New follower", - "turn_notifications_new_follower_description": "@curtis has started following you", - "turn_notifications_new_follower_time": "24m ago", - "turn_notifications_new_message_title": "New message", - "turn_notifications_new_message_description": "@marie has sent you a message", - "turn_notifications_new_message_time": "31m ago", - "send_nft_navigation_title": "Send NFT", - "send_nft_description": "Description", - "send_nft_token_id": "Token ID", - "send_nft_token_network": "Network", - "send_nft_token_standard": "Token standard", - "send_nft_token_contract_address": "Contract address", - "send_nft_title": "Send NFT", - "post_page_title": "Post", - "post_show_replies": "Show replies", - "post_hide_replies": "Hide replies", - "post_reply_hint": "Write your reply", - "post_replying_to": "Replying to", - "post_reply_sent": "Your reply was sent", - "send_nft_confirm_asset": "Asset", - "send_nft_confirm_network": "Network", - "send_nft_confirm_arrival_time": "Arrival time", - "send_nft_confirm_network_fee": "Network fee", - "transaction_details_title": "Transaction details", - "transaction_details_view_on_explorer": "View on explorer", - "post_menu_not_interested": "Not interested", - "post_menu_follow_nickname": "Follow @{nickname}", - "post_menu_unfollow_nickname": "Unfollow @{nickname}", - "post_menu_block_nickname": "Block @{nickname}", - "post_menu_report_post": "Report post", - "protect_account_header_security": "Security", - "protect_account_title_secure_account": "Secure your account", - "protect_account_description_secure_account": "Securing your account ensures you never lose access to your data and funds", - "protect_account_button": "Protect account", - "protect_account_description_secure_account_2fa": "To secure your account, back it up and enable at least one 2FA option", - "protect_account_create_recovery_error": "An error occurred while fetching the data.", - "backup_title": "Select backup", - "backup_description": "Backups enable you to restore your data and wallet if something goes wrong", - "backup_option_with_icloud_title": "Backup with iCloud", - "backup_option_with_google_drive_title": "Backup with Google Drive", - "backup_option_with_google_drive_description": "Safe and simple way to protect your account", - "backup_option_with_recovery_keys_title": "Recovery keys", - "backup_option_with_recovery_keys_description": "Write down and store your keys on paper for secure account recovery", - "secure_your_recovery_keys_title": "Secure your recovery keys", - "secure_your_recovery_keys_description": "Please complete this process in a private place to ensure your account's safety", - "recovery_keys_successfully_protected_title": "Successfully protected", - "recovery_keys_successfully_protected_description": "Your recovery keys have been securely backed up. Please keep them safe for future account recovery", - "error_recovery_keys_title": "Recovery keys error", - "error_recovery_keys_description": "You have entered incorrect data", - "error_screenshots_arent_secure_title": "Screenshots aren’t secure", - "error_screenshots_arent_secure_description": "Anyone who has access to your keys can use your assets. We recommend writing with your hands.", - "error_nickname_invalid": "Only letters, numbers, and dots are allowed", - "error_passwords_are_not_equal": "Passwords are not equal", - "error_website_invalid": "Invalid website url", - "error_general_title": "Something went wrong", - "error_general_description": "An unexpected error occurred. Please try again later. {info}", - "error_general_error_code": "Error code: {error}", - "error_input_length": "Must be over {amount} characters", - "error_input_numbers": "Must contain 1 number", - "error_input_all_cases": "Uppercase and lowercase letters", - "error_input_special_character": "Must contain 1 special character", - "error_identity_name_invalid": "Only lowercase letters, numbers, dots and hyphens are allowed", - "warning_avoid_storing_keys": "Avoid storing keys on any device to prevent losing access to funds in case of a hack", - "warning_authenticator_setup": "Keep this key safe to restore access if you lose your device.", - "authenticator_setup_title": "Authenticator setup", - "authenticator_setup_description": "In order to link the key, you need one of the following Authenticator applications", - "authenticator_setup_key": "Setup key", - "authenticator_is_linked_to_account": "Your account is linked to the authentication application", - "authenticator_delete_title": "Deleting an authenticator", - "authenticator_delete_description": "To delete the authenticator, you must confirm by selecting the options first", - "authenticator_has_deleted": "The authenticator was successfully deleted", - "follow_instructions_title": "Follow instructions", - "follow_instructions_description": "Copy the installation key and paste it into your Authentication application", - "confirm_the_code_title": "Confirm the code", - "authenticator_protected_description": "Your authenticator was successfully configured and you are now protected", - "email_verification_title": "Email verification", - "email_verification_description": "Enter your email address for verification", - "email_confirmation_title": "Confirm email", - "email_success_description": "The email address has been successfully verified and added for 2FA", - "phone_verification_title": "Phone number verification", - "phone_verification_description": "Enter your phone number for verification", - "phone_confirmation_title": "Confirm phone number", - "phone_success_description": "The phone number has been successfully verified and added for 2FA", - "phone_number": "Phone number", - "phone_number_invalid": "Invalid phone number", - "select_countries_nav_title": "Select country", - "create_post_modal_title": "New post", - "create_post_modal_placeholder": "What’s happening?", - "create_article_nav_title": "New article", - "create_article_title_placeholder": "Title", - "create_article_add_cover": "Add cover image", - "create_article_story_placeholder": "Write your story...", - "create_video_edit_cover": "Edit cover", - "create_video_new_video": "New video", - "create_video_input_placeholder": "Add a description (optional)", - "gallery_add_photo_title": "Add photo", - "gallery_add_media_title": "Add media", - "camera": "Camera", - "visibility_settings_title_video": "Who can view this video", - "visibility_settings_title_story": "Who can view this story", - "visibility_settings_everyone": "Everyone", - "visibility_settings_followed_accounts": "Accounts you follow", - "visibility_settings_verified_accounts": "Verified accounts", - "visibility_settings_mentioned_accounts": "Only accounts you mention", - "schedule_modal_nav_title": "Set date and time", - "cancel_creation_post_title": "Cancel post?", - "cancel_creation_description": "Are you sure you want to cancel your progress?", - "cancel_creation_article_title": "Cancel article?", - "photo_library_require_access_title": "\"{appName} app\" would like to access your photo library", - "photo_library_require_access_description": "This lets you share photos from your library and save photos to your camera roll.", - "camera_require_access_title": "\"{appName} app\" would like to access your camera", - "camera_require_access_description": "{appName} application would like to access the camera", - "push_notifications_require_access_title": "\"{appName} app\" would like to Send You Notifications", - "push_notifications_require_access_description": "Notifications may include alerts, sounds, and icon badges. These can be configured is Settings.", - "gallery_permission_headline": "Gallery permission", - "gallery_no_access_title": "There is no access to your gallery", - "camera_permission_headline": "Camera permission", - "camera_no_access_title": "There is no access to your camera", - "camera_no_access_description": "To grant access, go to settings and enable the appropriate settings", - "push_notifications_permission_headline": "Notifications permission", - "push_notifications_no_access_title": "Permission Not Available", - "push_notifications_no_access_description": "You have previously denied notification permission. Please go to settings to enable notifications.", - "story_preview_title": "Story preview", - "story_settings_title": "Who can reply this story", - "article_settings_title": "Who can reply this article", - "chat_title": "Chats", - "chat_empty_description": "You have no conversations yet", - "chat_new_message_button": "New message", - "poll_length_modal_title": "Pool length time", - "poll_length_button_title": "Poll length", - "poll_add_answer_button_title": "Add answer", - "poll_choice_placeholder": "Choice {number}", - "poll_title_placeholder": "Write a question for the poll", - "chat_recents_money_request_message": "Money requested", - "toolbar_link_title": "Add a link", - "toolbar_link_placeholder": "http://", - "messaging_empty_description": "Messages and voices are end-to-end encrypted.", - "chat_modal_title": "Start conversation", - "chat_modal_private_description": "Start a private, one-on-one chat", - "chat_modal_group_description": "Chat with multiple people together", - "chat_modal_channel_description": "Share updates with a wide audience", - "chat_profile_share_modal_title": "Share profile", - "new_chat_modal_title": "New chat", - "new_chat_modal_description": "Search above for users, groups, and channels...", - "new_chat_modal_new_group_button": "New group", - "new_chat_modal_new_channel_button": "New channel", - "chat_read_all": "Read All", - "chat_delete_modal_title": "Delete chat?", - "chat_delete_modal_description": "Are you sure you want to delete all selected chats?", - "chat_search_empty": "Search here for users, chats, groups, and channels...", - "chat_money_request_title": "Money requested", - "chat_money_received_title": "Money received", - "chat_money_received_button": "View transaction", - "chat_profile_share_button": "Write a message", - "chat_learn_more_modal_title": "Privacy First, Always", - "chat_learn_more_modal_description": "Your chats are private and encrypted by default. We prioritize your privacy, and your conversations are protected with end-to-end encryption.", - "chat_add_poll_title": "New poll", - "channel_create_title": "Create a new channel", - "channel_create_type": "Channel type", - "channel_create_admins": "Channel admins", - "channel_create_action": "Create channel", - "channel_create_add_photo": "Add channel photo", - "channel_create_type_select_title": "Choose channel type", - "channel_create_type_public_desc": "Public channels are searchable and open to all users.", - "channel_create_type_private_desc": "Private channels can't be found in search and aren't end-to-end encrypted.", - "channel_create_admins_title": "Admin management", - "channel_create_admins_action": "Add administrator", - "channel_create_admin_type_title": "Choose admin type", - "channel_create_admin_type_owner": "Owner", - "channel_create_admin_type_admin": "Admin", - "channel_create_admin_type_moderator": "Moderator", - "channel_create_admin_type_remove": "Remove role", - "channel_create_admin_type_remove_title": "Remove role?", - "channel_create_admin_type_remove_desc": "Are you sure you want to remove the role?", - "channel_created_message": "The channel has been created", - "group_create_title": "New Group", - "group_create_name_label": "Group Name", - "group_create_type": "Group type", - "group_create_members_number": "Group members ({members})", - "group_create_create_button": "Create group", - "group_create_type_title": "Choose group type", - "group_create_type_public_desc": "Public channels are searchable and open to all users.", - "group_create_type_private_desc": "Private channels can't be found in search and aren't end-to-end encrypted.", - "group_create_type_encrypted": "Encrypted", - "group_create_type_encrypted_desc": "End-to-end encryption is used. Only you and the people you communicate with can see the messages.", - "group_created_message": "The group has been created", - "notification_video_loading": "Your video is loading...", - "notification_story_loading": "Your story is loading...", - "notification_post_loading": "Your post is loading...", - "notification_article_loading": "Your article is loading...", - "notification_reply_loading": "Your reply is loading...", - "notification_repost_loading": "Your repost is loading...", - "notification_video_published": "Your video has been published", - "notification_story_published": "Your story has been published", - "notification_post_published": "Your post has been published", - "notification_article_published": "Your article has been published", - "notification_reply_published": "Your reply has been published", - "notification_repost_successful": "Successfully reposted", - "chat_groups_joined": "Joined", - "chat_groups_explore": "Explore", - "chat_groups_subscribed": "Subscribed", - "code_block_type_plain_text": "Plain text", - "code_block_type_swift": "Swift", - "code_block_type_c": "C", - "code_block_type_c_plus_plus": "C++", - "code_block_type_c_sharp": "C#", - "code_block_type_css": "CSS", - "code_block_type_java": "Java", - "code_block_type_javascript": "JavaScript", - "code_block_type_python": "Python", - "code_block_type_dart": "Dart", - "write_a_message": "Write a message...", - "reaction_was_sent": "Reaction was sent", - "share_via": "Share via...", - "topic_blockchain": "Blockchain", - "topic_business": "Business", - "topic_cryptocurrency": "Cryptocurrency", - "topic_data_science": "Data Science", - "topic_finance": "Finance", - "topic_games": "Games", - "topic_style": "Style", - "topic_lifechange": "Life Change", - "topic_life": "Life", - "topic_trading": "Trading", - "topic_technology": "Technology", - "topic_travel": "Travel", - "topic_news": "News", - "topic_people": "People", - "topic_world": "World", - "topics_add": "Add", - "topics_title": "Topics", - "article_preview_title": "Article preview", - "article_page_title": "Article", - "article_page_from_author": "from {name}", - "update_update_title": "Please update", - "update_update_desc": "You are using an old app version and need to update it in order to use it further", - "update_update_action": "Update now", - "update_uptodate_title": "You are up to date", - "update_uptodate_desc": "You are now using the latest version. Check all the changes below", - "update_uptodate_action": "View changelog", - "emoji_category_smileys_people": "Smileys & People", - "emoji_category_animals_nature": "Animals & Nature", - "emoji_category_food_drink": "Food & Drink", - "emoji_category_activities": "Activities", - "emoji_category_travel_places": "Travel & Places", - "emoji_category_objects": "Objects", - "emoji_category_symbols": "Symbols", - "emoji_category_flags": "Flags", - "recent_emoji_reactions": "Recent reactions", - "passkeys_prompt_title": "Verify with passkey", - "passkeys_prompt_description": "Your device will ask your fingerprint, face or screen lock to confirm", - "verify_with_password_title": "Verify with password", - "verify_with_password_desc": "Please confirm your password to continue.", - "verify_with_password_prompt_desc": "Your device will ask your password to confirm", - "verify_with_biometrics_title": "Verify with biometrics", -<<<<<<< Updated upstream - "members_count": "{count, plural, =1 {{count} member} other {{count} members}}", - "all_chains_item": "All chains" -======= - "biometrics_suggestion_title": "Add biometric authentication", - "biometrics_suggestion_desc": "Do you want to use your device biometric data for a faster authentication?", - "members_count": "{count, plural, =1 {{count} member} other {{count} members}}" ->>>>>>> Stashed changes -} diff --git a/packages/ion_identity_client/lib/src/auth/services/create_credentials/create_new_credentials_service.dart b/packages/ion_identity_client/lib/src/auth/services/create_credentials/create_new_credentials_service.dart index 7fdbaa1c6..1fb3f4eef 100644 --- a/packages/ion_identity_client/lib/src/auth/services/create_credentials/create_new_credentials_service.dart +++ b/packages/ion_identity_client/lib/src/auth/services/create_credentials/create_new_credentials_service.dart @@ -52,6 +52,23 @@ class CreateNewCredentialsService { ), ); }, + onBiometricsFlow: ({required String localisedReason}) { + return identitySigner.registerWithPasskey( + UserRegistrationChallenge( + null, + credentialChallenge.rp, + credentialChallenge.user, + null, + null, + credentialChallenge.challenge, + credentialChallenge.authenticatorSelection, + credentialChallenge.attestation, + credentialChallenge.pubKeyCredParams, + credentialChallenge.excludeCredentials ?? [], + null, + ), + ); + }, ); final credentialRequest = dataSource.buildCreateCredentialSigningRequest( diff --git a/packages/ion_identity_client/lib/src/auth/services/create_credentials/create_recovery_credentials_service.dart b/packages/ion_identity_client/lib/src/auth/services/create_credentials/create_recovery_credentials_service.dart index 99d2bc182..0c78c47b8 100644 --- a/packages/ion_identity_client/lib/src/auth/services/create_credentials/create_recovery_credentials_service.dart +++ b/packages/ion_identity_client/lib/src/auth/services/create_credentials/create_recovery_credentials_service.dart @@ -63,6 +63,13 @@ class CreateRecoveryCredentialsService { CredentialResponse.fromJson, ); }, + onBiometricsFlow: ({required String localisedReason}) { + return userActionSigner.signWithBiometrics( + credentialRequest, + CredentialResponse.fromJson, + localisedReason, + ); + }, ); return CreateRecoveryCredentialsSuccess( diff --git a/packages/ion_identity_client/lib/src/auth/services/key_service.dart b/packages/ion_identity_client/lib/src/auth/services/key_service.dart index 4fa4e2103..01c65ae0a 100644 --- a/packages/ion_identity_client/lib/src/auth/services/key_service.dart +++ b/packages/ion_identity_client/lib/src/auth/services/key_service.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:asn1lib/asn1lib.dart'; +import 'package:convert/convert.dart'; import 'package:cryptography/cryptography.dart' as crypto; import 'package:ion_identity_client/src/auth/dtos/key_pair_data.dart'; @@ -31,6 +32,34 @@ class KeyService { ); } + /// Reconstructs a KeyPairData object from a hex-encoded Ed25519 private key (seed). + Future reconstructKeyPairFromPrivateKeyBytes(String hexEncodedPrivateKeyBytes) async { + final privateKeyBytes = Uint8List.fromList(hex.decode(hexEncodedPrivateKeyBytes)); + + if (privateKeyBytes.length != 32) { + throw ArgumentError( + 'Invalid private key seed length: expected 32 bytes, got ${privateKeyBytes.length}', + ); + } + + final algorithm = crypto.Ed25519(); + final keyPair = await algorithm.newKeyPairFromSeed(privateKeyBytes); + final keyPairData = await keyPair.extract(); + final publicKey = keyPairData.publicKey; + + // Convert to PEM + final publicKeyPem = _encodeEd25519PublicKeyToPem(Uint8List.fromList(publicKey.bytes)); + final privateKeyPem = _encodeEd25519PrivateKeyToPem(privateKeyBytes); + + return KeyPairData( + keyPair: keyPairData, + publicKey: publicKey, + publicKeyPem: publicKeyPem, + privateKeyPem: privateKeyPem, + privateKeyBytes: privateKeyBytes, + ); + } + Future reconstructKeyPairFromEncryptedPrivateKey( String encryptedPrivateKey, String recoveryCode, diff --git a/packages/ion_identity_client/lib/src/auth/services/twofa/twofa_service.dart b/packages/ion_identity_client/lib/src/auth/services/twofa/twofa_service.dart index e2f5dc6cd..b0da0164a 100644 --- a/packages/ion_identity_client/lib/src/auth/services/twofa/twofa_service.dart +++ b/packages/ion_identity_client/lib/src/auth/services/twofa/twofa_service.dart @@ -117,6 +117,13 @@ class TwoFAService { hash.toString(), ); }, + onBiometricsFlow: ({required String localisedReason}) { + return _wallets.generateHashSignatureWithBiometrics( + mainWallet.id, + hash.toString(), + localisedReason, + ); + }, ); final signature = signatureResponse.signature['encoded'].toString().substring(2); diff --git a/packages/ion_identity_client/lib/src/core/types/ion_exception.dart b/packages/ion_identity_client/lib/src/core/types/ion_exception.dart index e4eea5d5f..601c06250 100644 --- a/packages/ion_identity_client/lib/src/core/types/ion_exception.dart +++ b/packages/ion_identity_client/lib/src/core/types/ion_exception.dart @@ -30,6 +30,10 @@ class PasskeyValidationException extends IONIdentityException { const PasskeyValidationException() : super('Passkey validation failed'); } +class BiometricsValidationException extends IONIdentityException { + const BiometricsValidationException() : super('Biometrics validation failed'); +} + class PasswordFlowNotAvailableForTheUserException extends IONIdentityException { const PasswordFlowNotAvailableForTheUserException() : super('Password flow is not available for this user'); diff --git a/packages/ion_identity_client/lib/src/core/types/types.dart b/packages/ion_identity_client/lib/src/core/types/types.dart index 0a0b271cc..cdb898b4f 100644 --- a/packages/ion_identity_client/lib/src/core/types/types.dart +++ b/packages/ion_identity_client/lib/src/core/types/types.dart @@ -4,8 +4,10 @@ typedef JsonObject = Map; typedef OnPasswordFlow = Future Function({required String password}); typedef OnPasskeyFlow = Future Function(); +typedef OnBiometricsFlow = Future Function({required String localisedReason}); typedef OnVerifyIdentity = Future Function({ + required OnBiometricsFlow onBiometricsFlow, required OnPasswordFlow onPasswordFlow, required OnPasskeyFlow onPasskeyFlow, }); diff --git a/packages/ion_identity_client/lib/src/signer/identity_signer.dart b/packages/ion_identity_client/lib/src/signer/identity_signer.dart index e0d7df8a0..687e4c74c 100644 --- a/packages/ion_identity_client/lib/src/signer/identity_signer.dart +++ b/packages/ion_identity_client/lib/src/signer/identity_signer.dart @@ -50,7 +50,7 @@ class IdentitySigner { required String credentialId, required CredentialKind credentialKind, }) async { - return passwordSigner.createCredentialAssertion( + return passwordSigner.signWithPassword( challenge: challenge, encryptedPrivateKey: encryptedPrivateKey, password: password, @@ -59,6 +59,22 @@ class IdentitySigner { ); } + Future signWithBiometrics({ + required String username, + required String localisedReason, + required String challenge, + required String credentialId, + required CredentialKind credentialKind, + }) async { + return passwordSigner.signWithBiometrics( + username: username, + localisedReason: localisedReason, + challenge: challenge, + credentialKind: credentialKind, + credentialId: credentialId, + ); + } + Future isPasskeyAvailable() { return passkeySigner.canAuthenticate(); } diff --git a/packages/ion_identity_client/lib/src/signer/password_signer.dart b/packages/ion_identity_client/lib/src/signer/password_signer.dart index b8fdcd1ac..182d64e19 100644 --- a/packages/ion_identity_client/lib/src/signer/password_signer.dart +++ b/packages/ion_identity_client/lib/src/signer/password_signer.dart @@ -90,7 +90,7 @@ class PasswordSigner { ); } - Future createCredentialAssertion({ + Future signWithPassword({ required String challenge, required String encryptedPrivateKey, required String password, @@ -99,25 +99,43 @@ class PasswordSigner { }) async { final keyPair = await keyService.reconstructKeyPairFromEncryptedPrivateKey(encryptedPrivateKey, password); - final clientData = _buildClientData( + return _createCredentialAssertion( + keyPair: keyPair, challenge: challenge, - origin: config.origin, - clientDataType: ClientDataType.getKey, + credentialId: credentialId, + credentialKind: credentialKind, ); + } - final signature = await _signDataWithPrivateKey( - data: clientData, - privateKey: keyPair.keyPair, - signatureEncryption: SignatureEncryption.base64Url, + Future signWithBiometrics({ + required String challenge, + required String credentialId, + required String username, + required String localisedReason, + required CredentialKind credentialKind, + }) async { + final biometricsState = biometricsStateStorage.getBiometricsState(username: username); + if (biometricsState != BiometricsState.enabled) { + throw const BiometricsValidationException(); + } + final privateKey = privateKeyStorage.getPrivateKey( + username: username, ); + if (privateKey == null) { + throw const BiometricsValidationException(); + } - return AssertionRequestData( - kind: credentialKind, - credentialAssertion: CredentialAssertionData( - clientData: base64UrlEncode(utf8.encode(clientData)), - credId: credentialId, - signature: signature, - ), + final didAuthenticate = await _authWithBiometrics(localisedReason: localisedReason); + if (didAuthenticate == false) { + throw const BiometricsValidationException(); + } + + final keyPair = await keyService.reconstructKeyPairFromPrivateKeyBytes(privateKey); + return _createCredentialAssertion( + keyPair: keyPair, + challenge: challenge, + credentialId: credentialId, + credentialKind: credentialKind, ); } @@ -132,22 +150,24 @@ class PasswordSigner { required String username, required String localisedReason, }) async { - final auth = LocalAuthentication(); + final didAuthenticate = await _authWithBiometrics(localisedReason: localisedReason); + await biometricsStateStorage.updateBiometricsState( + username: username, + biometricsState: didAuthenticate ? BiometricsState.enabled : BiometricsState.failed, + ); + } + + Future _authWithBiometrics({ + required String localisedReason, + }) async { + final localAuth = LocalAuthentication(); try { - final didAuthenticate = await auth.authenticate( + return await localAuth.authenticate( localizedReason: localisedReason, options: const AuthenticationOptions(stickyAuth: true), ); - await biometricsStateStorage.updateBiometricsState( - username: username, - biometricsState: didAuthenticate ? BiometricsState.enabled : BiometricsState.failed, - ); } catch (_) { - await biometricsStateStorage.updateBiometricsState( - username: username, - biometricsState: BiometricsState.failed, - ); - rethrow; + return false; } } @@ -271,4 +291,32 @@ class PasswordSigner { ? formattedStr.substring(0, formattedStr.length - 1) : formattedStr; } + + Future _createCredentialAssertion({ + required KeyPairData keyPair, + required String challenge, + required String credentialId, + required CredentialKind credentialKind, + }) async { + final clientData = _buildClientData( + challenge: challenge, + origin: config.origin, + clientDataType: ClientDataType.getKey, + ); + + final signature = await _signDataWithPrivateKey( + data: clientData, + privateKey: keyPair.keyPair, + signatureEncryption: SignatureEncryption.base64Url, + ); + + return AssertionRequestData( + kind: credentialKind, + credentialAssertion: CredentialAssertionData( + clientData: base64UrlEncode(utf8.encode(clientData)), + credId: credentialId, + signature: signature, + ), + ); + } } diff --git a/packages/ion_identity_client/lib/src/signer/user_action_signer.dart b/packages/ion_identity_client/lib/src/signer/user_action_signer.dart index 01efc2f14..4da898661 100644 --- a/packages/ion_identity_client/lib/src/signer/user_action_signer.dart +++ b/packages/ion_identity_client/lib/src/signer/user_action_signer.dart @@ -52,11 +52,7 @@ class UserActionSigner { // The assertion here is obtained by using the password to unlock // a password-protected key. If this key is unavailable, an exception is thrown. obtainAssertion: (challenge) async { - final credentialDescriptor = challenge.allowCredentials.passwordProtectedKey?.firstOrNull; - // If no password-protected credential is available, throw an exception. - if (credentialDescriptor == null || credentialDescriptor.encryptedPrivateKey == null) { - throw const PasswordFlowNotAvailableForTheUserException(); - } + final credentialDescriptor = _extractPasswordProtectedCredentials(challenge); return identitySigner.signWithPassword( challenge: challenge.challenge, @@ -69,6 +65,40 @@ class UserActionSigner { ); } + Future signWithBiometrics( + UserActionSigningRequest request, + T Function(JsonObject) responseDecoder, + String localisedReason, + ) async { + return _sign( + request: request, + responseDecoder: responseDecoder, + obtainAssertion: (challenge) async { + final credentialDescriptor = _extractPasswordProtectedCredentials(challenge); + + return identitySigner.signWithBiometrics( + challenge: challenge.challenge, + username: request.username, + credentialId: credentialDescriptor.id, + credentialKind: CredentialKind.PasswordProtectedKey, + localisedReason: localisedReason, + ); + }, + ); + } + + PublicKeyCredentialDescriptor _extractPasswordProtectedCredentials( + UserActionChallenge challenge, + ) { + final credentialDescriptor = challenge.allowCredentials.passwordProtectedKey?.firstOrNull; + // If no password-protected credential is available, throw an exception. + if (credentialDescriptor == null || credentialDescriptor.encryptedPrivateKey == null) { + throw const PasswordFlowNotAvailableForTheUserException(); + } + + return credentialDescriptor; + } + /// A private helper method that encapsulates the shared logic for both signWithPasskey and signWithPassword. /// /// Steps: diff --git a/packages/ion_identity_client/lib/src/wallets/ion_identity_wallets.dart b/packages/ion_identity_client/lib/src/wallets/ion_identity_wallets.dart index 746a5b6c7..46e9a1119 100644 --- a/packages/ion_identity_client/lib/src/wallets/ion_identity_wallets.dart +++ b/packages/ion_identity_client/lib/src/wallets/ion_identity_wallets.dart @@ -124,4 +124,15 @@ class IONIdentityWallets { hash: hash, password: password, ); + + Future generateHashSignatureWithBiometrics( + String walletId, + String hash, + String localisedReason, + ) => + _generateSignatureService.generateHashSignatureWithBiometrics( + walletId: walletId, + hash: hash, + localisedReason: localisedReason, + ); } diff --git a/packages/ion_identity_client/lib/src/wallets/services/create_wallet/create_wallet_service.dart b/packages/ion_identity_client/lib/src/wallets/services/create_wallet/create_wallet_service.dart index c93f59f30..b25c576c1 100644 --- a/packages/ion_identity_client/lib/src/wallets/services/create_wallet/create_wallet_service.dart +++ b/packages/ion_identity_client/lib/src/wallets/services/create_wallet/create_wallet_service.dart @@ -41,6 +41,13 @@ class CreateWalletService { Wallet.fromJson, ); }, + onBiometricsFlow: ({required String localisedReason}) { + return _userActionSigner.signWithBiometrics( + request, + Wallet.fromJson, + localisedReason, + ); + }, ); } } diff --git a/packages/ion_identity_client/lib/src/wallets/services/generate_signature/generate_signature_service.dart b/packages/ion_identity_client/lib/src/wallets/services/generate_signature/generate_signature_service.dart index 9e7b606ab..c7477b173 100644 --- a/packages/ion_identity_client/lib/src/wallets/services/generate_signature/generate_signature_service.dart +++ b/packages/ion_identity_client/lib/src/wallets/services/generate_signature/generate_signature_service.dart @@ -51,6 +51,24 @@ class GenerateSignatureService { ); } + Future generateHashSignatureWithBiometrics({ + required String walletId, + required String hash, + required String localisedReason, + String? externalId, + }) async { + return _generateSignature( + walletId: walletId, + hash: hash, + externalId: externalId, + signFn: (request) => _userActionSigner.signWithBiometrics( + request, + GenerateSignatureResponse.fromJson, + localisedReason, + ), + ); + } + Future generateMessageSignatureWithPasskey({ required String walletId, required String message, From 5818e03493ae87d9f43eb3f1c47c0dee5a387fd3 Mon Sep 17 00:00:00 2001 From: Ice Hades <119406114+ice-hades@users.noreply.github.com> Date: Thu, 9 Jan 2025 22:25:37 +0400 Subject: [PATCH 3/6] chore: changes after review --- ios/Runner/Info.plist | 2 +- .../create_new_credentials_service.dart | 16 +--------------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 5cc077ba8..edf36eaa4 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -69,7 +69,7 @@ This app needs access to your photo library to save images. NSMicrophoneUsageDescription This app requires microphone access for recording audio. - NSFaceIDUsageDescription + NSFaceIDUsageDescription This app uses Face ID to authenticate the user. diff --git a/packages/ion_identity_client/lib/src/auth/services/create_credentials/create_new_credentials_service.dart b/packages/ion_identity_client/lib/src/auth/services/create_credentials/create_new_credentials_service.dart index 1fb3f4eef..514715136 100644 --- a/packages/ion_identity_client/lib/src/auth/services/create_credentials/create_new_credentials_service.dart +++ b/packages/ion_identity_client/lib/src/auth/services/create_credentials/create_new_credentials_service.dart @@ -53,21 +53,7 @@ class CreateNewCredentialsService { ); }, onBiometricsFlow: ({required String localisedReason}) { - return identitySigner.registerWithPasskey( - UserRegistrationChallenge( - null, - credentialChallenge.rp, - credentialChallenge.user, - null, - null, - credentialChallenge.challenge, - credentialChallenge.authenticatorSelection, - credentialChallenge.attestation, - credentialChallenge.pubKeyCredParams, - credentialChallenge.excludeCredentials ?? [], - null, - ), - ); + throw UnimplementedError('Cannot register with biometrics'); }, ); From e2550c85cb2b6de4bf703b6e9685b6fe9a58ea53 Mon Sep 17 00:00:00 2001 From: Ice Hades <119406114+ice-hades@users.noreply.github.com> Date: Fri, 10 Jan 2025 12:50:36 +0400 Subject: [PATCH 4/6] chore: changes after review --- lib/app/features/user/providers/biometrics_provider.c.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/app/features/user/providers/biometrics_provider.c.dart b/lib/app/features/user/providers/biometrics_provider.c.dart index 6869405cf..baab8bb7d 100644 --- a/lib/app/features/user/providers/biometrics_provider.c.dart +++ b/lib/app/features/user/providers/biometrics_provider.c.dart @@ -12,7 +12,7 @@ Future userBiometricsState( Ref ref, { required String username, }) async { - final ionIdentity = await ref.read(ionIdentityProvider.future); + final ionIdentity = await ref.watch(ionIdentityProvider.future); return ionIdentity(username: username).auth.getBiometricsState(); } From f609276cf59353ee3ad90afbc09ea9d09baaf96d Mon Sep 17 00:00:00 2001 From: ice-hades <119406114+ice-hades@users.noreply.github.com> Date: Fri, 10 Jan 2025 13:46:00 +0400 Subject: [PATCH 5/6] Update Info.plist --- ios/Runner/Info.plist | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index edf36eaa4..a17ba3571 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -69,8 +69,8 @@ This app needs access to your photo library to save images. NSMicrophoneUsageDescription This app requires microphone access for recording audio. - NSFaceIDUsageDescription - This app uses Face ID to authenticate the user. + NSFaceIDUsageDescription + This app uses Face ID to authenticate the user. From e3ca65d9b5f8fa940c29cfacb25cada45b2ca58c Mon Sep 17 00:00:00 2001 From: ion-endymion <188437551+ice-endymion@users.noreply.github.com> Date: Fri, 10 Jan 2025 10:55:02 +0100 Subject: [PATCH 6/6] chore: format Info.plist --- ios/Runner/Info.plist | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index a17ba3571..f9752c196 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -69,8 +69,8 @@ This app needs access to your photo library to save images. NSMicrophoneUsageDescription This app requires microphone access for recording audio. - NSFaceIDUsageDescription - This app uses Face ID to authenticate the user. + NSFaceIDUsageDescription + This app uses Face ID to authenticate the user.