1
0
mirror of https://github.com/bitwarden/mobile synced 2025-12-22 19:23:58 +00:00

[PM-5731] Create C# WebAuthn authenticator to support maui apps (#2951)

* [PM-5731] feat: implement get assertion params object

* [PM-5731] feat: add first test

* [PM-5731] feat: add rp mismatch test

* [PM-5731] feat: ask for credentials when found

* [PM-5731] feat: find discoverable credentials

* [PM-5731] feat: add tests for successful UV requests

* [PM-5731] feat: add user does not consent test

* [PM-5731] feat: check for UV when reprompt is active

* [PM-5731] fix: tests a bit, needed some additional "arrange" steps

* [PM-5731] feat: add support for counter

* [PM-5731] feat: implement assertion without signature

* [PM-5732] feat: finish authenticator assertion implementation

note: CryptoFunctionService still needs Sign implemenation

* [PM-5731] chore: minor clean up

* [PM-5731] feat: scaffold make credential

* [PM-5731] feat: start implementing attestation

* [PM-5731] feat: implement credential exclusion

* [PM-5731] feat: add new credential confirmaiton

* [PM-5731] feat: implement credential creation

* [PM-5731] feat: add user verification checks

* [PM-5731] feat: add unknown error handling

* [PM-5731] chore: clean up unusued params

* [PM-5731] feat: partial attestation implementation

* [PM-5731] feat: implement key generation

* [PM-5731] feat: return public key in DER format

* [PM-5731] feat: implement signing

* [PM-5731] feat: remove logging

* [PM-5731] chore: use primary constructor

* [PM-5731] chore: add Async to method names

* [PM-5731] feat: add support for silent discoverability

* [PM-5731] feat: add support for specifying user presence requirement

* [PM-5731] feat: ensure unlocked vault

* [PM-5731] chore: clean up and refactor assertion tests

* [PM-5731] chore: clean up and refactor attestation tests

* [PM-5731] chore: add user presence todo comment

* [PM-5731] feat: scaffold fido2 client

* PM-5731 Fix build updating discoverable flag

* [PM-5731] fix: failing test

* [PM-5731] feat: add sameOriginWithAncestor and user id length checks

* [PM-5731] feat: add incomplete rpId verification

* [PM-5731] chore: document uri helpers

* [PM-5731] feat: implement fido2 client createCredential

* [PM-5731] feat: implement credential assertion in client

* fix wrong signature format

(cherry picked from commit a1c9ebf01f)

* [PM-5731] fix: issues after cherry-pick

* Fix incompatible GUID conversions

(cherry picked from commit c801b2fc3a)

* [PM-5731] chore: remove default constructor

* [PM-5731] feat: refactor user interface to increase flexibility

* [PM-5731] feat: implement generic assertion user interface class

* [PM-5731] feat: remove ability to make user presence optional

* [PM-5731] chore: remove logging comments

* [PM-5731] feat: add native reprompt support to the authenticator

* [PM-5731] feat: allow pre and post UV

* [PM-5731] chore: add `Async` to method name. Remove `I` from struct

* [PM-5731] fix: discoverable string repr lowercase

* [PM-5731] chore: don't use C# 12 features

* [PM-5731] fix: replace magic strings and numbers with contants and enums

* [PM-5731] fix: use UTC creation date

* [PM-5731] fix: formatting

* [PM-5731] chore: use properties for public fields

* [PM-5731] chore: remove TODO

* [PM-5731] fix: IsValidRpId

---------

Co-authored-by: Federico Maccaroni <fedemkr@gmail.com>
Co-authored-by: mpbw2 <59324545+mpbw2@users.noreply.github.com>
This commit is contained in:
Andreas Coroiu
2024-02-21 16:12:52 +01:00
committed by GitHub
parent d339514d9a
commit 71de3bedf4
45 changed files with 3166 additions and 21 deletions

View File

@@ -13,6 +13,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.0" />
<PackageReference Include="NSubstitute" Version="5.0.0" />
<PackageReference Include="System.Formats.Cbor" Version="8.0.0" />
<PackageReference Include="xunit" Version="2.5.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.0">
<PrivateAssets>all</PrivateAssets>

View File

