diff --git a/.gitignore b/.gitignore index d5148da27..1e0f2d44b 100644 --- a/.gitignore +++ b/.gitignore @@ -148,6 +148,7 @@ publish/ # NuGet Packages *.nupkg +!**/Xamarin.AndroidX.Credentials.1.0.0.nupkg # The packages folder can be ignored because of Package Restore **/packages/* # except build/, which is used as an MSBuild target. diff --git a/lib/android/Xamarin.AndroidX.Credentials/Xamarin.AndroidX.Credentials.1.0.0.nupkg b/lib/android/Xamarin.AndroidX.Credentials/Xamarin.AndroidX.Credentials.1.0.0.nupkg new file mode 100644 index 000000000..0a8cb5a88 Binary files /dev/null and b/lib/android/Xamarin.AndroidX.Credentials/Xamarin.AndroidX.Credentials.1.0.0.nupkg differ diff --git a/lib/android/Xamarin.AndroidX.Credentials/net8.0-android/Xamarin.AndroidX.Credentials.dll b/lib/android/Xamarin.AndroidX.Credentials/net8.0-android/Xamarin.AndroidX.Credentials.dll new file mode 100644 index 000000000..22434e53d Binary files /dev/null and b/lib/android/Xamarin.AndroidX.Credentials/net8.0-android/Xamarin.AndroidX.Credentials.dll differ diff --git a/lib/android/Xamarin.AndroidX.Credentials/net8.0-android/Xamarin.AndroidX.Credentials.xml b/lib/android/Xamarin.AndroidX.Credentials/net8.0-android/Xamarin.AndroidX.Credentials.xml new file mode 100644 index 000000000..f7f8c6217 --- /dev/null +++ b/lib/android/Xamarin.AndroidX.Credentials/net8.0-android/Xamarin.AndroidX.Credentials.xml @@ -0,0 +1,8 @@ + + + + Xamarin.AndroidX.Credentials + + + + diff --git a/lib/android/Xamarin.AndroidX.Credentials/net8.0-android/credentials-1.2.0.aar b/lib/android/Xamarin.AndroidX.Credentials/net8.0-android/credentials-1.2.0.aar new file mode 100644 index 000000000..a3fdcfb52 Binary files /dev/null and b/lib/android/Xamarin.AndroidX.Credentials/net8.0-android/credentials-1.2.0.aar differ diff --git a/nuget.config b/nuget.config index 16502a7fd..e18f80b9a 100644 --- a/nuget.config +++ b/nuget.config @@ -2,5 +2,6 @@ + \ No newline at end of file diff --git a/src/App/App.csproj b/src/App/App.csproj index e8b607055..3c2bc8c12 100644 --- a/src/App/App.csproj +++ b/src/App/App.csproj @@ -117,10 +117,13 @@ + + + @@ -256,8 +259,13 @@ + + + + + diff --git a/src/App/Platforms/Android/Autofill/CredentialHelpers.cs b/src/App/Platforms/Android/Autofill/CredentialHelpers.cs new file mode 100644 index 000000000..6153a8aa4 --- /dev/null +++ b/src/App/Platforms/Android/Autofill/CredentialHelpers.cs @@ -0,0 +1,303 @@ +using System.Text.Json.Nodes; +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.App.Abstractions; +using Bit.App.Droid.Utilities; +using Bit.Core.Abstractions; +using Bit.Core.Resources.Localization; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Bit.Core.Utilities.Fido2; +using Bit.Core.Utilities.Fido2.Extensions; +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); + + // We need to change the request code for every pending intent on mapping the credential so the extras are not overriten by the last + // credential entry created. + int requestCodeAddition = 0; + passkeyEntries = credentials.Select(credential => MapCredential(credential, option, context, hasVaultBeenUnlockedInThisTransaction, Bit.Droid.Autofill.CredentialProviderService.UniqueGetRequestCode + requestCodeAddition++) as CredentialEntry).ToList(); + + return passkeyEntries; + } + + private static PublicKeyCredentialEntry MapCredential(Fido2AuthenticatorDiscoverableCredentialMetadata credential, BeginGetPublicKeyCredentialOption option, Context context, bool hasVaultBeenUnlockedInThisTransaction, int requestCode) + { + 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, requestCode, 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(); + } + + private static PublicKeyCredentialCreationOptions GetPublicKeyCredentialCreationOptionsFromJson(string json) + { + var request = new PublicKeyCredentialCreationOptions(json); + var jsonObj = new JSONObject(json); + var authenticatorSelection = jsonObj.GetJSONObject("authenticatorSelection"); + request.AuthenticatorSelection = new AndroidX.Credentials.WebAuthn.AuthenticatorSelectionCriteria( + authenticatorSelection.OptString("authenticatorAttachment", "platform"), + authenticatorSelection.OptString("residentKey", null), + authenticatorSelection.OptBoolean("requireResidentKey", false), + authenticatorSelection.OptString("userVerification", "preferred")); + + return request; + } + + public static async Task CreateCipherPasskeyAsync(ProviderCreateCredentialRequest getRequest, Activity activity) + { + var callingRequest = getRequest?.CallingRequest as CreatePublicKeyCredentialRequest; + + if (callingRequest is null) + { + if (ServiceContainer.TryResolve(out var deviceActionService)) + { + await deviceActionService.DisplayAlertAsync(AppResources.ErrorCreatingPasskey, string.Empty, AppResources.Ok); + } + FailAndFinish(); + return; + } + + var credentialCreationOptions = GetPublicKeyCredentialCreationOptionsFromJson(callingRequest.RequestJson); + var origin = await ValidateCallingAppInfoAndGetOriginAsync(getRequest.CallingAppInfo, credentialCreationOptions.Rp.Id); + + if (origin is null) + { + if (ServiceContainer.TryResolve(out var deviceActionService)) + { + await deviceActionService.DisplayAlertAsync(AppResources.ErrorCreatingPasskey, AppResources.PasskeysNotSupportedForThisApp, AppResources.Ok); + } + FailAndFinish(); + return; + } + + 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 Fido2ClientCreateCredentialParams() + { + Challenge = credentialCreationOptions.GetChallenge(), + Origin = origin, + PubKeyCredParams = pubKeyCredParams.ToArray(), + Rp = rp, + User = user, + Timeout = timeout, + Attestation = credentialCreationOptions.Attestation, + AuthenticatorSelection = authenticatorSelection, + ExcludeCredentials = excludeCredentials.ToArray(), + Extensions = MapExtensionsFromJson(credentialCreationOptions), + SameOriginWithAncestors = true + }; + + var credentialExtraCreateParams = new Fido2ExtraCreateCredentialParams + ( + callingRequest.GetClientDataHash(), + getRequest.CallingAppInfo?.PackageName + ); + + var fido2MediatorService = ServiceContainer.Resolve(); + var clientCreateCredentialResult = await fido2MediatorService.CreateCredentialAsync(credentialCreateParams, credentialExtraCreateParams); + if (clientCreateCredentialResult == null) + { + FailAndFinish(); + return; + } + + var transportsArray = new JSONArray(); + if (clientCreateCredentialResult.Transports != null) + { + foreach (var transport in clientCreateCredentialResult.Transports) + { + transportsArray.Put(transport); + } + } + + var responseInnerAndroidJson = new JSONObject(); + if (clientCreateCredentialResult.ClientDataJSON != null) + { + 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", MapExtensionsToJson(clientCreateCredentialResult.Extensions)); + rootAndroidJson.Put("response", responseInnerAndroidJson); + + var result = new Intent(); + var publicKeyResponse = new CreatePublicKeyCredentialResponse(rootAndroidJson.ToString()); + PendingIntentHandler.SetCreateCredentialResponse(result, publicKeyResponse); + + activity.SetResult(Result.Ok, result); + activity.Finish(); + + void FailAndFinish() + { + var result = new Intent(); + PendingIntentHandler.SetCreateCredentialException(result, new CreateCredentialUnknownException()); + + activity.SetResult(Result.Ok, result); + activity.Finish(); + } + } + + private static Fido2CreateCredentialExtensionsParams MapExtensionsFromJson(PublicKeyCredentialCreationOptions options) + { + if (options == null || !options.Json.Has("extensions")) + { + return null; + } + + var extensions = options.Json.GetJSONObject("extensions"); + return new Fido2CreateCredentialExtensionsParams + { + CredProps = extensions.Has("credProps") && extensions.GetBoolean("credProps") + }; + } + + private static JSONObject MapExtensionsToJson(Fido2CreateCredentialExtensionsResult extensions) + { + if (extensions == null) + { + return null; + } + + var extensionsJson = new JSONObject(); + if (extensions.CredProps != null) + { + var credPropsJson = new JSONObject(); + credPropsJson.Put("rk", extensions.CredProps.Rk); + extensionsJson.Put("credProps", credPropsJson); + } + + return extensionsJson; + } + + public static async Task LoadFido2PriviligedAllowedListAsync() + { + try + { + using var stream = await FileSystem.OpenAppPackageFileAsync("fido2_priviliged_allow_list.json"); + using var reader = new StreamReader(stream); + + return reader.ReadToEnd(); + } + catch + { + return null; + } + } + + public static async Task ValidateCallingAppInfoAndGetOriginAsync(CallingAppInfo callingAppInfo, string rpId) + { + if (callingAppInfo.Origin is null) + { + return await ValidateAssetLinksAndGetOriginAsync(callingAppInfo, rpId); + } + + var priviligedAllowedList = await LoadFido2PriviligedAllowedListAsync(); + if (priviligedAllowedList is null) + { + throw new InvalidOperationException("Could not load Fido2 priviliged allowed list"); + } + + try + { + return callingAppInfo.GetOrigin(priviligedAllowedList); + } + catch (Java.Lang.IllegalStateException) + { + return null; // not priviliged + } + catch (Java.Lang.IllegalArgumentException) + { + return null; // wrong list format + } + } + + private static async Task ValidateAssetLinksAndGetOriginAsync(CallingAppInfo callingAppInfo, string rpId) + { + if (!ServiceContainer.TryResolve(out var assetLinksService)) + { + throw new InvalidOperationException("Can't resolve IAssetLinksService"); + } + + var normalizedFingerprint = callingAppInfo.GetLatestCertificationFingerprint(); + + var isValid = await assetLinksService.ValidateAssetLinksAsync(rpId, callingAppInfo.PackageName, normalizedFingerprint); + + return isValid ? callingAppInfo.GetAndroidOrigin() : null; + } + } +} diff --git a/src/App/Platforms/Android/Autofill/CredentialProviderSelectionActivity.cs b/src/App/Platforms/Android/Autofill/CredentialProviderSelectionActivity.cs new file mode 100644 index 000000000..7d406939a --- /dev/null +++ b/src/App/Platforms/Android/Autofill/CredentialProviderSelectionActivity.cs @@ -0,0 +1,172 @@ +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.Droid.Utilities; +using Bit.App.Abstractions; +using Bit.Core.Abstractions; +using Bit.Core.Utilities; +using Bit.Core.Resources.Localization; +using Bit.Core.Utilities.Fido2; +using Bit.Core.Services; +using Bit.App.Platforms.Android.Autofill; +using AndroidX.Credentials.Exceptions; +using Org.Json; + +namespace Bit.Droid.Autofill +{ + [Activity( + NoHistory = true, + LaunchMode = LaunchMode.SingleTop)] + public class CredentialProviderSelectionActivity : MauiAppCompatActivity + { + private LazyResolve _fido2MediatorService = new LazyResolve(); + private LazyResolve _fido2GetAssertionUserInterface = 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(); + base.OnCreate(bundle); + + var cipherId = Intent?.GetStringExtra(CredentialProviderConstants.CredentialProviderCipherId); + if (string.IsNullOrEmpty(cipherId)) + { + Finish(); + return; + } + + GetCipherAndPerformFido2AuthAsync(cipherId).FireAndForget(); + } + + //Used to avoid crash on MAUI when doing back + public override void OnBackPressed() + { + Finish(); + } + + private async Task GetCipherAndPerformFido2AuthAsync(string cipherId) + { + string RpId = string.Empty; + try + { + var getRequest = PendingIntentHandler.RetrieveProviderGetCredentialRequest(Intent); + + if (getRequest is null) + { + FailAndFinish(); + return; + } + + var credentialOption = getRequest.CredentialOptions.FirstOrDefault(); + var credentialPublic = credentialOption as GetPublicKeyCredentialOption; + + var requestOptions = new PublicKeyCredentialRequestOptions(credentialPublic.RequestJson); + RpId = requestOptions.RpId; + + var requestInfo = Intent.GetBundleExtra(CredentialProviderConstants.CredentialDataIntentExtra); + var credentialId = requestInfo?.GetByteArray(CredentialProviderConstants.CredentialIdIntentExtra); + var hasVaultBeenUnlockedInThisTransaction = Intent.GetBooleanExtra(CredentialProviderConstants.CredentialHasVaultBeenUnlockedInThisTransactionExtra, false); + + var packageName = getRequest.CallingAppInfo.PackageName; + + var origin = await CredentialHelpers.ValidateCallingAppInfoAndGetOriginAsync(getRequest.CallingAppInfo, RpId); + if (origin is null) + { + await _deviceActionService.Value.DisplayAlertAsync(AppResources.ErrorReadingPasskey, AppResources.PasskeysNotSupportedForThisApp, AppResources.Ok); + FailAndFinish(); + return; + } + + _fido2GetAssertionUserInterface.Value.Init( + cipherId, + false, + () => hasVaultBeenUnlockedInThisTransaction, + RpId + ); + + var clientAssertParams = new Fido2ClientAssertCredentialParams + { + Challenge = requestOptions.GetChallenge(), + RpId = RpId, + AllowCredentials = new Core.Utilities.Fido2.PublicKeyCredentialDescriptor[] { new Core.Utilities.Fido2.PublicKeyCredentialDescriptor { Id = credentialId } }, + Origin = origin, + SameOriginWithAncestors = true, + UserVerification = requestOptions.UserVerification + }; + + var extraAssertParams = new Fido2ExtraAssertCredentialParams + ( + getRequest.CallingAppInfo.Origin != null ? credentialPublic.GetClientDataHash() : null, + packageName + ); + + var assertResult = await _fido2MediatorService.Value.AssertCredentialAsync(clientAssertParams, extraAssertParams); + + var result = new Intent(); + + var responseInnerAndroidJson = new JSONObject(); + if (assertResult.ClientDataJSON != null) + { + responseInnerAndroidJson.Put("clientDataJSON", CoreHelpers.Base64UrlEncode(assertResult.ClientDataJSON)); + } + responseInnerAndroidJson.Put("authenticatorData", CoreHelpers.Base64UrlEncode(assertResult.AuthenticatorData)); + responseInnerAndroidJson.Put("signature", CoreHelpers.Base64UrlEncode(assertResult.Signature)); + responseInnerAndroidJson.Put("userHandle", CoreHelpers.Base64UrlEncode(assertResult.SelectedCredential.UserHandle)); + + var rootAndroidJson = new JSONObject(); + rootAndroidJson.Put("id", CoreHelpers.Base64UrlEncode(assertResult.SelectedCredential.Id)); + rootAndroidJson.Put("rawId", CoreHelpers.Base64UrlEncode(assertResult.SelectedCredential.Id)); + rootAndroidJson.Put("authenticatorAttachment", "platform"); + rootAndroidJson.Put("type", "public-key"); + rootAndroidJson.Put("clientExtensionResults", new JSONObject()); + rootAndroidJson.Put("response", responseInnerAndroidJson); + + var json = rootAndroidJson.ToString(); + + var cred = new PublicKeyCredential(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); + FailAndFinish(); + }); + } + catch (Exception ex) + { + LoggerHelper.LogEvenIfCantBeResolved(ex); + await MainThread.InvokeOnMainThreadAsync(async () => + { + await _deviceActionService.Value.DisplayAlertAsync(AppResources.ErrorReadingPasskey, string.Format(AppResources.ThereWasAProblemReadingAPasskeyForXTryAgainLater, RpId), AppResources.Ok); + FailAndFinish(); + }); + } + } + + private void FailAndFinish() + { + var result = new Intent(); + PendingIntentHandler.SetGetCredentialException(result, new GetCredentialUnknownException()); + + SetResult(Result.Ok, result); + Finish(); + } + } +} diff --git a/src/App/Platforms/Android/Autofill/CredentialProviderService.cs b/src/App/Platforms/Android/Autofill/CredentialProviderService.cs new file mode 100644 index 000000000..9be7377f8 --- /dev/null +++ b/src/App/Platforms/Android/Autofill/CredentialProviderService.cs @@ -0,0 +1,168 @@ +using Android; +using Android.App; +using Android.Content; +using Android.OS; +using Android.Runtime; +using AndroidX.Credentials.Provider; +using Bit.Core.Abstractions; +using Bit.Core.Utilities; +using AndroidX.Credentials.Exceptions; +using Bit.App.Droid.Utilities; +using Bit.Core.Resources.Localization; +using Bit.Core.Utilities.Fido2; + +namespace Bit.Droid.Autofill +{ + [Service(Permission = Manifest.Permission.BindCredentialProviderService, Label = "Bitwarden", Exported = true)] + [IntentFilter(new string[] { "android.service.credentials.CredentialProviderService" })] + [MetaData("android.credentials.provider", Resource = "@xml/provider")] + [Register("com.x8bit.bitwarden.Autofill.CredentialProviderService")] + public class CredentialProviderService : AndroidX.Credentials.Provider.CredentialProviderService + { + 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 readonly LazyResolve _vaultTimeoutService = new LazyResolve(); + private readonly LazyResolve _stateService = new LazyResolve(); + private readonly LazyResolve _logger = new LazyResolve(); + + public override async void OnBeginCreateCredentialRequest(BeginCreateCredentialRequest request, + CancellationSignal cancellationSignal, IOutcomeReceiver callback) + { + try + { + var response = await ProcessCreateCredentialsRequestAsync(request); + if (response != null) + { + await MainThread.InvokeOnMainThreadAsync(() => callback.OnResult(response)); + return; + } + } + catch (Exception ex) + { + _logger.Value.Exception(ex); + } + MainThread.BeginInvokeOnMainThread(() => callback.OnError(AppResources.ErrorCreatingPasskey)); + } + + public override async void OnBeginGetCredentialRequest(BeginGetCredentialRequest request, + CancellationSignal cancellationSignal, IOutcomeReceiver callback) + { + try + { + await _vaultTimeoutService.Value.CheckVaultTimeoutAsync(); + var locked = await _vaultTimeoutService.Value.IsLockedAsync(); + if (!locked) + { + var response = await ProcessGetCredentialsRequestAsync(request); + callback.OnResult(response); + return; + } + + 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) + { + _logger.Value.Exception(e); + callback.OnError(e.ErrorMessage ?? AppResources.ErrorReadingPasskey); + } + catch (Exception e) + { + _logger.Value.Exception(e); + callback.OnError(AppResources.ErrorReadingPasskey); + } + } + + private async Task 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 await HandleCreatePasskeyQueryAsync(beginCreatePublicKeyCredentialRequest); + } + + return null; + } + + private async Task HandleCreatePasskeyQueryAsync(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)); + + var userEmail = await GetSafeActiveAccountEmailAsync(); + + var createEntryBuilder = new CreateEntry.Builder(userEmail ?? AppResources.Bitwarden, pendingIntent) + .SetDescription(userEmail != null + ? string.Format(AppResources.YourPasskeyWillBeSavedToYourBitwardenVaultForX, userEmail) + : AppResources.YourPasskeyWillBeSavedToYourBitwardenVault) + .Build(); + + var createCredentialResponse = new BeginCreateCredentialResponse.Builder() + .AddCreateEntry(createEntryBuilder); + + return createCredentialResponse.Build(); + } + + private async Task ProcessGetCredentialsRequestAsync( + BeginGetCredentialRequest request) + { + var credentialEntries = new List(); + + foreach (var option in request.BeginGetCredentialOptions.OfType()) + { + credentialEntries.AddRange(await Bit.App.Platforms.Android.Autofill.CredentialHelpers.PopulatePasskeyDataAsync(request.CallingAppInfo, option, ApplicationContext, false)); + } + + if (!credentialEntries.Any()) + { + return new BeginGetCredentialResponse(); + } + + return new BeginGetCredentialResponse.Builder() + .SetCredentialEntries(credentialEntries) + .Build(); + } + + public override void OnClearCredentialStateRequest(ProviderClearCredentialStateRequest request, + CancellationSignal cancellationSignal, IOutcomeReceiver callback) + { + callback.OnResult(null); + } + + private async Task GetSafeActiveAccountEmailAsync() + { + try + { + return await _stateService.Value.GetEmailAsync(); + } + catch (Exception ex) + { + // if it throws to get the user's email then we log and continue showing a more generic message + _logger.Value.Exception(ex); + return 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..9d1e503ec --- /dev/null +++ b/src/App/Platforms/Android/Autofill/Fido2GetAssertionUserInterface.cs @@ -0,0 +1,77 @@ +using Bit.Core.Abstractions; +using Bit.Core.Services; +using Bit.Core.Utilities.Fido2; + +namespace Bit.App.Platforms.Android.Autofill +{ + public interface IFido2AndroidGetAssertionUserInterface : IFido2GetAssertionUserInterface + { + void Init(string cipherId, + bool userVerified, + Func hasVaultBeenUnlockedInThisTransaction, + string rpId); + } + + public class Fido2GetAssertionUserInterface : Core.Utilities.Fido2.Fido2GetAssertionUserInterface, IFido2AndroidGetAssertionUserInterface + { + private readonly IStateService _stateService; + private readonly IVaultTimeoutService _vaultTimeoutService; + private readonly ICipherService _cipherService; + private readonly IUserVerificationMediatorService _userVerificationMediatorService; + + public Fido2GetAssertionUserInterface(IStateService stateService, + IVaultTimeoutService vaultTimeoutService, + ICipherService cipherService, + IUserVerificationMediatorService userVerificationMediatorService) + { + _stateService = stateService; + _vaultTimeoutService = vaultTimeoutService; + _cipherService = cipherService; + _userVerificationMediatorService = userVerificationMediatorService; + } + + public void Init(string cipherId, + bool userVerified, + Func hasVaultBeenUnlockedInThisTransaction, + string rpId) + { + Init(cipherId, + userVerified, + EnsureAuthenAndVaultUnlockedAsync, + hasVaultBeenUnlockedInThisTransaction, + (cipherId, userVerificationPreference) => VerifyUserAsync(cipherId, userVerificationPreference, rpId, hasVaultBeenUnlockedInThisTransaction())); + } + + public async Task EnsureAuthenAndVaultUnlockedAsync() + { + if (!await _stateService.IsAuthenticatedAsync() || await _vaultTimeoutService.IsLockedAsync()) + { + // this should never happen but just in case. + throw new InvalidOperationException("Not authed or vault locked"); + } + } + + private async Task VerifyUserAsync(string selectedCipherId, Fido2UserVerificationPreference userVerificationPreference, string rpId, bool vaultUnlockedDuringThisTransaction) + { + try + { + var encrypted = await _cipherService.GetAsync(selectedCipherId); + var cipher = await encrypted.DecryptAsync(); + + var userVerification = await _userVerificationMediatorService.VerifyUserForFido2Async( + new Fido2UserVerificationOptions( + cipher?.Reprompt == Core.Enums.CipherRepromptType.Password, + userVerificationPreference, + vaultUnlockedDuringThisTransaction, + rpId) + ); + return !userVerification.IsCancelled && userVerification.Result; + } + catch (Exception ex) + { + LoggerHelper.LogEvenIfCantBeResolved(ex); + return false; + } + } + } +} diff --git a/src/App/Platforms/Android/Autofill/Fido2MakeCredentialUserInterface.cs b/src/App/Platforms/Android/Autofill/Fido2MakeCredentialUserInterface.cs new file mode 100644 index 000000000..f68ecaf7b --- /dev/null +++ b/src/App/Platforms/Android/Autofill/Fido2MakeCredentialUserInterface.cs @@ -0,0 +1,202 @@ +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 readonly IPlatformUtilsService _platformUtilsService; + private LazyResolve _messagingService = new LazyResolve(); + + private TaskCompletionSource<(string cipherId, bool? userVerified)> _confirmCredentialTcs; + private TaskCompletionSource _unlockVaultTcs; + private Fido2UserVerificationOptions? _currentDefaultUserVerificationOptions; + private Func _checkHasVaultBeenUnlockedInThisTransaction; + + public Fido2MakeCredentialUserInterface(IStateService stateService, + IVaultTimeoutService vaultTimeoutService, + ICipherService cipherService, + IUserVerificationMediatorService userVerificationMediatorService, + IDeviceActionService deviceActionService, + IPlatformUtilsService platformUtilsService) + { + _stateService = stateService; + _vaultTimeoutService = vaultTimeoutService; + _cipherService = cipherService; + _userVerificationMediatorService = userVerificationMediatorService; + _deviceActionService = deviceActionService; + _platformUtilsService = platformUtilsService; + } + + public bool HasVaultBeenUnlockedInThisTransaction => _checkHasVaultBeenUnlockedInThisTransaction?.Invoke() == true; + + public bool IsConfirmingNewCredential => _confirmCredentialTcs?.Task != null && !_confirmCredentialTcs.Task.IsCompleted; + public bool IsWaitingUnlockVault => _unlockVaultTcs?.Task != null && !_unlockVaultTcs.Task.IsCompleted; + + 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, HasVaultBeenUnlockedInThisTransaction, confirmNewCredentialParams.RpId); + + _messagingService.Value.Send(Bit.Core.Constants.CredentialNavigateToAutofillCipherMessageCommand, 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.IsLoggedOutByTimeoutAsync() + || + await _vaultTimeoutService.ShouldLogOutByTimeoutAsync()) + { + await NavigateAndWaitForUnlockAsync(Bit.Core.Enums.NavigationTarget.HomeLogin); + return; + } + + if (!await _vaultTimeoutService.IsLockedAsync()) + { + return; + } + + await NavigateAndWaitForUnlockAsync(Bit.Core.Enums.NavigationTarget.Lock); + } + + private async Task NavigateAndWaitForUnlockAsync(Bit.Core.Enums.NavigationTarget navTarget) + { + _unlockVaultTcs?.TrySetCanceled(); + _unlockVaultTcs = new TaskCompletionSource(); + + _messagingService.Value.Send(Bit.Core.Constants.NavigateToMessageCommand, navTarget); + + await _unlockVaultTcs.Task; + } + + public Task InformExcludedCredentialAsync(string[] existingCipherIds) + { + // TODO: Show excluded credential to the user in some screen. + return Task.FromResult(true); + } + + public void SetCheckHasVaultBeenUnlockedInThisTransaction(Func checkHasVaultBeenUnlockedInThisTransaction) + { + _checkHasVaultBeenUnlockedInThisTransaction = checkHasVaultBeenUnlockedInThisTransaction; + } + + public void Confirm(string cipherId, bool? userVerified) => _confirmCredentialTcs?.TrySetResult((cipherId, userVerified)); + public void ConfirmVaultUnlocked() => _unlockVaultTcs?.TrySetResult(true); + + public async Task ConfirmAsync(string cipherId, bool alreadyHasFido2Credential, bool? userVerified) + { + if (alreadyHasFido2Credential + && + !await _platformUtilsService.ShowDialogAsync( + AppResources.ThisItemAlreadyContainsAPasskeyAreYouSureYouWantToOverwriteTheCurrentPasskey, + AppResources.OverwritePasskey, + AppResources.Yes, + AppResources.No)) + { + return; + } + + Confirm(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, + HasVaultBeenUnlockedInThisTransaction, + 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..441b8d309 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 { @@ -167,6 +168,13 @@ namespace Bit.Droid base.OnNewIntent(intent); try { + if (intent?.GetStringExtra(CredentialProviderConstants.Fido2CredentialAction) == CredentialProviderConstants.Fido2CredentialCreate + && + _appOptions != null) + { + _appOptions.HasUnlockedInThisTransaction = false; + } + if (intent?.GetStringExtra("uri") is string uri) { _messagingService.Send(App.App.POP_ALL_AND_GO_TO_AUTOFILL_CIPHERS_MESSAGE); @@ -325,12 +333,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 f23363b42..69b4b01e6 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 @@ -85,6 +88,57 @@ namespace Bit.Droid ServiceContainer.Resolve(), ServiceContainer.Resolve()); ServiceContainer.Register("accountsManager", accountsManager); + + var userPinService = new UserPinService( + ServiceContainer.Resolve(), + 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 fido2GetAssertionUserInterface = new Fido2GetAssertionUserInterface( + ServiceContainer.Resolve(), + ServiceContainer.Resolve(), + ServiceContainer.Resolve(), + ServiceContainer.Resolve()); + ServiceContainer.Register(fido2GetAssertionUserInterface); + + var fido2MakeCredentialUserInterface = new Fido2MakeCredentialUserInterface( + ServiceContainer.Resolve(), + 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(), + fido2GetAssertionUserInterface, + fido2MakeCredentialUserInterface); + ServiceContainer.Register(fido2ClientService); + + ServiceContainer.Register(new Fido2MediatorService( + fido2AuthenticatorService, + fido2ClientService, + ServiceContainer.Resolve())); } #if !FDROID if (Build.VERSION.SdkInt <= BuildVersionCodes.Kitkat) @@ -160,7 +214,6 @@ namespace Bit.Droid var cryptoFunctionService = new PclCryptoFunctionService(cryptoPrimitiveService); var cryptoService = new CryptoService(stateService, cryptoFunctionService, logger); var biometricService = new BiometricService(stateService, cryptoService); - var userPinService = new UserPinService(stateService, cryptoService); var passwordRepromptService = new MobilePasswordRepromptService(platformUtilsService, cryptoService, stateService); ServiceContainer.Register(preferencesStorage); @@ -184,7 +237,6 @@ namespace Bit.Droid ServiceContainer.Register("cryptoService", cryptoService); ServiceContainer.Register("passwordRepromptService", passwordRepromptService); ServiceContainer.Register("avatarImageSourcePool", new AvatarImageSourcePool()); - ServiceContainer.Register(userPinService); // Push #if FDROID diff --git a/src/App/Platforms/Android/Resources/xml/provider.xml b/src/App/Platforms/Android/Resources/xml/provider.xml new file mode 100644 index 000000000..eb901638a --- /dev/null +++ b/src/App/Platforms/Android/Resources/xml/provider.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/App/Platforms/Android/Services/AutofillHandler.cs b/src/App/Platforms/Android/Services/AutofillHandler.cs index 12429b841..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; @@ -37,6 +37,42 @@ namespace Bit.Droid.Services _eventService = eventService; } + public bool CredentialProviderServiceEnabled() + { + if (Build.VERSION.SdkInt < BuildVersionCodes.UpsideDownCake) + { + return false; + } + + try + { + 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 + { + return false; + } + } + public bool AutofillServiceEnabled() { if (Build.VERSION.SdkInt < BuildVersionCodes.O) @@ -163,7 +199,17 @@ namespace Bit.Droid.Services return Accessibility.AccessibilityHelpers.OverlayPermitted(); } - + public void DisableCredentialProviderService() + { + try + { + // 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 { } + } public void DisableAutofillService() { diff --git a/src/App/Platforms/Android/Services/DeviceActionService.cs b/src/App/Platforms/Android/Services/DeviceActionService.cs index 0a3498114..82174220f 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; @@ -11,16 +9,20 @@ using Android.Text.Method; using Android.Views; using Android.Views.InputMethods; using Android.Widget; +using AndroidX.Credentials; using Bit.App.Abstractions; 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 { @@ -203,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); @@ -260,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); @@ -337,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); @@ -370,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); } @@ -393,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); @@ -474,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); @@ -501,11 +503,32 @@ namespace Bit.Droid.Services } } + public void OpenCredentialProviderSettings() + { + var activity = Microsoft.Maui.ApplicationModel.Platform.CurrentActivity; + try + { + var pendingIntent = ICredentialManager.Create(activity).CreateSettingsPendingIntent(); + pendingIntent.Send(); + } + catch (ActivityNotFoundException) + { + var alertBuilder = new AlertDialog.Builder(activity); + alertBuilder.SetMessage(AppResources.BitwardenCredentialProviderGoToSettings); + alertBuilder.SetCancelable(true); + alertBuilder.SetPositiveButton(AppResources.Ok, (sender, args) => + { + (sender as AlertDialog)?.Cancel(); + }); + alertBuilder.Create().Show(); + } + } + public void OpenAccessibilitySettings() { 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); } @@ -514,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); @@ -542,10 +565,92 @@ 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(); + } + + // Clear CredentialAction and FromFido2Framework values to avoid erratic behaviors in subsequent navigation/flows + // For Fido2CredentialGet these are no longer needed as a new Activity will be initiated. + // For Fido2CredentialCreate the app will rely on IFido2MakeCredentialConfirmationUserInterface.IsConfirmingNewCredential + appOptions.Fido2CredentialAction = null; + appOptions.FromFido2Framework = false; + } + + 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; @@ -559,6 +664,8 @@ namespace Bit.Droid.Services return true; } + public bool SupportsCredentialProviderService() => Build.VERSION.SdkInt >= BuildVersionCodes.UpsideDownCake; + public bool SupportsAutofillServices() => Build.VERSION.SdkInt >= BuildVersionCodes.O; public bool SupportsInlineAutofill() => Build.VERSION.SdkInt >= BuildVersionCodes.R; @@ -584,7 +691,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/App/Platforms/Android/Utilities/CallingAppInfoExtensions.cs b/src/App/Platforms/Android/Utilities/CallingAppInfoExtensions.cs new file mode 100644 index 000000000..1d5cd8654 --- /dev/null +++ b/src/App/Platforms/Android/Utilities/CallingAppInfoExtensions.cs @@ -0,0 +1,37 @@ +using Android.OS; +using AndroidX.Credentials.Provider; +using Bit.Core.Utilities; +using Java.Security; + +namespace Bit.App.Droid.Utilities +{ + public static class CallingAppInfoExtensions + { + public static string GetAndroidOrigin(this CallingAppInfo callingAppInfo) + { + if (Build.VERSION.SdkInt < BuildVersionCodes.P || callingAppInfo?.SigningInfo?.GetApkContentsSigners().Any() != true) + { + return null; + } + + var cert = callingAppInfo.SigningInfo.GetApkContentsSigners()[0].ToByteArray(); + var md = MessageDigest.GetInstance("SHA-256"); + var certHash = md.Digest(cert); + return $"android:apk-key-hash:{CoreHelpers.Base64UrlEncode(certHash)}"; + } + + public static string GetLatestCertificationFingerprint(this CallingAppInfo callingAppInfo) + { + if (callingAppInfo.SigningInfo.HasMultipleSigners) + { + return null; + } + + var signature = callingAppInfo.SigningInfo.GetSigningCertificateHistory()[0].ToByteArray(); + var md = MessageDigest.GetInstance("SHA-256"); + var digestedSignature = md.Digest(signature); + var normalizedFingerprint = string.Join(":", digestedSignature.Select(b => b.ToString("X2"))); + return normalizedFingerprint; + } + } +} diff --git a/src/App/Platforms/iOS/AppDelegate.cs b/src/App/Platforms/iOS/AppDelegate.cs index b9f648805..066eb54bc 100644 --- a/src/App/Platforms/iOS/AppDelegate.cs +++ b/src/App/Platforms/iOS/AppDelegate.cs @@ -88,7 +88,7 @@ namespace Bit.iOS Core.Constants.AutofillNeedsIdentityReplacementKey); if (needsAutofillReplacement.GetValueOrDefault()) { - await ASHelpers.ReplaceAllIdentities(); + await ASHelpers.ReplaceAllIdentitiesAsync(); } } else if (message.Command == "showAppExtension") @@ -102,7 +102,7 @@ namespace Bit.iOS var success = value as bool?; if (success.GetValueOrDefault() && _deviceActionService.SystemMajorVersion() >= 12) { - await ASHelpers.ReplaceAllIdentities(); + await ASHelpers.ReplaceAllIdentitiesAsync(); } } } @@ -114,22 +114,21 @@ namespace Bit.iOS return; } - if (await ASHelpers.IdentitiesCanIncremental()) + if (await ASHelpers.IdentitiesSupportIncrementalAsync()) { var cipherId = message.Data as string; if (message.Command == "addedCipher" && !string.IsNullOrWhiteSpace(cipherId)) { - var identity = await ASHelpers.GetCipherIdentityAsync(cipherId); + var identity = await ASHelpers.GetCipherPasswordIdentityAsync(cipherId); if (identity == null) { return; } - await ASCredentialIdentityStore.SharedStore?.SaveCredentialIdentitiesAsync( - new ASPasswordCredentialIdentity[] { identity }); + await ASCredentialIdentityStoreExtensions.SaveCredentialIdentitiesAsync(identity); return; } } - await ASHelpers.ReplaceAllIdentities(); + await ASHelpers.ReplaceAllIdentitiesAsync(); } else if (message.Command == "deletedCipher" || message.Command == "softDeletedCipher") { @@ -138,28 +137,27 @@ namespace Bit.iOS return; } - if (await ASHelpers.IdentitiesCanIncremental()) + if (await ASHelpers.IdentitiesSupportIncrementalAsync()) { - var identity = ASHelpers.ToCredentialIdentity( + var identity = ASHelpers.ToPasswordCredentialIdentity( message.Data as Bit.Core.Models.View.CipherView); if (identity == null) { return; } - await ASCredentialIdentityStore.SharedStore?.RemoveCredentialIdentitiesAsync( - new ASPasswordCredentialIdentity[] { identity }); + await ASCredentialIdentityStoreExtensions.RemoveCredentialIdentitiesAsync(identity); return; } - await ASHelpers.ReplaceAllIdentities(); + await ASHelpers.ReplaceAllIdentitiesAsync(); } else if (message.Command == "logout" && UIDevice.CurrentDevice.CheckSystemVersion(12, 0)) { - await ASCredentialIdentityStore.SharedStore?.RemoveAllCredentialIdentitiesAsync(); + await ASCredentialIdentityStore.SharedStore.RemoveAllCredentialIdentitiesAsync(); } else if ((message.Command == "softDeletedCipher" || message.Command == "restoredCipher") && UIDevice.CurrentDevice.CheckSystemVersion(12, 0)) { - await ASHelpers.ReplaceAllIdentities(); + await ASHelpers.ReplaceAllIdentitiesAsync(); } else if (message.Command == AppHelpers.VAULT_TIMEOUT_ACTION_CHANGED_MESSAGE_COMMAND) { @@ -168,12 +166,12 @@ namespace Bit.iOS { if (UIDevice.CurrentDevice.CheckSystemVersion(12, 0)) { - await ASCredentialIdentityStore.SharedStore?.RemoveAllCredentialIdentitiesAsync(); + await ASCredentialIdentityStore.SharedStore.RemoveAllCredentialIdentitiesAsync(); } } else { - await ASHelpers.ReplaceAllIdentities(); + await ASHelpers.ReplaceAllIdentitiesAsync(); } } } diff --git a/src/App/Resources/Raw/fido2_priviliged_allow_list.json b/src/App/Resources/Raw/fido2_priviliged_allow_list.json new file mode 100644 index 000000000..dd23740e0 --- /dev/null +++ b/src/App/Resources/Raw/fido2_priviliged_allow_list.json @@ -0,0 +1,481 @@ +{ + "apps": [ + { + "type": "android", + "info": { + "package_name": "com.android.chrome", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.chrome.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "DA:63:3D:34:B6:9E:63:AE:21:03:B4:9D:53:CE:05:2F:C5:F7:F3:C5:3A:AB:94:FD:C2:A2:08:BD:FD:14:24:9C" + }, + { + "build": "release", + "cert_fingerprint_sha256": "3D:7A:12:23:01:9A:A3:9D:9E:A0:E3:43:6A:B7:C0:89:6B:FB:4F:B6:79:F4:DE:5F:E7:C2:3F:32:6C:8F:99:4A" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.chrome.dev", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "90:44:EE:5F:EE:4B:BC:5E:21:DD:44:66:54:31:C4:EB:1F:1F:71:A3:27:16:A0:BC:92:7B:CB:B3:92:33:CA:BF" + }, + { + "build": "release", + "cert_fingerprint_sha256": "3D:7A:12:23:01:9A:A3:9D:9E:A0:E3:43:6A:B7:C0:89:6B:FB:4F:B6:79:F4:DE:5F:E7:C2:3F:32:6C:8F:99:4A" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.chrome.canary", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "20:19:DF:A1:FB:23:EF:BF:70:C5:BC:D1:44:3C:5B:EA:B0:4F:3F:2F:F4:36:6E:9A:C1:E3:45:76:39:A2:4C:FC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.chromium.chrome", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C6:AD:B8:B8:3C:6D:4C:17:D2:92:AF:DE:56:FD:48:8A:51:D3:16:FF:8F:2C:11:C5:41:02:23:BF:F8:A7:DB:B3" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.google.android.apps.chrome", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.fennec_webauthndebug", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "BD:AE:82:02:80:D2:AF:B7:74:94:EF:22:58:AA:78:A9:AE:A1:36:41:7E:8B:C2:3D:C9:87:75:2E:6F:48:E8:48" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.firefox", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "A7:8B:62:A5:16:5B:44:94:B2:FE:AD:9E:76:A2:80:D2:2D:93:7F:EE:62:51:AE:CE:59:94:46:B2:EA:31:9B:04" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.firefox_beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "A7:8B:62:A5:16:5B:44:94:B2:FE:AD:9E:76:A2:80:D2:2D:93:7F:EE:62:51:AE:CE:59:94:46:B2:EA:31:9B:04" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.focus", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "62:03:A4:73:BE:36:D6:4E:E3:7F:87:FA:50:0E:DB:C7:9E:AB:93:06:10:AB:9B:9F:A4:CA:7D:5C:1F:1B:4F:FC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.fennec_aurora", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "BC:04:88:83:8D:06:F4:CA:6B:F3:23:86:DA:AB:0D:D8:EB:CF:3E:77:30:78:74:59:F6:2F:B3:CD:14:A1:BA:AA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.rocket", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "86:3A:46:F0:97:39:32:B7:D0:19:9B:54:91:12:74:1C:2D:27:31:AC:72:EA:11:B7:52:3A:A9:0A:11:BF:56:91" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.canary", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.dev", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.rolling", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "32:A2:FC:74:D7:31:10:58:59:E5:A8:5D:F1:6D:95:F1:02:D8:5B:22:09:9B:80:64:C5:D8:91:5C:61:DA:D1:E0" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.local", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "32:A2:FC:74:D7:31:10:58:59:E5:A8:5D:F1:6D:95:F1:02:D8:5B:22:09:9B:80:64:C5:D8:91:5C:61:DA:D1:E0" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.brave.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.brave.browser_beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.brave.browser_nightly", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "app.vanadium.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C6:AD:B8:B8:3C:6D:4C:17:D2:92:AF:DE:56:FD:48:8A:51:D3:16:FF:8F:2C:11:C5:41:02:23:BF:F8:A7:DB:B3" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.vivaldi.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "E8:A7:85:44:65:5B:A8:C0:98:17:F7:32:76:8F:56:89:B1:66:2E:C4:B2:BC:5A:0B:C0:EC:13:8D:33:CA:3D:1E" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.vivaldi.browser.snapshot", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "E8:A7:85:44:65:5B:A8:C0:98:17:F7:32:76:8F:56:89:B1:66:2E:C4:B2:BC:5A:0B:C0:EC:13:8D:33:CA:3D:1E" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.vivaldi.browser.sopranos", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "E8:A7:85:44:65:5B:A8:C0:98:17:F7:32:76:8F:56:89:B1:66:2E:C4:B2:BC:5A:0B:C0:EC:13:8D:33:CA:3D:1E" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.citrix.Receiver", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "3D:D1:12:67:10:69:AB:36:4E:F9:BE:73:9A:B7:B5:EE:15:E1:CD:E9:D8:75:7B:1B:F0:64:F5:0C:55:68:9A:49" + }, + { + "build": "release", + "cert_fingerprint_sha256": "CE:B2:23:D7:77:09:F2:B6:BC:0B:3A:78:36:F5:A5:AF:4C:E1:D3:55:F4:A7:28:86:F7:9D:F8:0D:C9:D6:12:2E" + }, + { + "build": "release", + "cert_fingerprint_sha256": "AA:D0:D4:57:E6:33:C3:78:25:77:30:5B:C1:B2:D9:E3:81:41:C7:21:DF:0D:AA:6E:29:07:2F:C4:1D:34:F0:AB" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.android.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C9:00:9D:01:EB:F9:F5:D0:30:2B:C7:1B:2F:E9:AA:9A:47:A4:32:BB:A1:73:08:A3:11:1B:75:D7:B2:14:90:25" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.sec.android.app.sbrowser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C8:A2:E9:BC:CF:59:7C:2F:B6:DC:66:BE:E2:93:FC:13:F2:FC:47:EC:77:BC:6B:2B:0D:52:C1:1F:51:19:2A:B8" + }, + { + "build": "release", + "cert_fingerprint_sha256": "34:DF:0E:7A:9F:1C:F1:89:2E:45:C0:56:B4:97:3C:D8:1C:CF:14:8A:40:50:D1:1A:EA:4A:C5:A6:5F:90:0A:42" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.sec.android.app.sbrowser.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C8:A2:E9:BC:CF:59:7C:2F:B6:DC:66:BE:E2:93:FC:13:F2:FC:47:EC:77:BC:6B:2B:0D:52:C1:1F:51:19:2A:B8" + }, + { + "build": "release", + "cert_fingerprint_sha256": "34:DF:0E:7A:9F:1C:F1:89:2E:45:C0:56:B4:97:3C:D8:1C:CF:14:8A:40:50:D1:1A:EA:4A:C5:A6:5F:90:0A:42" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.google.android.gms", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "7C:E8:3C:1B:71:F3:D5:72:FE:D0:4C:8D:40:C5:CB:10:FF:75:E6:D8:7D:9D:F6:FB:D5:3F:04:68:C2:90:50:53" + }, + { + "build": "release", + "cert_fingerprint_sha256": "D2:2C:C5:00:29:9F:B2:28:73:A0:1A:01:0D:E1:C8:2F:BE:4D:06:11:19:B9:48:14:DD:30:1D:AB:50:CB:76:78" + }, + { + "build": "release", + "cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83" + }, + { + "build": "release", + "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.alpha", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.corp", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.canary", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "1D:A9:CB:AE:2D:CC:C6:A5:8D:6C:94:7B:E9:4C:DB:B7:33:D6:5D:A4:D1:77:0F:A1:4A:53:64:CB:4A:28:EB:49" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.broteam", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "1D:A9:CB:AE:2D:CC:C6:A5:8D:6C:94:7B:E9:4C:DB:B7:33:D6:5D:A4:D1:77:0F:A1:4A:53:64:CB:4A:28:EB:49" + } + ] + } + } + ] + } + \ No newline at end of file diff --git a/src/Core/Abstractions/IApiService.cs b/src/Core/Abstractions/IApiService.cs index b60d90267..bbd3b9357 100644 --- a/src/Core/Abstractions/IApiService.cs +++ b/src/Core/Abstractions/IApiService.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Models.Request; using Bit.Core.Models.Response; @@ -100,5 +95,6 @@ namespace Bit.Core.Abstractions Task GetDevicesExistenceByTypes(DeviceType[] deviceTypes); Task GetConfigsAsync(); Task GetFastmailAccountIdAsync(string apiKey); + Task> GetDigitalAssetLinksForRpAsync(string rpId); } } diff --git a/src/Core/Abstractions/IAssetLinksService.cs b/src/Core/Abstractions/IAssetLinksService.cs new file mode 100644 index 000000000..c9dc5ca85 --- /dev/null +++ b/src/Core/Abstractions/IAssetLinksService.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Services +{ + public interface IAssetLinksService + { + Task ValidateAssetLinksAsync(string rpId, string packageName, string normalizedFingerprint); + } +} diff --git a/src/Core/Abstractions/IAutofillHandler.cs b/src/Core/Abstractions/IAutofillHandler.cs index 84c9489b9..81a8016f8 100644 --- a/src/Core/Abstractions/IAutofillHandler.cs +++ b/src/Core/Abstractions/IAutofillHandler.cs @@ -4,6 +4,7 @@ namespace Bit.Core.Abstractions { public interface IAutofillHandler { + bool CredentialProviderServiceEnabled(); bool AutofillServicesEnabled(); bool SupportsAutofillService(); void Autofill(CipherView cipher); @@ -11,6 +12,7 @@ namespace Bit.Core.Abstractions bool AutofillAccessibilityServiceRunning(); bool AutofillAccessibilityOverlayPermitted(); bool AutofillServiceEnabled(); + void DisableCredentialProviderService(); void DisableAutofillService(); } } diff --git a/src/Core/Abstractions/ICipherService.cs b/src/Core/Abstractions/ICipherService.cs index 91b93e9ce..59c395b44 100644 --- a/src/Core/Abstractions/ICipherService.cs +++ b/src/Core/Abstractions/ICipherService.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Models.Domain; using Bit.Core.Models.View; @@ -37,6 +34,8 @@ namespace Bit.Core.Abstractions Task DownloadAndDecryptAttachmentAsync(string cipherId, AttachmentView attachment, string organizationId); Task SoftDeleteWithServerAsync(string id); Task RestoreWithServerAsync(string id); + Task CreateNewLoginForPasskeyAsync(Fido2ConfirmNewCredentialParams newPasskeyParams); + Task CopyTotpCodeIfNeededAsync(CipherView cipher); Task VerifyOrganizationHasUnassignedItemsAsync(); } } diff --git a/src/Core/Abstractions/IConditionedAwaiterManager.cs b/src/Core/Abstractions/IConditionedAwaiterManager.cs index 6eb4df5dc..3a6a0ec0f 100644 --- a/src/Core/Abstractions/IConditionedAwaiterManager.cs +++ b/src/Core/Abstractions/IConditionedAwaiterManager.cs @@ -1,12 +1,10 @@ -using System; -using System.Threading.Tasks; - -namespace Bit.Core.Abstractions +namespace Bit.Core.Abstractions { public enum AwaiterPrecondition { EnvironmentUrlsInited, - AndroidWindowCreated + AndroidWindowCreated, + AutofillIOSExtensionViewDidAppear } public interface IConditionedAwaiterManager @@ -14,5 +12,6 @@ namespace Bit.Core.Abstractions Task GetAwaiterForPrecondition(AwaiterPrecondition awaiterPrecondition); void SetAsCompleted(AwaiterPrecondition awaiterPrecondition); void SetException(AwaiterPrecondition awaiterPrecondition, Exception ex); + void Recreate(AwaiterPrecondition awaiterPrecondition); } } diff --git a/src/Core/Abstractions/ICryptoFunctionService.cs b/src/Core/Abstractions/ICryptoFunctionService.cs index 39b6ba6a1..630dc1b96 100644 --- a/src/Core/Abstractions/ICryptoFunctionService.cs +++ b/src/Core/Abstractions/ICryptoFunctionService.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using Bit.Core.Enums; +using Bit.Core.Models.Domain; namespace Bit.Core.Abstractions { diff --git a/src/Core/Abstractions/IDeviceActionService.cs b/src/Core/Abstractions/IDeviceActionService.cs index 3a15fde80..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; @@ -28,6 +29,7 @@ namespace Bit.App.Abstractions bool SupportsNfc(); bool SupportsCamera(); bool SupportsFido2(); + bool SupportsCredentialProviderService(); bool SupportsAutofillServices(); bool SupportsInlineAutofill(); bool SupportsDrawOver(); @@ -36,8 +38,10 @@ namespace Bit.App.Abstractions void RateApp(); void OpenAccessibilitySettings(); void OpenAccessibilityOverlayPermissionSettings(); + void OpenCredentialProviderSettings(); void OpenAutofillSettings(); long GetActiveTime(); + Task ExecuteFido2CredentialActionAsync(AppOptions appOptions); void CloseMainApp(); float GetSystemFontSizeScale(); Task OnAccountSwitchCompleteAsync(); diff --git a/src/Core/Abstractions/IFido2AuthenticatorService.cs b/src/Core/Abstractions/IFido2AuthenticatorService.cs new file mode 100644 index 000000000..32ec5c0b8 --- /dev/null +++ b/src/Core/Abstractions/IFido2AuthenticatorService.cs @@ -0,0 +1,12 @@ +using Bit.Core.Utilities.Fido2; + +namespace Bit.Core.Abstractions +{ + public interface IFido2AuthenticatorService + { + Task MakeCredentialAsync(Fido2AuthenticatorMakeCredentialParams makeCredentialParams, IFido2MakeCredentialUserInterface userInterface); + Task GetAssertionAsync(Fido2AuthenticatorGetAssertionParams assertionParams, IFido2GetAssertionUserInterface userInterface); + // TODO: Should this return a List? Or maybe IEnumerable? + Task SilentCredentialDiscoveryAsync(string rpId); + } +} diff --git a/src/Core/Abstractions/IFido2ClientService.cs b/src/Core/Abstractions/IFido2ClientService.cs new file mode 100644 index 000000000..8684ff4e1 --- /dev/null +++ b/src/Core/Abstractions/IFido2ClientService.cs @@ -0,0 +1,37 @@ +using Bit.Core.Utilities.Fido2; + +namespace Bit.Core.Abstractions +{ + /// + /// This class represents an abstraction of the WebAuthn Client as described by W3C: + /// https://www.w3.org/TR/webauthn-3/#webauthn-client + /// + /// The WebAuthn Client is an intermediary entity typically implemented in the user agent + /// (in whole, or in part). Conceptually, it underlies the Web Authentication API and embodies + /// the implementation of the Web Authentication API's operations. + /// + /// It is responsible for both marshalling the inputs for the underlying authenticator operations, + /// and for returning the results of the latter operations to the Web Authentication API's callers. + /// + public interface IFido2ClientService + { + /// + /// Allows WebAuthn Relying Party scripts to request the creation of a new public key credential source. + /// For more information please see: https://www.w3.org/TR/webauthn-3/#sctn-createCredential + /// + /// The parameters for the credential creation operation + /// Extra parameters for the credential creation operation + /// The new credential + Task CreateCredentialAsync(Fido2ClientCreateCredentialParams createCredentialParams, Fido2ExtraCreateCredentialParams extraParams); + + /// + /// Allows WebAuthn Relying Party scripts to discover and use an existing public key credential, with the user’s consent. + /// Relying Party script can optionally specify some criteria to indicate what credential sources are acceptable to it. + /// For more information please see: https://www.w3.org/TR/webauthn-3/#sctn-getAssertion + /// + /// The parameters for the credential assertion operation + /// Extra parameters for the credential assertion operation + /// The asserted credential + Task AssertCredentialAsync(Fido2ClientAssertCredentialParams assertCredentialParams, Fido2ExtraAssertCredentialParams extraParams); + } +} diff --git a/src/Core/Abstractions/IFido2GetAssertionUserInterface.cs b/src/Core/Abstractions/IFido2GetAssertionUserInterface.cs new file mode 100644 index 000000000..b0633a4d3 --- /dev/null +++ b/src/Core/Abstractions/IFido2GetAssertionUserInterface.cs @@ -0,0 +1,20 @@ +using Bit.Core.Utilities.Fido2; + +namespace Bit.Core.Abstractions +{ + public struct Fido2GetAssertionUserInterfaceCredential + { + public string CipherId { get; set; } + public Fido2UserVerificationPreference UserVerificationPreference { get; set; } + } + + public interface IFido2GetAssertionUserInterface : IFido2UserInterface + { + /// + /// Ask the user to pick a credential from a list of existing credentials. + /// + /// The credentials that the user can pick from, and if the user must be verified before completing the operation + /// The ID of the cipher that contains the credentials the user picked, and if the user was verified before completing the operation + Task<(string CipherId, bool UserVerified)> PickCredentialAsync(Fido2GetAssertionUserInterfaceCredential[] credentials); + } +} diff --git a/src/Core/Abstractions/IFido2MakeCredentialConfirmationUserInterface.cs b/src/Core/Abstractions/IFido2MakeCredentialConfirmationUserInterface.cs new file mode 100644 index 000000000..e2ec22614 --- /dev/null +++ b/src/Core/Abstractions/IFido2MakeCredentialConfirmationUserInterface.cs @@ -0,0 +1,66 @@ +using Bit.Core.Utilities.Fido2; + +namespace Bit.Core.Abstractions +{ + public interface IFido2MakeCredentialConfirmationUserInterface : IFido2MakeCredentialUserInterface + { + /// + /// Call this method after the user 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); + + /// + /// Call this method after the user 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 + /// + /// + /// If the cipher corresponding to the already has a Fido2 credential. + /// + /// + /// Whether the user has been verified or not. + /// If null verification has not taken place yet. + /// + Task ConfirmAsync(string cipherId, bool alreadyHasFido2Credential, 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); + + + /// + /// True if we are already confirming a new credential. + /// + bool IsConfirmingNewCredential { get; } + + /// + /// Call this after the vault was unlocked so that Fido2 credential creation can proceed. + /// + void ConfirmVaultUnlocked(); + + /// + /// True if we are waiting for the vault to be unlocked. + /// + bool IsWaitingUnlockVault { get; } + + Fido2UserVerificationOptions? GetCurrentUserVerificationOptions(); + + void SetCheckHasVaultBeenUnlockedInThisTransaction(Func checkHasVaultBeenUnlockedInThisTransaction); + } +} diff --git a/src/Core/Abstractions/IFido2MakeCredentialUserInterface.cs b/src/Core/Abstractions/IFido2MakeCredentialUserInterface.cs new file mode 100644 index 000000000..90fc9f3f7 --- /dev/null +++ b/src/Core/Abstractions/IFido2MakeCredentialUserInterface.cs @@ -0,0 +1,44 @@ +using Bit.Core.Utilities.Fido2; + +namespace Bit.Core.Abstractions +{ + public struct Fido2ConfirmNewCredentialParams + { + /// + /// The name of the credential. + /// + public string CredentialName { get; set; } + + /// + /// The name of the user. + /// + public string UserName { get; set; } + + /// + /// The preference to whether or not the user must be verified before completing the operation. + /// + public Fido2UserVerificationPreference UserVerificationPreference { get; set; } + + /// + /// The relying party identifier + /// + public string RpId { get; set; } + } + + public interface IFido2MakeCredentialUserInterface : IFido2UserInterface + { + /// + /// Inform the user that the operation was cancelled because their vault contains excluded credentials. + /// + /// The IDs of the excluded credentials. + /// When user has confirmed the message + Task InformExcludedCredentialAsync(string[] existingCipherIds); + + /// + /// Ask the user to confirm the creation of a new credential. + /// + /// The parameters to use when asking the user to confirm the creation of a new credential. + /// The ID of the cipher where the new credential should be saved, and if the user was verified before completing the operation + Task<(string CipherId, bool UserVerified)> ConfirmNewCredentialAsync(Fido2ConfirmNewCredentialParams confirmNewCredentialParams); + } +} diff --git a/src/Core/Abstractions/IFido2MediatorService.cs b/src/Core/Abstractions/IFido2MediatorService.cs new file mode 100644 index 000000000..a177d1e80 --- /dev/null +++ b/src/Core/Abstractions/IFido2MediatorService.cs @@ -0,0 +1,14 @@ +using Bit.Core.Utilities.Fido2; + +namespace Bit.Core.Abstractions +{ + public interface IFido2MediatorService + { + Task CreateCredentialAsync(Fido2ClientCreateCredentialParams createCredentialParams, Fido2ExtraCreateCredentialParams extraParams); + Task AssertCredentialAsync(Fido2ClientAssertCredentialParams assertCredentialParams, Fido2ExtraAssertCredentialParams extraParams); + + Task MakeCredentialAsync(Fido2AuthenticatorMakeCredentialParams makeCredentialParams, IFido2MakeCredentialUserInterface userInterface); + Task GetAssertionAsync(Fido2AuthenticatorGetAssertionParams assertionParams, IFido2GetAssertionUserInterface userInterface); + Task SilentCredentialDiscoveryAsync(string rpId); + } +} diff --git a/src/Core/Abstractions/IFido2UserInterface.cs b/src/Core/Abstractions/IFido2UserInterface.cs new file mode 100644 index 000000000..d2ff9ff50 --- /dev/null +++ b/src/Core/Abstractions/IFido2UserInterface.cs @@ -0,0 +1,17 @@ +namespace Bit.Core.Abstractions +{ + public interface IFido2UserInterface + { + /// + /// Whether the vault has been unlocked during this transaction + /// + bool HasVaultBeenUnlockedInThisTransaction { get; } + + /// + /// Make sure that the vault is unlocked. + /// This should open a window and ask the user to login or unlock the vault if necessary. + /// + /// When vault has been unlocked. + Task EnsureUnlockedVaultAsync(); + } +} diff --git a/src/Core/Abstractions/IPasswordRepromptService.cs b/src/Core/Abstractions/IPasswordRepromptService.cs index 2490271c2..7660c766f 100644 --- a/src/Core/Abstractions/IPasswordRepromptService.cs +++ b/src/Core/Abstractions/IPasswordRepromptService.cs @@ -1,5 +1,4 @@ -using System.Threading.Tasks; -using Bit.Core.Enums; +using Bit.Core.Enums; namespace Bit.App.Abstractions { @@ -10,5 +9,7 @@ namespace Bit.App.Abstractions Task PromptAndCheckPasswordIfNeededAsync(CipherRepromptType repromptType = CipherRepromptType.Password); Task<(string password, bool valid)> ShowPasswordPromptAndGetItAsync(); + + Task ShouldByPassMasterPasswordRepromptAsync(); } } diff --git a/src/Core/Abstractions/IPlatformUtilsService.cs b/src/Core/Abstractions/IPlatformUtilsService.cs index 2462e29a8..2d09952f6 100644 --- a/src/Core/Abstractions/IPlatformUtilsService.cs +++ b/src/Core/Abstractions/IPlatformUtilsService.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Bit.Core.Enums; +using Bit.Core.Enums; namespace Bit.Core.Abstractions { @@ -29,7 +26,7 @@ namespace Bit.Core.Abstractions bool SupportsDuo(); Task SupportsBiometricAsync(); Task IsBiometricIntegrityValidAsync(string bioIntegritySrcKey = null); - Task AuthenticateBiometricAsync(string text = null, string fallbackText = null, Action fallback = null, bool logOutOnTooManyAttempts = false); + Task AuthenticateBiometricAsync(string text = null, string fallbackText = null, Action fallback = null, bool logOutOnTooManyAttempts = false, bool allowAlternativeAuthentication = false); long GetActiveTime(); } } diff --git a/src/Core/Abstractions/IStateService.cs b/src/Core/Abstractions/IStateService.cs index 2d8391cfa..0afb34da6 100644 --- a/src/Core/Abstractions/IStateService.cs +++ b/src/Core/Abstractions/IStateService.cs @@ -186,6 +186,7 @@ namespace Bit.Core.Abstractions Task GetActiveUserRegionAsync(); Task GetPreAuthRegionAsync(); Task SetPreAuthRegionAsync(BwRegion value); + Task ReloadStateAsync(); Task GetShouldCheckOrganizationUnassignedItemsAsync(string userId = null); Task SetShouldCheckOrganizationUnassignedItemsAsync(bool shouldCheck, string userId = null); [Obsolete("Use GetPinKeyEncryptedUserKeyAsync instead, left for migration purposes")] diff --git a/src/Core/Abstractions/IUserPinService.cs b/src/Core/Abstractions/IUserPinService.cs index 1a8b69d6e..270da5e92 100644 --- a/src/Core/Abstractions/IUserPinService.cs +++ b/src/Core/Abstractions/IUserPinService.cs @@ -1,9 +1,12 @@ -using System.Threading.Tasks; +using Bit.Core.Services; namespace Bit.Core.Abstractions { public interface IUserPinService { + Task IsPinLockEnabledAsync(); Task SetupPinAsync(string pin, bool requireMasterPasswordOnRestart); + Task VerifyPinAsync(string inputPin); + Task VerifyPinAsync(string inputPin, string email, KdfConfig kdfConfig, PinLockType pinLockType); } } diff --git a/src/Core/Abstractions/IUserVerificationMediatorService.cs b/src/Core/Abstractions/IUserVerificationMediatorService.cs new file mode 100644 index 000000000..2382873da --- /dev/null +++ b/src/Core/Abstractions/IUserVerificationMediatorService.cs @@ -0,0 +1,28 @@ +using Bit.Core.Utilities; +using Bit.Core.Utilities.Fido2; + +namespace Bit.Core.Abstractions +{ + public interface IUserVerificationMediatorService + { + Task> VerifyUserForFido2Async(Fido2UserVerificationOptions options); + Task CanPerformUserVerificationPreferredAsync(Fido2UserVerificationOptions options); + Task ShouldPerformMasterPasswordRepromptAsync(Fido2UserVerificationOptions options); + Task ShouldEnforceFido2RequiredUserVerificationAsync(Fido2UserVerificationOptions options); + Task> PerformOSUnlockAsync(); + Task> VerifyPinCodeAsync(); + Task> VerifyMasterPasswordAsync(bool isMasterPasswordReprompt); + + public struct UVResult + { + public UVResult(bool canPerform, bool isVerified) + { + CanPerform = canPerform; + IsVerified = isVerified; + } + + public bool CanPerform { get; set; } + public bool IsVerified { get; set; } + } + } +} diff --git a/src/Core/Abstractions/IUserVerificationService.cs b/src/Core/Abstractions/IUserVerificationService.cs index 8a20595ed..e6ee1bfe8 100644 --- a/src/Core/Abstractions/IUserVerificationService.cs +++ b/src/Core/Abstractions/IUserVerificationService.cs @@ -1,11 +1,11 @@ -using System.Threading.Tasks; -using Bit.Core.Enums; +using Bit.Core.Enums; namespace Bit.Core.Abstractions { public interface IUserVerificationService { Task VerifyUser(string secret, VerificationType verificationType); + Task VerifyMasterPasswordAsync(string masterPassword); Task HasMasterPasswordAsync(bool checkMasterKeyHash = false); } } diff --git a/src/Core/App.xaml.cs b/src/Core/App.xaml.cs index 31a3ba8ca..a213cb02b 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 @@ -37,6 +38,9 @@ namespace Bit.App private readonly IPushNotificationService _pushNotificationService; private readonly IConfigService _configService; private readonly ILogger _logger; +#if ANDROID + private LazyResolve _fido2MakeCredentialConfirmationUserInterface = new LazyResolve(); +#endif private static bool _isResumed; // these variables are static because the app is launching new activities on notification click, creating new instances of App. @@ -104,7 +108,10 @@ 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; + Options.HasUnlockedInThisTransaction = appOptions.HasUnlockedInThisTransaction; } } @@ -120,6 +127,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 +198,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 +207,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 +235,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 +256,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 +284,19 @@ namespace Bit.App } } } + else if (message.Command == Constants.CredentialNavigateToAutofillCipherMessageCommand && 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,12 +335,26 @@ namespace Bit.App || message.Command == "unlocked" || message.Command == AccountsManagerMessageCommands.ACCOUNT_SWITCH_COMPLETED) { +#if ANDROID + if (message.Command == AccountsManagerMessageCommands.ACCOUNT_SWITCH_COMPLETED && _fido2MakeCredentialConfirmationUserInterface.Value.IsConfirmingNewCredential) + { + _fido2MakeCredentialConfirmationUserInterface.Value.OnConfirmationException(new AccountSwitchedException()); + } +#endif + lock (_processingLoginRequestLock) { // lock doesn't allow for async execution CheckPasswordlessLoginRequestsAsync().Wait(); } } + else if (message.Command == Constants.NavigateToMessageCommand && message.Data is NavigationTarget navigationTarget) + { + await MainThread.InvokeOnMainThreadAsync(() => + { + Navigate(navigationTarget, null); + }); + } } catch (Exception ex) { @@ -680,6 +725,15 @@ namespace Bit.App // If we are in background we add the Navigation Actions to a queue to execute when the app resumes. // Links: https://github.com/dotnet/maui/issues/11501 and https://bitwarden.atlassian.net/wiki/spaces/NMME/pages/664862722/MainPage+Assignments+not+working+on+Android+on+Background+or+App+resume #if ANDROID + if (_fido2MakeCredentialConfirmationUserInterface != null && _fido2MakeCredentialConfirmationUserInterface.Value.IsConfirmingNewCredential) + { + // if it's creating passkey + // and we have an active pending TaskCompletionSource + // then we let the Fido2 Authenticator flow manage the navigation to avoid issues + // like duplicated navigation to lock page. + return; + } + if (!_isResumed) { _onResumeActions.Enqueue(() => NavigateImpl(navTarget, navParams)); diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index a6ca528e4..f24e87016 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -49,6 +49,8 @@ namespace Bit.Core public const string UnassignedItemsBannerFlag = "unassigned-items-banner"; public const string RegionEnvironment = "regionEnvironment"; public const string DuoCallback = "bitwarden://duo-callback"; + public const string NavigateToMessageCommand = "navigateTo"; + public const string CredentialNavigateToAutofillCipherMessageCommand = "credentialNavigateToAutofillCipher"; /// /// This key is used to store the value of "ShouldConnectToWatch" of the last user that had logged in diff --git a/src/Core/Controls/CipherViewCell/CipherViewCell.xaml b/src/Core/Controls/CipherViewCell/CipherViewCell.xaml index bbfdd41ee..4a8ebaeba 100644 --- a/src/Core/Controls/CipherViewCell/CipherViewCell.xaml +++ b/src/Core/Controls/CipherViewCell/CipherViewCell.xaml @@ -50,7 +50,7 @@ HorizontalOptions="Center" VerticalOptions="Center" StyleClass="list-icon, list-icon-platform" - Text="{Binding Cipher, Converter={StaticResource iconGlyphConverter}}" + Text="{Binding ., Converter={StaticResource iconGlyphConverter}}" ShouldUpdateFontSizeDynamicallyForAccesibility="True" AutomationProperties.IsInAccessibleTree="False" AutomationId="CipherTypeIcon" /> 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 aa4fba0f9..3f880f911 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -34,6 +34,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -52,6 +53,7 @@ + @@ -75,14 +77,14 @@ + + + - - 168,208 - @@ -91,6 +93,9 @@ AppResources.Designer.cs PublicResXFileCodeGenerator + + ExternalLinkSubtitleItemView.xaml + AndroidNavigationRedirectPage.xaml @@ -101,13 +106,25 @@ + + MSBuild:Compile + MSBuild:Compile + + + + + + + + + \ No newline at end of file diff --git a/src/Core/Models/Api/Fido2CredentialApi.cs b/src/Core/Models/Api/Fido2CredentialApi.cs index 7953e06a1..672a7ec47 100644 --- a/src/Core/Models/Api/Fido2CredentialApi.cs +++ b/src/Core/Models/Api/Fido2CredentialApi.cs @@ -1,5 +1,4 @@ -using System; -using Bit.Core.Models.Domain; +using Bit.Core.Models.Domain; namespace Bit.Core.Models.Api { @@ -21,6 +20,7 @@ namespace Bit.Core.Models.Api RpName = fido2Key.RpName?.EncryptedString; UserHandle = fido2Key.UserHandle?.EncryptedString; UserName = fido2Key.UserName?.EncryptedString; + UserDisplayName = fido2Key.UserDisplayName?.EncryptedString; Counter = fido2Key.Counter?.EncryptedString; CreationDate = fido2Key.CreationDate; } @@ -35,6 +35,7 @@ namespace Bit.Core.Models.Api public string RpName { get; set; } public string UserHandle { get; set; } public string UserName { get; set; } + public string UserDisplayName { get; set; } public string Counter { get; set; } public DateTime CreationDate { get; set; } } diff --git a/src/Core/Models/AppOptions.cs b/src/Core/Models/AppOptions.cs index 4d5939e51..6f99f40b9 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 bool HasJustLoggedInOrUnlocked { get; set; } public void SetAllFrom(AppOptions o) @@ -36,6 +38,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; @@ -52,6 +55,7 @@ namespace Bit.App.Models CopyInsteadOfShareAfterSaving = o.CopyInsteadOfShareAfterSaving; HideAccountSwitcher = o.HideAccountSwitcher; OtpData = o.OtpData; + HasUnlockedInThisTransaction = o.HasUnlockedInThisTransaction; } } } diff --git a/src/Core/Models/Data/Fido2CredentialData.cs b/src/Core/Models/Data/Fido2CredentialData.cs index 846df59f4..103d03cbf 100644 --- a/src/Core/Models/Data/Fido2CredentialData.cs +++ b/src/Core/Models/Data/Fido2CredentialData.cs @@ -19,6 +19,7 @@ namespace Bit.Core.Models.Data RpName = apiData.RpName; UserHandle = apiData.UserHandle; UserName = apiData.UserName; + UserDisplayName = apiData.UserDisplayName; Counter = apiData.Counter; CreationDate = apiData.CreationDate; } @@ -33,6 +34,7 @@ namespace Bit.Core.Models.Data public string RpName { get; set; } public string UserHandle { get; set; } public string UserName { get; set; } + public string UserDisplayName { get; set; } public string Counter { get; set; } public DateTime CreationDate { get; set; } } diff --git a/src/Core/Models/Domain/Fido2Credential.cs b/src/Core/Models/Domain/Fido2Credential.cs index 7c6928204..313ff2c8f 100644 --- a/src/Core/Models/Domain/Fido2Credential.cs +++ b/src/Core/Models/Domain/Fido2Credential.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Bit.Core.Models.Data; +using Bit.Core.Models.Data; using Bit.Core.Models.View; namespace Bit.Core.Models.Domain @@ -21,6 +17,7 @@ namespace Bit.Core.Models.Domain nameof(RpName), nameof(UserHandle), nameof(UserName), + nameof(UserDisplayName), nameof(Counter) }; @@ -48,6 +45,7 @@ namespace Bit.Core.Models.Domain public EncString RpName { get; set; } public EncString UserHandle { get; set; } public EncString UserName { get; set; } + public EncString UserDisplayName { get; set; } public EncString Counter { get; set; } public DateTime CreationDate { get; set; } diff --git a/src/Core/Models/Domain/SymmetricCryptoKey.cs b/src/Core/Models/Domain/SymmetricCryptoKey.cs index 09d0e9268..7d248a6e7 100644 --- a/src/Core/Models/Domain/SymmetricCryptoKey.cs +++ b/src/Core/Models/Domain/SymmetricCryptoKey.cs @@ -1,5 +1,4 @@ -using System; -using Bit.Core.Enums; +using Bit.Core.Enums; namespace Bit.Core.Models.Domain { @@ -9,7 +8,7 @@ namespace Bit.Core.Models.Domain { if (key == null) { - throw new Exception("Must provide key."); + throw new ArgumentKeyNullException(nameof(key)); } if (encType == null) @@ -24,7 +23,7 @@ namespace Bit.Core.Models.Domain } else { - throw new Exception("Unable to determine encType."); + throw new InvalidKeyOperationException("Unable to determine encType."); } } @@ -48,7 +47,7 @@ namespace Bit.Core.Models.Domain } else { - throw new Exception("Unsupported encType/key length."); + throw new InvalidKeyOperationException("Unsupported encType/key length."); } if (Key != null) @@ -72,6 +71,32 @@ namespace Bit.Core.Models.Domain public string KeyB64 { get; set; } public string EncKeyB64 { get; set; } public string MacKeyB64 { get; set; } + + public class ArgumentKeyNullException : ArgumentNullException + { + public ArgumentKeyNullException(string paramName) : base(paramName) + { + } + + public ArgumentKeyNullException(string message, Exception innerException) : base(message, innerException) + { + } + + public ArgumentKeyNullException(string paramName, string message) : base(paramName, message) + { + } + } + + public class InvalidKeyOperationException : InvalidOperationException + { + public InvalidKeyOperationException(string message) : base(message) + { + } + + public InvalidKeyOperationException(string message, Exception innerException) : base(message, innerException) + { + } + } } public class UserKey : SymmetricCryptoKey diff --git a/src/Core/Models/View/CipherView.cs b/src/Core/Models/View/CipherView.cs index df8a47eb4..6919e3854 100644 --- a/src/Core/Models/View/CipherView.cs +++ b/src/Core/Models/View/CipherView.cs @@ -1,8 +1,7 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.Models.Domain; +using Bit.Core.Resources.Localization; +using Bit.Core.Utilities; namespace Bit.Core.Models.View { @@ -52,7 +51,7 @@ namespace Bit.Core.Models.View public DateTime? DeletedDate { get; set; } public CipherRepromptType Reprompt { get; set; } public CipherKey Key { get; set; } - + public ItemView Item { get @@ -122,5 +121,14 @@ namespace Bit.Core.Models.View public bool IsClonable => OrganizationId is null; public bool HasFido2Credential => Type == CipherType.Login && Login?.HasFido2Credentials == true; + + public string GetMainFido2CredentialUsername() + { + return Login?.MainFido2Credential?.UserName + .FallbackOnNullOrWhiteSpace(Login?.MainFido2Credential?.UserDisplayName) + .FallbackOnNullOrWhiteSpace(Login?.Username) + .FallbackOnNullOrWhiteSpace(Name) + .FallbackOnNullOrWhiteSpace(AppResources.UnknownAccount); + } } } diff --git a/src/Core/Models/View/Fido2CredentialView.cs b/src/Core/Models/View/Fido2CredentialView.cs index 049d82047..89058fc64 100644 --- a/src/Core/Models/View/Fido2CredentialView.cs +++ b/src/Core/Models/View/Fido2CredentialView.cs @@ -1,7 +1,7 @@ -using System; -using System.Collections.Generic; +using System.Text.Json.Serialization; using Bit.Core.Enums; using Bit.Core.Models.Domain; +using Bit.Core.Utilities; namespace Bit.Core.Models.View { @@ -26,13 +26,42 @@ namespace Bit.Core.Models.View public string RpName { get; set; } public string UserHandle { get; set; } public string UserName { get; set; } + public string UserDisplayName { get; set; } public string Counter { get; set; } public DateTime CreationDate { get; set; } + [JsonIgnore] + public int CounterValue { + get => int.TryParse(Counter, out var counter) ? counter : 0; + set => Counter = value.ToString(); + } + + [JsonIgnore] + public byte[] UserHandleValue { + get => UserHandle == null ? null : CoreHelpers.Base64UrlDecode(UserHandle); + set => UserHandle = value == null ? null : CoreHelpers.Base64UrlEncode(value); + } + + [JsonIgnore] + public byte[] KeyBytes { + get => KeyValue == null ? null : CoreHelpers.Base64UrlDecode(KeyValue); + set => KeyValue = value == null ? null : CoreHelpers.Base64UrlEncode(value); + } + + [JsonIgnore] + public bool DiscoverableValue { + get => bool.TryParse(Discoverable, out var discoverable) && discoverable; + set => Discoverable = value.ToString().ToLower(); + } + + [JsonIgnore] public override string SubTitle => UserName; + public override List> LinkedFieldOptions => new List>(); - public bool IsDiscoverable => !string.IsNullOrWhiteSpace(Discoverable); + + [JsonIgnore] public bool CanLaunch => !string.IsNullOrEmpty(RpId); + [JsonIgnore] public string LaunchUri => $"https://{RpId}"; public bool IsUniqueAgainst(Fido2CredentialView fido2View) => fido2View?.RpId != RpId || fido2View?.UserName != UserName; diff --git a/src/Core/Models/View/LoginView.cs b/src/Core/Models/View/LoginView.cs index 9993c2f11..528026dc6 100644 --- a/src/Core/Models/View/LoginView.cs +++ b/src/Core/Models/View/LoginView.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Bit.Core.Enums; +using Bit.Core.Enums; using Bit.Core.Models.Domain; namespace Bit.Core.Models.View diff --git a/src/Core/Pages/Accounts/LockPage.xaml.cs b/src/Core/Pages/Accounts/LockPage.xaml.cs index 77d499d8a..b90b2c04e 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 b3bd61015..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) @@ -515,7 +518,7 @@ namespace Bit.App.Pages var success = await _platformUtilsService.AuthenticateBiometricAsync(null, PinEnabled ? AppResources.PIN : AppResources.MasterPassword, () => _secretEntryFocusWeakEventManager.RaiseEvent((int?)null, nameof(FocusSecretEntry)), - !PinEnabled && !HasMasterPassword); + !PinEnabled && !HasMasterPassword) ?? false; await _stateService.SetBiometricLockedAsync(!success); if (success) diff --git a/src/Core/Pages/Settings/AutofillPage.xaml b/src/Core/Pages/Settings/AutofillPage.xaml index 89635d69d..48cc988aa 100644 --- a/src/Core/Pages/Settings/AutofillPage.xaml +++ b/src/Core/Pages/Settings/AutofillPage.xaml @@ -5,7 +5,7 @@ x:Class="Bit.App.Pages.AutofillPage" xmlns:pages="clr-namespace:Bit.App.Pages" xmlns:u="clr-namespace:Bit.App.Utilities" - Title="{u:I18n PasswordAutofill}"> + Title="{u:I18n SetUpAutofill}"> @@ -15,26 +15,22 @@ - + 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..2d16e8656 100644 --- a/src/Core/Pages/Vault/CipherAddEditPage.xaml.cs +++ b/src/Core/Pages/Vault/CipherAddEditPage.xaml.cs @@ -19,6 +19,9 @@ namespace Bit.App.Pages private readonly IAutofillHandler _autofillHandler; private readonly IVaultTimeoutService _vaultTimeoutService; private readonly IUserVerificationService _userVerificationService; +#if ANDROID + private readonly LazyResolve _fido2MakeCredentialConfirmationUserInterface = new LazyResolve(); +#endif private CipherAddEditPageViewModel _vm; private bool _fromAutofill; @@ -45,6 +48,9 @@ namespace Bit.App.Pages _appOptions = appOptions; _fromAutofill = fromAutofill; FromAutofillFramework = _appOptions?.FromAutofillFramework ?? false; +#if ANDROID + FromAndroidFido2Framework = _fido2MakeCredentialConfirmationUserInterface.Value.IsConfirmingNewCredential; +#endif InitializeComponent(); _vm = BindingContext as CipherAddEditPageViewModel; _vm.Page = this; @@ -144,6 +150,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..65a0c3979 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,11 @@ namespace Bit.App.Pages _autofillHandler = ServiceContainer.Resolve(); _watchDeviceService = ServiceContainer.Resolve(); _accountsManager = ServiceContainer.Resolve(); + if (ServiceContainer.TryResolve(out var fido2MakeService)) + { + _fido2MakeCredentialConfirmationUserInterface = fido2MakeService; + } + _userVerificationMediatorService = ServiceContainer.Resolve(); GeneratePasswordCommand = new Command(GeneratePassword); TogglePasswordCommand = new Command(TogglePassword); @@ -292,7 +301,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 +335,7 @@ namespace Bit.App.Pages public async Task LoadAsync(AppOptions appOptions = null) { _fromOtp = appOptions?.OtpData != null; + IsFromFido2Framework = _fido2MakeCredentialConfirmationUserInterface?.IsConfirmingNewCredential == true; var myEmail = await _stateService.GetEmailAsync(); OwnershipOptions.Add(new KeyValuePair(myEmail, null)); @@ -536,6 +548,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 +586,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 +626,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/CipherItemViewModel.cs b/src/Core/Pages/Vault/CipherItemViewModel.cs index 28dee6780..a5f094d9d 100644 --- a/src/Core/Pages/Vault/CipherItemViewModel.cs +++ b/src/Core/Pages/Vault/CipherItemViewModel.cs @@ -44,5 +44,7 @@ namespace Bit.App.Pages /// This is useful to check when the cell is being reused. /// public bool IconImageSuccesfullyLoaded { get; set; } + + public bool UsePasskeyIconAsPlaceholderFallback { get; set; } } } diff --git a/src/Core/Pages/Vault/CipherSelectionPage.xaml b/src/Core/Pages/Vault/CipherSelectionPage.xaml index 77cb06bdd..17ae849bb 100644 --- a/src/Core/Pages/Vault/CipherSelectionPage.xaml +++ b/src/Core/Pages/Vault/CipherSelectionPage.xaml @@ -78,12 +78,13 @@ Spacing="20" IsVisible="{Binding ShowNoData}"> + + + + + + + + + + + + + + + + + +