1
0
mirror of https://github.com/bitwarden/mobile synced 2026-01-09 03:53:15 +00:00

[PM-6466] Implement passkeys User Verification (#3044)

* PM-6441 Implement passkeys User Verification

* PM-6441 Reorganized UserVerificationMediatorService so everything is not in the same file

* PM-6441 Fix Unit tests

* PM-6441 Refactor UserVerification on Fido2Authenticator and Client services to be of an enum type so we can see which specific preference the RP sent and to be passed into the user verification mediator service to perform the correct flow depending on that. Also updated Unit tests.

* PM-6441 Changed user verification logic a bit so if preference is Preferred and the app has the ability to verify the user then enforce required UV and fix issue on on Discouraged to take into account MP reprompt
This commit is contained in:
Federico Maccaroni
2024-03-06 12:32:39 -03:00
committed by GitHub
parent e41abf5003
commit 4292542155
46 changed files with 1110 additions and 255 deletions

View File

@@ -5,9 +5,11 @@ using AuthenticationServices;
using Bit.App.Abstractions;
using Bit.Core.Abstractions;
using Bit.Core.Models.View;
using Bit.Core.Resources.Localization;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Core.Utilities.Fido2;
using Bit.iOS.Autofill.Utilities;
using Bit.iOS.Core.Utilities;
using Foundation;
using Microsoft.Maui.ApplicationModel;
@@ -19,7 +21,10 @@ namespace Bit.iOS.Autofill
public partial class CredentialProviderViewController : ASCredentialProviderViewController, IAccountsManagerHost
{
private readonly LazyResolve<IFido2AuthenticatorService> _fido2AuthService = new LazyResolve<IFido2AuthenticatorService>();
private readonly LazyResolve<IPlatformUtilsService> _platformUtilsService = new LazyResolve<IPlatformUtilsService>();
private readonly LazyResolve<IUserVerificationMediatorService> _userVerificationMediatorService = new LazyResolve<IUserVerificationMediatorService>();
private readonly LazyResolve<ICipherService> _cipherService = new LazyResolve<ICipherService>();
public override async void PrepareInterfaceForPasskeyRegistration(IASCredentialRequest registrationRequest)
{
if (!UIDevice.CurrentDevice.CheckSystemVersion(17, 0))
@@ -27,6 +32,8 @@ namespace Bit.iOS.Autofill
return;
}
_context.VaultUnlockedDuringThisSession = false;
try
{
switch (registrationRequest?.Type)
@@ -68,33 +75,55 @@ namespace Bit.iOS.Autofill
var credIdentity = Runtime.GetNSObject<ASPasskeyCredentialIdentity>(passkeyRegistrationRequest.CredentialIdentity.GetHandle());
_context.UrlString = credIdentity?.RelyingPartyIdentifier;
var result = await _fido2AuthService.Value.MakeCredentialAsync(new Bit.Core.Utilities.Fido2.Fido2AuthenticatorMakeCredentialParams
try
{
Hash = passkeyRegistrationRequest.ClientDataHash.ToArray(),
CredTypesAndPubKeyAlgs = GetCredTypesAndPubKeyAlgs(passkeyRegistrationRequest.SupportedAlgorithms),
RequireUserVerification = passkeyRegistrationRequest.UserVerificationPreference == "required",
RequireResidentKey = true,
RpEntity = new PublicKeyCredentialRpEntity
var result = await _fido2AuthService.Value.MakeCredentialAsync(new Bit.Core.Utilities.Fido2.Fido2AuthenticatorMakeCredentialParams
{
Id = credIdentity.RelyingPartyIdentifier,
Name = credIdentity.RelyingPartyIdentifier
},
UserEntity = new PublicKeyCredentialUserEntity
Hash = passkeyRegistrationRequest.ClientDataHash.ToArray(),
CredTypesAndPubKeyAlgs = GetCredTypesAndPubKeyAlgs(passkeyRegistrationRequest.SupportedAlgorithms),
UserVerificationPreference = Fido2UserVerificationPreferenceExtensions.ToFido2UserVerificationPreference(passkeyRegistrationRequest.UserVerificationPreference),
RequireResidentKey = true,
RpEntity = new PublicKeyCredentialRpEntity
{
Id = credIdentity.RelyingPartyIdentifier,
Name = credIdentity.RelyingPartyIdentifier
},
UserEntity = new PublicKeyCredentialUserEntity
{
Id = credIdentity.UserHandle.ToArray(),
Name = credIdentity.UserName,
DisplayName = credIdentity.UserName
}
}, new Fido2MakeCredentialUserInterface(EnsureUnlockedVaultAsync,
() => _context.VaultUnlockedDuringThisSession,
_context,
OnConfirmingNewCredential,
VerifyUserAsync));
await ASHelpers.ReplaceAllIdentitiesAsync();
var expired = await ExtensionContext.CompleteRegistrationRequestAsync(new ASPasskeyRegistrationCredential(
credIdentity.RelyingPartyIdentifier,
passkeyRegistrationRequest.ClientDataHash,
NSData.FromArray(result.CredentialId),
NSData.FromArray(result.AttestationObject)));
}
catch
{
try
{
Id = credIdentity.UserHandle.ToArray(),
Name = credIdentity.UserName,
DisplayName = credIdentity.UserName
await _platformUtilsService.Value.ShowDialogAsync(
string.Format(AppResources.ThereWasAProblemCreatingAPasskeyForXTryAgainLater, credIdentity?.RelyingPartyIdentifier),
AppResources.ErrorCreatingPasskey);
}
catch (Exception ex)
{
LoggerHelper.LogEvenIfCantBeResolved(ex);
}
}, new Fido2MakeCredentialUserInterface(EnsureUnlockedVaultAsync, _context, OnConfirmingNewCredential));
await ASHelpers.ReplaceAllIdentitiesAsync();
var expired = await ExtensionContext.CompleteRegistrationRequestAsync(new ASPasskeyRegistrationCredential(
credIdentity.RelyingPartyIdentifier,
passkeyRegistrationRequest.ClientDataHash,
NSData.FromArray(result.CredentialId),
NSData.FromArray(result.AttestationObject)));
throw;
}
}
private PublicKeyCredentialParameters[] GetCredTypesAndPubKeyAlgs(NSNumber[] supportedAlgorithms)
@@ -155,12 +184,11 @@ namespace Bit.iOS.Autofill
try
{
// TODO: Add user verification and remove hardcoding on the user interface "userVerified"
var fido2AssertionResult = await _fido2AuthService.Value.GetAssertionAsync(new Bit.Core.Utilities.Fido2.Fido2AuthenticatorGetAssertionParams
{
RpId = rpId,
Hash = _context.PasskeyCredentialRequest.ClientDataHash.ToArray(),
RequireUserVerification = _context.PasskeyCredentialRequest.UserVerificationPreference == "required",
UserVerificationPreference = Fido2UserVerificationPreferenceExtensions.ToFido2UserVerificationPreference(_context.PasskeyCredentialRequest.UserVerificationPreference),
AllowCredentialDescriptorList = new Bit.Core.Utilities.Fido2.PublicKeyCredentialDescriptor[]
{
new Bit.Core.Utilities.Fido2.PublicKeyCredentialDescriptor
@@ -168,29 +196,81 @@ namespace Bit.iOS.Autofill
Id = credentialIdData.ToArray()
}
}
}, new Fido2GetAssertionUserInterface(cipherId, true, EnsureUnlockedVaultAsync, () => Task.FromResult(true)));
}, new Fido2GetAssertionUserInterface(cipherId, false,
EnsureUnlockedVaultAsync,
() => _context?.VaultUnlockedDuringThisSession ?? false,
VerifyUserAsync));
var selectedUserHandleData = fido2AssertionResult.SelectedCredential != null
? NSData.FromArray(fido2AssertionResult.SelectedCredential.UserHandle)
: (NSData)userHandleData;
var selectedCredentialIdData = fido2AssertionResult.SelectedCredential != null
? NSData.FromArray(fido2AssertionResult.SelectedCredential.Id)
: credentialIdData;
if (fido2AssertionResult.SelectedCredential is null)
{
throw new NullReferenceException("SelectedCredential must have a value");
}
await CompleteAssertionRequest(new ASPasskeyAssertionCredential(
selectedUserHandleData,
NSData.FromArray(fido2AssertionResult.SelectedCredential.UserHandle),
rpId,
NSData.FromArray(fido2AssertionResult.Signature),
_context.PasskeyCredentialRequest.ClientDataHash,
NSData.FromArray(fido2AssertionResult.AuthenticatorData),
selectedCredentialIdData
NSData.FromArray(fido2AssertionResult.SelectedCredential.Id)
));
}
catch (InvalidOperationException)
catch (InvalidOperationNeedsUIException)
{
return;
}
catch
{
try
{
if (_context?.IsExecutingWithoutUserInteraction == false)
{
await _platformUtilsService.Value.ShowDialogAsync(
string.Format(AppResources.ThereWasAProblemReadingAPasskeyForXTryAgainLater, rpId),
AppResources.ErrorReadingPasskey);
}
}
catch (Exception ex)
{
LoggerHelper.LogEvenIfCantBeResolved(ex);
}
throw;
}
}
private async Task<bool> VerifyUserAsync(string selectedCipherId, Fido2UserVerificationPreference userVerificationPreference)
{
try
{
var encrypted = await _cipherService.Value.GetAsync(selectedCipherId);
var cipher = await encrypted.DecryptAsync();
return await _userVerificationMediatorService.Value.VerifyUserForFido2Async(
new Fido2UserVerificationOptions(
cipher?.Reprompt == Bit.Core.Enums.CipherRepromptType.Password,
userVerificationPreference,
_context.VaultUnlockedDuringThisSession,
_context.PasskeyCredentialIdentity?.RelyingPartyIdentifier,
() =>
{
if (_context.IsExecutingWithoutUserInteraction)
{
CancelRequest(ASExtensionErrorCode.UserInteractionRequired);
throw new InvalidOperationNeedsUIException();
}
})
);
}
catch (InvalidOperationNeedsUIException)
{
throw;
}
catch (Exception ex)
{
LoggerHelper.LogEvenIfCantBeResolved(ex);
return false;
}
}
public async Task CompleteAssertionRequest(ASPasskeyAssertionCredential assertionCredential)
@@ -266,7 +346,7 @@ namespace Bit.iOS.Autofill
if (!await IsAuthed() || await IsLocked())
{
CancelRequest(ASExtensionErrorCode.UserInteractionRequired);
throw new InvalidOperationException("Not authed or locked");
throw new InvalidOperationNeedsUIException("Not authed or locked");
}
}
}