@@ -0,0 +1,343 @@
using System;
using System.Threading.Tasks;
using Bit.Core.Abstractions;
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 : IDisposable
{
private readonly string _rpId = "bitwarden.com";
private readonly SutProvider<Fido2AuthenticatorService> _sutProvider = new SutProvider<Fido2AuthenticatorService>().Create();
private readonly IFido2GetAssertionUserInterface _userInterface = Substitute.For<IFido2GetAssertionUserInterface>();
private List<string> _credentialIds;
private List<byte[]> _rawCredentialIds;
private List<CipherView> _ciphers;
private Fido2AuthenticatorGetAssertionParams _params;
private CipherView _selectedCipher;
private string _selectedCipherCredentialId;
private byte[] _selectedCipherRawCredentialId;
/// <summary>
/// Sets up a working environment for the tests.
/// </summary>
public Fido2AuthenticatorGetAssertionTests()
{
_credentialIds = [
"2a346a27-02c5-4967-ae9e-8a090a1a8ef3",
"924e812b-540e-445f-a2fc-b392a1bf9f27",
"547d7aea-0d0e-493c-bf86-d8587e730dc1",
"c07c71c4-030f-4e24-b284-c853aad72e2b"
];
_rawCredentialIds = [
[0x2a, 0x34, 0x6a, 0x27, 0x02, 0xc5, 0x49, 0x67, 0xae, 0x9e, 0x8a, 0x09, 0x0a, 0x1a, 0x8e, 0xf3],
[0x92, 0x4e, 0x81, 0x2b, 0x54, 0x0e, 0x44, 0x5f, 0xa2, 0xfc, 0xb3, 0x92, 0xa1, 0xbf, 0x9f, 0x27],
[0x54, 0x7d, 0x7a, 0xea, 0x0d, 0x0e, 0x49, 0x3c, 0xbf, 0x86, 0xd8, 0x58, 0x7e, 0x73, 0x0d, 0xc1],
[0xc0, 0x7c, 0x71, 0xc4, 0x03, 0x0f, 0x4e, 0x24, 0xb2, 0x84, 0xc8, 0x53, 0xaa, 0xd7, 0x2e, 0x2b]
];
_ciphers = [
CreateCipherView(_credentialIds[0].ToString(), _rpId, false, false),
CreateCipherView(_credentialIds[1].ToString(), _rpId, true, true),
];
_selectedCipher = _ciphers[0];
_selectedCipherCredentialId = _credentialIds[0];
_selectedCipherRawCredentialId = _rawCredentialIds[0];
_params = CreateParams(
rpId: _rpId,
allowCredentialDescriptorList: [
new PublicKeyCredentialDescriptor {
Id = _rawCredentialIds[0],
Type = Constants.DefaultFido2CredentialType
},
new PublicKeyCredentialDescriptor {
Id = _rawCredentialIds[1],
Type = Constants.DefaultFido2CredentialType
},
],
requireUserVerification: false
);
_sutProvider.GetDependency<ICipherService>().GetAllDecryptedAsync().Returns(_ciphers);
_userInterface.PickCredentialAsync(Arg.Any<Fido2GetAssertionUserInterfaceCredential[]>()).Returns((_ciphers[0].Id, false));
}
public void Dispose()
{
}
#region missing non-discoverable credential
[Fact]
// Spec: If credentialOptions is now empty, return an error code equivalent to "NotAllowedError" and terminate the operation.
public async Task GetAssertionAsync_ThrowsNotAllowed_NoCredentialsExists()
{
// Arrange
_ciphers.Clear();
// Act & Assert
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.GetAssertionAsync(_params, _userInterface));
}
[Fact]
public async Task GetAssertionAsync_ThrowsNotAllowed_CredentialExistsButRpIdDoesNotMatch()
{
// Arrange
_params.RpId = "mismatch-rpid";
// Act & Assert
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.GetAssertionAsync(_params, _userInterface));
}
#endregion
#region vault contains credential
[Fact]
public async Task GetAssertionAsync_AsksForAllCredentials_ParamsContainsAllowedCredentialsList()
{
// Arrange
_params.AllowCredentialDescriptorList = [
new PublicKeyCredentialDescriptor {
Id = _rawCredentialIds[0],
Type = Constants.DefaultFido2CredentialType
},
new PublicKeyCredentialDescriptor {
Id = _rawCredentialIds[1],
Type = Constants.DefaultFido2CredentialType
},
];
// Act
await _sutProvider.Sut.GetAssertionAsync(_params, _userInterface);
// Assert
await _userInterface.Received().PickCredentialAsync(Arg.Is<Fido2GetAssertionUserInterfaceCredential[]>(
(credentials) => credentials.Select(c => c.CipherId).SequenceEqual(_ciphers.Select((c) => c.Id))
));
}
[Fact]
public async Task GetAssertionAsync_AsksForDiscoverableCredentials_ParamsDoesNotContainAllowedCredentialsList()
{
// Arrange
_params.AllowCredentialDescriptorList = null;
var discoverableCiphers = _ciphers.Where((cipher) => cipher.Login.MainFido2Credential.DiscoverableValue).ToList();
_userInterface.PickCredentialAsync(Arg.Any<Fido2GetAssertionUserInterfaceCredential[]>()).Returns((discoverableCiphers[0].Id, true));
// Act
await _sutProvider.Sut.GetAssertionAsync(_params, _userInterface);
// Assert
await _userInterface.Received().PickCredentialAsync(Arg.Is<Fido2GetAssertionUserInterfaceCredential[]>(
(credentials) => credentials.Select(c => c.CipherId).SequenceEqual(discoverableCiphers.Select((c) => c.Id))
));
}
[Fact]
// 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() {
// Arrange
_params.RequireUserVerification = true;
_userInterface.PickCredentialAsync(Arg.Any<Fido2GetAssertionUserInterfaceCredential[]>()).Returns((_ciphers[0].Id, true));
// Act
await _sutProvider.Sut.GetAssertionAsync(_params, _userInterface);
// Assert
await _userInterface.Received().PickCredentialAsync(Arg.Is<Fido2GetAssertionUserInterfaceCredential[]>(
(credentials) => credentials.All((c) => c.RequireUserVerification == true)
));
}
[Fact]
// 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 is implied by the UI returning a credential.
// Extension: UserVerification is required if the cipher requires reprompting.
public async Task GetAssertionAsync_DoesNotRequestUserVerification_ParamsDoNotRequireUserVerification() {
// Arrange
_params.RequireUserVerification = false;
// Act
await _sutProvider.Sut.GetAssertionAsync(_params, _userInterface);
// Assert
await _userInterface.Received().PickCredentialAsync(Arg.Is<Fido2GetAssertionUserInterfaceCredential[]>(
(credentials) => credentials.Select(c => c.RequireUserVerification).SequenceEqual(_ciphers.Select((c) => c.Reprompt == CipherRepromptType.Password))
));
}
[Fact]
// Spec: If the user does not consent, return an error code equivalent to "NotAllowedError" and terminate the operation.
public async Task GetAssertionAsync_ThrowsNotAllowed_UserDoesNotConsent() {
// Arrange
_userInterface.PickCredentialAsync(Arg.Any<Fido2GetAssertionUserInterfaceCredential[]>()).Returns((null, false));
// Act & Assert
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.GetAssertionAsync(_params, _userInterface));
}
[Fact]
// Spec: If the user does not consent, return an error code equivalent to "NotAllowedError" and terminate the operation.
public async Task GetAssertionAsync_ThrowsNotAllowed_NoUserVerificationWhenRequired() {
// Arrange
_params.RequireUserVerification = true;
_userInterface.PickCredentialAsync(Arg.Any<Fido2GetAssertionUserInterfaceCredential[]>()).Returns((_selectedCipher.Id, false));
// Act and assert
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.GetAssertionAsync(_params, _userInterface));
}
[Fact]
// Spec: If the user does not consent, return an error code equivalent to "NotAllowedError" and terminate the operation.
public async Task GetAssertionAsync_ThrowsNotAllowed_NoUserVerificationForCipherWithReprompt() {
// Arrange
_selectedCipher.Reprompt = CipherRepromptType.Password;
_params.RequireUserVerification = false;
_userInterface.PickCredentialAsync(Arg.Any<Fido2GetAssertionUserInterfaceCredential[]>()).Returns((_selectedCipher.Id, false));
// Act & Assert
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.GetAssertionAsync(_params, _userInterface));
}
#endregion
#region assertion of credential
[Theory]
[InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })]
// Spec: Increment the credential associated signature counter
public async Task GetAssertionAsync_IncrementsCounter_CounterIsLargerThanZero(Cipher encryptedCipher) {
// Arrange
_selectedCipher.Login.MainFido2Credential.CounterValue = 9000;
_sutProvider.GetDependency<ICipherService>().EncryptAsync(_selectedCipher).Returns(encryptedCipher);
// Act
await _sutProvider.Sut.GetAssertionAsync(_params, _userInterface);
// Assert
await _sutProvider.GetDependency<ICipherService>().Received().SaveWithServerAsync(encryptedCipher);
await _sutProvider.GetDependency<ICipherService>().Received().EncryptAsync(Arg.Is<CipherView>(
(cipher) => cipher.Login.MainFido2Credential.CounterValue == 9001
));
}
[Theory]
[InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })]
// Spec: Increment the credential associated signature counter
public async Task GetAssertionAsync_DoesNotIncrementsCounter_CounterIsZero(Cipher encryptedCipher) {
// Arrange
_selectedCipher.Login.MainFido2Credential.CounterValue = 0;
_sutProvider.GetDependency<ICipherService>().EncryptAsync(_selectedCipher).Returns(encryptedCipher);
// Act
await _sutProvider.Sut.GetAssertionAsync(_params, _userInterface);
// Assert
await _sutProvider.GetDependency<ICipherService>().Received().SaveWithServerAsync(encryptedCipher);
await _sutProvider.GetDependency<ICipherService>().Received().EncryptAsync(Arg.Is<CipherView>(
(cipher) => cipher.Login.MainFido2Credential.CounterValue == 0
));
}
[Fact]
public async Task GetAssertionAsync_ReturnsAssertion() {
// Arrange
var keyPair = GenerateKeyPair();
var rpIdHashMock = RandomBytes(32);
_params.Hash = RandomBytes(32);
_params.RequireUserVerification = true;
_selectedCipher.Login.MainFido2Credential.CounterValue = 9000;
_selectedCipher.Login.MainFido2Credential.KeyValue = CoreHelpers.Base64UrlEncode(keyPair.ExportPkcs8PrivateKey());
_sutProvider.GetDependency<ICryptoFunctionService>().HashAsync(_params.RpId, CryptoHashAlgorithm.Sha256).Returns(rpIdHashMock);
_userInterface.PickCredentialAsync(Arg.Any<Fido2GetAssertionUserInterfaceCredential[]>()).Returns((_selectedCipher.Id, true));
// Act
var result = await _sutProvider.Sut.GetAssertionAsync(_params, _userInterface);
// 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(_selectedCipherRawCredentialId, result.SelectedCredential.Id);
Assert.Equal(CoreHelpers.Base64UrlDecode(_selectedCipher.Login.MainFido2Credential.UserHandle), result.SelectedCredential.UserHandle);
Assert.Equal(rpIdHashMock, rpIdHash);
Assert.Equal(new byte[] { 0b00011101 }, flags); // UP = true, UV = true, BS = true, BE = true
Assert.Equal(new byte[] { 0, 0, 0x23, 0x29 }, counter); // 9001 in binary big-endian format
Assert.True(keyPair.VerifyData(authData.Concat(_params.Hash).ToArray(), result.Signature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence), "Signature verification failed");
}
[Fact]
public async Task GetAssertionAsync_ThrowsUnknownError_SaveFails() {
// Arrange
_sutProvider.GetDependency<ICipherService>().SaveWithServerAsync(Arg.Any<Cipher>()).Throws(new Exception());
// Act & Assert
await Assert.ThrowsAsync<UnknownError>(() => _sutProvider.Sut.GetAssertionAsync(_params, _userInterface));
}
#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, bool reprompt = false)
{
return new CipherView {
Type = CipherType.Login,
Id = Guid.NewGuid().ToString(),
Reprompt = reprompt ? CipherRepromptType.Password : CipherRepromptType.None,
Login = new LoginView {
Fido2Credentials = new List<Fido2CredentialView> {
new Fido2CredentialView {
CredentialId = credentialId,
RpId = rpId ?? "bitwarden.com",
Discoverable = discoverable.HasValue ? discoverable.ToString() : "true",
UserHandleValue = RandomBytes(32),
KeyValue = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgO4wC7AlY4eJP7uedRUJGYsAIJAd6gN1Vp7uJh6xXAp6hRANCAARGvr56F_t27DEG1Tzl-qJRhrTUtC7jOEbasAEEZcE3TiMqoWCan0sxKDPylhRYk-1qyrBC_feN1UtGWH57sROa"
}
}
}
};
}
private Fido2AuthenticatorGetAssertionParams CreateParams(string? rpId = null, byte[]? hash = null, PublicKeyCredentialDescriptor[]? allowCredentialDescriptorList = null, bool? requireUserPresence = null, bool? requireUserVerification = null)
{
return new Fido2AuthenticatorGetAssertionParams {
RpId = rpId ?? "bitwarden.com",
Hash = hash ?? RandomBytes(32),
AllowCredentialDescriptorList = allowCredentialDescriptorList ?? null,
RequireUserVerification = requireUserPresence ?? false
};
}
}
}

