diff --git a/src/App/Platforms/Android/Autofill/CredentialHelpers.cs b/src/App/Platforms/Android/Autofill/CredentialHelpers.cs new file mode 100644 index 000000000..e87df501c --- /dev/null +++ b/src/App/Platforms/Android/Autofill/CredentialHelpers.cs @@ -0,0 +1,159 @@ +using Android.App; +using Android.Content; +using Android.OS; +using AndroidX.Credentials; +using AndroidX.Credentials.Exceptions; +using AndroidX.Credentials.Provider; +using AndroidX.Credentials.WebAuthn; +using Bit.Core.Abstractions; +using Bit.Core.Utilities; +using Bit.Droid; +using Org.Json; +using Activity = Android.App.Activity; +using Drawables = Android.Graphics.Drawables; + +namespace Bit.App.Platforms.Android.Autofill +{ + public static class CredentialHelpers + { + public static async Task> PopulatePasskeyDataAsync(CallingAppInfo callingAppInfo, + BeginGetPublicKeyCredentialOption option, Context context, bool hasVaultBeenUnlockedInThisTransaction) + { + var passkeyEntries = new List(); + var requestOptions = new PublicKeyCredentialRequestOptions(option.RequestJson); + + var authenticator = Bit.Core.Utilities.ServiceContainer.Resolve(); + var credentials = await authenticator.SilentCredentialDiscoveryAsync(requestOptions.RpId); + + passkeyEntries = credentials.Select(credential => MapCredential(credential, option, context, hasVaultBeenUnlockedInThisTransaction) as CredentialEntry).ToList(); + + return passkeyEntries; + } + + private static PublicKeyCredentialEntry MapCredential(Fido2AuthenticatorDiscoverableCredentialMetadata credential, BeginGetPublicKeyCredentialOption option, Context context, bool hasVaultBeenUnlockedInThisTransaction) + { + var credDataBundle = new Bundle(); + credDataBundle.PutByteArray(Bit.Core.Utilities.Fido2.CredentialProviderConstants.CredentialIdIntentExtra, credential.Id); + + var intent = new Intent(context, typeof(Bit.Droid.Autofill.CredentialProviderSelectionActivity)) + .SetAction(Bit.Droid.Autofill.CredentialProviderService.GetFido2IntentAction).SetPackage(Constants.PACKAGE_NAME); + intent.PutExtra(Bit.Core.Utilities.Fido2.CredentialProviderConstants.CredentialDataIntentExtra, credDataBundle); + intent.PutExtra(Bit.Core.Utilities.Fido2.CredentialProviderConstants.CredentialProviderCipherId, credential.CipherId); + intent.PutExtra(Bit.Core.Utilities.Fido2.CredentialProviderConstants.CredentialHasVaultBeenUnlockedInThisTransactionExtra, hasVaultBeenUnlockedInThisTransaction); + var pendingIntent = PendingIntent.GetActivity(context, Bit.Droid.Autofill.CredentialProviderService.UniqueGetRequestCode, intent, + PendingIntentFlags.Mutable | PendingIntentFlags.UpdateCurrent); + + return new PublicKeyCredentialEntry.Builder( + context, + credential.UserName ?? "No username", + pendingIntent, + option) + .SetDisplayName(credential.UserName ?? "No username") + .SetIcon(Drawables.Icon.CreateWithResource(context, Microsoft.Maui.Resource.Drawable.icon)) + .Build(); + } + + public static async Task CreateCipherPasskeyAsync(ProviderCreateCredentialRequest getRequest, Activity activity) + { + var callingRequest = getRequest?.CallingRequest as CreatePublicKeyCredentialRequest; + var origin = callingRequest.Origin; + var credentialCreationOptions = new PublicKeyCredentialCreationOptions(callingRequest.RequestJson); + + var rp = new Core.Utilities.Fido2.PublicKeyCredentialRpEntity() + { + Id = credentialCreationOptions.Rp.Id, + Name = credentialCreationOptions.Rp.Name + }; + + var user = new Core.Utilities.Fido2.PublicKeyCredentialUserEntity() + { + Id = credentialCreationOptions.User.GetId(), + Name = credentialCreationOptions.User.Name, + DisplayName = credentialCreationOptions.User.DisplayName + }; + + var pubKeyCredParams = new List(); + foreach (var pubKeyCredParam in credentialCreationOptions.PubKeyCredParams) + { + pubKeyCredParams.Add(new Core.Utilities.Fido2.PublicKeyCredentialParameters() { Alg = Convert.ToInt32(pubKeyCredParam.Alg), Type = pubKeyCredParam.Type }); + } + + var excludeCredentials = new List(); + foreach (var excludeCred in credentialCreationOptions.ExcludeCredentials) + { + excludeCredentials.Add(new Core.Utilities.Fido2.PublicKeyCredentialDescriptor(){ Id = excludeCred.GetId(), Type = excludeCred.Type, Transports = excludeCred.Transports.ToArray() }); + } + + var authenticatorSelection = new Core.Utilities.Fido2.AuthenticatorSelectionCriteria() + { + UserVerification = credentialCreationOptions.AuthenticatorSelection.UserVerification, + ResidentKey = credentialCreationOptions.AuthenticatorSelection.ResidentKey, + RequireResidentKey = credentialCreationOptions.AuthenticatorSelection.RequireResidentKey + }; + + var timeout = Convert.ToInt32(credentialCreationOptions.Timeout); + + var credentialCreateParams = new Bit.Core.Utilities.Fido2.Fido2ClientCreateCredentialParams() + { + Challenge = credentialCreationOptions.GetChallenge(), + Origin = origin, + PubKeyCredParams = pubKeyCredParams.ToArray(), + Rp = rp, + User = user, + Timeout = timeout, + Attestation = credentialCreationOptions.Attestation, + AuthenticatorSelection = authenticatorSelection, + ExcludeCredentials = excludeCredentials.ToArray(), + //Extensions = // Can be improved later to add support for 'credProps' + SameOriginWithAncestors = true + }; + + var fido2MediatorService = ServiceContainer.Resolve(); + var clientCreateCredentialResult = await fido2MediatorService.CreateCredentialAsync(credentialCreateParams); + if (clientCreateCredentialResult == null) + { + var resultErrorIntent = new Intent(); + PendingIntentHandler.SetCreateCredentialException(resultErrorIntent, new CreateCredentialUnknownException()); + activity.SetResult(Result.Ok, resultErrorIntent); + activity.Finish(); + return; + } + + var transportsArray = new JSONArray(); + if (clientCreateCredentialResult.Transports != null) + { + foreach (var transport in clientCreateCredentialResult.Transports) + { + transportsArray.Put(transport); + } + } + + var responseInnerAndroidJson = new JSONObject(); + responseInnerAndroidJson.Put("clientDataJSON", CoreHelpers.Base64UrlEncode(clientCreateCredentialResult.ClientDataJSON)); + responseInnerAndroidJson.Put("authenticatorData", CoreHelpers.Base64UrlEncode(clientCreateCredentialResult.AuthData)); + responseInnerAndroidJson.Put("attestationObject", CoreHelpers.Base64UrlEncode(clientCreateCredentialResult.AttestationObject)); + responseInnerAndroidJson.Put("transports", transportsArray); + responseInnerAndroidJson.Put("publicKeyAlgorithm", clientCreateCredentialResult.PublicKeyAlgorithm); + responseInnerAndroidJson.Put("publicKey", CoreHelpers.Base64UrlEncode(clientCreateCredentialResult.PublicKey)); + + var rootAndroidJson = new JSONObject(); + rootAndroidJson.Put("id", CoreHelpers.Base64UrlEncode(clientCreateCredentialResult.CredentialId)); + rootAndroidJson.Put("rawId", CoreHelpers.Base64UrlEncode(clientCreateCredentialResult.CredentialId)); + rootAndroidJson.Put("authenticatorAttachment", "platform"); + rootAndroidJson.Put("type", "public-key"); + rootAndroidJson.Put("clientExtensionResults", new JSONObject()); + rootAndroidJson.Put("response", responseInnerAndroidJson); + + var responseAndroidJson = rootAndroidJson.ToString(); + + System.Diagnostics.Debug.WriteLine(responseAndroidJson); + + var result = new Intent(); + var publicKeyResponse = new CreatePublicKeyCredentialResponse(responseAndroidJson); + PendingIntentHandler.SetCreateCredentialResponse(result, publicKeyResponse); + + activity.SetResult(Result.Ok, result); + activity.Finish(); + } + } +} diff --git a/src/App/Platforms/Android/Autofill/CredentialProviderConstants.cs b/src/App/Platforms/Android/Autofill/CredentialProviderConstants.cs deleted file mode 100644 index 9104303d7..000000000 --- a/src/App/Platforms/Android/Autofill/CredentialProviderConstants.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Bit.Droid.Autofill -{ - public class CredentialProviderConstants - { - public const string CredentialProviderCipherId = "credentialProviderCipherId"; - public const string CredentialDataIntentExtra = "CREDENTIAL_DATA"; - public const string CredentialIdIntentExtra = "credId"; - } -} diff --git a/src/App/Platforms/Android/Autofill/CredentialProviderSelectionActivity.cs b/src/App/Platforms/Android/Autofill/CredentialProviderSelectionActivity.cs index 1b7ffbb3a..30622f108 100644 --- a/src/App/Platforms/Android/Autofill/CredentialProviderSelectionActivity.cs +++ b/src/App/Platforms/Android/Autofill/CredentialProviderSelectionActivity.cs @@ -1,12 +1,18 @@ -using System.Threading.Tasks; -using Android.App; +using Android.App; +using Android.Content; using Android.Content.PM; using Android.OS; +using AndroidX.Credentials; using AndroidX.Credentials.Provider; using AndroidX.Credentials.WebAuthn; +using Bit.App.Abstractions; using Bit.Core.Abstractions; using Bit.Core.Utilities; using Bit.App.Droid.Utilities; +using Bit.Core.Resources.Localization; +using Bit.Core.Utilities.Fido2; +using Java.Security; +using Bit.Core.Services; namespace Bit.Droid.Autofill { @@ -15,6 +21,13 @@ namespace Bit.Droid.Autofill LaunchMode = LaunchMode.SingleTop)] public class CredentialProviderSelectionActivity : MauiAppCompatActivity { + private LazyResolve _fido2MediatorService = new LazyResolve(); + private LazyResolve _vaultTimeoutService = new LazyResolve(); + private LazyResolve _stateService = new LazyResolve(); + private LazyResolve _cipherService = new LazyResolve(); + private LazyResolve _userVerificationMediatorService = new LazyResolve(); + private LazyResolve _deviceActionService = new LazyResolve(); + protected override void OnCreate(Bundle bundle) { Intent?.Validate(); @@ -23,43 +36,142 @@ namespace Bit.Droid.Autofill var cipherId = Intent?.GetStringExtra(CredentialProviderConstants.CredentialProviderCipherId); if (string.IsNullOrEmpty(cipherId)) { - SetResult(Result.Canceled); Finish(); return; } - GetCipherAndPerformPasskeyAuthAsync(cipherId).FireAndForget(); + GetCipherAndPerformFido2AuthAsync(cipherId).FireAndForget(); } - private async Task GetCipherAndPerformPasskeyAuthAsync(string cipherId) + //Used to avoid crash on MAUI when doing back + public override void OnBackPressed() { - // TODO this is a work in progress - // https://developer.android.com/training/sign-in/credential-provider#passkeys-implement + Finish(); + } - var getRequest = PendingIntentHandler.RetrieveProviderGetCredentialRequest(Intent); - // var publicKeyRequest = getRequest?.CredentialOptions as PublicKeyCredentialRequestOptions; + private async Task GetCipherAndPerformFido2AuthAsync(string cipherId) + { + string RpId = string.Empty; + try + { + var getRequest = PendingIntentHandler.RetrieveProviderGetCredentialRequest(Intent); - var requestInfo = Intent.GetBundleExtra(CredentialProviderConstants.CredentialDataIntentExtra); - var credIdEnc = requestInfo?.GetString(CredentialProviderConstants.CredentialIdIntentExtra); + var credentialOption = getRequest?.CredentialOptions.FirstOrDefault(); + var credentialPublic = credentialOption as GetPublicKeyCredentialOption; - var cipherService = ServiceContainer.Resolve(); - var cipher = await cipherService.GetAsync(cipherId); - var decCipher = await cipher.DecryptAsync(); + var requestOptions = new PublicKeyCredentialRequestOptions(credentialPublic.RequestJson); + RpId = requestOptions.RpId; - var passkey = decCipher.Login.Fido2Credentials.Find(f => f.CredentialId == credIdEnc); + var requestInfo = Intent.GetBundleExtra(CredentialProviderConstants.CredentialDataIntentExtra); + var credentialId = requestInfo?.GetByteArray(CredentialProviderConstants.CredentialIdIntentExtra); + var hasVaultBeenUnlockedInThisTransaction = Intent.GetBooleanExtra(CredentialProviderConstants.CredentialHasVaultBeenUnlockedInThisTransactionExtra, false); - var credId = Convert.FromBase64String(credIdEnc); - // var privateKey = Convert.FromBase64String(passkey.PrivateKey); - // var uid = Convert.FromBase64String(passkey.uid); + var androidOrigin = AppInfoToOrigin(getRequest?.CallingAppInfo); + var packageName = getRequest?.CallingAppInfo.PackageName; - var origin = getRequest?.CallingAppInfo.Origin; - var packageName = getRequest?.CallingAppInfo.PackageName; + var userInterface = new Fido2GetAssertionUserInterface( + cipherId: cipherId, + userVerified: false, + ensureUnlockedVaultCallback: EnsureUnlockedVaultAsync, + hasVaultBeenUnlockedInThisTransaction: () => hasVaultBeenUnlockedInThisTransaction, + verifyUserCallback: (cipherId, uvPreference) => VerifyUserAsync(cipherId, uvPreference, RpId, hasVaultBeenUnlockedInThisTransaction)); - // --- continue WIP here (save TOTP copy as last step) --- + var assertParams = new Fido2AuthenticatorGetAssertionParams + { + Challenge = requestOptions.GetChallenge(), + RpId = RpId, + UserVerificationPreference = Fido2UserVerificationPreferenceExtensions.ToFido2UserVerificationPreference(requestOptions.UserVerification), + Hash = credentialPublic.GetClientDataHash(), + AllowCredentialDescriptorList = new Core.Utilities.Fido2.PublicKeyCredentialDescriptor[] { new Core.Utilities.Fido2.PublicKeyCredentialDescriptor { Id = credentialId } }, + Extensions = new object() + }; - // Copy TOTP if needed - var autofillHandler = ServiceContainer.Resolve(); - autofillHandler.Autofill(decCipher); + var assertResult = await _fido2MediatorService.Value.GetAssertionAsync(assertParams, userInterface); + + var response = new AuthenticatorAssertionResponse( + requestOptions, + assertResult.SelectedCredential.Id, + androidOrigin, + false, // These flags have no effect, we set our own within `SetAuthenticatorData` + false, + false, + false, + assertResult.SelectedCredential.UserHandle, + packageName, + credentialPublic.GetClientDataHash() //clientDataHash + ); + response.SetAuthenticatorData(assertResult.AuthenticatorData); + response.SetSignature(assertResult.Signature); + + var result = new Intent(); + var fidoCredential = new FidoPublicKeyCredential(assertResult.SelectedCredential.Id, response, "platform"); + var cred = new PublicKeyCredential(fidoCredential.Json()); + var credResponse = new GetCredentialResponse(cred); + PendingIntentHandler.SetGetCredentialResponse(result, credResponse); + + await MainThread.InvokeOnMainThreadAsync(() => + { + SetResult(Result.Ok, result); + Finish(); + }); + } + catch (NotAllowedError) + { + await MainThread.InvokeOnMainThreadAsync(async() => + { + await _deviceActionService.Value.DisplayAlertAsync(AppResources.ErrorReadingPasskey, string.Format(AppResources.ThereWasAProblemReadingAPasskeyForXTryAgainLater, RpId), AppResources.Ok); + Finish(); + }); + } + catch (Exception ex) + { + LoggerHelper.LogEvenIfCantBeResolved(ex); + await MainThread.InvokeOnMainThreadAsync(async() => + { + await _deviceActionService.Value.DisplayAlertAsync(AppResources.ErrorReadingPasskey, string.Format(AppResources.ThereWasAProblemReadingAPasskeyForXTryAgainLater, RpId), AppResources.Ok); + Finish(); + }); + } + } + + private async Task EnsureUnlockedVaultAsync() + { + if (!await _stateService.Value.IsAuthenticatedAsync() || await _vaultTimeoutService.Value.IsLockedAsync()) + { + // this should never happen but just in case. + throw new InvalidOperationException("Not authed or vault locked"); + } + } + + internal async Task VerifyUserAsync(string selectedCipherId, Fido2UserVerificationPreference userVerificationPreference, string rpId, bool vaultUnlockedDuringThisTransaction) + { + try + { + var encrypted = await _cipherService.Value.GetAsync(selectedCipherId); + var cipher = await encrypted.DecryptAsync(); + + var userVerification = await _userVerificationMediatorService.Value.VerifyUserForFido2Async( + new Fido2UserVerificationOptions( + cipher?.Reprompt == Bit.Core.Enums.CipherRepromptType.Password, + userVerificationPreference, + vaultUnlockedDuringThisTransaction, + rpId) + ); + return !userVerification.IsCancelled && userVerification.Result; + } + catch (Exception ex) + { + LoggerHelper.LogEvenIfCantBeResolved(ex); + return false; + } + } + + private string AppInfoToOrigin(CallingAppInfo info) + { + var cert = info.SigningInfo.GetApkContentsSigners()[0].ToByteArray(); + var md = MessageDigest.GetInstance("SHA-256"); + var certHash = md.Digest(cert); + return $"android:apk-key-hash:${CoreHelpers.Base64UrlEncode(certHash)}"; } } } diff --git a/src/App/Platforms/Android/Autofill/CredentialProviderService.cs b/src/App/Platforms/Android/Autofill/CredentialProviderService.cs index 8355b0873..00e415017 100644 --- a/src/App/Platforms/Android/Autofill/CredentialProviderService.cs +++ b/src/App/Platforms/Android/Autofill/CredentialProviderService.cs @@ -1,16 +1,15 @@ using Android; using Android.App; using Android.Content; -using Android.Graphics.Drawables; using Android.OS; using Android.Runtime; using AndroidX.Credentials.Provider; using Bit.Core.Abstractions; using Bit.Core.Utilities; using AndroidX.Credentials.Exceptions; -using AndroidX.Credentials.WebAuthn; -using Bit.Core.Models.View; -using Resource = Microsoft.Maui.Resource; +using Bit.App.Droid.Utilities; +using Bit.Core.Resources.Localization; +using Bit.Core.Utilities.Fido2; namespace Bit.Droid.Autofill { @@ -20,32 +19,53 @@ namespace Bit.Droid.Autofill [Register("com.x8bit.bitwarden.Autofill.CredentialProviderService")] public class CredentialProviderService : AndroidX.Credentials.Provider.CredentialProviderService { - private const string GetPasskeyIntentAction = "PACKAGE_NAME.GET_PASSKEY"; - private const int UniqueRequestCode = 94556023; + public const string GetFido2IntentAction = "PACKAGE_NAME.GET_PASSKEY"; + public const string CreateFido2IntentAction = "PACKAGE_NAME.CREATE_PASSKEY"; + public const int UniqueGetRequestCode = 94556023; + public const int UniqueCreateRequestCode = 94556024; - private ICipherService _cipherService; - private IUserVerificationService _userVerificationService; - private IVaultTimeoutService _vaultTimeoutService; - private LazyResolve _logger = new LazyResolve("logger"); + private readonly LazyResolve _vaultTimeoutService = new LazyResolve(); + private readonly LazyResolve _logger = new LazyResolve(); - public override async void OnBeginCreateCredentialRequest(BeginCreateCredentialRequest request, - CancellationSignal cancellationSignal, IOutcomeReceiver callback) => throw new NotImplementedException(); + public override void OnBeginCreateCredentialRequest(BeginCreateCredentialRequest request, + CancellationSignal cancellationSignal, IOutcomeReceiver callback) + { + var response = ProcessCreateCredentialsRequestAsync(request); + if (response != null) + { + callback.OnResult(response); + } + else + { + callback.OnError("Error creating credential"); + } + } public override async void OnBeginGetCredentialRequest(BeginGetCredentialRequest request, CancellationSignal cancellationSignal, IOutcomeReceiver callback) { try { - _vaultTimeoutService ??= ServiceContainer.Resolve(); - - await _vaultTimeoutService.CheckVaultTimeoutAsync(); - var locked = await _vaultTimeoutService.IsLockedAsync(); + await _vaultTimeoutService.Value.CheckVaultTimeoutAsync(); + var locked = await _vaultTimeoutService.Value.IsLockedAsync(); if (!locked) { var response = await ProcessGetCredentialsRequestAsync(request); callback.OnResult(response); + return; } - // TODO handle auth/unlock account flow + + var intent = new Intent(ApplicationContext, typeof(MainActivity)); + intent.PutExtra(CredentialProviderConstants.Fido2CredentialAction, CredentialProviderConstants.Fido2CredentialGet); + var pendingIntent = PendingIntent.GetActivity(ApplicationContext, UniqueGetRequestCode, intent, + AndroidHelpers.AddPendingIntentMutabilityFlag(PendingIntentFlags.UpdateCurrent, true)); + + var unlockAction = new AuthenticationAction(AppResources.Unlock, pendingIntent); + + var unlockResponse = new BeginGetCredentialResponse.Builder() + .SetAuthenticationActions(new List() { unlockAction } ) + .Build(); + callback.OnResult(unlockResponse); } catch (GetCredentialException e) { @@ -54,28 +74,59 @@ namespace Bit.Droid.Autofill } catch (Exception e) { - _logger.Value.Exception(e); + _logger.Value.Exception(e); throw; } } + private BeginCreateCredentialResponse ProcessCreateCredentialsRequestAsync( + BeginCreateCredentialRequest request) + { + if (request == null) { return null; } + + if (request is BeginCreatePasswordCredentialRequest beginCreatePasswordCredentialRequest) + { + //This flow can be used if Password flow needs to be implemented + throw new NotImplementedException(); + //return HandleCreatePasswordQuery(beginCreatePasswordCredentialRequest); + } + else if (request is BeginCreatePublicKeyCredentialRequest beginCreatePublicKeyCredentialRequest) + { + return HandleCreatePasskeyQuery(beginCreatePublicKeyCredentialRequest); + } + + return null; + } + + private BeginCreateCredentialResponse HandleCreatePasskeyQuery(BeginCreatePublicKeyCredentialRequest optionRequest) + { + var intent = new Intent(ApplicationContext, typeof(MainActivity)); + intent.PutExtra(CredentialProviderConstants.Fido2CredentialAction, CredentialProviderConstants.Fido2CredentialCreate); + var pendingIntent = PendingIntent.GetActivity(ApplicationContext, UniqueCreateRequestCode, intent, + AndroidHelpers.AddPendingIntentMutabilityFlag(PendingIntentFlags.UpdateCurrent, true)); + + //TODO: i81n needs to be done + var createEntryBuilder = new CreateEntry.Builder("Bitwarden Vault", pendingIntent) + .SetDescription("Your passkey will be saved securely to the Bitwarden Vault. You can use it from any other device for sign-in in the future.") + .Build(); + + var createCredentialResponse = new BeginCreateCredentialResponse.Builder() + .AddCreateEntry(createEntryBuilder); + + return createCredentialResponse.Build(); + } + private async Task ProcessGetCredentialsRequestAsync( BeginGetCredentialRequest request) { - IList credentialEntries = null; + var credentialEntries = new List(); - foreach (var option in request.BeginGetCredentialOptions) + foreach (var option in request.BeginGetCredentialOptions.OfType()) { - var credentialOption = option as BeginGetPublicKeyCredentialOption; - if (credentialOption != null) - { - credentialEntries ??= new List(); - ((List)credentialEntries).AddRange( - await PopulatePasskeyDataAsync(request.CallingAppInfo, credentialOption)); - } + credentialEntries.AddRange(await Bit.App.Platforms.Android.Autofill.CredentialHelpers.PopulatePasskeyDataAsync(request.CallingAppInfo, option, ApplicationContext, false)); } - if (credentialEntries == null) + if (!credentialEntries.Any()) { return new BeginGetCredentialResponse(); } @@ -85,63 +136,10 @@ namespace Bit.Droid.Autofill .Build(); } - private async Task> PopulatePasskeyDataAsync(CallingAppInfo callingAppInfo, - BeginGetPublicKeyCredentialOption option) - { - var packageName = callingAppInfo.PackageName; - var origin = callingAppInfo.Origin; - var signingInfo = callingAppInfo.SigningInfo; - - var request = new PublicKeyCredentialRequestOptions(option.RequestJson); - - var passkeyEntries = new List(); - - _cipherService ??= ServiceContainer.Resolve(); - var ciphers = await _cipherService.GetAllDecryptedForUrlAsync(origin); - if (ciphers == null) - { - return passkeyEntries; - } - - var passkeyCiphers = ciphers.Where(cipher => cipher.HasFido2Credential).ToList(); - if (!passkeyCiphers.Any()) - { - return passkeyEntries; - } - - foreach (var cipher in passkeyCiphers) - { - var passkeyEntry = GetPasskey(cipher, option); - passkeyEntries.Add(passkeyEntry); - } - - return passkeyEntries; - } - - private PublicKeyCredentialEntry GetPasskey(CipherView cipher, BeginGetPublicKeyCredentialOption option) - { - var credDataBundle = new Bundle(); - credDataBundle.PutString(CredentialProviderConstants.CredentialIdIntentExtra, - cipher.Login.MainFido2Credential.CredentialId); - - var intent = new Intent(ApplicationContext, typeof(CredentialProviderSelectionActivity)) - .SetAction(GetPasskeyIntentAction).SetPackage(Constants.PACKAGE_NAME); - intent.PutExtra(CredentialProviderConstants.CredentialDataIntentExtra, credDataBundle); - intent.PutExtra(CredentialProviderConstants.CredentialProviderCipherId, cipher.Id); - var pendingIntent = PendingIntent.GetActivity(ApplicationContext, UniqueRequestCode, intent, - PendingIntentFlags.Mutable | PendingIntentFlags.UpdateCurrent); - - return new PublicKeyCredentialEntry.Builder( - ApplicationContext, - cipher.Login.Username ?? "No username", - pendingIntent, - option) - .SetDisplayName(cipher.Name) - .SetIcon(Icon.CreateWithResource(ApplicationContext, Resource.Drawable.icon)) - .Build(); - } - public override void OnClearCredentialStateRequest(ProviderClearCredentialStateRequest request, - CancellationSignal cancellationSignal, IOutcomeReceiver callback) => throw new NotImplementedException(); + CancellationSignal cancellationSignal, IOutcomeReceiver callback) + { + callback.OnResult(null); + } } } diff --git a/src/App/Platforms/Android/Autofill/Fido2GetAssertionUserInterface.cs b/src/App/Platforms/Android/Autofill/Fido2GetAssertionUserInterface.cs new file mode 100644 index 000000000..4fa326f9f --- /dev/null +++ b/src/App/Platforms/Android/Autofill/Fido2GetAssertionUserInterface.cs @@ -0,0 +1,21 @@ +using Bit.Core.Abstractions; + +namespace Bit.App.Platforms.Android.Autofill +{ + //TODO: WIP: Temporary Dummy implementation + public class Fido2GetAssertionUserInterface : IFido2GetAssertionUserInterface + { + public bool HasVaultBeenUnlockedInThisTransaction => true; + + public Task EnsureUnlockedVaultAsync() + { + return Task.FromResult(true); + } + + public Task<(string CipherId, bool UserVerified)> PickCredentialAsync(Fido2GetAssertionUserInterfaceCredential[] credentials) + { + var credential = credentials[0]; + return Task.FromResult<(string CipherId, bool UserVerified)>((credential.CipherId, true)); + } + } +} diff --git a/src/App/Platforms/Android/Autofill/Fido2MakeCredentialUserInterface.cs b/src/App/Platforms/Android/Autofill/Fido2MakeCredentialUserInterface.cs new file mode 100644 index 000000000..a96c97f11 --- /dev/null +++ b/src/App/Platforms/Android/Autofill/Fido2MakeCredentialUserInterface.cs @@ -0,0 +1,151 @@ +using Bit.App.Abstractions; +using Bit.Core.Abstractions; +using Bit.Core.Resources.Localization; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Bit.Core.Utilities.Fido2; + +namespace Bit.App.Platforms.Android.Autofill +{ + public class Fido2MakeCredentialUserInterface : IFido2MakeCredentialConfirmationUserInterface + { + private readonly IStateService _stateService; + private readonly IVaultTimeoutService _vaultTimeoutService; + private readonly ICipherService _cipherService; + private readonly IUserVerificationMediatorService _userVerificationMediatorService; + private readonly IDeviceActionService _deviceActionService; + + private TaskCompletionSource<(string cipherId, bool? userVerified)> _confirmCredentialTcs; + Fido2UserVerificationOptions? _currentDefaultUserVerificationOptions; + + public Fido2MakeCredentialUserInterface(IStateService stateService, + IVaultTimeoutService vaultTimeoutService, + ICipherService cipherService, + IUserVerificationMediatorService userVerificationMediatorService, + IDeviceActionService deviceActionService) + { + _stateService = stateService; + _vaultTimeoutService = vaultTimeoutService; + _cipherService = cipherService; + _userVerificationMediatorService = userVerificationMediatorService; + _deviceActionService = deviceActionService; + } + + public bool HasVaultBeenUnlockedInThisTransaction => true; + + public async Task<(string CipherId, bool UserVerified)> ConfirmNewCredentialAsync(Fido2ConfirmNewCredentialParams confirmNewCredentialParams) + { + _confirmCredentialTcs?.TrySetCanceled(); + _confirmCredentialTcs = null; + _confirmCredentialTcs = new TaskCompletionSource<(string cipherId, bool? userVerified)>(); + + _currentDefaultUserVerificationOptions = new Fido2UserVerificationOptions(false, confirmNewCredentialParams.UserVerificationPreference, true, confirmNewCredentialParams.RpId); + + var messagingService = ServiceContainer.Resolve("messagingService"); + messagingService?.Send("fidoNavigateToAutofillCipher", confirmNewCredentialParams); + var (cipherId, isUserVerified) = await _confirmCredentialTcs.Task; + + + var verified = isUserVerified; + if (verified is null) + { + var userVerification = await VerifyUserAsync(cipherId, confirmNewCredentialParams.UserVerificationPreference, confirmNewCredentialParams.RpId); + // TODO: If cancelled then let the user choose another cipher. + // I think this can be done by showing a message to the uesr and recursive calling of this method ConfirmNewCredentialAsync + verified = !userVerification.IsCancelled && userVerification.Result; + } + + if (cipherId is null) + { + return await CreateNewLoginForFido2CredentialAsync(confirmNewCredentialParams, verified.Value); + } + + return (cipherId, verified.Value); + } + + private async Task<(string CipherId, bool UserVerified)> CreateNewLoginForFido2CredentialAsync(Fido2ConfirmNewCredentialParams confirmNewCredentialParams, bool userVerified) + { + if (!userVerified && await _userVerificationMediatorService.ShouldEnforceFido2RequiredUserVerificationAsync(new Fido2UserVerificationOptions + ( + false, + confirmNewCredentialParams.UserVerificationPreference, + true, + confirmNewCredentialParams.RpId + ))) + { + return (null, false); + } + + try + { + await _deviceActionService.ShowLoadingAsync(AppResources.Loading); + + var cipherId = await _cipherService.CreateNewLoginForPasskeyAsync(confirmNewCredentialParams); + + await _deviceActionService.HideLoadingAsync(); + + return (cipherId, userVerified); + } + catch + { + await _deviceActionService.HideLoadingAsync(); + throw; + } + } + + public async Task EnsureUnlockedVaultAsync() + { + if (!await _stateService.IsAuthenticatedAsync() || await _vaultTimeoutService.IsLockedAsync()) + { + // this should never happen but just in case. + throw new InvalidOperationException("Not authed or vault locked"); + } + } + + public Task InformExcludedCredentialAsync(string[] existingCipherIds) + { + // TODO: Show excluded credential to the user in some screen. + return Task.FromResult(true); + } + + public void Confirm(string cipherId, bool? userVerified) => _confirmCredentialTcs?.TrySetResult((cipherId, userVerified)); + + public void Cancel() => _confirmCredentialTcs?.TrySetCanceled(); + + public void OnConfirmationException(Exception ex) => _confirmCredentialTcs?.TrySetException(ex); + + private async Task> VerifyUserAsync(string selectedCipherId, Fido2UserVerificationPreference userVerificationPreference, string rpId) + { + try + { + if (selectedCipherId is null && userVerificationPreference == Fido2UserVerificationPreference.Discouraged) + { + return new CancellableResult(false); + } + + var shouldCheckMasterPasswordReprompt = false; + if (selectedCipherId != null) + { + var encrypted = await _cipherService.GetAsync(selectedCipherId); + var cipher = await encrypted.DecryptAsync(); + shouldCheckMasterPasswordReprompt = cipher?.Reprompt == Core.Enums.CipherRepromptType.Password; + } + + return await _userVerificationMediatorService.VerifyUserForFido2Async( + new Fido2UserVerificationOptions( + shouldCheckMasterPasswordReprompt, + userVerificationPreference, + true, + rpId) + ); + } + catch (Exception ex) + { + LoggerHelper.LogEvenIfCantBeResolved(ex); + return new CancellableResult(false); + } + } + + public Fido2UserVerificationOptions? GetCurrentUserVerificationOptions() => _currentDefaultUserVerificationOptions; + } +} diff --git a/src/App/Platforms/Android/MainActivity.cs b/src/App/Platforms/Android/MainActivity.cs index fe852fc7e..741b970d6 100644 --- a/src/App/Platforms/Android/MainActivity.cs +++ b/src/App/Platforms/Android/MainActivity.cs @@ -24,6 +24,7 @@ using Bit.App.Droid.Utilities; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using FileProvider = AndroidX.Core.Content.FileProvider; +using Bit.Core.Utilities.Fido2; namespace Bit.Droid { @@ -325,12 +326,15 @@ namespace Bit.Droid private AppOptions GetOptions() { + var fido2CredentialAction = Intent.GetStringExtra(CredentialProviderConstants.Fido2CredentialAction); var options = new AppOptions { Uri = Intent.GetStringExtra("uri") ?? Intent.GetStringExtra(AutofillConstants.AutofillFrameworkUri), MyVaultTile = Intent.GetBooleanExtra("myVaultTile", false), GeneratorTile = Intent.GetBooleanExtra("generatorTile", false), FromAutofillFramework = Intent.GetBooleanExtra(AutofillConstants.AutofillFramework, false), + Fido2CredentialAction = fido2CredentialAction, + FromFido2Framework = !string.IsNullOrWhiteSpace(fido2CredentialAction), CreateSend = GetCreateSendRequest(Intent) }; var fillType = Intent.GetIntExtra(AutofillConstants.AutofillFrameworkFillType, 0); diff --git a/src/App/Platforms/Android/MainApplication.cs b/src/App/Platforms/Android/MainApplication.cs index a00f1f14c..8be77d4c4 100644 --- a/src/App/Platforms/Android/MainApplication.cs +++ b/src/App/Platforms/Android/MainApplication.cs @@ -20,7 +20,10 @@ using Bit.App.Utilities; using Bit.App.Pages; using Bit.App.Utilities.AccountManagement; using Bit.App.Controls; +using Bit.App.Platforms.Android.Autofill; using Bit.Core.Enums; +using Bit.Core.Services.UserVerification; + #if !FDROID using Android.Gms.Security; #endif @@ -91,6 +94,43 @@ namespace Bit.Droid ServiceContainer.Resolve(), ServiceContainer.Resolve()); ServiceContainer.Register(userPinService); + + var userVerificationMediatorService = new UserVerificationMediatorService( + ServiceContainer.Resolve("platformUtilsService"), + ServiceContainer.Resolve("passwordRepromptService"), + userPinService, + deviceActionService, + ServiceContainer.Resolve()); + ServiceContainer.Register(userVerificationMediatorService); + + var fido2AuthenticatorService = new Fido2AuthenticatorService( + ServiceContainer.Resolve(), + ServiceContainer.Resolve(), + ServiceContainer.Resolve(), + userVerificationMediatorService); + ServiceContainer.Register(fido2AuthenticatorService); + + var fido2MakeCredentialUserInterface = new Fido2MakeCredentialUserInterface( + ServiceContainer.Resolve(), + ServiceContainer.Resolve(), + ServiceContainer.Resolve(), + ServiceContainer.Resolve(), + ServiceContainer.Resolve()); + ServiceContainer.Register(fido2MakeCredentialUserInterface); + + var fido2ClientService = new Fido2ClientService( + ServiceContainer.Resolve(), + ServiceContainer.Resolve(), + ServiceContainer.Resolve(), + ServiceContainer.Resolve(), + new Fido2GetAssertionUserInterface(), + fido2MakeCredentialUserInterface); + ServiceContainer.Register(fido2ClientService); + + ServiceContainer.Register(new Fido2MediatorService( + fido2AuthenticatorService, + fido2ClientService, + ServiceContainer.Resolve())); } #if !FDROID if (Build.VERSION.SdkInt <= BuildVersionCodes.Kitkat) diff --git a/src/App/Platforms/Android/Services/AutofillHandler.cs b/src/App/Platforms/Android/Services/AutofillHandler.cs index 239343b95..6b629446c 100644 --- a/src/App/Platforms/Android/Services/AutofillHandler.cs +++ b/src/App/Platforms/Android/Services/AutofillHandler.cs @@ -1,11 +1,11 @@ -using System.Linq; -using System.Threading.Tasks; -using Android.App; +using Android.App; using Android.App.Assist; using Android.Content; +using Android.Credentials; using Android.OS; using Android.Provider; using Android.Views.Autofill; +using Bit.App.Abstractions; using Bit.Core.Resources.Localization; using Bit.Core.Abstractions; using Bit.Core.Enums; @@ -43,9 +43,28 @@ namespace Bit.Droid.Services { return false; } + try { - // TODO - find a way to programmatically check if the credential provider service is enabled + var activity = (MainActivity)Platform.CurrentActivity; + if (activity == null) + { + return false; + } + + var credManager = activity.GetSystemService(Java.Lang.Class.FromType(typeof(CredentialManager))) as CredentialManager; + if (credManager == null) + { + return false; + } + + var credentialProviderServiceComponentName = new ComponentName(activity, Java.Lang.Class.FromType(typeof(CredentialProviderService))); + return credManager.IsEnabledCredentialProviderService(credentialProviderServiceComponentName); + } + catch (Java.Lang.NullPointerException) + { + // CredentialManager API is not working fully and may return a NullPointerException even if the CredentialProviderService is working and enabled + // Info Here: https://developer.android.com/reference/android/credentials/CredentialManager#isEnabledCredentialProviderService(android.content.ComponentName) return false; } catch @@ -184,7 +203,10 @@ namespace Bit.Droid.Services { try { - // TODO - find a way to programmatically disable the provider service, or take the user to the settings page where they can do it + // We should try to find a way to programmatically disable the provider service when the API allows for it. + // For now we'll take the user to Credential Settings so they can manually disable it + var deviceActionService = ServiceContainer.Resolve(); + deviceActionService.OpenCredentialProviderSettings(); } catch { } } diff --git a/src/App/Platforms/Android/Services/DeviceActionService.cs b/src/App/Platforms/Android/Services/DeviceActionService.cs index 5abd0019d..e75843806 100644 --- a/src/App/Platforms/Android/Services/DeviceActionService.cs +++ b/src/App/Platforms/Android/Services/DeviceActionService.cs @@ -1,6 +1,4 @@ -using System; -using System.Threading.Tasks; -using Android.App; +using Android.App; using Android.Content; using Android.Content.PM; using Android.Nfc; @@ -17,11 +15,14 @@ using Bit.Core.Resources.Localization; using Bit.App.Utilities; using Bit.App.Utilities.Prompts; using Bit.Core.Abstractions; -using Bit.Core.Enums; using Bit.App.Droid.Utilities; +using Bit.App.Models; +using Bit.Droid.Autofill; using Microsoft.Maui.Controls.Compatibility.Platform.Android; using Resource = Bit.Core.Resource; using Application = Android.App.Application; +using Bit.Core.Services; +using Bit.Core.Utilities.Fido2; namespace Bit.Droid.Services { @@ -204,7 +205,7 @@ namespace Bit.Droid.Services string text = null, string okButtonText = null, string cancelButtonText = null, bool numericKeyboard = false, bool autofocus = true, bool password = false) { - var activity = (MainActivity)Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; + var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; if (activity == null) { return Task.FromResult(null); @@ -261,7 +262,7 @@ namespace Bit.Droid.Services public Task DisplayValidatablePromptAsync(ValidatablePromptConfig config) { - var activity = (MainActivity)Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; + var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; if (activity == null) { return Task.FromResult(null); @@ -338,7 +339,7 @@ namespace Bit.Droid.Services public void RateApp() { - var activity = (MainActivity)Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; + var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; try { var rateIntent = RateIntentForUrl("market://details", activity); @@ -371,14 +372,14 @@ namespace Bit.Droid.Services public bool SupportsNfc() { - var activity = (MainActivity)Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; + var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; var manager = activity.GetSystemService(Context.NfcService) as NfcManager; return manager.DefaultAdapter?.IsEnabled ?? false; } public bool SupportsCamera() { - var activity = (MainActivity)Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; + var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; return activity.PackageManager.HasSystemFeature(PackageManager.FeatureCamera); } @@ -394,7 +395,7 @@ namespace Bit.Droid.Services public Task DisplayAlertAsync(string title, string message, string cancel, params string[] buttons) { - var activity = (MainActivity)Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; + var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; if (activity == null) { return Task.FromResult(null); @@ -475,7 +476,7 @@ namespace Bit.Droid.Services public void OpenAccessibilityOverlayPermissionSettings() { - var activity = (MainActivity)Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; + var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; try { var intent = new Intent(Settings.ActionManageOverlayPermission); @@ -504,10 +505,10 @@ namespace Bit.Droid.Services public void OpenCredentialProviderSettings() { - var activity = (MainActivity)Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; + var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; try { - var pendingIntent = CredentialManager.Create(activity).CreateSettingsPendingIntent(); + var pendingIntent = ICredentialManager.Create(activity).CreateSettingsPendingIntent(); pendingIntent.Send(); } catch (ActivityNotFoundException) @@ -527,7 +528,7 @@ namespace Bit.Droid.Services { try { - var activity = (MainActivity)Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; + var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; var intent = new Intent(Settings.ActionAccessibilitySettings); activity.StartActivity(intent); } @@ -536,7 +537,7 @@ namespace Bit.Droid.Services public void OpenAutofillSettings() { - var activity = (MainActivity)Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; + var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; try { var intent = new Intent(Settings.ActionRequestSetAutofillService); @@ -564,10 +565,88 @@ namespace Bit.Droid.Services // ref: https://developer.android.com/reference/android/os/SystemClock#elapsedRealtime() return SystemClock.ElapsedRealtime(); } + + public async Task ExecuteFido2CredentialActionAsync(AppOptions appOptions) + { + var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; + if (activity == null || string.IsNullOrWhiteSpace(appOptions.Fido2CredentialAction)) + { + return; + } + + if (appOptions.Fido2CredentialAction == CredentialProviderConstants.Fido2CredentialGet) + { + await ExecuteFido2GetCredentialAsync(appOptions); + } + else if (appOptions.Fido2CredentialAction == CredentialProviderConstants.Fido2CredentialCreate) + { + await ExecuteFido2CreateCredentialAsync(); + } + + appOptions.Fido2CredentialAction = null; //Clear CredentialAction Value + } + + private async Task ExecuteFido2GetCredentialAsync(AppOptions appOptions) + { + var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; + if (activity == null) + { + return; + } + + try + { + var request = AndroidX.Credentials.Provider.PendingIntentHandler.RetrieveBeginGetCredentialRequest(activity.Intent); + var response = new AndroidX.Credentials.Provider.BeginGetCredentialResponse();; + var credentialEntries = new List(); + foreach (var option in request.BeginGetCredentialOptions.OfType()) + { + credentialEntries.AddRange(await Bit.App.Platforms.Android.Autofill.CredentialHelpers.PopulatePasskeyDataAsync(request.CallingAppInfo, option, activity, appOptions.HasUnlockedInThisTransaction)); + } + + if (credentialEntries.Any()) + { + response = new AndroidX.Credentials.Provider.BeginGetCredentialResponse.Builder() + .SetCredentialEntries(credentialEntries) + .Build(); + } + + var result = new Android.Content.Intent(); + AndroidX.Credentials.Provider.PendingIntentHandler.SetBeginGetCredentialResponse(result, response); + activity.SetResult(Result.Ok, result); + activity.Finish(); + } + catch (Exception ex) + { + Bit.Core.Services.LoggerHelper.LogEvenIfCantBeResolved(ex); + + activity.SetResult(Result.Canceled); + activity.Finish(); + } + } + + private async Task ExecuteFido2CreateCredentialAsync() + { + var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; + if (activity == null) { return; } + + try + { + var getRequest = AndroidX.Credentials.Provider.PendingIntentHandler.RetrieveProviderCreateCredentialRequest(activity.Intent); + await Bit.App.Platforms.Android.Autofill.CredentialHelpers.CreateCipherPasskeyAsync(getRequest, activity); + } + catch (Exception ex) + { + Bit.Core.Services.LoggerHelper.LogEvenIfCantBeResolved(ex); + + activity.SetResult(Result.Canceled); + activity.Finish(); + } + } public void CloseMainApp() { - var activity = (MainActivity)Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; + var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; if (activity == null) { return; @@ -608,7 +687,7 @@ namespace Bit.Droid.Services public float GetSystemFontSizeScale() { - var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity as MainActivity; + var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; return activity?.Resources?.Configuration?.FontScale ?? 1; } diff --git a/src/Core/Abstractions/IDeviceActionService.cs b/src/Core/Abstractions/IDeviceActionService.cs index 8c78e3a1f..c6188b6b4 100644 --- a/src/Core/Abstractions/IDeviceActionService.cs +++ b/src/Core/Abstractions/IDeviceActionService.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using Bit.App.Models; using Bit.App.Utilities.Prompts; using Bit.Core.Enums; using Bit.Core.Models; @@ -40,6 +41,7 @@ namespace Bit.App.Abstractions void OpenCredentialProviderSettings(); void OpenAutofillSettings(); long GetActiveTime(); + Task ExecuteFido2CredentialActionAsync(AppOptions appOptions); void CloseMainApp(); float GetSystemFontSizeScale(); Task OnAccountSwitchCompleteAsync(); diff --git a/src/Core/Abstractions/IFido2MakeCredentialConfirmationUserInterface.cs b/src/Core/Abstractions/IFido2MakeCredentialConfirmationUserInterface.cs new file mode 100644 index 000000000..efec99cd2 --- /dev/null +++ b/src/Core/Abstractions/IFido2MakeCredentialConfirmationUserInterface.cs @@ -0,0 +1,32 @@ +using Bit.Core.Utilities.Fido2; + +namespace Bit.Core.Abstractions +{ + public interface IFido2MakeCredentialConfirmationUserInterface : IFido2MakeCredentialUserInterface + { + /// + /// Call this method after the use chose where to save the new Fido2 credential. + /// + /// + /// Cipher ID where to save the new credential. + /// If null a new default passkey cipher item will be created + /// + /// + /// Whether the user has been verified or not. + /// If null verification has not taken place yet. + /// + void Confirm(string cipherId, bool? userVerified); + + /// + /// Cancels the current flow to make a credential + /// + void Cancel(); + + /// + /// Call this if an exception needs to happen on the credential making process + /// + void OnConfirmationException(Exception ex); + + Fido2UserVerificationOptions? GetCurrentUserVerificationOptions(); + } +} diff --git a/src/Core/App.xaml.cs b/src/Core/App.xaml.cs index 31a3ba8ca..df8ba9306 100644 --- a/src/Core/App.xaml.cs +++ b/src/Core/App.xaml.cs @@ -14,6 +14,7 @@ using Bit.Core.Models.Response; using Bit.Core.Pages; using Bit.Core.Services; using Bit.Core.Utilities; +using Bit.Core.Utilities.Fido2; [assembly: XamlCompilation(XamlCompilationOptions.Compile)] namespace Bit.App @@ -104,6 +105,8 @@ namespace Bit.App Options.MyVaultTile = appOptions.MyVaultTile; Options.GeneratorTile = appOptions.GeneratorTile; Options.FromAutofillFramework = appOptions.FromAutofillFramework; + Options.FromFido2Framework = appOptions.FromFido2Framework; + Options.Fido2CredentialAction = appOptions.Fido2CredentialAction; Options.CreateSend = appOptions.CreateSend; } } @@ -120,6 +123,15 @@ namespace Bit.App return new Window(new NavigationPage()); //No actual page needed. Only used for auto-filling the fields directly (externally) } + //When executing from CredentialProviderSelectionActivity we don't have "Options" so we need to filter "manually" + //In the CredentialProviderSelectionActivity we don't need to show any Page, so we just create a "dummy" Window with a NavigationPage to avoid crashing. + if (activationState != null + && activationState.State.ContainsKey("CREDENTIAL_DATA") + && activationState.State.ContainsKey("credentialProviderCipherId")) + { + return new Window(new NavigationPage()); //No actual page needed. Only used for auto-filling the fields directly (externally) + } + _isResumed = true; return new ResumeWindow(new NavigationPage(new AndroidNavigationRedirectPage(Options))); } @@ -182,7 +194,6 @@ namespace Bit.App { var details = message.Data as DialogDetails; ArgumentNullException.ThrowIfNull(details); - ArgumentNullException.ThrowIfNull(MainPage); var confirmed = true; var confirmText = string.IsNullOrWhiteSpace(details.ConfirmText) ? @@ -192,12 +203,14 @@ namespace Bit.App { if (!string.IsNullOrWhiteSpace(details.CancelText)) { + ArgumentNullException.ThrowIfNull(MainPage); + confirmed = await MainPage.DisplayAlert(details.Title, details.Text, confirmText, details.CancelText); } else { - await MainPage.DisplayAlert(details.Title, details.Text, confirmText); + await _deviceActionService.DisplayAlertAsync(details.Title, details.Text, confirmText); } _messagingService.Send("showDialogResolve", new Tuple(details.DialogId, confirmed)); } @@ -218,17 +231,17 @@ namespace Bit.App await _accountsManager.NavigateOnAccountChangeAsync(); } else if (message.Command == POP_ALL_AND_GO_TO_TAB_GENERATOR_MESSAGE || - message.Command == POP_ALL_AND_GO_TO_TAB_MYVAULT_MESSAGE || - message.Command == POP_ALL_AND_GO_TO_TAB_SEND_MESSAGE || - message.Command == POP_ALL_AND_GO_TO_AUTOFILL_CIPHERS_MESSAGE || - message.Command == DeepLinkContext.NEW_OTP_MESSAGE) + message.Command == POP_ALL_AND_GO_TO_TAB_MYVAULT_MESSAGE || + message.Command == POP_ALL_AND_GO_TO_TAB_SEND_MESSAGE || + message.Command == POP_ALL_AND_GO_TO_AUTOFILL_CIPHERS_MESSAGE || + message.Command == DeepLinkContext.NEW_OTP_MESSAGE) { if (message.Command == DeepLinkContext.NEW_OTP_MESSAGE) { Options.OtpData = new OtpData((string)message.Data); } - await MainThread.InvokeOnMainThreadAsync(ExecuteNavigationAction); + await MainThread.InvokeOnMainThreadAsync(ExecuteNavigationAction); async Task ExecuteNavigationAction() { if (MainPage is TabsPage tabsPage) @@ -239,6 +252,7 @@ namespace Bit.App { await tabsPage.Navigation.PopModalAsync(false); } + if (message.Command == POP_ALL_AND_GO_TO_AUTOFILL_CIPHERS_MESSAGE) { MainPage = new NavigationPage(new CipherSelectionPage(Options)); @@ -266,6 +280,19 @@ namespace Bit.App } } } + else if (message.Command == "fidoNavigateToAutofillCipher" && message.Data is Fido2ConfirmNewCredentialParams createParams) + { + ArgumentNullException.ThrowIfNull(MainPage); + ArgumentNullException.ThrowIfNull(Options); + await MainThread.InvokeOnMainThreadAsync(NavigateToCipherSelectionPageAction); + void NavigateToCipherSelectionPageAction() + { + Options.Uri = createParams.RpId; + Options.SaveUsername = createParams.UserName; + Options.SaveName = createParams.CredentialName; + MainPage = new NavigationPage(new CipherSelectionPage(Options)); + } + } else if (message.Command == "convertAccountToKeyConnector") { ArgumentNullException.ThrowIfNull(MainPage); @@ -304,6 +331,12 @@ namespace Bit.App || message.Command == "unlocked" || message.Command == AccountsManagerMessageCommands.ACCOUNT_SWITCH_COMPLETED) { + if (message.Command == AccountsManagerMessageCommands.ACCOUNT_SWITCH_COMPLETED) + { + var userVerificationMediatorService = ServiceContainer.Resolve(); + userVerificationMediatorService?.OnConfirmationException(new AccountSwitchedException()); + } + lock (_processingLoginRequestLock) { // lock doesn't allow for async execution diff --git a/src/Core/Controls/Settings/ExternalLinkSubtitleItemView.xaml b/src/Core/Controls/Settings/ExternalLinkSubtitleItemView.xaml new file mode 100644 index 000000000..bacbe0360 --- /dev/null +++ b/src/Core/Controls/Settings/ExternalLinkSubtitleItemView.xaml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/src/Core/Controls/Settings/ExternalLinkSubtitleItemView.xaml.cs b/src/Core/Controls/Settings/ExternalLinkSubtitleItemView.xaml.cs new file mode 100644 index 000000000..edecd21b0 --- /dev/null +++ b/src/Core/Controls/Settings/ExternalLinkSubtitleItemView.xaml.cs @@ -0,0 +1,26 @@ +using System.Windows.Input; + +namespace Bit.App.Controls +{ + public partial class ExternalLinkSubtitleItemView : BaseSettingItemView + { + public static readonly BindableProperty GoToLinkCommandProperty = BindableProperty.Create( + nameof(GoToLinkCommand), typeof(ICommand), typeof(ExternalLinkSubtitleItemView)); + + public ExternalLinkSubtitleItemView() + { + InitializeComponent(); + } + + public ICommand GoToLinkCommand + { + get => GetValue(GoToLinkCommandProperty) as ICommand; + set => SetValue(GoToLinkCommandProperty, value); + } + + void ContentView_Tapped(System.Object sender, System.EventArgs e) + { + GoToLinkCommand?.Execute(null); + } + } +} diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 1b5dc7643..9eafd2762 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -95,6 +95,9 @@ AppResources.Designer.cs PublicResXFileCodeGenerator + + ExternalLinkSubtitleItemView.xaml + AndroidNavigationRedirectPage.xaml @@ -105,6 +108,9 @@ + + MSBuild:Compile + MSBuild:Compile diff --git a/src/Core/Models/AppOptions.cs b/src/Core/Models/AppOptions.cs index 58fe79d49..6682db887 100644 --- a/src/Core/Models/AppOptions.cs +++ b/src/Core/Models/AppOptions.cs @@ -1,5 +1,4 @@ -using System; -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.Utilities; namespace Bit.App.Models @@ -9,6 +8,8 @@ namespace Bit.App.Models public bool MyVaultTile { get; set; } public bool GeneratorTile { get; set; } public bool FromAutofillFramework { get; set; } + public bool FromFido2Framework { get; set; } + public string Fido2CredentialAction { get; set; } public CipherType? FillType { get; set; } public string Uri { get; set; } public CipherType? SaveType { get; set; } @@ -25,6 +26,7 @@ namespace Bit.App.Models public bool CopyInsteadOfShareAfterSaving { get; set; } public bool HideAccountSwitcher { get; set; } public OtpData? OtpData { get; set; } + public bool HasUnlockedInThisTransaction { get; set; } public void SetAllFrom(AppOptions o) { @@ -35,6 +37,7 @@ namespace Bit.App.Models MyVaultTile = o.MyVaultTile; GeneratorTile = o.GeneratorTile; FromAutofillFramework = o.FromAutofillFramework; + Fido2CredentialAction = o.Fido2CredentialAction; FillType = o.FillType; Uri = o.Uri; SaveType = o.SaveType; @@ -51,6 +54,7 @@ namespace Bit.App.Models CopyInsteadOfShareAfterSaving = o.CopyInsteadOfShareAfterSaving; HideAccountSwitcher = o.HideAccountSwitcher; OtpData = o.OtpData; + HasUnlockedInThisTransaction = o.HasUnlockedInThisTransaction; } } } diff --git a/src/Core/Pages/Accounts/LockPage.xaml.cs b/src/Core/Pages/Accounts/LockPage.xaml.cs index bd3be1858..bdf69b13b 100644 --- a/src/Core/Pages/Accounts/LockPage.xaml.cs +++ b/src/Core/Pages/Accounts/LockPage.xaml.cs @@ -168,7 +168,7 @@ namespace Bit.App.Pages var tasks = Task.Run(async () => { await Task.Delay(50); - MainThread.BeginInvokeOnMainThread(async () => await _vm.SubmitAsync()); + _vm.SubmitCommand.Execute(null); }); } } diff --git a/src/Core/Pages/Accounts/LockPageViewModel.cs b/src/Core/Pages/Accounts/LockPageViewModel.cs index 64d35c4cb..3838df361 100644 --- a/src/Core/Pages/Accounts/LockPageViewModel.cs +++ b/src/Core/Pages/Accounts/LockPageViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using System.Windows.Input; using Bit.App.Abstractions; using Bit.App.Controls; using Bit.Core.Resources.Localization; @@ -73,7 +74,7 @@ namespace Bit.App.Pages PageTitle = AppResources.VerifyMasterPassword; TogglePasswordCommand = new Command(TogglePassword); - SubmitCommand = new Command(async () => await SubmitAsync()); + SubmitCommand = CreateDefaultAsyncRelayCommand(SubmitAsync, onException: _logger.Exception, allowsMultipleExecutions: false); AccountSwitchingOverlayViewModel = new AccountSwitchingOverlayViewModel(_stateService, _messagingService, _logger) @@ -157,7 +158,7 @@ namespace Bit.App.Pages public AccountSwitchingOverlayViewModel AccountSwitchingOverlayViewModel { get; } - public Command SubmitCommand { get; } + public ICommand SubmitCommand { get; } public Command TogglePasswordCommand { get; } public string ShowPasswordIcon => ShowPassword ? BitwardenIcons.EyeSlash : BitwardenIcons.Eye; @@ -233,8 +234,8 @@ namespace Bit.App.Pages } BiometricButtonVisible = true; BiometricButtonText = AppResources.UseBiometricsToUnlock; - // TODO Xamarin.Forms.Device.RuntimePlatform is no longer supported. Use Microsoft.Maui.Devices.DeviceInfo.Platform instead. For more details see https://learn.microsoft.com/en-us/dotnet/maui/migration/forms-projects#device-changes - if (Device.RuntimePlatform == Device.iOS) + + if (DeviceInfo.Platform == DevicePlatform.iOS) { var supportsFace = await _deviceActionService.SupportsFaceBiometricAsync(); BiometricButtonText = supportsFace ? AppResources.UseFaceIDToUnlock : @@ -330,6 +331,7 @@ namespace Bit.App.Pages Pin = string.Empty; await AppHelpers.ResetInvalidUnlockAttemptsAsync(); await SetUserKeyAndContinueAsync(userKey); + await Task.Delay(150); //Workaround Delay to avoid "duplicate" execution of SubmitAsync on Android when invoked from the ReturnCommand } } catch (LegacyUserException) @@ -418,6 +420,7 @@ namespace Bit.App.Pages var userKey = await _cryptoService.DecryptUserKeyWithMasterKeyAsync(masterKey); await _cryptoService.SetMasterKeyAsync(masterKey); await SetUserKeyAndContinueAsync(userKey); + await Task.Delay(150); //Workaround Delay to avoid "duplicate" execution of SubmitAsync on Android when invoked from the ReturnCommand // Re-enable biometrics if (BiometricEnabled & !BiometricIntegrityValid) diff --git a/src/Core/Pages/Settings/AutofillSettingsPage.xaml b/src/Core/Pages/Settings/AutofillSettingsPage.xaml index 9799e6b1a..f53489d7c 100644 --- a/src/Core/Pages/Settings/AutofillSettingsPage.xaml +++ b/src/Core/Pages/Settings/AutofillSettingsPage.xaml @@ -19,15 +19,6 @@ Text="{u:I18n Autofill}" StyleClass="settings-header" /> - - + + DeviceInfo.Platform == DevicePlatform.Android && _deviceActionService.SupportsCredentialProviderService(); - public bool UseCredentialProviderService - { - get => _useCredentialProviderService; - set - { - if (SetProperty(ref _useCredentialProviderService, value)) - { - ((ICommand)ToggleUseCredentialProviderServiceCommand).Execute(null); - } - } - } - public bool SupportsAndroidAutofillServices => DeviceInfo.Platform == DevicePlatform.Android && _deviceActionService.SupportsAutofillServices(); public bool UseAutofillServices @@ -99,23 +86,23 @@ namespace Bit.App.Pages } } - public AsyncRelayCommand ToggleUseCredentialProviderServiceCommand { get; private set; } public AsyncRelayCommand ToggleUseAutofillServicesCommand { get; private set; } public AsyncRelayCommand ToggleUseInlineAutofillCommand { get; private set; } public AsyncRelayCommand ToggleUseAccessibilityCommand { get; private set; } public AsyncRelayCommand ToggleUseDrawOverCommand { get; private set; } public AsyncRelayCommand ToggleAskToAddLoginCommand { get; private set; } public ICommand GoToBlockAutofillUrisCommand { get; private set; } + public ICommand GoToCredentialProviderSettingsCommand { get; private set; } private void InitAndroidCommands() { - ToggleUseCredentialProviderServiceCommand = CreateDefaultAsyncRelayCommand(() => MainThread.InvokeOnMainThreadAsync(() => ToggleUseCredentialProviderService()), () => _inited, allowsMultipleExecutions: false); ToggleUseAutofillServicesCommand = CreateDefaultAsyncRelayCommand(() => MainThread.InvokeOnMainThreadAsync(() => ToggleUseAutofillServices()), () => _inited, allowsMultipleExecutions: false); ToggleUseInlineAutofillCommand = CreateDefaultAsyncRelayCommand(() => MainThread.InvokeOnMainThreadAsync(() => ToggleUseInlineAutofillEnabledAsync()), () => _inited, allowsMultipleExecutions: false); ToggleUseAccessibilityCommand = CreateDefaultAsyncRelayCommand(ToggleUseAccessibilityAsync, () => _inited, allowsMultipleExecutions: false); ToggleUseDrawOverCommand = CreateDefaultAsyncRelayCommand(() => MainThread.InvokeOnMainThreadAsync(() => ToggleDrawOver()), () => _inited, allowsMultipleExecutions: false); ToggleAskToAddLoginCommand = CreateDefaultAsyncRelayCommand(ToggleAskToAddLoginAsync, () => _inited, allowsMultipleExecutions: false); GoToBlockAutofillUrisCommand = CreateDefaultAsyncRelayCommand(() => Page.Navigation.PushAsync(new BlockAutofillUrisPage()), allowsMultipleExecutions: false); + GoToCredentialProviderSettingsCommand = CreateDefaultAsyncRelayCommand(() => MainThread.InvokeOnMainThreadAsync(() => GoToCredentialProviderSettings()), () => _inited, allowsMultipleExecutions: false); } private async Task InitAndroidAutofillSettingsAsync() @@ -132,9 +119,6 @@ namespace Bit.App.Pages private async Task UpdateAndroidAutofillSettingsAsync() { - // TODO - uncomment once _autofillHandler.CredentialProviderServiceEnabled() returns a real value - // _useCredentialProviderService = - // SupportsCredentialProviderService && _autofillHandler.CredentialProviderServiceEnabled(); _useAutofillServices = _autofillHandler.SupportsAutofillService() && _autofillHandler.AutofillServiceEnabled(); _useAccessibility = _autofillHandler.AutofillAccessibilityServiceRunning(); @@ -143,7 +127,6 @@ namespace Bit.App.Pages await MainThread.InvokeOnMainThreadAsync(() => { - TriggerPropertyChanged(nameof(UseCredentialProviderService)); TriggerPropertyChanged(nameof(UseAutofillServices)); TriggerPropertyChanged(nameof(UseAccessibility)); TriggerPropertyChanged(nameof(UseDrawOver)); @@ -151,16 +134,15 @@ namespace Bit.App.Pages }); } - private void ToggleUseCredentialProviderService() + private async Task GoToCredentialProviderSettings() { - if (UseCredentialProviderService) + var confirmed = await _platformUtilsService.ShowDialogAsync(AppResources.SetBitwardenAsPasskeyManagerDescription, AppResources.ContinueToDeviceSettings, + AppResources.Continue, + AppResources.Cancel); + if (confirmed) { _deviceActionService.OpenCredentialProviderSettings(); } - else - { - _autofillHandler.DisableCredentialProviderService(); - } } private void ToggleUseAutofillServices() diff --git a/src/Core/Pages/Vault/AutofillCiphersPageViewModel.cs b/src/Core/Pages/Vault/AutofillCiphersPageViewModel.cs index a13d61f32..40a6962ce 100644 --- a/src/Core/Pages/Vault/AutofillCiphersPageViewModel.cs +++ b/src/Core/Pages/Vault/AutofillCiphersPageViewModel.cs @@ -1,17 +1,23 @@ using Bit.App.Models; using Bit.App.Utilities; using Bit.Core; +using Bit.Core.Abstractions; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Models.View; using Bit.Core.Resources.Localization; using Bit.Core.Utilities; +using Bit.Core.Utilities.Fido2; namespace Bit.App.Pages { public class AutofillCiphersPageViewModel : CipherSelectionPageViewModel { private CipherType? _fillType; + private bool _isAndroidFido2CredentialCreation; + private AppOptions _appOptions; + + private readonly LazyResolve _fido2MakeCredentialConfirmationUserInterface = new LazyResolve(); public string Uri { get; set; } @@ -19,6 +25,8 @@ namespace Bit.App.Pages { Uri = appOptions?.Uri; _fillType = appOptions.FillType; + _isAndroidFido2CredentialCreation = appOptions.FromFido2Framework; + _appOptions = appOptions; string name = null; if (Uri?.StartsWith(Constants.AndroidAppProtocol) ?? false) @@ -36,6 +44,7 @@ namespace Bit.App.Pages Name = name; PageTitle = string.Format(AppResources.ItemsForUri, Name ?? "--"); NoDataText = string.Format(AppResources.NoItemsForUri, Name ?? "--"); + AddNewItemText = appOptions.FromFido2Framework ? AppResources.SavePasskeyAsNewLogin : AppResources.AddAnItem; } protected override async Task> LoadGroupedItemsAsync() @@ -78,6 +87,15 @@ namespace Bit.App.Pages return; } + if (_appOptions.FromFido2Framework) + { + if (_appOptions.Fido2CredentialAction == CredentialProviderConstants.Fido2CredentialCreate) + { + await CreateFido2CredentialIntoAsync(cipher); + } + return; + } + if (!await _passwordRepromptService.PromptAndCheckPasswordIfNeededAsync(cipher.Reprompt)) { return; @@ -130,8 +148,46 @@ namespace Bit.App.Pages } } + private async Task CreateFido2CredentialIntoAsync(CipherView cipher) + { + if (cipher.Login.HasFido2Credentials + && + !await _platformUtilsService.ShowDialogAsync( + AppResources.ThisItemAlreadyContainsAPasskeyAreYouSureYouWantToOverwriteTheCurrentPasskey, + AppResources.OverwritePasskey, + AppResources.Yes, + AppResources.No)) + { + return; + } + + _fido2MakeCredentialConfirmationUserInterface.Value.Confirm(cipher.Id, null); + } + + protected override async Task AddFabCipherAsync() + { + //Scenario for creating a new Fido2 credential on Android but showing the Cipher Page + if (_isAndroidFido2CredentialCreation) + { + var pageForOther = new CipherAddEditPage(null, CipherType.Login, appOptions: _appOptions); + await Page.Navigation.PushModalAsync(new NavigationPage(pageForOther)); + return; + } + else + { + await AddCipherAsync(); + } + } + protected override async Task AddCipherAsync() { + //Scenario for creating a new Fido2 credential on Android + if (_isAndroidFido2CredentialCreation) + { + _fido2MakeCredentialConfirmationUserInterface.Value.Confirm(null, null); + return; + } + if (_fillType.HasValue && _fillType != CipherType.Login) { var pageForOther = new CipherAddEditPage(type: _fillType, fromAutofill: true); @@ -143,5 +199,15 @@ namespace Bit.App.Pages fromAutofill: true); await Page.Navigation.PushModalAsync(new NavigationPage(pageForLogin)); } + + public void Cancel() + { + if (_appOptions?.FromFido2Framework == true + && + _appOptions?.Fido2CredentialAction == CredentialProviderConstants.Fido2CredentialCreate) + { + _fido2MakeCredentialConfirmationUserInterface.Value.Cancel(); + } + } } } diff --git a/src/Core/Pages/Vault/CipherAddEditPage.xaml b/src/Core/Pages/Vault/CipherAddEditPage.xaml index b1c2e747e..ce2ea1ea1 100644 --- a/src/Core/Pages/Vault/CipherAddEditPage.xaml +++ b/src/Core/Pages/Vault/CipherAddEditPage.xaml @@ -112,7 +112,7 @@ StyleClass="box-header, box-header-platform" /> + IsVisible="{Binding TypeEditMode, Converter={StaticResource inverseBool}}"> diff --git a/src/Core/Pages/Vault/CipherAddEditPage.xaml.cs b/src/Core/Pages/Vault/CipherAddEditPage.xaml.cs index 2aa659d1b..45ad912b2 100644 --- a/src/Core/Pages/Vault/CipherAddEditPage.xaml.cs +++ b/src/Core/Pages/Vault/CipherAddEditPage.xaml.cs @@ -45,6 +45,7 @@ namespace Bit.App.Pages _appOptions = appOptions; _fromAutofill = fromAutofill; FromAutofillFramework = _appOptions?.FromAutofillFramework ?? false; + FromAndroidFido2Framework = _appOptions?.FromFido2Framework ?? false; InitializeComponent(); _vm = BindingContext as CipherAddEditPageViewModel; _vm.Page = this; @@ -144,6 +145,7 @@ namespace Bit.App.Pages } public bool FromAutofillFramework { get; set; } + public bool FromAndroidFido2Framework { get; set; } public CipherAddEditPageViewModel ViewModel => _vm; protected override async void OnAppearing() diff --git a/src/Core/Pages/Vault/CipherAddEditPageViewModel.cs b/src/Core/Pages/Vault/CipherAddEditPageViewModel.cs index c4e679df3..5e5376d5f 100644 --- a/src/Core/Pages/Vault/CipherAddEditPageViewModel.cs +++ b/src/Core/Pages/Vault/CipherAddEditPageViewModel.cs @@ -17,6 +17,8 @@ using Microsoft.Maui.Controls; using Microsoft.Maui; using Bit.App.Utilities; using CommunityToolkit.Mvvm.Input; +using Bit.Core.Utilities.Fido2; +using Bit.Core.Services; #nullable enable @@ -37,7 +39,9 @@ namespace Bit.App.Pages private readonly IAutofillHandler _autofillHandler; private readonly IWatchDeviceService _watchDeviceService; private readonly IAccountsManager _accountsManager; - + private readonly IFido2MakeCredentialConfirmationUserInterface _fido2MakeCredentialConfirmationUserInterface; + private readonly IUserVerificationMediatorService _userVerificationMediatorService; + private bool _showNotesSeparator; private bool _showPassword; private bool _showCardNumber; @@ -92,6 +96,8 @@ namespace Bit.App.Pages _autofillHandler = ServiceContainer.Resolve(); _watchDeviceService = ServiceContainer.Resolve(); _accountsManager = ServiceContainer.Resolve(); + _fido2MakeCredentialConfirmationUserInterface = ServiceContainer.Resolve(); + _userVerificationMediatorService = ServiceContainer.Resolve(); GeneratePasswordCommand = new Command(GeneratePassword); TogglePasswordCommand = new Command(TogglePassword); @@ -292,7 +298,9 @@ namespace Bit.App.Pages }); } public bool ShowCollections => (!EditMode || CloneMode) && Cipher?.OrganizationId != null; + public bool IsFromFido2Framework { get; set; } public bool EditMode => !string.IsNullOrWhiteSpace(CipherId); + public bool TypeEditMode => !string.IsNullOrWhiteSpace(CipherId) || IsFromFido2Framework; public bool ShowOwnershipOptions => !EditMode || CloneMode; public bool OwnershipPolicyInEffect => ShowOwnershipOptions && !AllowPersonal; public bool CloneMode { get; set; } @@ -324,6 +332,7 @@ namespace Bit.App.Pages public async Task LoadAsync(AppOptions appOptions = null) { _fromOtp = appOptions?.OtpData != null; + IsFromFido2Framework = appOptions?.FromFido2Framework ?? false; var myEmail = await _stateService.GetEmailAsync(); OwnershipOptions.Add(new KeyValuePair(myEmail, null)); @@ -536,6 +545,26 @@ namespace Bit.App.Pages } try { + bool isFido2UserVerified = false; + if (IsFromFido2Framework) + { + // Verify the user and prevent saving cipher if enforcing is needed and it's not verified. + var userVerification = await VerifyUserAsync(); + if (userVerification.IsCancelled) + { + return false; + } + isFido2UserVerified = userVerification.Result; + + var options = _fido2MakeCredentialConfirmationUserInterface.GetCurrentUserVerificationOptions(); + + if (!isFido2UserVerified && await _userVerificationMediatorService.ShouldEnforceFido2RequiredUserVerificationAsync(options.Value)) + { + await _platformUtilsService.ShowDialogAsync(AppResources.ErrorCreatingPasskey, AppResources.SavePasskey); + return false; + } + } + await _deviceActionService.ShowLoadingAsync(AppResources.Saving); await _cipherService.SaveWithServerAsync(cipher); @@ -554,6 +583,11 @@ namespace Bit.App.Pages // Close and go back to app _autofillHandler.CloseAutofill(); } + else if (IsFromFido2Framework) + { + _fido2MakeCredentialConfirmationUserInterface.Confirm(cipher.Id, isFido2UserVerified); + return true; + } else if (_fromOtp) { await _accountsManager.StartDefaultNavigationFlowAsync(op => op.OtpData = null); @@ -589,6 +623,27 @@ namespace Bit.App.Pages return false; } + private async Task> VerifyUserAsync() + { + try + { + var options = _fido2MakeCredentialConfirmationUserInterface.GetCurrentUserVerificationOptions(); + ArgumentNullException.ThrowIfNull(options); + + if (options.Value.UserVerificationPreference == Fido2UserVerificationPreference.Discouraged) + { + return new CancellableResult(false); + } + + return await _userVerificationMediatorService.VerifyUserForFido2Async(options.Value); + } + catch (Exception ex) + { + LoggerHelper.LogEvenIfCantBeResolved(ex); + return new CancellableResult(false); + } + } + public async Task DeleteAsync() { if (Microsoft.Maui.Networking.Connectivity.NetworkAccess == Microsoft.Maui.Networking.NetworkAccess.None) diff --git a/src/Core/Pages/Vault/CipherSelectionPage.xaml b/src/Core/Pages/Vault/CipherSelectionPage.xaml index 77cb06bdd..2dfe44de5 100644 --- a/src/Core/Pages/Vault/CipherSelectionPage.xaml +++ b/src/Core/Pages/Vault/CipherSelectionPage.xaml @@ -83,7 +83,7 @@ Text="{Binding NoDataText}" HorizontalTextAlignment="Center">