From 66a01e30d38bd10d8751691e459737bab30117e4 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Fri, 19 Jan 2024 10:45:03 +0100 Subject: [PATCH] [PM-5731] feat: ask for credentials when found --- src/Core/Abstractions/IFido2UserInterface.cs | 46 ++++++++++ src/Core/Models/View/Fido2CredentialView.cs | 2 +- .../Services/Fido2AuthenticatorService.cs | 84 +++++++++++++++++-- .../Services/Fido2AuthenticatorTests.cs | 65 ++++++++++---- 4 files changed, 174 insertions(+), 23 deletions(-) create mode 100644 src/Core/Abstractions/IFido2UserInterface.cs diff --git a/src/Core/Abstractions/IFido2UserInterface.cs b/src/Core/Abstractions/IFido2UserInterface.cs new file mode 100644 index 000000000..b01b52d23 --- /dev/null +++ b/src/Core/Abstractions/IFido2UserInterface.cs @@ -0,0 +1,46 @@ +using Bit.Core.Utilities.Fido2; + +namespace Bit.Core.Abstractions +{ + /// + /// Parameters used to ask the user to pick a credential from a list of existing credentials. + /// + public struct Fido2PickCredentialParams + { + /// + /// The IDs of the credentials that the user can pick from. + /// + public string[] CipherIds { get; set; } + + /// + /// Whether or not the user must be verified before completing the operation. + /// + public bool UserVerification { get; set; } + } + + /// + /// The result of asking the user to pick a credential from a list of existing credentials. + /// + public struct Fido2PickCredentialResult + { + /// + /// The ID of the cipher that contains the credentials the user picked. + /// + public string CipherId { get; set; } + + /// + /// Whether or not the user was verified before completing the operation. + /// + public bool UserVerified { get; set; } + } + + public interface IFido2UserInterface + { + /// + /// Ask the user to pick a credential from a list of existing credentials. + /// + /// The parameters to use when asking the user to pick a credential. + /// The ID of the cipher that contains the credentials the user picked. + Task PickCredentialAsync(Fido2PickCredentialParams pickCredentialParams); + } +} diff --git a/src/Core/Models/View/Fido2CredentialView.cs b/src/Core/Models/View/Fido2CredentialView.cs index 92d3ff85b..5086c72d3 100644 --- a/src/Core/Models/View/Fido2CredentialView.cs +++ b/src/Core/Models/View/Fido2CredentialView.cs @@ -29,7 +29,7 @@ namespace Bit.Core.Models.View public override string SubTitle => UserName; public override List> LinkedFieldOptions => new List>(); - public bool IsDiscoverable => !string.IsNullOrWhiteSpace(Discoverable); + public bool IsDiscoverable => bool.TryParse(Discoverable, out var isDiscoverable) && isDiscoverable; public bool CanLaunch => !string.IsNullOrEmpty(RpId); public string LaunchUri => $"https://{RpId}"; diff --git a/src/Core/Services/Fido2AuthenticatorService.cs b/src/Core/Services/Fido2AuthenticatorService.cs index 26a8024f1..fb897044d 100644 --- a/src/Core/Services/Fido2AuthenticatorService.cs +++ b/src/Core/Services/Fido2AuthenticatorService.cs @@ -1,27 +1,95 @@ using Bit.Core.Abstractions; +using Bit.Core.Models.View; +using Bit.Core.Enums; using Bit.Core.Utilities.Fido2; namespace Bit.Core.Services { public class Fido2AuthenticatorService : IFido2AuthenticatorService { + private INativeLogService _logService; private ICipherService _cipherService; + private ISyncService _syncService; + private IFido2UserInterface _userInterface; - public Fido2AuthenticatorService(ICipherService cipherService) + public Fido2AuthenticatorService(INativeLogService logService, ICipherService cipherService, ISyncService syncService, IFido2UserInterface userInterface) { + _logService = logService; _cipherService = cipherService; + _syncService = syncService; + _userInterface = userInterface; } - public Task GetAssertionAsync(Fido2AuthenticatorGetAssertionParams assertionParams) + public async Task GetAssertionAsync(Fido2AuthenticatorGetAssertionParams assertionParams) { - throw new NotAllowedError(); + // throw new NotAllowedError(); + List cipherOptions; + + // await userInterfaceSession.ensureUnlockedVault(); + await _syncService.FullSyncAsync(false); + + if (assertionParams.AllowCredentialDescriptorList?.Length > 0) { + cipherOptions = await FindCredentialsById( + assertionParams.AllowCredentialDescriptorList, + assertionParams.RpId + ); + } else { + cipherOptions = new List(); + // cipherOptions = await this.findCredentialsByRp(params.rpId); + } + + if (cipherOptions.Count == 0) { + _logService.Info( + "[Fido2Authenticator] Aborting because no matching credentials were found in the vault." + ); + + throw new NotAllowedError(); + } + + var response = await _userInterface.PickCredentialAsync(new Fido2PickCredentialParams { + CipherIds = cipherOptions.Select((cipher) => cipher.Id).ToArray(), + UserVerification = assertionParams.RequireUserVerification + }); // TODO: IMPLEMENT this - // return Task.FromResult(new Fido2AuthenticatorGetAssertionResult - // { - // AuthenticatorData = new byte[32], - // Signature = new byte[8] - // }); + return new Fido2AuthenticatorGetAssertionResult + { + AuthenticatorData = new byte[32], + Signature = new byte[8] + }; } + + private async Task> FindCredentialsById(PublicKeyCredentialDescriptor[] credentials, string rpId) + { + var ids = new List(); + + foreach (var credential in credentials) + { + try + { + ids.Add(GuidToStandardFormat(credential.Id)); + } + catch {} + } + + if (ids.Count == 0) + { + return new List(); + } + + var ciphers = await _cipherService.GetAllDecryptedAsync(); + return ciphers.FindAll((cipher) => + !cipher.IsDeleted && + cipher.Type == CipherType.Login && + cipher.Login.HasFido2Credentials && + cipher.Login.Fido2Credentials[0].RpId == rpId && + ids.Contains(cipher.Login.Fido2Credentials[0].CredentialId) + ); + } + + private string GuidToStandardFormat(byte[] bytes) + { + return new Guid(bytes).ToString(); + } } } diff --git a/test/Core.Test/Services/Fido2AuthenticatorTests.cs b/test/Core.Test/Services/Fido2AuthenticatorTests.cs index 76b144bf4..577f3a8cd 100644 --- a/test/Core.Test/Services/Fido2AuthenticatorTests.cs +++ b/test/Core.Test/Services/Fido2AuthenticatorTests.cs @@ -5,6 +5,7 @@ using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core.Models.Domain; using Bit.Core.Models.View; +using Bit.Core.Enums; using Bit.Core.Test.AutoFixture; using Bit.Core.Utilities.Fido2; using Bit.Test.Common.AutoFixture; @@ -14,6 +15,7 @@ using NSubstitute.ExceptionExtensions; using Xunit; using Bit.Core.Utilities; using System.Collections.Generic; +using System.Linq; namespace Bit.Core.Test.Services { @@ -33,37 +35,72 @@ namespace Bit.Core.Test.Services [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] public async Task GetAssertionAsync_Throws_CredentialExistsButRpIdDoesNotMatch(SutProvider sutProvider, Fido2AuthenticatorGetAssertionParams aParams) { - var credentialId = RandomBytes(32); + var credentialId = Guid.NewGuid(); aParams.RpId = "bitwarden.com"; aParams.AllowCredentialDescriptorList = [ new PublicKeyCredentialDescriptor { - Id = credentialId, + Id = credentialId.ToByteArray(), Type = "public-key" } ]; - sutProvider.GetDependency().GetAllDecryptedAsync().Returns(new List { - new CipherView { - Login = new LoginView { - Fido2Credentials = new List { - new Fido2CredentialView { - CredentialId = CoreHelpers.Base64UrlEncode(credentialId), - RpId = "mismatch-rpid" - } - } - } - } - }); + sutProvider.GetDependency().GetAllDecryptedAsync().Returns([ + CreateCipherView(credentialId.ToString(), "mismatch-rpid", false), + ]); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.GetAssertionAsync(aParams)); } #endregion + #region vault contains credential + + [Theory] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] + public async Task GetAssertionAsync_AsksForAllCredentials_ParamsContainsAllowedCredentialsList(SutProvider sutProvider, Fido2AuthenticatorGetAssertionParams aParams) + { + var credentialIds = new[] { Guid.NewGuid(), Guid.NewGuid() }; + List ciphers = [ + CreateCipherView(credentialIds[0].ToString(), "bitwarden.com", false), + CreateCipherView(credentialIds[1].ToString(), "bitwarden.com", true) + ]; + aParams.RpId = "bitwarden.com"; + aParams.AllowCredentialDescriptorList = credentialIds.Select((credentialId) => new PublicKeyCredentialDescriptor { + Id = credentialId.ToByteArray(), + Type = "public-key" + }).ToArray(); + sutProvider.GetDependency().GetAllDecryptedAsync().Returns(ciphers); + + await sutProvider.Sut.GetAssertionAsync(aParams); + + await sutProvider.GetDependency().Received().PickCredentialAsync(Arg.Is( + (pickCredentialParams) => pickCredentialParams.CipherIds.SequenceEqual(ciphers.Select((cipher) => cipher.Id)) && pickCredentialParams.UserVerification == aParams.RequireUserVerification + )); + } + + #endregion + private byte[] RandomBytes(int length) { var bytes = new byte[length]; new Random().NextBytes(bytes); return bytes; } + + #nullable enable + private CipherView CreateCipherView(string? credentialId, string? rpId, bool? discoverable) + { + return new CipherView { + Type = CipherType.Login, + Login = new LoginView { + Fido2Credentials = new List { + new Fido2CredentialView { + CredentialId = credentialId ?? Guid.NewGuid().ToString(), + RpId = rpId ?? "bitwarden.com", + Discoverable = discoverable.HasValue ? discoverable.ToString() : "true" + } + } + } + }; + } } }