View File

@@ -0,0 +1,390 @@
using System;
using System.Threading.Tasks;
using Bit.Core.Abstractions;
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 NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
using Bit.Core.Utilities;
using System.Collections.Generic;
using System.Linq;
using System.Formats.Cbor;
namespace Bit.Core.Test.Services
{
public class Fido2AuthenticatorMakeCredentialTests : IDisposable
{
private readonly string _rpId = "bitwarden.com";
private readonly SutProvider<Fido2AuthenticatorService> _sutProvider = new SutProvider<Fido2AuthenticatorService>().Create();
private readonly IFido2MakeCredentialUserInterface _userInterface = Substitute.For<IFido2MakeCredentialUserInterface>();
private Fido2AuthenticatorMakeCredentialParams _params;
private List<string> _credentialIds;
private List<byte[]> _rawCredentialIds;
private List<CipherView> _ciphers;
private Cipher _encryptedSelectedCipher;
private CipherView _selectedCipherView;
private string _selectedCipherCredentialId;
private byte[] _selectedCipherRawCredentialId;
public Fido2AuthenticatorMakeCredentialTests() {
_credentialIds = new List<string> { "21d6aa04-92bd-4def-bf81-33f046924599", "f70c01ca-d1bf-4704-86e1-b07573aa17fa" };
_rawCredentialIds = [
[0x21, 0xd6, 0xaa, 0x04, 0x92, 0xbd, 0x4d, 0xef, 0xbf, 0x81, 0x33, 0xf0, 0x46, 0x92, 0x45, 0x99],
[0xf7, 0x0c, 0x01, 0xca, 0xd1, 0xbf, 0x47, 0x04, 0x86, 0xe1, 0xb0, 0x75, 0x73, 0xaa, 0x17, 0xfa]
];
_ciphers = [
CreateCipherView(true, _credentialIds[0], "bitwarden.com", false),
CreateCipherView(true, _credentialIds[1], "bitwarden.com", true)
];
_selectedCipherView = _ciphers[0];
_selectedCipherCredentialId = _credentialIds[0];
_selectedCipherRawCredentialId = _rawCredentialIds[0];
_encryptedSelectedCipher = CreateCipher();
_encryptedSelectedCipher.Id = _selectedCipherView.Id;
_params = new Fido2AuthenticatorMakeCredentialParams {
UserEntity = new PublicKeyCredentialUserEntity {
Id = RandomBytes(32),
Name = "test"
},
RpEntity = new PublicKeyCredentialRpEntity {
Id = _rpId,
Name = "Bitwarden"
},
CredTypesAndPubKeyAlgs = [
new PublicKeyCredentialParameters {
Type = Constants.DefaultFido2CredentialType,
Alg = (int) Fido2AlgorithmIdentifier.ES256
}
],
RequireResidentKey = false,
RequireUserVerification = false,
ExcludeCredentialDescriptorList = null
};
_sutProvider.GetDependency<ICipherService>().GetAllDecryptedAsync().Returns(_ciphers);
_sutProvider.GetDependency<ICipherService>().EncryptAsync(Arg.Any<CipherView>()).Returns(_encryptedSelectedCipher);
_sutProvider.GetDependency<ICipherService>().GetAsync(Arg.Is(_encryptedSelectedCipher.Id)).Returns(_encryptedSelectedCipher);
_userInterface.ConfirmNewCredentialAsync(Arg.Any<Fido2ConfirmNewCredentialParams>()).Returns((_selectedCipherView.Id, false));
var cryptoServiceMock = Substitute.For<ICryptoService>();
ServiceContainer.Register(typeof(CryptoService), cryptoServiceMock);
}
public void Dispose()
{
ServiceContainer.Reset();
}
#region invalid input parameters
[Fact]
// Spec: Check if at least one of the specified combinations of PublicKeyCredentialType and cryptographic parameters in credTypesAndPubKeyAlgs is supported. If not, return an error code equivalent to "NotSupportedError" and terminate the operation.
public async Task MakeCredentialAsync_ThrowsNotSupported_NoSupportedAlgorithm()
{
// Arrange
_params.CredTypesAndPubKeyAlgs = [
new PublicKeyCredentialParameters {
Type = Constants.DefaultFido2CredentialType,
Alg = -257 // RS256 which we do not support
}
];
// Act & Assert
await Assert.ThrowsAsync<NotSupportedError>(() => _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface));
}
#endregion
#region vault contains excluded credential
[Fact]
// Spec: collect an authorization gesture confirming user consent for creating a new credential.
// Deviation: Consent is not asked and the user is simply informed of the situation.
public async Task MakeCredentialAsync_InformsUser_ExcludedCredentialFound()
{
// Arrange
_params.ExcludeCredentialDescriptorList = [
new PublicKeyCredentialDescriptor {
Type = Constants.DefaultFido2CredentialType,
Id = _rawCredentialIds[0]
}
];
// Act
try
{
await _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface);
}
catch {}
// Assert
await _userInterface.Received().InformExcludedCredentialAsync(Arg.Is<string[]>(
(c) => c.SequenceEqual(new string[] { _ciphers[0].Id })
));
}
[Fact]
// Spec: return an error code equivalent to "NotAllowedError" and terminate the operation.
public async Task MakeCredentialAsync_ThrowsNotAllowed_ExcludedCredentialFound()
{
_params.ExcludeCredentialDescriptorList = [
new PublicKeyCredentialDescriptor {
Type = Constants.DefaultFido2CredentialType,
Id = _rawCredentialIds[0]
}
];
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface));
}
[Fact]
// Deviation: Organization ciphers are not checked against excluded credentials, even if the user has access to them.
public async Task MakeCredentialAsync_DoesNotInformAboutExcludedCredential_ExcludedCredentialBelongsToOrganization()
{
_ciphers[0].OrganizationId = "someOrganizationId";
_params.ExcludeCredentialDescriptorList = [
new PublicKeyCredentialDescriptor {
Type = Constants.DefaultFido2CredentialType,
Id = _rawCredentialIds[0]
}
];
await _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface);
await _userInterface.DidNotReceive().InformExcludedCredentialAsync(Arg.Any<string[]>());
}
#endregion
#region credential creation
[Fact]
public async Task MakeCredentialAsync_RequestsUserVerification_ParamsRequireUserVerification()
{
// Arrange
_params.RequireUserVerification = true;
_userInterface.ConfirmNewCredentialAsync(Arg.Any<Fido2ConfirmNewCredentialParams>()).Returns((_selectedCipherView.Id, true));
// Act
await _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface);
// Assert
await _userInterface.Received().ConfirmNewCredentialAsync(Arg.Is<Fido2ConfirmNewCredentialParams>(
(p) => p.UserVerification == true
));
}
[Fact]
public async Task MakeCredentialAsync_DoesNotRequestUserVerification_ParamsDoNotRequireUserVerification()
{
// Arrange
_params.RequireUserVerification = false;
// Act
await _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface);
// Assert
await _userInterface.Received().ConfirmNewCredentialAsync(Arg.Is<Fido2ConfirmNewCredentialParams>(
(p) => p.UserVerification == false
));
}
[Fact]
public async Task MakeCredentialAsync_SavesNewCredential_RequestConfirmedByUser()
{
// Arrange
_params.RequireResidentKey = true;
// Act
await _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface);
// Assert
await _sutProvider.GetDependency<ICipherService>().Received().EncryptAsync(Arg.Is<CipherView>(
(c) =>
c.Login.MainFido2Credential.KeyType == Constants.DefaultFido2CredentialType &&
c.Login.MainFido2Credential.KeyAlgorithm == Constants.DefaultFido2CredentialAlgorithm &&
c.Login.MainFido2Credential.KeyCurve == Constants.DefaultFido2CredentialCurve &&
c.Login.MainFido2Credential.RpId == _params.RpEntity.Id &&
c.Login.MainFido2Credential.RpName == _params.RpEntity.Name &&
c.Login.MainFido2Credential.UserHandle == CoreHelpers.Base64UrlEncode(_params.UserEntity.Id) &&
c.Login.MainFido2Credential.UserName == _params.UserEntity.Name &&
c.Login.MainFido2Credential.CounterValue == 0 &&
// c.Login.MainFido2Credential.UserDisplayName == _params.UserEntity.DisplayName &&
c.Login.MainFido2Credential.DiscoverableValue == true
));
await _sutProvider.GetDependency<ICipherService>().Received().SaveWithServerAsync(_encryptedSelectedCipher);
}
[Fact]
// Spec: If the user does not consent or if user verification fails, return an error code equivalent to "NotAllowedError" and terminate the operation.
public async Task MakeCredentialAsync_ThrowsNotAllowed_RequestNotConfirmedByUser()
{
// Arrange
_userInterface.ConfirmNewCredentialAsync(Arg.Any<Fido2ConfirmNewCredentialParams>()).Returns((null, false));
// Act & Assert
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface));
}
[Fact]
public async Task MakeCredentialAsync_ThrowsNotAllowed_NoUserVerificationWhenRequiredByParams()
{
// Arrange
_params.RequireUserVerification = true;
_userInterface.ConfirmNewCredentialAsync(Arg.Any<Fido2ConfirmNewCredentialParams>()).Returns((_encryptedSelectedCipher.Id, false));
// Act & Assert
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface));
}
[Fact]
public async Task MakeCredentialAsync_ThrowsNotAllowed_NoUserVerificationForCipherWithReprompt()
{
// Arrange
_params.RequireUserVerification = false;
_encryptedSelectedCipher.Reprompt = CipherRepromptType.Password;
_userInterface.ConfirmNewCredentialAsync(Arg.Any<Fido2ConfirmNewCredentialParams>()).Returns((_encryptedSelectedCipher.Id, false));
// Act & Assert
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface));
}
[Fact]
public async Task MakeCredentialAsync_ThrowsUnknownError_SavingCipherFails()
{
// Arrange
_sutProvider.GetDependency<ICipherService>().SaveWithServerAsync(Arg.Any<Cipher>()).Throws(new Exception("Error"));
// Act & Assert
await Assert.ThrowsAsync<UnknownError>(() => _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface));
}
[Fact]
public async Task MakeCredentialAsync_ReturnsAttestation()
{
// Arrange
var rpIdHashMock = RandomBytes(32);
_sutProvider.GetDependency<ICryptoFunctionService>().HashAsync(_params.RpEntity.Id, CryptoHashAlgorithm.Sha256).Returns(rpIdHashMock);
CipherView generatedCipherView = null;
_sutProvider.GetDependency<ICipherService>().EncryptAsync(Arg.Any<CipherView>()).Returns((call) => {
generatedCipherView = call.Arg<CipherView>();
return _encryptedSelectedCipher;
});
// Act
var result = await _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface);
// Assert
var credentialIdBytes = generatedCipherView.Login.MainFido2Credential.CredentialId.GuidToRawFormat();
var attestationObject = DecodeAttestationObject(result.AttestationObject);
Assert.Equal("none", attestationObject.Fmt);
var authData = attestationObject.AuthData;
var rpIdHash = authData.Take(32).ToArray();
var flags = authData.Skip(32).Take(1).ToArray();
var counter = authData.Skip(33).Take(4).ToArray();
var aaguid = authData.Skip(37).Take(16).ToArray();
var credentialIdLength = authData.Skip(53).Take(2).ToArray();
var credentialId = authData.Skip(55).Take(16).ToArray();
// Unsure how to test public key
// const publicKey = authData.Skip(71).ToArray(); // Key data is 77 bytes long
Assert.Equal(71 + 77, authData.Length);
Assert.Equal(rpIdHashMock, rpIdHash);
Assert.Equal([0b01011001], flags); // UP = true, AD = true, BS = true, BE = true
Assert.Equal([0, 0, 0, 0], counter);
Assert.Equal(Fido2AuthenticatorService.AAGUID, aaguid);
Assert.Equal([0, 16], credentialIdLength); // 16 bytes because we're using GUIDs
Assert.Equal(credentialIdBytes, credentialId);
}
#endregion
private byte[] RandomBytes(int length)
{
var bytes = new byte[length];
new Random().NextBytes(bytes);
return bytes;
}
#nullable enable
private CipherView CreateCipherView(bool? withFido2Credential, string credentialId, string? rpId = null, bool? discoverable = null)
{
return new CipherView {
Type = CipherType.Login,
Id = Guid.NewGuid().ToString(),
Reprompt = CipherRepromptType.None,
Login = new LoginView {
Fido2Credentials = withFido2Credential.HasValue && withFido2Credential.Value ? new List<Fido2CredentialView> {
new Fido2CredentialView {
CredentialId = credentialId,
RpId = rpId ?? "bitwarden.com",
Discoverable = discoverable.HasValue ? discoverable.ToString() : "true",
UserHandleValue = RandomBytes(32)
}
} : null
}
};
}
private Cipher CreateCipher()
{
return new Cipher {
Id = Guid.NewGuid().ToString(),
Type = CipherType.Login,
Key = null,
Attachments = [],
Login = new Login {},
};
}
private struct AttestationObject
{
public string? Fmt { get; set; }
public object? AttStmt { get; set; }
public byte[]? AuthData { get; set; }
}
private AttestationObject DecodeAttestationObject(byte[] attestationObject)
{
string? fmt = null;
object? attStmt = null;
byte[]? authData = null;
var reader = new CborReader(attestationObject, CborConformanceMode.Ctap2Canonical);
reader.ReadStartMap();
while (reader.BytesRemaining != 0)
{
var key = reader.ReadTextString();
switch (key)
{
case "fmt":
fmt = reader.ReadTextString();
break;
case "attStmt":
reader.ReadStartMap();
reader.ReadEndMap();
break;
case "authData":
authData = reader.ReadByteString();
break;
default:
throw new Exception("Unknown key");
}
}
return new AttestationObject {
Fmt = fmt,
AttStmt = attStmt,
AuthData = authData
};
}
}
}

