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 commita1c9ebf01f) * [PM-5731] fix: issues after cherry-pick * Fix incompatible GUID conversions (cherry picked from commitc801b2fc3a) * [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:
@@ -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>
|
||||
|
||||
343
test/Core.Test/Services/Fido2AuthenticatorGetAssertionTests.cs
Normal file
343
test/Core.Test/Services/Fido2AuthenticatorGetAssertionTests.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
390
test/Core.Test/Services/Fido2AuthenticatorMakeCredentialTests.cs
Normal file
390
test/Core.Test/Services/Fido2AuthenticatorMakeCredentialTests.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
224
test/Core.Test/Services/Fido2ClientAssertCredentialTests.cs
Normal file
224
test/Core.Test/Services/Fido2ClientAssertCredentialTests.cs
Normal 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 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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
308
test/Core.Test/Services/Fido2ClientCreateCredentialTests.cs
Normal file
308
test/Core.Test/Services/Fido2ClientCreateCredentialTests.cs
Normal 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 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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
test/Core.Test/Utilities/Fido2/Fido2DomainUtilsTests.cs
Normal file
45
test/Core.Test/Utilities/Fido2/Fido2DomainUtilsTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
81
test/Core.Test/Utilities/GuidExtensionsTests.cs
Normal file
81
test/Core.Test/Utilities/GuidExtensionsTests.cs
Normal 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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user