View File

@@ -51,6 +51,7 @@ namespace Bit.iOS.Autofill
Logo.Image = new UIImage(ThemeHelpers.LightTheme ? "logo.png" : "logo_white.png");
View.BackgroundColor = ThemeHelpers.SplashBackgroundColor;
_context = new Context
{
ExtContext = ExtensionContext
@@ -109,6 +110,7 @@ namespace Bit.iOS.Autofill
try
{
InitAppIfNeeded();
_context.VaultUnlockedDuringThisSession = false;
_context.ServiceIdentifiers = serviceIdentifiers;
if (serviceIdentifiers.Length > 0)
{
@@ -153,6 +155,9 @@ namespace Bit.iOS.Autofill
return;
}
_context.VaultUnlockedDuringThisSession = false;
_context.IsExecutingWithoutUserInteraction = true;
try
{
switch (credentialRequest?.Type)
@@ -196,6 +201,8 @@ namespace Bit.iOS.Autofill
return;
}
_context.VaultUnlockedDuringThisSession = false;
try
{
switch (credentialRequest?.Type)
@@ -237,6 +244,7 @@ namespace Bit.iOS.Autofill
{
InitAppIfNeeded();
_context.Configuring = true;
_context.VaultUnlockedDuringThisSession = false;
if (!await IsAuthed())
{
@@ -326,7 +334,7 @@ namespace Bit.iOS.Autofill
{
if (_context?.IsPasskey == true)
{
_context.ConfirmNewCredentialTcs?.TrySetCanceled();
_context.PickCredentialForFido2CreationTcs?.TrySetCanceled();
_context.UnlockVaultTcs?.TrySetCanceled();
}
@@ -395,6 +403,8 @@ namespace Bit.iOS.Autofill
{
try
{
_context.VaultUnlockedDuringThisSession = true;
if (_context.IsCreatingPasskey)
{
_context.UnlockVaultTcs.SetResult(true);

View File

@@ -1,6 +1,7 @@
using System;
using System.Threading.Tasks;
using Bit.Core.Abstractions;
using Bit.Core.Utilities.Fido2;
using Bit.iOS.Autofill.Models;
namespace Bit.iOS.Autofill
@@ -8,30 +9,49 @@ namespace Bit.iOS.Autofill
public class Fido2MakeCredentialUserInterface : IFido2MakeCredentialUserInterface
{
private readonly Func<Task> _ensureUnlockedVaultCallback;
private readonly Func<bool> _hasVaultBeenUnlockedInThisTransaction;
private readonly Context _context;
private readonly Action _onConfirmingNewCredential;
private readonly Func<string, Fido2UserVerificationPreference, Task<bool>> _verifyUserCallback;
public Fido2MakeCredentialUserInterface(Func<Task> ensureUnlockedVaultCallback, Context context, Action onConfirmingNewCredential)
public Fido2MakeCredentialUserInterface(Func<Task> ensureUnlockedVaultCallback,
Func<bool> hasVaultBeenUnlockedInThisTransaction,
Context context,
Action onConfirmingNewCredential,
Func<string, Fido2UserVerificationPreference, Task<bool>> verifyUserCallback)
{
_ensureUnlockedVaultCallback = ensureUnlockedVaultCallback;
_hasVaultBeenUnlockedInThisTransaction = hasVaultBeenUnlockedInThisTransaction;
_context = context;
_onConfirmingNewCredential = onConfirmingNewCredential;
_verifyUserCallback = verifyUserCallback;
}
public bool HasVaultBeenUnlockedInThisTransaction { get; private set; }
public async Task<(string CipherId, bool UserVerified)> ConfirmNewCredentialAsync(Fido2ConfirmNewCredentialParams confirmNewCredentialParams)
{
_context.ConfirmNewCredentialTcs?.SetCanceled();
_context.ConfirmNewCredentialTcs = new TaskCompletionSource<(string CipherId, bool UserVerified)>();
_context.PickCredentialForFido2CreationTcs?.SetCanceled();
_context.PickCredentialForFido2CreationTcs = new TaskCompletionSource<(string, bool?)>();
_context.PasskeyCreationParams = confirmNewCredentialParams;
_onConfirmingNewCredential();
return await _context.ConfirmNewCredentialTcs.Task;
var (cipherId, isUserVerified) = await _context.PickCredentialForFido2CreationTcs.Task;
var verified = isUserVerified ?? await _verifyUserCallback(cipherId, confirmNewCredentialParams.UserVerificationPreference);
return (cipherId, verified);
}
// iOS doesn't seem to provide the ExcludeCredentialDescriptorList so nothing to do here currently.
public Task InformExcludedCredentialAsync(string[] existingCipherIds) => Task.CompletedTask;
public Task EnsureUnlockedVaultAsync() => _ensureUnlockedVaultCallback();
public async Task EnsureUnlockedVaultAsync()
{
await _ensureUnlockedVaultCallback();
HasVaultBeenUnlockedInThisTransaction = _hasVaultBeenUnlockedInThisTransaction();
}
}
}

View File

@@ -5,7 +5,9 @@ using Bit.Core.Models.View;
using Bit.Core.Resources.Localization;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Core.Utilities.Fido2;
using Bit.iOS.Autofill.Models;
using Bit.iOS.Autofill.Utilities;
using Bit.iOS.Core.Utilities;
using Bit.iOS.Core.Views;
using Foundation;
@@ -16,6 +18,7 @@ namespace Bit.iOS.Autofill
public partial class LoginAddViewController : Core.Controllers.LoginAddViewController
{
LazyResolve<ICipherService> _cipherService = new LazyResolve<ICipherService>();
LazyResolve<IUserVerificationMediatorService> _userVerificationMediatorService = new LazyResolve<IUserVerificationMediatorService>();
public LoginAddViewController(IntPtr handle)
: base(handle)
@@ -32,11 +35,13 @@ namespace Bit.iOS.Autofill
private new Context Context => (Context)base.Context;
private bool? _isUserVerified;
public override Action<string> Success => cipherId =>
{
if (IsCreatingPasskey)
{
Context.ConfirmNewCredentialTcs.TrySetResult((cipherId, true));
Context.PickCredentialForFido2CreationTcs.TrySetResult((cipherId, _isUserVerified));
return;
}
@@ -76,10 +81,15 @@ namespace Bit.iOS.Autofill
if (!UIDevice.CurrentDevice.CheckSystemVersion(17, 0))
{
Context?.ConfirmNewCredentialTcs?.TrySetException(new InvalidOperationException("Trying to save passkey as new login on iOS less than 17."));
Context?.PickCredentialForFido2CreationTcs?.TrySetException(new InvalidOperationException("Trying to save passkey as new login on iOS less than 17."));
return;
}
if (Context?.PasskeyCreationParams?.UserVerificationPreference != Fido2UserVerificationPreference.Discouraged)
{
_isUserVerified = await VerifyUserAsync();
}
var loadingAlert = Dialogs.CreateLoadingAlert(AppResources.Saving);
try
{
@@ -99,6 +109,30 @@ namespace Bit.iOS.Autofill
}
}
private async Task<bool> VerifyUserAsync()
{
try
{
if (Context?.PasskeyCreationParams is null)
{
return false;
}
return await _userVerificationMediatorService.Value.VerifyUserForFido2Async(
new Fido2UserVerificationOptions(
false,
Context.PasskeyCreationParams.Value.UserVerificationPreference,
Context.VaultUnlockedDuringThisSession,
Context.PasskeyCredentialIdentity?.RelyingPartyIdentifier)
);
}
catch (Exception ex)
{
LoggerHelper.LogEvenIfCantBeResolved(ex);
return false;
}
}
async partial void SaveBarButton_Activated(UIBarButtonItem sender)
{
await SaveAsync();

View File

@@ -7,6 +7,7 @@ using Bit.Core.Exceptions;
using Bit.Core.Resources.Localization;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Core.Utilities.Fido2;
using Bit.iOS.Autofill.ListItems;
using Bit.iOS.Autofill.Models;
using Bit.iOS.Autofill.Utilities;
@@ -17,7 +18,6 @@ using CoreFoundation;
using CoreGraphics;
using Foundation;
using UIKit;
using static System.Runtime.InteropServices.JavaScript.JSType;
namespace Bit.iOS.Autofill
{
@@ -44,6 +44,7 @@ namespace Bit.iOS.Autofill
LazyResolve<ICipherService> _cipherService = new LazyResolve<ICipherService>();
LazyResolve<IPlatformUtilsService> _platformUtilsService = new LazyResolve<IPlatformUtilsService>();
LazyResolve<ILogger> _logger = new LazyResolve<ILogger>();
LazyResolve<IUserVerificationMediatorService> _userVerificationMediatorService = new LazyResolve<IUserVerificationMediatorService>();
bool _alreadyLoadItemsOnce = false;
@@ -163,24 +164,29 @@ namespace Bit.iOS.Autofill
{
if (!UIDevice.CurrentDevice.CheckSystemVersion(17, 0))
{
Context?.ConfirmNewCredentialTcs?.TrySetException(new InvalidOperationException("Trying to save passkey as new login on iOS less than 17."));
Context?.PickCredentialForFido2CreationTcs?.TrySetException(new InvalidOperationException("Trying to save passkey as new login on iOS less than 17."));
return;
}
if (Context.PasskeyCreationParams is null)
{
Context?.ConfirmNewCredentialTcs?.TrySetException(new InvalidOperationException("Trying to save passkey as new login wihout creation params."));
Context?.PickCredentialForFido2CreationTcs?.TrySetException(new InvalidOperationException("Trying to save passkey as new login wihout creation params."));
return;
}
var loadingAlert = Dialogs.CreateLoadingAlert(AppResources.Saving);
bool? isUserVerified = null;
if (Context?.PasskeyCreationParams?.UserVerificationPreference != Fido2UserVerificationPreference.Discouraged)
{
isUserVerified = await VerifyUserAsync();
}
var loadingAlert = Dialogs.CreateLoadingAlert(AppResources.Saving);
try
{
PresentViewController(loadingAlert, true, null);
var cipherId = await _cipherService.Value.CreateNewLoginForPasskeyAsync(Context.PasskeyCreationParams.Value);
Context.ConfirmNewCredentialTcs.TrySetResult((cipherId, true));
Context.PickCredentialForFido2CreationTcs.TrySetResult((cipherId, isUserVerified));
}
catch
{
@@ -189,6 +195,30 @@ namespace Bit.iOS.Autofill
}
}
private async Task<bool> VerifyUserAsync()
{
try
{
if (Context?.PasskeyCreationParams is null)
{
return false;
}
return await _userVerificationMediatorService.Value.VerifyUserForFido2Async(
new Fido2UserVerificationOptions(
false,
Context.PasskeyCreationParams.Value.UserVerificationPreference,
Context.VaultUnlockedDuringThisSession,
Context.PasskeyCredentialIdentity?.RelyingPartyIdentifier)
);
}
catch (Exception ex)
{
LoggerHelper.LogEvenIfCantBeResolved(ex);
return false;
}
}
public override void PrepareForSegue(UIStoryboardSegue segue, NSObject sender)
{
if (segue.DestinationViewController is UINavigationController navController)

View File

@@ -652,7 +652,7 @@
</scene>
</scenes>
<inferredMetricsTieBreakers>
<segue reference="12574"/>
<segue reference="12959"/>
<segue reference="3731"/>
</inferredMetricsTieBreakers>
<resources>

View File

@@ -15,11 +15,21 @@ namespace Bit.iOS.Autofill.Models
public ASPasswordCredentialIdentity PasswordCredentialIdentity { get; set; }
public ASPasskeyCredentialRequest PasskeyCredentialRequest { get; set; }
public bool Configuring { get; set; }
public bool IsExecutingWithoutUserInteraction { get; set; }
public bool IsCreatingPasskey { get; set; }
public Fido2ConfirmNewCredentialParams? PasskeyCreationParams { get; set; }
/// <summary>
/// This is used to defer the completion until the vault is unlocked.
/// </summary>
public TaskCompletionSource<bool> UnlockVaultTcs { get; set; }
public TaskCompletionSource<(string CipherId, bool UserVerified)> ConfirmNewCredentialTcs { get; set; }
/// <summary>
/// This is used to defer the completion until a vault item is chosen to add the passkey to.
/// Param: cipher ID to add the passkey to.
/// Param: isUserVerified if the user was verified. If null then the verification hasn't been done.
/// </summary>
public TaskCompletionSource<(string cipherId, bool? isUserVerified)> PickCredentialForFido2CreationTcs { get; set; }
public bool VaultUnlockedDuringThisSession { get; set; }
public ASPasskeyCredentialIdentity PasskeyCredentialIdentity
{

View File

@@ -78,9 +78,7 @@ namespace Bit.iOS.Autofill.Utilities
return;
}
// TODO: Check user verification
Context.ConfirmNewCredentialTcs.SetResult((item.Id, true));
Context.PickCredentialForFido2CreationTcs.SetResult((item.Id, null));
}
}
}

View File

@@ -0,0 +1,24 @@
using System;
namespace Bit.iOS.Autofill.Utilities
{
/// <summary>
/// Custom exception to be thrown when we need UI from the extension.
/// This is likely to be thrown when initiating on "...WithoutUserInteraction(...)" and doing some logic that
/// requires user interaction.
/// </summary>
public class InvalidOperationNeedsUIException : InvalidOperationException
{
public InvalidOperationNeedsUIException()
{
}
public InvalidOperationNeedsUIException(string message) : base(message)
{
}
public InvalidOperationNeedsUIException(string message, Exception innerException) : base(message, innerException)
{
}
}
}

View File

@@ -92,6 +92,7 @@
<Compile Include="Utilities\BaseLoginListTableSource.cs" />
<Compile Include="ILoginListViewController.cs" />
<Compile Include="Fido2MakeCredentialUserInterface.cs" />
<Compile Include="Utilities\InvalidOperationNeedsUIException.cs" />
</ItemGroup>
<ItemGroup>
<BundleResource Include="Resources\check.png" />