View File

@@ -0,0 +1,124 @@
using System;
using System.Threading.Tasks;
using Bit.Core.Abstractions;
using Bit.Core.Services;
using Bit.Core.Models.View;
using Bit.Core.Enums;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
using System.Collections.Generic;
using System.Linq;
using System.Diagnostics.CodeAnalysis;
using Bit.Core.Utilities;
namespace Bit.Core.Test.Services
{
public class Fido2AuthenticatorSilentCredentialDiscoveryTests
{
[Theory]
[InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })]
public async Task SilentCredentialDiscoveryAsync_ReturnsEmptyArray_NoCredentialsExist(SutProvider<Fido2AuthenticatorService> sutProvider)
{
sutProvider.GetDependency<ICipherService>().GetAllDecryptedAsync().Returns([]);
var result = await sutProvider.Sut.SilentCredentialDiscoveryAsync("bitwarden.com");
Assert.Empty(result);
}
[Theory]
[InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })]
public async Task SilentCredentialDiscoveryAsync_ReturnsEmptyArray_OnlyNonDiscoverableCredentialsExist(SutProvider<Fido2AuthenticatorService> sutProvider)
{
sutProvider.GetDependency<ICipherService>().GetAllDecryptedAsync().Returns([
CreateCipherView("bitwarden.com", false),
CreateCipherView("bitwarden.com", false),
CreateCipherView("bitwarden.com", false)
]);
var result = await sutProvider.Sut.SilentCredentialDiscoveryAsync("bitwarden.com");
Assert.Empty(result);
}
[Theory]
[InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })]
public async Task SilentCredentialDiscoveryAsync_ReturnsEmptyArray_NoCredentialsWithMatchingRpIdExist(SutProvider<Fido2AuthenticatorService> sutProvider)
{
sutProvider.GetDependency<ICipherService>().GetAllDecryptedAsync().Returns([
CreateCipherView("a.bitwarden.com", true),
CreateCipherView("example.com", true)
]);
var result = await sutProvider.Sut.SilentCredentialDiscoveryAsync("bitwarden.com");
Assert.Empty(result);
}
[Theory]
[InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })]
public async Task SilentCredentialDiscoveryAsync_ReturnsCredentials_DiscoverableCredentialsWithMatchingRpIdExist(SutProvider<Fido2AuthenticatorService> sutProvider)
{
var matchingCredentials = new List<CipherView> {
CreateCipherView("bitwarden.com", true),
CreateCipherView("bitwarden.com", true)
};
var nonMatchingCredentials = new List<CipherView> {
CreateCipherView("example.com", true)
};
sutProvider.GetDependency<ICipherService>().GetAllDecryptedAsync().Returns(
matchingCredentials.Concat(nonMatchingCredentials).ToList()
);
var result = await sutProvider.Sut.SilentCredentialDiscoveryAsync("bitwarden.com");
Assert.True(
result.SequenceEqual(matchingCredentials.Select(c => new Fido2AuthenticatorDiscoverableCredentialMetadata {
Type = Constants.DefaultFido2CredentialType,
Id = c.Login.MainFido2Credential.CredentialId.GuidToRawFormat(),
RpId = "bitwarden.com",
UserHandle = c.Login.MainFido2Credential.UserHandleValue,
UserName = c.Login.MainFido2Credential.UserName
}), new MetadataComparer())
);
}
private byte[] RandomBytes(int length)
{
var bytes = new byte[length];
new Random().NextBytes(bytes);
return bytes;
}
#nullable enable
private CipherView CreateCipherView(string rpId, bool discoverable)
{
return new CipherView {
Type = CipherType.Login,
Id = Guid.NewGuid().ToString(),
Reprompt = CipherRepromptType.None,
Login = new LoginView {
Fido2Credentials = new List<Fido2CredentialView> {
new Fido2CredentialView {
CredentialId = Guid.NewGuid().ToString(),
RpId = rpId ?? "null.com",
DiscoverableValue = discoverable,
UserHandleValue = RandomBytes(32),
KeyValue = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgO4wC7AlY4eJP7uedRUJGYsAIJAd6gN1Vp7uJh6xXAp6hRANCAARGvr56F_t27DEG1Tzl-qJRhrTUtC7jOEbasAEEZcE3TiMqoWCan0sxKDPylhRYk-1qyrBC_feN1UtGWH57sROa"
}
}
}
};
}
private class MetadataComparer : IEqualityComparer<Fido2AuthenticatorDiscoverableCredentialMetadata>
{
public int GetHashCode([DisallowNull] Fido2AuthenticatorDiscoverableCredentialMetadata obj) => throw new NotImplementedException();
public bool Equals(Fido2AuthenticatorDiscoverableCredentialMetadata? a, Fido2AuthenticatorDiscoverableCredentialMetadata? b) =>
a != null && b != null && a.Type == b.Type && a.RpId == b.RpId && a.UserName == b.UserName && a.Id.SequenceEqual(b.Id) && a.UserHandle.SequenceEqual(b.UserHandle);
}
}
}

