using System; using System.Threading.Tasks; using Bit.Core.Abstractions; 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.Utilities.Fido2; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using NSubstitute.ExceptionExtensions; using Xunit; using Bit.Core.Utilities; using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; namespace Bit.Core.Test.Services { public class Fido2AuthenticatorGetAssertionTests { #region missing non-discoverable credential // Spec: If credentialOptions is now empty, return an error code equivalent to "NotAllowedError" and terminate the operation. [Theory] [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] public async Task GetAssertionAsync_ThrowsNotAllowed_NoCredentialExists(SutProvider sutProvider, Fido2AuthenticatorGetAssertionParams aParams) { await Assert.ThrowsAsync(() => sutProvider.Sut.GetAssertionAsync(aParams)); } [Theory] [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] public async Task GetAssertionAsync_ThrowsNotAllowed_CredentialExistsButRpIdDoesNotMatch(SutProvider sutProvider, Fido2AuthenticatorGetAssertionParams aParams) { var credentialId = Guid.NewGuid(); aParams.RpId = "bitwarden.com"; aParams.AllowCredentialDescriptorList = [ new PublicKeyCredentialDescriptor { Id = credentialId.ToByteArray(), Type = "public-key" } ]; sutProvider.GetDependency().GetAllDecryptedAsync().Returns([ CreateCipherView(credentialId.ToString(), "mismatch-rpid", false), ]); 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.RequireUserVerification = false; aParams.AllowCredentialDescriptorList = credentialIds.Select((credentialId) => new PublicKeyCredentialDescriptor { Id = credentialId.ToByteArray(), Type = "public-key" }).ToArray(); sutProvider.GetDependency().GetAllDecryptedAsync().Returns(ciphers); sutProvider.GetDependency().PickCredentialAsync(Arg.Any()).Returns(new Fido2PickCredentialResult { CipherId = ciphers[0].Id, UserVerified = false }); await sutProvider.Sut.GetAssertionAsync(aParams); await sutProvider.GetDependency().Received().PickCredentialAsync(Arg.Is( (pickCredentialParams) => pickCredentialParams.CipherIds.SequenceEqual(ciphers.Select((cipher) => cipher.Id)) )); } [Theory] [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] public async Task GetAssertionAsync_AsksForDiscoverableCredentials_ParamsDoesNotContainAllowedCredentialsList(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) ]; var discoverableCiphers = ciphers.Where((cipher) => cipher.Login.MainFido2Credential.DiscoverableValue).ToList(); aParams.RpId = "bitwarden.com"; aParams.RequireUserVerification = false; aParams.AllowCredentialDescriptorList = null; sutProvider.GetDependency().GetAllDecryptedAsync().Returns(ciphers); sutProvider.GetDependency().PickCredentialAsync(Arg.Any()).Returns(new Fido2PickCredentialResult { CipherId = discoverableCiphers[0].Id, UserVerified = false }); await sutProvider.Sut.GetAssertionAsync(aParams); await sutProvider.GetDependency().Received().PickCredentialAsync(Arg.Is( (pickCredentialParams) => pickCredentialParams.CipherIds.SequenceEqual(discoverableCiphers.Select((cipher) => cipher.Id)) )); } [Theory] [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] // Spec: Prompt the user to select a public key credential source `selectedCredential` from `credentialOptions`. // If requireUserVerification is true, the authorization gesture MUST include user verification. public async Task GetAssertionAsync_RequestsUserVerification_ParamsRequireUserVerification(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) ]; var discoverableCiphers = ciphers.Where((cipher) => cipher.Login.MainFido2Credential.DiscoverableValue).ToList(); aParams.RpId = "bitwarden.com"; aParams.AllowCredentialDescriptorList = null; aParams.RequireUserVerification = true; sutProvider.GetDependency().GetAllDecryptedAsync().Returns(ciphers); sutProvider.GetDependency().PickCredentialAsync(Arg.Any()).Returns(new Fido2PickCredentialResult { CipherId = discoverableCiphers[0].Id, UserVerified = true }); await sutProvider.Sut.GetAssertionAsync(aParams); await sutProvider.GetDependency().Received().PickCredentialAsync(Arg.Is( (pickCredentialParams) => pickCredentialParams.UserVerification == true )); } [Theory] [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] // Spec: Prompt the user to select a public key credential source `selectedCredential` from `credentialOptions`. // If `requireUserPresence` is true, the authorization gesture MUST include a test of user presence. // Comment: User presence in implied by the UI returning a credential. public async Task GetAssertionAsync_DoesNotRequestUserVerification_ParamsDoNotRequireUserVerification(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) ]; var discoverableCiphers = ciphers.Where((cipher) => cipher.Login.MainFido2Credential.DiscoverableValue).ToList(); aParams.RpId = "bitwarden.com"; aParams.AllowCredentialDescriptorList = null; aParams.RequireUserVerification = false; sutProvider.GetDependency().GetAllDecryptedAsync().Returns(ciphers); sutProvider.GetDependency().PickCredentialAsync(Arg.Any()).Returns(new Fido2PickCredentialResult { CipherId = discoverableCiphers[0].Id, UserVerified = false }); await sutProvider.Sut.GetAssertionAsync(aParams); await sutProvider.GetDependency().Received().PickCredentialAsync(Arg.Is( (pickCredentialParams) => pickCredentialParams.UserVerification == false )); } [Theory] [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] // Spec: If the user does not consent, return an error code equivalent to "NotAllowedError" and terminate the operation. public async Task GetAssertionAsync_ThrowsNotAllowed_UserDoesNotConsent(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) ]; var discoverableCiphers = ciphers.Where((cipher) => cipher.Login.MainFido2Credential.DiscoverableValue).ToList(); aParams.RpId = "bitwarden.com"; aParams.AllowCredentialDescriptorList = null; sutProvider.GetDependency().GetAllDecryptedAsync().Returns(ciphers); sutProvider.GetDependency().PickCredentialAsync(Arg.Any()).Returns(new Fido2PickCredentialResult { CipherId = null, UserVerified = false }); await Assert.ThrowsAsync(() => sutProvider.Sut.GetAssertionAsync(aParams)); } [Theory] [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] // Spec: If the user does not consent, return an error code equivalent to "NotAllowedError" and terminate the operation. public async Task GetAssertionAsync_ThrowsNotAllowed_NoUserVerificationWhenRequired(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) ]; var discoverableCiphers = ciphers.Where((cipher) => cipher.Login.MainFido2Credential.DiscoverableValue).ToList(); aParams.RequireUserVerification = true; aParams.RpId = "bitwarden.com"; aParams.AllowCredentialDescriptorList = null; sutProvider.GetDependency().GetAllDecryptedAsync().Returns(ciphers); sutProvider.GetDependency().PickCredentialAsync(Arg.Any()).Returns(new Fido2PickCredentialResult { CipherId = discoverableCiphers[0].Id, UserVerified = false }); await Assert.ThrowsAsync(() => sutProvider.Sut.GetAssertionAsync(aParams)); } [Theory] [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] // Spec: If the user does not consent, return an error code equivalent to "NotAllowedError" and terminate the operation. public async Task GetAssertionAsync_ThrowsNotAllowed_NoUserVerificationForCipherWithReprompt(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) ]; ciphers[0].Reprompt = CipherRepromptType.Password; var discoverableCiphers = ciphers.Where((cipher) => cipher.Login.MainFido2Credential.DiscoverableValue).ToList(); aParams.RpId = "bitwarden.com"; aParams.AllowCredentialDescriptorList = null; sutProvider.GetDependency().GetAllDecryptedAsync().Returns(ciphers); sutProvider.GetDependency().PickCredentialAsync(Arg.Any()).Returns(new Fido2PickCredentialResult { CipherId = discoverableCiphers[0].Id, UserVerified = false }); await Assert.ThrowsAsync(() => sutProvider.Sut.GetAssertionAsync(aParams)); } #endregion #region assertion of credential [Theory] [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] // Spec: Increment the credential associated signature counter public async Task GetAssertionAsync_IncrementsCounter_CounterIsLargerThanZero(SutProvider sutProvider, Fido2AuthenticatorGetAssertionParams aParams, Cipher encryptedCipher) { // Common Arrange var cipherView = CreateCipherView(null, "bitwarden.com", true); aParams.RpId = cipherView.Login.MainFido2Credential.RpId; aParams.AllowCredentialDescriptorList = null; sutProvider.GetDependency().GetAllDecryptedAsync().Returns(new List { cipherView }); sutProvider.GetDependency().PickCredentialAsync(Arg.Any()).Returns(new Fido2PickCredentialResult { CipherId = cipherView.Id, UserVerified = true }); // Arrange cipherView.Login.MainFido2Credential.CounterValue = 9000; sutProvider.GetDependency().EncryptAsync(cipherView).Returns(encryptedCipher); // Act await sutProvider.Sut.GetAssertionAsync(aParams); // Assert await sutProvider.GetDependency().Received().SaveWithServerAsync(encryptedCipher); await sutProvider.GetDependency().Received().EncryptAsync(Arg.Is( (cipher) => cipher.Login.MainFido2Credential.CounterValue == 9001 )); } [Theory] [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] // Spec: Increment the credential associated signature counter public async Task GetAssertionAsync_DoesNotIncrementsCounter_CounterIsZero(SutProvider sutProvider, Fido2AuthenticatorGetAssertionParams aParams, Cipher encryptedCipher) { // Common Arrange var cipherView = CreateCipherView(null, "bitwarden.com", true); aParams.RpId = cipherView.Login.MainFido2Credential.RpId; aParams.AllowCredentialDescriptorList = null; sutProvider.GetDependency().GetAllDecryptedAsync().Returns(new List { cipherView }); sutProvider.GetDependency().PickCredentialAsync(Arg.Any()).Returns(new Fido2PickCredentialResult { CipherId = cipherView.Id, UserVerified = true }); // Arrange cipherView.Login.MainFido2Credential.CounterValue = 0; sutProvider.GetDependency().EncryptAsync(cipherView).Returns(encryptedCipher); // Act await sutProvider.Sut.GetAssertionAsync(aParams); // Assert await sutProvider.GetDependency().Received().SaveWithServerAsync(encryptedCipher); await sutProvider.GetDependency().Received().EncryptAsync(Arg.Is( (cipher) => cipher.Login.MainFido2Credential.CounterValue == 0 )); } [Theory] [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] public async Task GetAssertionAsync_ReturnsAssertion(SutProvider sutProvider, Fido2AuthenticatorGetAssertionParams aParams) { // Common Arrange var cipherView = CreateCipherView(null, "bitwarden.com", true); aParams.RpId = cipherView.Login.MainFido2Credential.RpId; aParams.AllowCredentialDescriptorList = null; sutProvider.GetDependency().GetAllDecryptedAsync().Returns(new List { cipherView }); sutProvider.GetDependency().PickCredentialAsync(Arg.Any()).Returns(new Fido2PickCredentialResult { CipherId = cipherView.Id, UserVerified = true }); // Arrange var keyPair = GenerateKeyPair(); var rpIdHashMock = RandomBytes(32); aParams.ClientDataHash = RandomBytes(32); sutProvider.GetDependency().HashAsync(aParams.RpId, CryptoHashAlgorithm.Sha256).Returns(rpIdHashMock); cipherView.Login.MainFido2Credential.CounterValue = 9000; cipherView.Login.MainFido2Credential.KeyValue = CoreHelpers.Base64UrlEncode(keyPair.ExportPkcs8PrivateKey()); // Act var result = await sutProvider.Sut.GetAssertionAsync(aParams); // Assert var authData = result.AuthenticatorData; var rpIdHash = authData.Take(32); var flags = authData.Skip(32).Take(1); var counter = authData.Skip(33).Take(4); Assert.Equal(Guid.Parse(cipherView.Login.MainFido2Credential.CredentialId).ToByteArray(), result.SelectedCredential.Id); Assert.Equal(CoreHelpers.Base64UrlDecode(cipherView.Login.MainFido2Credential.UserHandle), result.SelectedCredential.UserHandle); Assert.Equal(rpIdHashMock, rpIdHash); Assert.Equal(new byte[] { 0b00000101 }, flags); // UP = true, UV = true Assert.Equal(new byte[] { 0, 0, 0x23, 0x29 }, counter); // 9001 in binary big-endian format Assert.True(keyPair.VerifyData(authData.Concat(aParams.ClientDataHash).ToArray(), result.Signature, HashAlgorithmName.SHA256), "Signature verification failed"); } [Theory] [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] public async Task GetAssertionAsync_DoesNotAskForConfirmation_ParamsContainsOneAllowedCredentialAndUserPresenceIsFalse(SutProvider sutProvider, Fido2AuthenticatorGetAssertionParams aParams) { // Common arrange var credentialIds = new[] { Guid.NewGuid(), Guid.NewGuid() }; List ciphers = [ CreateCipherView(credentialIds[0].ToString(), "bitwarden.com", false), CreateCipherView(credentialIds[1].ToString(), "bitwarden.com", true) ]; var cipherView = ciphers[0]; aParams.RpId = "bitwarden.com"; aParams.RequireUserVerification = false; aParams.RequireUserPresence = false; aParams.AllowCredentialDescriptorList = [ new PublicKeyCredentialDescriptor { Id = credentialIds[1].ToByteArray(), Type = "public-key" } ]; sutProvider.GetDependency().GetAllDecryptedAsync().Returns(ciphers); // Arrange var keyPair = GenerateKeyPair(); var rpIdHashMock = RandomBytes(32); aParams.ClientDataHash = RandomBytes(32); sutProvider.GetDependency().HashAsync(aParams.RpId, CryptoHashAlgorithm.Sha256).Returns(rpIdHashMock); cipherView.Login.MainFido2Credential.CounterValue = 9000; cipherView.Login.MainFido2Credential.KeyValue = CoreHelpers.Base64UrlEncode(keyPair.ExportPkcs8PrivateKey()); // Act var result = await sutProvider.Sut.GetAssertionAsync(aParams); // Assert await sutProvider.GetDependency().DidNotReceive().PickCredentialAsync(Arg.Any()); var authData = result.AuthenticatorData; var flags = authData.Skip(32).Take(1); Assert.Equal(new byte[] { 0b00000000 }, flags); // UP = false, UV = false } [Theory] [InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })] // Spec: Increment the credential associated signature counter public async Task GetAssertionAsync_ThrowsUnknownError_SaveFails(SutProvider sutProvider, Fido2AuthenticatorGetAssertionParams aParams) { // Common Arrange var cipherView = CreateCipherView(null, "bitwarden.com", true); aParams.RpId = cipherView.Login.MainFido2Credential.RpId; aParams.AllowCredentialDescriptorList = null; sutProvider.GetDependency().GetAllDecryptedAsync().Returns(new List { cipherView }); sutProvider.GetDependency().PickCredentialAsync(Arg.Any()).Returns(new Fido2PickCredentialResult { CipherId = cipherView.Id, UserVerified = true }); // Arrange cipherView.Login.MainFido2Credential.CounterValue = 0; sutProvider.GetDependency().SaveWithServerAsync(Arg.Any()).Throws(new Exception()); // Act & Assert await Assert.ThrowsAsync(() => sutProvider.Sut.GetAssertionAsync(aParams)); } #endregion private byte[] RandomBytes(int length) { var bytes = new byte[length]; new Random().NextBytes(bytes); return bytes; } private ECDsa GenerateKeyPair() { var dsa = ECDsa.Create(); dsa.GenerateKey(ECCurve.NamedCurves.nistP256); return dsa; } #nullable enable private CipherView CreateCipherView(string? credentialId, string? rpId, bool? discoverable) { return new CipherView { Type = CipherType.Login, Id = Guid.NewGuid().ToString(), Reprompt = CipherRepromptType.None, Login = new LoginView { Fido2Credentials = new List { new Fido2CredentialView { CredentialId = credentialId ?? Guid.NewGuid().ToString(), RpId = rpId ?? "bitwarden.com", Discoverable = discoverable.HasValue ? discoverable.ToString() : "true", UserHandleValue = RandomBytes(32), KeyValue = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgO4wC7AlY4eJP7uedRUJGYsAIJAd6gN1Vp7uJh6xXAp6hRANCAARGvr56F_t27DEG1Tzl-qJRhrTUtC7jOEbasAEEZcE3TiMqoWCan0sxKDPylhRYk-1qyrBC_feN1UtGWH57sROa" } } } }; } } }