diff --git a/src/Core/Services/Fido2AuthenticatorService.cs b/src/Core/Services/Fido2AuthenticatorService.cs index e82dd5825..eeacf646d 100644 --- a/src/Core/Services/Fido2AuthenticatorService.cs +++ b/src/Core/Services/Fido2AuthenticatorService.cs @@ -15,7 +15,7 @@ namespace Bit.Core.Services public async Task MakeCredentialAsync(Fido2AuthenticatorMakeCredentialParams makeCredentialParams) { - if (makeCredentialParams.CredTypesAndPubKeyAlgs.All((p) => p.Algorithm != (int) Fido2AlgorithmIdentifier.ES256)) + if (makeCredentialParams.CredTypesAndPubKeyAlgs.All((p) => p.Alg != (int) Fido2AlgorithmIdentifier.ES256)) { // var requestedAlgorithms = string.Join(", ", makeCredentialParams.CredTypesAndPubKeyAlgs.Select((p) => p.Algorithm).ToArray()); // _logService.Warning( diff --git a/src/Core/Services/Fido2ClientService.cs b/src/Core/Services/Fido2ClientService.cs index 1f0bea35e..9c7ce6da0 100644 --- a/src/Core/Services/Fido2ClientService.cs +++ b/src/Core/Services/Fido2ClientService.cs @@ -1,17 +1,62 @@ +using System.Text; +using System.Text.Json; using Bit.Core.Abstractions; +using Bit.Core.Enums; +using Bit.Core.Utilities; using Bit.Core.Utilities.Fido2; namespace Bit.Core.Services { public class Fido2ClientService : IFido2ClientService { - public Task CreateCredentialAsync(Fido2ClientCreateCredentialParams createCredentialParams) + private readonly IStateService _stateService; + private readonly IEnvironmentService _environmentService; + private readonly ICryptoFunctionService _cryptoFunctionService; + private readonly IFido2AuthenticatorService _fido2AuthenticatorService; + + public Fido2ClientService( + IStateService stateService, + IEnvironmentService environmentService, + ICryptoFunctionService cryptoFunctionService, + IFido2AuthenticatorService fido2AuthenticatorService + ) { + _stateService = stateService; + _environmentService = environmentService; + _cryptoFunctionService = cryptoFunctionService; + _fido2AuthenticatorService = fido2AuthenticatorService; + } + + public async Task CreateCredentialAsync(Fido2ClientCreateCredentialParams createCredentialParams) + { + var blockedUris = await _stateService.GetAutofillBlacklistedUrisAsync(); + var domain = CoreHelpers.GetHostname(createCredentialParams.Origin); + if (blockedUris.Contains(domain)) + { + throw new Fido2ClientException( + Fido2ClientException.ErrorCode.UriBlockedError, + "Origin is blocked by the user"); + } + + if (!await _stateService.IsAuthenticatedAsync()) + { + throw new Fido2ClientException( + Fido2ClientException.ErrorCode.InvalidStateError, + "No user is logged in"); + } + + if (createCredentialParams.Origin == _environmentService.GetWebVaultUrl()) + { + throw new Fido2ClientException( + Fido2ClientException.ErrorCode.NotAllowedError, + "Saving Bitwarden credentials in a Bitwarden vault is not allowed"); + } + if (!createCredentialParams.SameOriginWithAncestors) { throw new Fido2ClientException( Fido2ClientException.ErrorCode.NotAllowedError, - "Credential creation is now allowed from embedded contexts with different origins."); + "Credential creation is now allowed from embedded contexts with different origins"); } if (createCredentialParams.User.Id.Length < 1 || createCredentialParams.User.Id.Length > 64) @@ -19,12 +64,101 @@ namespace Bit.Core.Services // TODO: Should we use ArgumentException here instead? throw new Fido2ClientException( Fido2ClientException.ErrorCode.TypeError, - "The length of user.id is not between 1 and 64 bytes (inclusive)."); + "The length of user.id is not between 1 and 64 bytes (inclusive)"); } - throw new NotImplementedException(); + if (!createCredentialParams.Origin.StartsWith("https://")) + { + throw new Fido2ClientException( + Fido2ClientException.ErrorCode.SecurityError, + "Origin is not a valid https origin"); + } + + if (!Fido2DomainUtils.IsValidRpId(createCredentialParams.Rp.Id, createCredentialParams.Origin)) + { + throw new Fido2ClientException( + Fido2ClientException.ErrorCode.SecurityError, + "RP ID cannot be used with this origin"); + } + + PublicKeyCredentialParameters[] credTypesAndPubKeyAlgs; + if (createCredentialParams.PubKeyCredParams?.Length > 0) + { + // Filter out all unsupported algorithms + credTypesAndPubKeyAlgs = createCredentialParams.PubKeyCredParams + .Where(kp => kp.Alg == -7 && kp.Type == "public-key") + .ToArray(); + } + else + { + // Assign default algorithms + credTypesAndPubKeyAlgs = [ + new PublicKeyCredentialParameters { Alg = -7, Type = "public-key" }, + new PublicKeyCredentialParameters { Alg = -257, Type = "public-key" } + ]; + } + + if (credTypesAndPubKeyAlgs.Length == 0) + { + throw new Fido2ClientException(Fido2ClientException.ErrorCode.NotSupportedError, "No supported algorithms found"); + } + + var clientDataJSON = JsonSerializer.Serialize(new { + type = "webauthn.create", + challenge = CoreHelpers.Base64UrlEncode(createCredentialParams.Challenge), + origin = createCredentialParams.Origin, + crossOrigin = !createCredentialParams.SameOriginWithAncestors, + // tokenBinding: {} // Not supported + }); + var clientDataJSONBytes = Encoding.UTF8.GetBytes(clientDataJSON); + var clientDataHash = await _cryptoFunctionService.HashAsync(clientDataJSONBytes, CryptoHashAlgorithm.Sha256); + var makeCredentialParams = MapToMakeCredentialParams(createCredentialParams, credTypesAndPubKeyAlgs, clientDataHash); + + try { + var makeCredentialResult = await _fido2AuthenticatorService.MakeCredentialAsync(makeCredentialParams); + + return new Fido2ClientCreateCredentialResult { + CredentialId = makeCredentialResult.CredentialId, + AttestationObject = makeCredentialResult.AttestationObject, + AuthData = makeCredentialResult.AuthData, + ClientDataJSON = clientDataJSONBytes, + PublicKey = makeCredentialResult.PublicKey, + PublicKeyAlgorithm = makeCredentialResult.PublicKeyAlgorithm, + Transports = createCredentialParams.Rp.Id == "google.com" ? ["internal", "usb"] : ["internal"] // workaround for a bug on Google's side + }; + } catch (InvalidStateError) { + throw new Fido2ClientException(Fido2ClientException.ErrorCode.InvalidStateError, "Unknown invalid state encountered"); + } catch (Exception) { + throw new Fido2ClientException(Fido2ClientException.ErrorCode.UnknownError, $"An unknown error occurred"); + } } public Task AssertCredentialAsync(Fido2ClientAssertCredentialParams assertCredentialParams) => throw new NotImplementedException(); + + private Fido2AuthenticatorMakeCredentialParams MapToMakeCredentialParams( + Fido2ClientCreateCredentialParams createCredentialParams, + PublicKeyCredentialParameters[] credTypesAndPubKeyAlgs, + byte[] clientDataHash) + { + var requireResidentKey = createCredentialParams.AuthenticatorSelection?.ResidentKey == "required" || + createCredentialParams.AuthenticatorSelection?.ResidentKey == "preferred" || + (createCredentialParams.AuthenticatorSelection?.ResidentKey == null && + createCredentialParams.AuthenticatorSelection?.RequireResidentKey == true); + + var requireUserVerification = createCredentialParams.AuthenticatorSelection?.UserVerification == "required" || + createCredentialParams.AuthenticatorSelection?.UserVerification == "preferred" || + createCredentialParams.AuthenticatorSelection?.UserVerification == null; + + return new Fido2AuthenticatorMakeCredentialParams { + RequireResidentKey = requireResidentKey, + RequireUserVerification = requireUserVerification, + ExcludeCredentialDescriptorList = createCredentialParams.ExcludeCredentials, + CredTypesAndPubKeyAlgs = credTypesAndPubKeyAlgs, + Hash = clientDataHash, + RpEntity = createCredentialParams.Rp, + UserEntity = createCredentialParams.User, + Extensions = createCredentialParams.Extensions + }; + } } } diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index 049f31229..4041ca1d4 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -40,7 +40,7 @@ namespace Bit.Core.Utilities /// /// Returns the host (and not port) of the given uri. - /// Does not support plain hostnames/only top level domain. + /// Does not support plain hostnames without a protocol. /// /// Input => Output examples: /// https://bitwarden.com => bitwarden.com @@ -59,7 +59,7 @@ namespace Bit.Core.Utilities /// /// Returns the host and port of the given uri. - /// Does not support plain hostnames/only top level domain. + /// Does not support plain hostnames without /// /// Input => Output examples: /// https://bitwarden.com => bitwarden.com @@ -89,7 +89,7 @@ namespace Bit.Core.Utilities /// /// Returns the second and top level domain of the given uri. - /// Does not support plain hostnames/only top level domain. + /// Does not support plain hostnames without /// /// Input => Output examples: /// https://bitwarden.com => bitwarden.com diff --git a/src/Core/Utilities/Fido2/Fido2AuthenticatorException.cs b/src/Core/Utilities/Fido2/Fido2AuthenticatorException.cs index 5a8a75405..be9fbee46 100644 --- a/src/Core/Utilities/Fido2/Fido2AuthenticatorException.cs +++ b/src/Core/Utilities/Fido2/Fido2AuthenticatorException.cs @@ -21,6 +21,13 @@ namespace Bit.Core.Utilities.Fido2 } } + public class InvalidStateError : Fido2AuthenticatorException + { + public InvalidStateError() : base("InvalidStateError") + { + } + } + public class UnknownError : Fido2AuthenticatorException { public UnknownError() : base("UnknownError") diff --git a/src/Core/Utilities/Fido2/Fido2AuthenticatorMakeCredentialParams.cs b/src/Core/Utilities/Fido2/Fido2AuthenticatorMakeCredentialParams.cs index 59b070905..54bb493cd 100644 --- a/src/Core/Utilities/Fido2/Fido2AuthenticatorMakeCredentialParams.cs +++ b/src/Core/Utilities/Fido2/Fido2AuthenticatorMakeCredentialParams.cs @@ -20,7 +20,7 @@ namespace Bit.Core.Utilities.Fido2 /// /// A sequence of pairs of PublicKeyCredentialType and public key algorithms (COSEAlgorithmIdentifier) requested by the Relying Party. This sequence is ordered from most preferred to least preferred. The authenticator makes a best-effort to create the most preferred credential that it can. /// - public PublicKeyCredentialAlgorithmDescriptor[] CredTypesAndPubKeyAlgs { get; set; } + public PublicKeyCredentialParameters[] CredTypesAndPubKeyAlgs { get; set; } /// ///An OPTIONAL list of PublicKeyCredentialDescriptor objects provided by the Relying Party with the intention that, if any of these are known to the authenticator, it SHOULD NOT create a new credential. excludeCredentialDescriptorList contains a list of known credentials. diff --git a/src/Core/Utilities/Fido2/Fido2ClientCreateCredentialParams.cs b/src/Core/Utilities/Fido2/Fido2ClientCreateCredentialParams.cs index f138f79d5..48695d463 100644 --- a/src/Core/Utilities/Fido2/Fido2ClientCreateCredentialParams.cs +++ b/src/Core/Utilities/Fido2/Fido2ClientCreateCredentialParams.cs @@ -39,7 +39,7 @@ namespace Bit.Core.Utilities.Fido2 /// the same account on a single authenticator. The client is requested to return an error if the new credential would /// be created on an authenticator that also contains one of the credentials enumerated in this parameter. /// - public List? ExcludeCredentials { get; set; } + public PublicKeyCredentialDescriptor[]? ExcludeCredentials { get; set; } /// /// This member contains additional parameters requesting additional processing by the client and authenticator. @@ -52,7 +52,7 @@ namespace Bit.Core.Utilities.Fido2 /// The sequence is ordered from most preferred to least preferred. /// The client makes a best-effort to create the most preferred credential that it can. /// - public required List PubKeyCredParams { get; set; } + public required PublicKeyCredentialParameters[] PubKeyCredParams { get; set; } /// /// Data about the Relying Party responsible for the request. diff --git a/src/Core/Utilities/Fido2/Fido2ClientException.cs b/src/Core/Utilities/Fido2/Fido2ClientException.cs index 4fcfa52d3..23a4919b0 100644 --- a/src/Core/Utilities/Fido2/Fido2ClientException.cs +++ b/src/Core/Utilities/Fido2/Fido2ClientException.cs @@ -7,6 +7,9 @@ namespace Bit.Core.Utilities.Fido2 NotAllowedError, TypeError, SecurityError, + UriBlockedError, + NotSupportedError, + InvalidStateError, UnknownError } diff --git a/test/Core.Test/Services/Fido2AuthenticatorMakeCredentialTests.cs b/test/Core.Test/Services/Fido2AuthenticatorMakeCredentialTests.cs index 7a885a6a5..14488367e 100644 --- a/test/Core.Test/Services/Fido2AuthenticatorMakeCredentialTests.cs +++ b/test/Core.Test/Services/Fido2AuthenticatorMakeCredentialTests.cs @@ -7,7 +7,6 @@ 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; @@ -49,9 +48,9 @@ namespace Bit.Core.Test.Services Name = "Bitwarden" }, CredTypesAndPubKeyAlgs = [ - new PublicKeyCredentialAlgorithmDescriptor { + new PublicKeyCredentialParameters { Type = "public-key", - Algorithm = -7 // ES256 + Alg = -7 // ES256 } ], RequireResidentKey = false, @@ -84,9 +83,9 @@ namespace Bit.Core.Test.Services { // Arrange _params.CredTypesAndPubKeyAlgs = [ - new PublicKeyCredentialAlgorithmDescriptor { + new PublicKeyCredentialParameters { Type = "public-key", - Algorithm = -257 // RS256 which we do not support + Alg = -257 // RS256 which we do not support } ]; diff --git a/test/Core.Test/Services/Fido2ClientCreateCredentialTests.cs b/test/Core.Test/Services/Fido2ClientCreateCredentialTests.cs index b250234e2..df2d5d880 100644 --- a/test/Core.Test/Services/Fido2ClientCreateCredentialTests.cs +++ b/test/Core.Test/Services/Fido2ClientCreateCredentialTests.cs @@ -1,8 +1,15 @@ using System; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading.Tasks; +using Bit.Core.Abstractions; 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 @@ -36,6 +43,9 @@ namespace Bit.Core.Test.Services DisplayName = "User" } }; + + _sutProvider.GetDependency().GetAutofillBlacklistedUrisAsync().Returns([]); + _sutProvider.GetDependency().IsAuthenticatedAsync().Returns(true); } public void Dispose() @@ -46,10 +56,13 @@ namespace Bit.Core.Test.Services // Spec: If sameOriginWithAncestors is false, return a "NotAllowedError" DOMException. public async Task CreateCredentialAsync_ThrowsNotAllowedError_SameOriginWithAncestorsIsFalse() { + // Arrange _params.SameOriginWithAncestors = false; + // Act var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + // Assert Assert.Equal(Fido2ClientException.ErrorCode.NotAllowedError, exception.Code); } @@ -57,10 +70,13 @@ namespace Bit.Core.Test.Services // Spec: If the length of options.user.id is not between 1 and 64 bytes (inclusive) then return a TypeError. public async Task CreateCredentialAsync_ThrowsTypeError_UserIdIsTooSmall() { + // Arrange _params.User.Id = RandomBytes(0); + // Act var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + // Assert Assert.Equal(Fido2ClientException.ErrorCode.TypeError, exception.Code); } @@ -68,13 +84,216 @@ namespace Bit.Core.Test.Services // Spec: If the length of options.user.id is not between 1 and 64 bytes (inclusive) then return a TypeError. public async Task CreateCredentialAsync_ThrowsTypeError_UserIdIsTooLarge() { + // Arrange _params.User.Id = RandomBytes(65); + // Act var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + // Assert Assert.Equal(Fido2ClientException.ErrorCode.TypeError, exception.Code); } + [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 CreateCredentialAsync_ThrowsNotAllowedError_UserIdIsTooLarge() => 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 CreateCredentialAsync_ThrowsSecurityError_OriginIsNotValidDomain() + { + // Arrange + _params.Origin = "invalid-domain-name"; + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_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 CreateCredentialAsync_ThrowsSecurityError_RpIdIsNotValidForOrigin() + { + // Arrange + _params.Origin = "https://passwordless.dev"; + _params.Rp.Id = "bitwarden.com"; + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.SecurityError, exception.Code); + } + + [Fact] + // Spec: The origin's scheme must be https. + public async Task CreateCredentialAsync_ThrowsSecurityError_OriginIsNotHttps() + { + // Arrange + _params.Origin = "http://bitwarden.com"; + _params.Rp.Id = "bitwarden.com"; + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_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 CreateCredentialAsync_ThrowsUriBlockedError_OriginIsBlocked() + { + // Arrange + _params.Origin = "https://sub.bitwarden.com"; + _sutProvider.GetDependency().GetAutofillBlacklistedUrisAsync().Returns([ + "sub.bitwarden.com" + ]); + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.UriBlockedError, exception.Code); + } + + [Fact] + // Spec: If credTypesAndPubKeyAlgs is empty, return a DOMException whose name is "NotSupportedError", and terminate this algorithm. + public async Task CreateCredentialAsync_ThrowsNotSupportedError_CredTypesAndPubKeyAlgsIsEmpty() + { + // Arrange + _params.PubKeyCredParams = [ + new PublicKeyCredentialParameters { + Type = "not-supported", + Alg = -7 + }, + new PublicKeyCredentialParameters { + Type = "public-key", + Alg = -9001 + } + ]; + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.NotSupportedError, exception.Code); + } + + [Fact(Skip = "Not implemented")] + // Spec: If the options.signal is present and its aborted flag is set to true, return a DOMException whose name is "AbortError" and terminate this algorithm. + public Task CreateCredentialAsync_ThrowsAbortError_AbortedByCaller() => throw new NotImplementedException(); + + [Fact] + public async Task CreateCredentialAsync_ReturnsNewCredential() + { + // Arrange + _params.AuthenticatorSelection = new AuthenticatorSelectionCriteria { + ResidentKey = "required", + UserVerification = "required" + }; + var authenticatorResult = new Fido2AuthenticatorMakeCredentialResult { + CredentialId = RandomBytes(32), + AttestationObject = RandomBytes(32), + AuthData = RandomBytes(32), + PublicKey = RandomBytes(32), + PublicKeyAlgorithm = -7, + }; + _sutProvider.GetDependency() + .MakeCredentialAsync(Arg.Any()) + .Returns(authenticatorResult); + + // Act + var result = await _sutProvider.Sut.CreateCredentialAsync(_params); + + // Assert + await _sutProvider.GetDependency() + .Received() + .MakeCredentialAsync(Arg.Is(x => + x.RequireResidentKey == true && + x.RequireUserVerification == true && + x.RpEntity.Id == _params.Rp.Id && + x.UserEntity.DisplayName == _params.User.DisplayName + )); + Assert.Equal(authenticatorResult.CredentialId, result.CredentialId); + Assert.Equal(authenticatorResult.AttestationObject, result.AttestationObject); + Assert.Equal(authenticatorResult.AuthData, result.AuthData); + Assert.Equal(authenticatorResult.PublicKey, result.PublicKey); + Assert.Equal(authenticatorResult.PublicKeyAlgorithm, result.PublicKeyAlgorithm); + Assert.Equal(["internal"], result.Transports); + + var clientDataJSON = JsonSerializer.Deserialize(Encoding.UTF8.GetString(result.ClientDataJSON)); + Assert.Equal("webauthn.create", 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()); + } + + [Fact] + public async Task CreateCredentialAsync_ThrowsInvalidStateError_AuthenticatorThrowsInvalidStateError() + { + // Arrange + _params.AuthenticatorSelection = new AuthenticatorSelectionCriteria { + ResidentKey = "required", + UserVerification = "required" + }; + _sutProvider.GetDependency() + .MakeCredentialAsync(Arg.Any()) + .Throws(new InvalidStateError()); + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.InvalidStateError, exception.Code); + } + + [Fact] + // This keeps sensetive information form leaking + public async Task CreateCredentialAsync_ThrowsUnknownError_AuthenticatorThrowsUnknownError() + { + // Arrange + _sutProvider.GetDependency() + .MakeCredentialAsync(Arg.Any()) + .Throws(new Exception("unknown error")); + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.UnknownError, exception.Code); + } + + [Fact] + public async Task CreateCredentialAsync_ThrowsInvalidStateError_UserIsLoggedOut() + { + // Arrange + _sutProvider.GetDependency().IsAuthenticatedAsync().Returns(false); + + // Act + var exception = await Assert.ThrowsAsync(() => _sutProvider.Sut.CreateCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.InvalidStateError, exception.Code); + } + + [Fact] + public async Task CreateCredentialAsync_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.CreateCredentialAsync(_params)); + + // Assert + Assert.Equal(Fido2ClientException.ErrorCode.NotAllowedError, exception.Code); + } private byte[] RandomBytes(int length) {