View File

@@ -0,0 +1,224 @@
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
{
public class Fido2ClientAssertCredentialTests : IDisposable
{
private readonly SutProvider<Fido2ClientService> _sutProvider = new SutProvider<Fido2ClientService>().Create();
private Fido2ClientAssertCredentialParams _params;
public Fido2ClientAssertCredentialTests()
{
_params = new Fido2ClientAssertCredentialParams {
Origin = "https://bitwarden.com",
Challenge = RandomBytes(32),
RpId = "bitwarden.com",
UserVerification = "required",
AllowCredentials = [
new PublicKeyCredentialDescriptor {
Id = RandomBytes(32),
Type = Constants.DefaultFido2CredentialType
}
],
Timeout = 60000,
};
_sutProvider.GetDependency<IStateService>().GetAutofillBlacklistedUrisAsync().Returns([]);
_sutProvider.GetDependency<IStateService>().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 callerOrigins 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<Fido2ClientException>(() => _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<Fido2ClientException>(() => _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<Fido2ClientException>(() => _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<IStateService>().GetAutofillBlacklistedUrisAsync().Returns([
"sub.bitwarden.com"
]);
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _sutProvider.Sut.AssertCredentialAsync(_params));
// Assert
Assert.Equal(Fido2ClientException.ErrorCode.UriBlockedError, exception.Code);
}
[Fact]
public async Task AssertCredentialAsync_ThrowsInvalidStateError_AuthenticatorThrowsInvalidStateError()
{
// Arrange
_sutProvider.GetDependency<IFido2AuthenticatorService>()
.GetAssertionAsync(Arg.Any<Fido2AuthenticatorGetAssertionParams>(), _sutProvider.GetDependency<IFido2GetAssertionUserInterface>())
.Throws(new InvalidStateError());
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _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<IFido2AuthenticatorService>()
.GetAssertionAsync(Arg.Any<Fido2AuthenticatorGetAssertionParams>(), _sutProvider.GetDependency<IFido2GetAssertionUserInterface>())
.Throws(new Exception("unknown error"));
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _sutProvider.Sut.AssertCredentialAsync(_params));
// Assert
Assert.Equal(Fido2ClientException.ErrorCode.UnknownError, exception.Code);
}
[Fact]
public async Task AssertCredentialAsync_ThrowsInvalidStateError_UserIsLoggedOut()
{
// Arrange
_sutProvider.GetDependency<IStateService>().IsAuthenticatedAsync().Returns(false);
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _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<IEnvironmentService>().GetWebVaultUrl().Returns("https://vault.bitwarden.com");
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _sutProvider.Sut.AssertCredentialAsync(_params));
// Assert
Assert.Equal(Fido2ClientException.ErrorCode.NotAllowedError, exception.Code);
}
[Fact]
public async Task AssertCredentialAsync_ReturnsAssertion()
{
// Arrange
_params.UserVerification = "required";
var authenticatorResult = new Fido2AuthenticatorGetAssertionResult {
AuthenticatorData = RandomBytes(32),
SelectedCredential = new Fido2AuthenticatorGetAssertionSelectedCredential {
Id = RandomBytes(16),
UserHandle = RandomBytes(32)
},
Signature = RandomBytes(32)
};
_sutProvider.GetDependency<IFido2AuthenticatorService>()
.GetAssertionAsync(Arg.Any<Fido2AuthenticatorGetAssertionParams>(), _sutProvider.GetDependency<IFido2GetAssertionUserInterface>())
.Returns(authenticatorResult);
// Act
var result = await _sutProvider.Sut.AssertCredentialAsync(_params);
// Assert
await _sutProvider.GetDependency<IFido2AuthenticatorService>()
.Received()
.GetAssertionAsync(
Arg.Is<Fido2AuthenticatorGetAssertionParams>(x =>
x.RpId == _params.RpId &&
x.RequireUserVerification == true &&
x.AllowCredentialDescriptorList.Length == 1 &&
x.AllowCredentialDescriptorList[0].Id == _params.AllowCredentials[0].Id
),
_sutProvider.GetDependency<IFido2GetAssertionUserInterface>()
);
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<JsonObject>(Encoding.UTF8.GetString(result.ClientDataJSON));
Assert.Equal("webauthn.get", clientDataJSON["type"].GetValue<string>());
Assert.Equal(CoreHelpers.Base64UrlEncode(_params.Challenge), clientDataJSON["challenge"].GetValue<string>());
Assert.Equal(_params.Origin, clientDataJSON["origin"].GetValue<string>());
Assert.Equal(!_params.SameOriginWithAncestors, clientDataJSON["crossOrigin"].GetValue<bool>());
}
private byte[] RandomBytes(int length)
{
var bytes = new byte[length];
new Random().NextBytes(bytes);
return bytes;
}
}
}

