diff --git a/src/Core/Resources/Localization/AppResources.Designer.cs b/src/Core/Resources/Localization/AppResources.Designer.cs index 768419883..6ea8bcc87 100644 --- a/src/Core/Resources/Localization/AppResources.Designer.cs +++ b/src/Core/Resources/Localization/AppResources.Designer.cs @@ -5407,6 +5407,15 @@ namespace Bit.Core.Resources.Localization { } } + /// + /// Looks up a localized string similar to Passwords. + /// + public static string Passwords { + get { + return ResourceManager.GetString("Passwords", resourceCulture); + } + } + /// /// Looks up a localized string similar to This password was not found in any known data breaches. It should be safe to use.. /// diff --git a/src/Core/Resources/Localization/AppResources.resx b/src/Core/Resources/Localization/AppResources.resx index c29ae6dba..bfe9be087 100644 --- a/src/Core/Resources/Localization/AppResources.resx +++ b/src/Core/Resources/Localization/AppResources.resx @@ -2936,4 +2936,7 @@ Do you want to switch to this account? There was a problem reading your passkey for {0}. Try again later. The parameter is the RpId + + Passwords + diff --git a/src/Core/Services/Fido2AuthenticatorService.cs b/src/Core/Services/Fido2AuthenticatorService.cs index 0649f327a..3992911cc 100644 --- a/src/Core/Services/Fido2AuthenticatorService.cs +++ b/src/Core/Services/Fido2AuthenticatorService.cs @@ -109,8 +109,9 @@ namespace Bit.Core.Services { throw; } - catch (Exception) + catch (Exception ex) { + LoggerHelper.LogEvenIfCantBeResolved(ex); throw new UnknownError(); } } @@ -203,8 +204,9 @@ namespace Bit.Core.Services Signature = signature }; } - catch (Exception) + catch (Exception ex) { + LoggerHelper.LogEvenIfCantBeResolved(ex); throw new UnknownError(); } } diff --git a/src/iOS.Autofill/CredentialProviderViewController.Passkeys.cs b/src/iOS.Autofill/CredentialProviderViewController.Passkeys.cs index d65816ad4..d5e5b02df 100644 --- a/src/iOS.Autofill/CredentialProviderViewController.Passkeys.cs +++ b/src/iOS.Autofill/CredentialProviderViewController.Passkeys.cs @@ -24,7 +24,25 @@ namespace Bit.iOS.Autofill private readonly LazyResolve _platformUtilsService = new LazyResolve(); private readonly LazyResolve _userVerificationMediatorService = new LazyResolve(); private readonly LazyResolve _cipherService = new LazyResolve(); - + + [Export("prepareCredentialListForServiceIdentifiers:requestParameters:")] + public override void PrepareCredentialList(ASCredentialServiceIdentifier[] serviceIdentifiers, ASPasskeyCredentialRequestParameters requestParameters) + { + try + { + if (UIDevice.CurrentDevice.CheckSystemVersion(17, 0) && !string.IsNullOrEmpty(requestParameters?.RelyingPartyIdentifier)) + { + _context.PasskeyCredentialRequestParameters = requestParameters; + } + + PrepareCredentialList(serviceIdentifiers); + } + catch (Exception ex) + { + OnProvidingCredentialException(ex); + } + } + public override async void PrepareInterfaceForPasskeyRegistration(IASCredentialRequest registrationRequest) { if (!UIDevice.CurrentDevice.CheckSystemVersion(17, 0)) @@ -239,7 +257,7 @@ namespace Bit.iOS.Autofill } } - private async Task VerifyUserAsync(string selectedCipherId, Fido2UserVerificationPreference userVerificationPreference) + internal async Task VerifyUserAsync(string selectedCipherId, Fido2UserVerificationPreference userVerificationPreference) { try { diff --git a/src/iOS.Autofill/CredentialProviderViewController.cs b/src/iOS.Autofill/CredentialProviderViewController.cs index 2cb8bba02..f2cf4d0eb 100644 --- a/src/iOS.Autofill/CredentialProviderViewController.cs +++ b/src/iOS.Autofill/CredentialProviderViewController.cs @@ -88,56 +88,13 @@ namespace Bit.iOS.Autofill } else { - if (_context.ServiceIdentifiers == null || _context.ServiceIdentifiers.Length == 0) - { - PerformSegue(SegueConstants.LOGIN_SEARCH, this); - } - else + if (_context.IsCreatingOrPreparingListForPasskey || _context.ServiceIdentifiers?.Length > 0) { PerformSegue(SegueConstants.LOGIN_LIST, this); } - } - } - catch (Exception ex) - { - OnProvidingCredentialException(ex); - } - } - - [Export("prepareCredentialListForServiceIdentifiers:requestParameters:")] - public override async void PrepareCredentialList(ASCredentialServiceIdentifier[] serviceIdentifiers, ASPasskeyCredentialRequestParameters requestParameters) - { - try - { - InitAppIfNeeded(); - _context.VaultUnlockedDuringThisSession = false; - _context.ServiceIdentifiers = serviceIdentifiers; - if (serviceIdentifiers.Length > 0) - { - var uri = serviceIdentifiers[0].Identifier; - if (serviceIdentifiers[0].Type == ASCredentialServiceIdentifierType.Domain) - { - uri = string.Concat("https://", uri); - } - _context.UrlString = uri; - } - if (!await IsAuthed()) - { - await _accountsManager.NavigateOnAccountChangeAsync(false); - } - else if (await IsLocked()) - { - PerformSegue(SegueConstants.LOCK, this); - } - else - { - if (_context.ServiceIdentifiers == null || _context.ServiceIdentifiers.Length == 0) - { - PerformSegue(SegueConstants.LOGIN_SEARCH, this); - } else { - PerformSegue(SegueConstants.LOGIN_LIST, this); + PerformSegue(SegueConstants.LOGIN_SEARCH, this); } } } @@ -306,6 +263,8 @@ namespace Bit.iOS.Autofill return; } + _context.PickCredentialForFido2GetAssertionFromListTcs?.TrySetCanceled(); + if (!string.IsNullOrWhiteSpace(totp)) { UIPasteboard.General.String = totp; @@ -324,7 +283,7 @@ namespace Bit.iOS.Autofill }); } - private void OnProvidingCredentialException(Exception ex) + internal void OnProvidingCredentialException(Exception ex) { LoggerHelper.LogEvenIfCantBeResolved(ex); CancelRequest(ASExtensionErrorCode.Failed); diff --git a/src/iOS.Autofill/ILoginListViewController.cs b/src/iOS.Autofill/ILoginListViewController.cs index 8f515d3f3..93fccc7ac 100644 --- a/src/iOS.Autofill/ILoginListViewController.cs +++ b/src/iOS.Autofill/ILoginListViewController.cs @@ -1,4 +1,5 @@ -using Bit.iOS.Autofill.Models; +using System.Threading.Tasks; +using Bit.iOS.Autofill.Models; namespace Bit.iOS.Autofill { @@ -6,5 +7,8 @@ namespace Bit.iOS.Autofill { Context Context { get; } CredentialProviderViewController CPViewController { get; } + void OnItemsLoaded(string searchFilter); + Task ReloadItemsAsync(); + void ReloadTableViewData(); } } diff --git a/src/iOS.Autofill/LoginListViewController.cs b/src/iOS.Autofill/LoginListViewController.cs index 7c1836f8e..fbda1d7fd 100644 --- a/src/iOS.Autofill/LoginListViewController.cs +++ b/src/iOS.Autofill/LoginListViewController.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Threading.Tasks; +using AuthenticationServices; using Bit.App.Controls; using Bit.Core.Abstractions; using Bit.Core.Exceptions; @@ -15,8 +16,8 @@ using Bit.iOS.Core.Controllers; using Bit.iOS.Core.Utilities; using Bit.iOS.Core.Views; using CoreFoundation; -using CoreGraphics; using Foundation; +using Microsoft.Maui.ApplicationModel; using UIKit; namespace Bit.iOS.Autofill @@ -45,8 +46,30 @@ namespace Bit.iOS.Autofill LazyResolve _platformUtilsService = new LazyResolve(); LazyResolve _logger = new LazyResolve(); LazyResolve _userVerificationMediatorService = new LazyResolve(); + LazyResolve _fido2AuthenticatorService = new LazyResolve(); bool _alreadyLoadItemsOnce = false; + bool _isLoading; + + private string NavTitle + { + get + { + if (Context.IsCreatingPasskey) + { + return AppResources.SavePasskey; + } + + if (Context.IsCreatingOrPreparingListForPasskey) + { + return AppResources.Autofill; + } + + return AppResources.Items; + } + } + + private TableSource Source => (TableSource)TableView.Source; public async override void ViewDidLoad() { @@ -58,16 +81,22 @@ namespace Bit.iOS.Autofill SubscribeSyncCompleted(); - NavItem.Title = Context.IsCreatingPasskey ? AppResources.SavePasskey : AppResources.Items; + NavItem.Title = NavTitle; + _cancelButton.Title = AppResources.Cancel; + _searchBar.Placeholder = AppResources.Search; + _searchBar.BackgroundColor = _searchBar.BarTintColor = ThemeHelpers.ListHeaderBackgroundColor; + _searchBar.UpdateThemeIfNeeded(); + _searchBar.Delegate = new ExtensionSearchDelegate(TableView); + TableView.BackgroundColor = ThemeHelpers.BackgroundColor; var tableSource = new TableSource(this); TableView.Source = tableSource; tableSource.RegisterTableViewCells(TableView); - if (Context.IsCreatingPasskey) + if (Context.IsCreatingOrPreparingListForPasskey) { TableView.SectionHeaderHeight = 55; TableView.RegisterClassForHeaderFooterViewReuse(typeof(HeaderItemView), HEADER_SECTION_IDENTIFIER); @@ -78,8 +107,6 @@ namespace Bit.iOS.Autofill TableView.SectionHeaderTopPadding = 0; } - await ((TableSource)TableView.Source).LoadAsync(); - if (Context.IsCreatingPasskey) { _emptyViewLabel.Text = string.Format(AppResources.NoItemsForUri, Context.UrlString); @@ -91,8 +118,6 @@ namespace Bit.iOS.Autofill _emptyViewButton.ClipsToBounds = true; } - _alreadyLoadItemsOnce = true; - var storageService = ServiceContainer.Resolve("storageService"); var needsAutofillReplacement = await storageService.GetAsync( Core.Constants.AutofillNeedsIdentityReplacementKey); @@ -113,6 +138,22 @@ namespace Bit.iOS.Autofill }, false); _accountSwitchingOverlayView = _accountSwitchingOverlayHelper.CreateAccountSwitchingOverlayView(OverlayView); + + if (Context.IsPreparingListForPasskey) + { + var fido2UserInterface = new Fido2GetAssertionFromListUserInterface(Context, + () => Task.CompletedTask, + () => Context?.VaultUnlockedDuringThisSession ?? false, + CPViewController.VerifyUserAsync, + Source.ReloadWithAllowedFido2Credentials); + + DoFido2GetAssertionAsync(fido2UserInterface).FireAndForget(); + } + else + { + await ReloadItemsAsync(); + _alreadyLoadItemsOnce = true; + } } catch (Exception ex) { @@ -120,6 +161,74 @@ namespace Bit.iOS.Autofill } } + public async Task DoFido2GetAssertionAsync(IFido2GetAssertionUserInterface fido2GetAssertionUserInterface) + { + if (!UIDevice.CurrentDevice.CheckSystemVersion(17, 0)) + { + CPViewController.OnProvidingCredentialException(new InvalidOperationException("Trying to get assertion request before iOS 17")); + return; + } + + if (Context.PasskeyCredentialRequestParameters is null) + { + CPViewController.OnProvidingCredentialException(new InvalidOperationException("Trying to get assertion request without a PasskeyCredentialRequestParameters")); + return; + } + + try + { + var fido2AssertionResult = await _fido2AuthenticatorService.Value.GetAssertionAsync(new Fido2AuthenticatorGetAssertionParams + { + RpId = Context.PasskeyCredentialRequestParameters.RelyingPartyIdentifier, + Hash = Context.PasskeyCredentialRequestParameters.ClientDataHash.ToArray(), + UserVerificationPreference = Fido2UserVerificationPreferenceExtensions.ToFido2UserVerificationPreference(Context.PasskeyCredentialRequestParameters.UserVerificationPreference), + AllowCredentialDescriptorList = Context.PasskeyCredentialRequestParameters.AllowedCredentials? + .Select(c => new PublicKeyCredentialDescriptor { Id = c.ToArray() }) + .ToArray() + }, fido2GetAssertionUserInterface); + + if (fido2AssertionResult.SelectedCredential is null) + { + throw new NullReferenceException("SelectedCredential must have a value"); + } + + await CPViewController.CompleteAssertionRequest(new ASPasskeyAssertionCredential( + NSData.FromArray(fido2AssertionResult.SelectedCredential.UserHandle), + Context.PasskeyCredentialRequestParameters.RelyingPartyIdentifier, + NSData.FromArray(fido2AssertionResult.Signature), + Context.PasskeyCredentialRequestParameters.ClientDataHash, + NSData.FromArray(fido2AssertionResult.AuthenticatorData), + NSData.FromArray(fido2AssertionResult.SelectedCredential.Id) + )); + } + catch (InvalidOperationNeedsUIException) + { + return; + } + catch (TaskCanceledException) + { + return; + } + catch + { + try + { + if (Context?.IsExecutingWithoutUserInteraction == false) + { + await _platformUtilsService.Value.ShowDialogAsync( + string.Format(AppResources.ThereWasAProblemReadingAPasskeyForXTryAgainLater, Context.PasskeyCredentialRequestParameters.RelyingPartyIdentifier), + AppResources.ErrorReadingPasskey); + } + } + catch (Exception ex) + { + LoggerHelper.LogEvenIfCantBeResolved(ex); + } + + throw; + } + } + private void CancelButton_TouchUpInside(object sender, EventArgs e) { Cancel(); @@ -142,7 +251,54 @@ namespace Bit.iOS.Autofill partial void SearchBarButton_Activated(UIBarButtonItem sender) { - PerformSegue(SegueConstants.LOGIN_SEARCH_FROM_LIST, this); + try + { + if (!Context.IsCreatingOrPreparingListForPasskey) + { + PerformSegue(SegueConstants.LOGIN_SEARCH_FROM_LIST, this); + return; + } + + if (_isLoading) + { + // if it's loading we simplify this logic to just avoid toggling the search bar visibility + // and reloading items while this is taking place. + return; + } + + UIView.Animate(0.3f, + () => + { + _tableViewTopToSearchBarConstraint.Active = !_tableViewTopToSearchBarConstraint.Active; + _searchBar.Hidden = !_searchBar.Hidden; + }, + () => + { + if (_tableViewTopToSearchBarConstraint.Active) + { + _searchBar?.BecomeFirstResponder(); + + if (Context.IsCreatingPasskey) + { + _emptyView.Hidden = true; + TableView.Hidden = false; + } + } + else + { + _searchBar.Text = string.Empty; + _searchBar.Text = null; + + _searchBar.ResignFirstResponder(); + + ReloadItemsAsync().FireAndForget(); + } + }); + } + catch (Exception ex) + { + LoggerHelper.LogEvenIfCantBeResolved(ex); + } } partial void EmptyButton_Activated(UIButton sender) @@ -250,8 +406,7 @@ namespace Bit.iOS.Autofill { try { - await ((TableSource)TableView.Source).LoadAsync(); - TableView.ReloadData(); + await ReloadItemsAsync(); } catch (Exception ex) { @@ -262,10 +417,18 @@ namespace Bit.iOS.Autofill }); } - public void OnEmptyList() + public void OnItemsLoaded(string searchFilter) { - _emptyView.Hidden = false; - TableView.Hidden = true; + if (Context.IsCreatingPasskey) + { + _emptyView.Hidden = !Source.IsEmpty; + TableView.Hidden = Source.IsEmpty; + + if (Source.IsEmpty) + { + _emptyViewLabel.Text = string.Format(AppResources.NoItemsForUri, string.IsNullOrEmpty(searchFilter) ? Context.UrlString : searchFilter); + } + } } public override void ViewDidUnload() @@ -281,8 +444,7 @@ namespace Bit.iOS.Autofill { try { - await ((TableSource)TableView.Source).LoadAsync(); - TableView.ReloadData(); + await ReloadItemsAsync(); } catch (Exception ex) { @@ -306,6 +468,53 @@ namespace Bit.iOS.Autofill base.Dispose(disposing); } + private async Task LoadSourceAsync() + { + _isLoading = true; + + try + { + await MainThread.InvokeOnMainThreadAsync(() => + { + TableView.Hidden = true; + _searchBar.Hidden = true; + _loadingView.Hidden = false; + }); + + await Source.LoadAsync(string.IsNullOrEmpty(_searchBar?.Text), _searchBar?.Text); + + await MainThread.InvokeOnMainThreadAsync(() => + { + _loadingView.Hidden = true; + TableView.Hidden = Context.IsCreatingPasskey && Source.IsEmpty; + _searchBar.Hidden = string.IsNullOrEmpty(_searchBar?.Text); + }); + } + finally + { + _isLoading = false; + } + } + + public async Task ReloadItemsAsync() + { + try + { + await LoadSourceAsync(); + + _alreadyLoadItemsOnce = true; + + await MainThread.InvokeOnMainThreadAsync(TableView.ReloadData); + } + catch + { + _platformUtilsService.Value.ShowDialogAsync(AppResources.GenericErrorMessage, AppResources.AnErrorHasOccurred).FireAndForget(); + throw; + } + } + + public void ReloadTableViewData() => TableView.ReloadData(); + public class TableSource : BaseLoginListTableSource { public TableSource(LoginListViewController controller) @@ -314,54 +523,6 @@ namespace Bit.iOS.Autofill } protected override string LoginAddSegue => SegueConstants.ADD_LOGIN; - - public override async Task LoadAsync(bool urlFilter = true, string searchFilter = null) - { - try - { - await base.LoadAsync(urlFilter, searchFilter); - - if (Context.IsCreatingPasskey && !Items.Any()) - { - Controller?.OnEmptyList(); - } - } - catch (Exception ex) - { - LoggerHelper.LogEvenIfCantBeResolved(ex); - } - } - - public override UIView GetViewForHeader(UITableView tableView, nint section) - { - try - { - if (Context.IsCreatingPasskey - && - tableView.DequeueReusableHeaderFooterView(LoginListViewController.HEADER_SECTION_IDENTIFIER) is HeaderItemView headerItemView) - { - headerItemView.SetHeaderText(AppResources.ChooseALoginToSaveThisPasskeyTo); - return headerItemView; - } - - return new UIView(CGRect.Empty); - } - catch (Exception ex) - { - LoggerHelper.LogEvenIfCantBeResolved(ex); - return new UIView(); - } - } - - public override nint RowsInSection(UITableView tableview, nint section) - { - if (Context.IsCreatingPasskey) - { - return Items?.Count() ?? 0; - } - - return base.RowsInSection(tableview, section); - } } } } diff --git a/src/iOS.Autofill/LoginListViewController.designer.cs b/src/iOS.Autofill/LoginListViewController.designer.cs index 82c06d363..fa34b2413 100644 --- a/src/iOS.Autofill/LoginListViewController.designer.cs +++ b/src/iOS.Autofill/LoginListViewController.designer.cs @@ -24,6 +24,15 @@ namespace Bit.iOS.Autofill [Outlet] UIKit.UILabel _emptyViewLabel { get; set; } + [Outlet] + UIKit.UIActivityIndicatorView _loadingView { get; set; } + + [Outlet] + UIKit.UISearchBar _searchBar { get; set; } + + [Outlet] + UIKit.NSLayoutConstraint _tableViewTopToSearchBarConstraint { get; set; } + [Outlet] [GeneratedCode ("iOS Designer", "1.0")] UIKit.UIBarButtonItem AddBarButton { get; set; } @@ -72,6 +81,16 @@ namespace Bit.iOS.Autofill _emptyViewLabel = null; } + if (_searchBar != null) { + _searchBar.Dispose (); + _searchBar = null; + } + + if (_tableViewTopToSearchBarConstraint != null) { + _tableViewTopToSearchBarConstraint.Dispose (); + _tableViewTopToSearchBarConstraint = null; + } + if (AddBarButton != null) { AddBarButton.Dispose (); AddBarButton = null; @@ -96,6 +115,11 @@ namespace Bit.iOS.Autofill TableView.Dispose (); TableView = null; } + + if (_loadingView != null) { + _loadingView.Dispose (); + _loadingView = null; + } } } } diff --git a/src/iOS.Autofill/LoginSearchViewController.cs b/src/iOS.Autofill/LoginSearchViewController.cs index cc7380cd3..c691e2443 100644 --- a/src/iOS.Autofill/LoginSearchViewController.cs +++ b/src/iOS.Autofill/LoginSearchViewController.cs @@ -1,14 +1,14 @@ using System; +using System.Threading.Tasks; +using Bit.Core.Resources.Localization; +using Bit.Core.Services; using Bit.iOS.Autofill.Models; +using Bit.iOS.Autofill.Utilities; +using Bit.iOS.Core.Controllers; +using Bit.iOS.Core.Utilities; +using Bit.iOS.Core.Views; using Foundation; using UIKit; -using Bit.iOS.Core.Controllers; -using Bit.Core.Resources.Localization; -using Bit.iOS.Core.Views; -using Bit.iOS.Autofill.Utilities; -using Bit.iOS.Core.Utilities; -using Bit.App.Abstractions; -using Bit.Core.Utilities; namespace Bit.iOS.Autofill { @@ -26,22 +26,35 @@ namespace Bit.iOS.Autofill public async override void ViewDidLoad() { - base.ViewDidLoad(); - NavItem.Title = AppResources.SearchVault; - CancelBarButton.Title = AppResources.Cancel; - SearchBar.Placeholder = AppResources.Search; - SearchBar.BackgroundColor = SearchBar.BarTintColor = ThemeHelpers.ListHeaderBackgroundColor; - SearchBar.UpdateThemeIfNeeded(); + try + { + base.ViewDidLoad(); - TableView.RowHeight = UITableView.AutomaticDimension; - TableView.EstimatedRowHeight = 44; - - var tableSource = new TableSource(this); - TableView.Source = tableSource; - tableSource.RegisterTableViewCells(TableView); - - SearchBar.Delegate = new ExtensionSearchDelegate(TableView); - await ((TableSource)TableView.Source).LoadAsync(false, SearchBar.Text); + NavItem.Title = AppResources.SearchVault; + CancelBarButton.Title = AppResources.Cancel; + SearchBar.Placeholder = AppResources.Search; + SearchBar.BackgroundColor = SearchBar.BarTintColor = ThemeHelpers.ListHeaderBackgroundColor; + SearchBar.UpdateThemeIfNeeded(); + + TableView.RowHeight = UITableView.AutomaticDimension; + TableView.EstimatedRowHeight = 55; + + var tableSource = new TableSource(this); + TableView.Source = tableSource; + tableSource.RegisterTableViewCells(TableView); + + if (UIDevice.CurrentDevice.CheckSystemVersion(15, 0)) + { + TableView.SectionHeaderTopPadding = 0; + } + + SearchBar.Delegate = new ExtensionSearchDelegate(TableView); + await ReloadItemsAsync(); + } + catch (Exception ex) + { + LoggerHelper.LogEvenIfCantBeResolved(ex); + } } public override void ViewDidAppear(bool animated) @@ -90,11 +103,20 @@ namespace Bit.iOS.Autofill { DismissViewController(true, async () => { - await ((TableSource)TableView.Source).LoadAsync(false, SearchBar.Text); - TableView.ReloadData(); + await ReloadItemsAsync(); }); } + public void OnItemsLoaded(string searchFilter) { } + + public async Task ReloadItemsAsync() + { + await((TableSource)TableView.Source).LoadAsync(false, SearchBar.Text); + TableView.ReloadData(); + } + + public void ReloadTableViewData() => TableView.ReloadData(); + public class TableSource : BaseLoginListTableSource { public TableSource(LoginSearchViewController controller) diff --git a/src/iOS.Autofill/MainInterface.storyboard b/src/iOS.Autofill/MainInterface.storyboard index bc59c7347..f54e1fde9 100644 --- a/src/iOS.Autofill/MainInterface.storyboard +++ b/src/iOS.Autofill/MainInterface.storyboard @@ -132,6 +132,13 @@ + - + + + +