using System; using System.Collections.Generic; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading.Tasks; using Bit.Core.Abstractions; using Bit.Core.Enums; using Bit.Core.Services; using Bit.Core.Utilities; using Bit.Core.Utilities.Fido2; using Bit.Test.Common.AutoFixture; using NSubstitute; using NSubstitute.ExceptionExtensions; using Xunit; namespace Bit.Core.Test.Services { public class Fido2ClientAssertCredentialTests : IDisposable { private readonly SutProvider _sutProvider = new SutProvider().Create(); private Fido2ClientAssertCredentialParams _params; private Fido2AuthenticatorGetAssertionResult _authenticatorResult; public Fido2ClientAssertCredentialTests() { _params = new Fido2ClientAssertCredentialParams { Origin = "https://bitwarden.com", Challenge = RandomBytes(32), RpId = "bitwarden.com", UserVerification = "required", AllowCredentials = new PublicKeyCredentialDescriptor[] { new PublicKeyCredentialDescriptor { Id = RandomBytes(32), Type = Constants.DefaultFido2CredentialType } }, Timeout = 60000, }; _authenticatorResult = new Fido2AuthenticatorGetAssertionResult { AuthenticatorData = RandomBytes(32), SelectedCredential = new Fido2AuthenticatorGetAssertionSelectedCredential { Id = RandomBytes(16), UserHandle = RandomBytes(32) }, Signature = RandomBytes(32) }; _sutProvider.GetDependency().GetAutofillBlacklistedUrisAsync().Returns(Task.FromResult(new List())); _sutProvider.GetDependency().IsAuthenticatedAsync().Returns(true); } public void Dispose() { } [Fact(Skip = "Not sure how to check this, or if it matters.")] // Spec: If callerOrigin is an opaque origin, return a DOMException whose name is "NotAllowedError", and terminate this algorithm. public Task AssertCredentialAsync_ThrowsNotAllowedError_OriginIsOpaque() => throw new NotImplementedException(); [Fact] // Spec: Let effectiveDomain be the callerOrigin’s effective domain. If effective domain is not a valid domain, // then return a DOMException whose name is "SecurityError" and terminate this algorithm. public async Task AssertCredentialAsync_ThrowsSecurityError_OriginIsNotValidDomain() { // Arrange _params.Origin = "invalid-domain-name"; // Act var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); // Assert Assert.Equal(Fido2ClientException.ErrorCode.SecurityError, exception.Code); } [Fact] // Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, // return a DOMException whose name is "SecurityError", and terminate this algorithm. public async Task AssertCredentialAsync_ThrowsSecurityError_RpIdIsNotValidForOrigin() { // Arrange _params.Origin = "https://passwordless.dev"; _params.RpId = "bitwarden.com"; // Act var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); // Assert Assert.Equal(Fido2ClientException.ErrorCode.SecurityError, exception.Code); } [Fact] // Spec: The origin's scheme must be https. public async Task AssertCredentialAsync_ThrowsSecurityError_OriginIsNotHttps() { // Arrange _params.Origin = "http://bitwarden.com"; _params.RpId = "bitwarden.com"; // Act var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); // Assert Assert.Equal(Fido2ClientException.ErrorCode.SecurityError, exception.Code); } [Fact] // Spec: If the origin's hostname is a blocked uri, then return UriBlockedError. public async Task AssertCredentialAsync_ThrowsUriBlockedError_OriginIsBlocked() { // Arrange _params.Origin = "https://sub.bitwarden.com"; _sutProvider.GetDependency().GetAutofillBlacklistedUrisAsync().Returns(Task.FromResult(new List { "sub.bitwarden.com" })); // Act var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); // Assert Assert.Equal(Fido2ClientException.ErrorCode.UriBlockedError, exception.Code); } [Fact] public async Task AssertCredentialAsync_ThrowsInvalidStateError_AuthenticatorThrowsInvalidStateError() { // Arrange _sutProvider.GetDependency() .GetAssertionAsync(Arg.Any(), _sutProvider.GetDependency()) .Throws(new InvalidStateError()); // Act var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); // Assert Assert.Equal(Fido2ClientException.ErrorCode.InvalidStateError, exception.Code); } [Fact] // This keeps sensetive information form leaking public async Task AssertCredentialAsync_ThrowsUnknownError_AuthenticatorThrowsUnknownError() { // Arrange _sutProvider.GetDependency() .GetAssertionAsync(Arg.Any(), _sutProvider.GetDependency()) .Throws(new Exception("unknown error")); // Act var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); // Assert Assert.Equal(Fido2ClientException.ErrorCode.UnknownError, exception.Code); } [Fact] public async Task AssertCredentialAsync_ThrowsInvalidStateError_UserIsLoggedOut() { // Arrange _sutProvider.GetDependency().IsAuthenticatedAsync().Returns(false); // Act var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); // Assert Assert.Equal(Fido2ClientException.ErrorCode.InvalidStateError, exception.Code); } [Fact] public async Task AssertCredentialAsync_ThrowsNotAllowedError_OriginIsBitwardenVault() { // Arrange _params.Origin = "https://vault.bitwarden.com"; _sutProvider.GetDependency().GetWebVaultUrl().Returns("https://vault.bitwarden.com"); // Act var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.AssertCredentialAsync(_params)); // Assert Assert.Equal(Fido2ClientException.ErrorCode.NotAllowedError, exception.Code); } [Fact] public async Task AssertCredentialAsync_ConstructsClientDataHash_WhenHashIsNotProvided() { // Arrange var mockHash = RandomBytes(32); _sutProvider.GetDependency() .HashAsync(Arg.Any(), Arg.Is(CryptoHashAlgorithm.Sha256)) .Returns(Task.FromResult(mockHash)); _sutProvider.GetDependency() .GetAssertionAsync(Arg.Any(), _sutProvider.GetDependency()) .Returns(_authenticatorResult); // Act await _sutProvider.Sut.AssertCredentialAsync(_params); // Assert await _sutProvider.GetDependency().Received() .GetAssertionAsync( Arg.Is((Fido2AuthenticatorGetAssertionParams x) => x.Hash == mockHash), Arg.Any() ); } [Fact] public async Task AssertCredentialAsync_UsesProvidedClientDataHash_WhenHashIsProvided() { // Arrange var mockHash = RandomBytes(32); _sutProvider.GetDependency() .GetAssertionAsync(Arg.Any(), _sutProvider.GetDependency()) .Returns(_authenticatorResult); // Act await _sutProvider.Sut.AssertCredentialAsync(_params, mockHash); // Assert await _sutProvider.GetDependency().Received() .GetAssertionAsync( Arg.Is((Fido2AuthenticatorGetAssertionParams x) => x.Hash == mockHash), Arg.Any() ); } [Fact] public async Task AssertCredentialAsync_ReturnsAssertion() { // Arrange _params.UserVerification = "required"; _sutProvider.GetDependency() .GetAssertionAsync(Arg.Any(), _sutProvider.GetDependency()) .Returns(_authenticatorResult); // Act var result = await _sutProvider.Sut.AssertCredentialAsync(_params); // Assert await _sutProvider.GetDependency() .Received() .GetAssertionAsync( Arg.Is(x => x.RpId == _params.RpId && x.UserVerificationPreference == Fido2UserVerificationPreference.Required && x.AllowCredentialDescriptorList.Length == 1 && x.AllowCredentialDescriptorList[0].Id == _params.AllowCredentials[0].Id ), _sutProvider.GetDependency() ); Assert.Equal(_authenticatorResult.SelectedCredential.Id, result.RawId); Assert.Equal(CoreHelpers.Base64UrlEncode(_authenticatorResult.SelectedCredential.Id), result.Id); Assert.Equal(_authenticatorResult.AuthenticatorData, result.AuthenticatorData); Assert.Equal(_authenticatorResult.Signature, result.Signature); var clientDataJSON = JsonSerializer.Deserialize(Encoding.UTF8.GetString(result.ClientDataJSON)); Assert.Equal("webauthn.get", clientDataJSON["type"].GetValue()); Assert.Equal(CoreHelpers.Base64UrlEncode(_params.Challenge), clientDataJSON["challenge"].GetValue()); Assert.Equal(_params.Origin, clientDataJSON["origin"].GetValue()); Assert.Equal(!_params.SameOriginWithAncestors, clientDataJSON["crossOrigin"].GetValue()); } private byte[] RandomBytes(int length) { var bytes = new byte[length]; new Random().NextBytes(bytes); return bytes; } } }