View File

@@ -0,0 +1,308 @@
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
{
public class Fido2ClientCreateCredentialTests : IDisposable
{
private readonly SutProvider<Fido2ClientService> _sutProvider = new SutProvider<Fido2ClientService>().Create();
private Fido2ClientCreateCredentialParams _params;
public Fido2ClientCreateCredentialTests()
{
_params = new Fido2ClientCreateCredentialParams {
Origin = "https://bitwarden.com",
SameOriginWithAncestors = true,
Attestation = "none",
Challenge = RandomBytes(32),
PubKeyCredParams = [
new PublicKeyCredentialParameters {
Type = Constants.DefaultFido2CredentialType,
Alg = (int) Fido2AlgorithmIdentifier.ES256
}
],
Rp = new PublicKeyCredentialRpEntity {
Id = "bitwarden.com",
Name = "Bitwarden"
},
User = new PublicKeyCredentialUserEntity {
Id = RandomBytes(32),
Name = "user@bitwarden.com",
DisplayName = "User"
}
};
_sutProvider.GetDependency<IStateService>().GetAutofillBlacklistedUrisAsync().Returns([]);
_sutProvider.GetDependency<IStateService>().IsAuthenticatedAsync().Returns(true);
}
public void Dispose()
{
}
[Fact]
// 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<Fido2ClientException>(() => _sutProvider.Sut.CreateCredentialAsync(_params));
// Assert
Assert.Equal(Fido2ClientException.ErrorCode.NotAllowedError, exception.Code);
}
[Fact]
// 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<Fido2ClientException>(() => _sutProvider.Sut.CreateCredentialAsync(_params));
// Assert
Assert.Equal(Fido2ClientException.ErrorCode.TypeError, exception.Code);
}
[Fact]
// 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<Fido2ClientException>(() => _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_OriginIsOpaque() => throw new NotImplementedException();
[Fact]
// Spec: Let effectiveDomain be the callerOrigins 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<Fido2ClientException>(() => _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<Fido2ClientException>(() => _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<Fido2ClientException>(() => _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<IStateService>().GetAutofillBlacklistedUrisAsync().Returns([
"sub.bitwarden.com"
]);
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _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 = (int) Fido2AlgorithmIdentifier.ES256
},
new PublicKeyCredentialParameters {
Type = Constants.DefaultFido2CredentialType,
Alg = -9001
}
];
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _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 = (int) Fido2AlgorithmIdentifier.ES256,
};
_sutProvider.GetDependency<IFido2AuthenticatorService>()
.MakeCredentialAsync(Arg.Any<Fido2AuthenticatorMakeCredentialParams>(), _sutProvider.GetDependency<IFido2MakeCredentialUserInterface>())
.Returns(authenticatorResult);
// Act
var result = await _sutProvider.Sut.CreateCredentialAsync(_params);
// Assert
await _sutProvider.GetDependency<IFido2AuthenticatorService>()
.Received()
.MakeCredentialAsync(
Arg.Is<Fido2AuthenticatorMakeCredentialParams>(x =>
x.RequireResidentKey == true &&
x.RequireUserVerification == true &&
x.RpEntity.Id == _params.Rp.Id &&
x.UserEntity.DisplayName == _params.User.DisplayName
),
_sutProvider.GetDependency<IFido2MakeCredentialUserInterface>()
);
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<JsonObject>(Encoding.UTF8.GetString(result.ClientDataJSON));
Assert.Equal("webauthn.create", clientDataJSON["type"].GetValue<string>());
Assert.Equal(CoreHelpers.Base64UrlEncode(_params.Challenge), clientDataJSON["challenge"].GetValue<string>());
Assert.Equal(_params.Origin, clientDataJSON["origin"].GetValue<string>());
Assert.Equal(!_params.SameOriginWithAncestors, clientDataJSON["crossOrigin"].GetValue<bool>());
}
[Fact]
public async Task CreateCredentialAsync_ThrowsInvalidStateError_AuthenticatorThrowsInvalidStateError()
{
// Arrange
_params.AuthenticatorSelection = new AuthenticatorSelectionCriteria {
ResidentKey = "required",
UserVerification = "required"
};
_sutProvider.GetDependency<IFido2AuthenticatorService>()
.MakeCredentialAsync(Arg.Any<Fido2AuthenticatorMakeCredentialParams>(), _sutProvider.GetDependency<IFido2MakeCredentialUserInterface>())
.Throws(new InvalidStateError());
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _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<IFido2AuthenticatorService>()
.MakeCredentialAsync(Arg.Any<Fido2AuthenticatorMakeCredentialParams>(), _sutProvider.GetDependency<IFido2MakeCredentialUserInterface>())
.Throws(new Exception("unknown error"));
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _sutProvider.Sut.CreateCredentialAsync(_params));
// Assert
Assert.Equal(Fido2ClientException.ErrorCode.UnknownError, exception.Code);
}
[Fact]
public async Task CreateCredentialAsync_ThrowsInvalidStateError_UserIsLoggedOut()
{
// Arrange
_sutProvider.GetDependency<IStateService>().IsAuthenticatedAsync().Returns(false);
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _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<IEnvironmentService>().GetWebVaultUrl().Returns("https://vault.bitwarden.com");
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _sutProvider.Sut.CreateCredentialAsync(_params));
// Assert
Assert.Equal(Fido2ClientException.ErrorCode.NotAllowedError, exception.Code);
}
private byte[] RandomBytes(int length)
{
var bytes = new byte[length];
new Random().NextBytes(bytes);
return bytes;
}
}
}

View File

@@ -0,0 +1,45 @@
using Bit.Core.Utilities.Fido2;
using Xunit;
namespace Bit.Core.Test.Utilities.Fido2
{
public class Fido2DomainUtilsTests
{
[Theory]
// From https://html.spec.whatwg.org/multipage/browsers.html#is-a-registrable-domain-suffix-of-or-is-equal-to
// [InlineData("0.0.0.0", "0.0.0.0", true)] // IP-addresses not allowed by WebAuthn spec
// [InlineData("0x10203", "0.1.2.3", true)]
// [InlineData("[0::1]", "::1", true)]
[InlineData("example.com", "example.com", true)]
[InlineData("example.com", "example.com.", false)]
[InlineData("example.com.", "example.com", false)]
[InlineData("example.com", "www.example.com", true)]
[InlineData("com", "example.com", false)]
[InlineData("example", "example", true)]
[InlineData("compute.amazonaws.com", "example.compute.amazonaws.com", false)]
[InlineData("example.compute.amazonaws.com", "www.example.compute.amazonaws.com", false)]
[InlineData("amazonaws.com", "www.example.compute.amazonaws.com", false)]
[InlineData("amazonaws.com", "test.amazonaws.com", true)]
// Overrides by the WebAuthn spec
[InlineData("0.0.0.0", "0.0.0.0", false)] // IPs not allowed
[InlineData("0x10203", "0.1.2.3", false)]
[InlineData("[0::1]", "::1", false)]
[InlineData("127.0.0.1", "127.0.0.1", false)]
[InlineData("", "", false)]
// Custom tests
[InlineData("sub.login.bitwarden.com", "https://login.bitwarden.com:1337", false)]
[InlineData("passwordless.dev", "https://login.bitwarden.com:1337", false)]
[InlineData("login.passwordless.dev", "https://login.bitwarden.com:1337", false)]
[InlineData("bitwarden", "localhost", false)]
[InlineData("bitwarden", "bitwarden", true)]
[InlineData("localhost", "https://localhost:8080", true)]
[InlineData("bitwarden.com", "https://bitwarden.com", true)]
[InlineData("bitwarden.com", "https://login.bitwarden.com:1337", true)]
[InlineData("login.bitwarden.com", "https://login.bitwarden.com:1337", true)]
[InlineData("login.bitwarden.com", "https://sub.login.bitwarden.com:1337", true)]
public void ValidateRpId(string rpId, string origin, bool isValid)
{
Assert.Equal(isValid, Fido2DomainUtils.IsValidRpId(rpId, origin));
}
}
}

View File

@@ -0,0 +1,109 @@
using System.Threading.Tasks;
using Bit.Core.Abstractions;
using Bit.Core.Utilities.Fido2;
using Xunit;
namespace Bit.Core.Test.Utilities.Fido2
{
public class Fido2GetAssertionUserInterfaceTests
{
[Fact]
public async Task PickCredentialAsync_ThrowsNotAllowed_PrePickedCredentialDoesNotMatch()
{
// Arrange
var userInterface = new Fido2GetAssertionUserInterface("cipherId", false, null, null);
// Act & Assert
await Assert.ThrowsAsync<NotAllowedError>(() => userInterface.PickCredentialAsync([CreateCredential("notMatching", false)]));
}
[Fact]
public async Task PickCredentialAsync_ReturnPrePickedCredential_CredentialsMatch()
{
// Arrange
var userInterface = new Fido2GetAssertionUserInterface("cipherId", false, null, null);
// Act
var result = await userInterface.PickCredentialAsync([CreateCredential("cipherId", false), CreateCredential("cipherId2", true)]);
// Assert
Assert.Equal("cipherId", result.CipherId);
Assert.False(result.UserVerified);
}
[Fact]
public async Task PickCredentialAsync_CallsUserVerificationCallback_UserIsAlreadyVerified()
{
// Arrange
var called = false;
var callback = () => { called = true; return Task.FromResult(true); };
var userInterface = new Fido2GetAssertionUserInterface("cipherId", false, null, callback);
// Act
var result = await userInterface.PickCredentialAsync([CreateCredential("cipherId", true), CreateCredential("cipherId2", false)]);
// Assert
Assert.Equal("cipherId", result.CipherId);
Assert.True(result.UserVerified);
Assert.True(called);
}
[Fact]
public async Task PickCredentialAsync_DoesNotCallUserVerificationCallback_UserVerificationIsAlreadyPerformed()
{
// Arrange
var called = false;
var callback = () => { called = true; return Task.FromResult(true); };
var userInterface = new Fido2GetAssertionUserInterface("cipherId2", true, null, callback);
// Act
var result = await userInterface.PickCredentialAsync([CreateCredential("cipherId", true), CreateCredential("cipherId2", false)]);
// Assert
Assert.Equal("cipherId2", result.CipherId);
Assert.True(result.UserVerified);
Assert.False(called);
}
[Fact]
public async Task PickCredentialAsync_DoesNotCallUserVerificationCallback_UserVerificationIsNotRequired()
{
// Arrange
var called = false;
var callback = () => { called = true; return Task.FromResult(true); };
var userInterface = new Fido2GetAssertionUserInterface("cipherId2", false, null, callback);
// Act
var result = await userInterface.PickCredentialAsync([CreateCredential("cipherId", true), CreateCredential("cipherId2", false)]);
// Assert
Assert.Equal("cipherId2", result.CipherId);
Assert.False(result.UserVerified);
Assert.False(called);
}
[Fact]
public async Task EnsureUnlockedVaultAsync_CallsCallback()
{
// Arrange
var called = false;
var callback = () => { called = true; return Task.CompletedTask; };
var userInterface = new Fido2GetAssertionUserInterface("cipherId", false, callback, null);
// Act
await userInterface.EnsureUnlockedVaultAsync();
// Assert
Assert.True(called);
}
private Fido2GetAssertionUserInterfaceCredential CreateCredential(string cipherId, bool requireUserVerification)
{
return new Fido2GetAssertionUserInterfaceCredential
{
CipherId = cipherId,
RequireUserVerification = requireUserVerification
};
}
}
}

View File

@@ -0,0 +1,81 @@
using System;
using Bit.Core.Utilities;
using Xunit;
namespace Bit.Core.Test.Utilities.Fido2
{
public class GuidExtensionsTests
{
[Theory]
[InlineData("59788da2-4221-4725-8503-52fea66df0b2", new byte[] {0x59, 0x78, 0x8d, 0xa2, 0x42, 0x21, 0x47, 0x25, 0x85, 0x03, 0x52, 0xfe, 0xa6, 0x6d, 0xf0, 0xb2})]
[InlineData("e7895b55-2149-4cad-9e53-989192320a8a", new byte[] {0xe7, 0x89, 0x5b, 0x55, 0x21, 0x49, 0x4c, 0xad, 0x9e, 0x53, 0x98, 0x91, 0x92, 0x32, 0x0a, 0x8a})]
[InlineData("d12f1371-5c89-4d20-a72f-0522674bdec7", new byte[] {0xd1, 0x2f, 0x13, 0x71, 0x5c, 0x89, 0x4d, 0x20, 0xa7, 0x2f, 0x05, 0x22, 0x67, 0x4b, 0xde, 0xc7})]
[InlineData("040b76e4-aff1-4090-aaa2-7f781eb1f1ac", new byte[] {0x04, 0x0b, 0x76, 0xe4, 0xaf, 0xf1, 0x40, 0x90, 0xaa, 0xa2, 0x7f, 0x78, 0x1e, 0xb1, 0xf1, 0xac})]
[InlineData("bda63808-9bf6-427b-97b6-37f3b8d8f0ea", new byte[] {0xbd, 0xa6, 0x38, 0x08, 0x9b, 0xf6, 0x42, 0x7b, 0x97, 0xb6, 0x37, 0xf3, 0xb8, 0xd8, 0xf0, 0xea})]
[InlineData("5dfb0c92-0243-4c39-bf2b-29ffea097b96", new byte[] {0x5d, 0xfb, 0x0c, 0x92, 0x02, 0x43, 0x4c, 0x39, 0xbf, 0x2b, 0x29, 0xff, 0xea, 0x09, 0x7b, 0x96})]
[InlineData("5a65a8aa-6b88-4c72-bc11-a8a80ba9431e", new byte[] {0x5a, 0x65, 0xa8, 0xaa, 0x6b, 0x88, 0x4c, 0x72, 0xbc, 0x11, 0xa8, 0xa8, 0x0b, 0xa9, 0x43, 0x1e})]
[InlineData("76e7c061-892a-4740-a33c-2a52ea7ccb57", new byte[] {0x76, 0xe7, 0xc0, 0x61, 0x89, 0x2a, 0x47, 0x40, 0xa3, 0x3c, 0x2a, 0x52, 0xea, 0x7c, 0xcb, 0x57})]
[InlineData("322d5ade-6f81-4d7e-ab9c-8155d9a6a50f", new byte[] {0x32, 0x2d, 0x5a, 0xde, 0x6f, 0x81, 0x4d, 0x7e, 0xab, 0x9c, 0x81, 0x55, 0xd9, 0xa6, 0xa5, 0x0f})]
[InlineData("51927742-4e17-40af-991c-d958514ceedb", new byte[] {0x51, 0x92, 0x77, 0x42, 0x4e, 0x17, 0x40, 0xaf, 0x99, 0x1c, 0xd9, 0x58, 0x51, 0x4c, 0xee, 0xdb})]
public void GuidToRawFormat_ReturnsRawFormat_GivenCorrectlyFormattedGuid(string standardFormat, byte[] rawFormat)
{
var result = GuidExtensions.GuidToRawFormat(standardFormat);
Assert.Equal(rawFormat, result);
}
[Theory]
[InlineData("59788da-4221-4725-8503-52fea66df0b2")]
[InlineData("e7895b552-149-4cad-9e53-989192320a8a")]
[InlineData("x12f1371-5c89-4d20-a72f-0522674bdec7")]
[InlineData("040b76e4-aff1-4090-Aaa2-7f781eb1f1ac")]
[InlineData("bda63808-9bf6-427b-97b63-7f3b8d8f0ea")]
[InlineData("")]
public void GuidToRawFormat_ThrowsFormatException_IncorrectlyFormattedGuid(string standardFormat)
{
Assert.Throws<FormatException>(() => GuidExtensions.GuidToRawFormat(standardFormat));
}
[Fact]
public void GuidToRawFormat_ThrowsArgumentException_NullArgument()
{
Assert.Throws<ArgumentException>(() => GuidExtensions.GuidToRawFormat(null));
}
[Theory]
[InlineData(new byte[] {0x59, 0x78, 0x8d, 0xa2, 0x42, 0x21, 0x47, 0x25, 0x85, 0x03, 0x52, 0xfe, 0xa6, 0x6d, 0xf0, 0xb2}, "59788da2-4221-4725-8503-52fea66df0b2")]
[InlineData(new byte[] {0xe7, 0x89, 0x5b, 0x55, 0x21, 0x49, 0x4c, 0xad, 0x9e, 0x53, 0x98, 0x91, 0x92, 0x32, 0x0a, 0x8a}, "e7895b55-2149-4cad-9e53-989192320a8a")]
[InlineData(new byte[] {0xd1, 0x2f, 0x13, 0x71, 0x5c, 0x89, 0x4d, 0x20, 0xa7, 0x2f, 0x05, 0x22, 0x67, 0x4b, 0xde, 0xc7}, "d12f1371-5c89-4d20-a72f-0522674bdec7")]
[InlineData(new byte[] {0x04, 0x0b, 0x76, 0xe4, 0xaf, 0xf1, 0x40, 0x90, 0xaa, 0xa2, 0x7f, 0x78, 0x1e, 0xb1, 0xf1, 0xac}, "040b76e4-aff1-4090-aaa2-7f781eb1f1ac")]
[InlineData(new byte[] {0xbd, 0xa6, 0x38, 0x08, 0x9b, 0xf6, 0x42, 0x7b, 0x97, 0xb6, 0x37, 0xf3, 0xb8, 0xd8, 0xf0, 0xea}, "bda63808-9bf6-427b-97b6-37f3b8d8f0ea")]
[InlineData(new byte[] {0x5d, 0xfb, 0x0c, 0x92, 0x02, 0x43, 0x4c, 0x39, 0xbf, 0x2b, 0x29, 0xff, 0xea, 0x09, 0x7b, 0x96}, "5dfb0c92-0243-4c39-bf2b-29ffea097b96")]
[InlineData(new byte[] {0x5a, 0x65, 0xa8, 0xaa, 0x6b, 0x88, 0x4c, 0x72, 0xbc, 0x11, 0xa8, 0xa8, 0x0b, 0xa9, 0x43, 0x1e}, "5a65a8aa-6b88-4c72-bc11-a8a80ba9431e")]
[InlineData(new byte[] {0x76, 0xe7, 0xc0, 0x61, 0x89, 0x2a, 0x47, 0x40, 0xa3, 0x3c, 0x2a, 0x52, 0xea, 0x7c, 0xcb, 0x57}, "76e7c061-892a-4740-a33c-2a52ea7ccb57")]
[InlineData(new byte[] {0x32, 0x2d, 0x5a, 0xde, 0x6f, 0x81, 0x4d, 0x7e, 0xab, 0x9c, 0x81, 0x55, 0xd9, 0xa6, 0xa5, 0x0f}, "322d5ade-6f81-4d7e-ab9c-8155d9a6a50f")]
[InlineData(new byte[] {0x51, 0x92, 0x77, 0x42, 0x4e, 0x17, 0x40, 0xaf, 0x99, 0x1c, 0xd9, 0x58, 0x51, 0x4c, 0xee, 0xdb}, "51927742-4e17-40af-991c-d958514ceedb")]
public void GuidToStandardFormat(byte[] rawFormat, string standardFormat)
{
var result = GuidExtensions.GuidToStandardFormat(rawFormat);
Assert.Equal(standardFormat, result);
}
[Fact]
public void GuidToStandardFormat_ThrowsArgumentException_NullArgument()
{
Assert.Throws<ArgumentException>(() => GuidExtensions.GuidToStandardFormat(null));
}
[Fact]
public void GuidToStandardFormat_ThrowsArgumentException_TooLarge()
{
Assert.Throws<ArgumentException>(() => GuidExtensions.GuidToStandardFormat(new byte[17]));
}
[Fact]
public void GuidToStandardFormat_ThrowsArgumentException_TooShort()
{
Assert.Throws<ArgumentException>(() => GuidExtensions.GuidToStandardFormat(new byte[15]));
}
}
}