mirror of
https://github.com/bitwarden/mobile
synced 2026-02-26 01:13:28 +00:00
[PM-6466] Implement passkeys User Verification (#3044)
* PM-6441 Implement passkeys User Verification * PM-6441 Reorganized UserVerificationMediatorService so everything is not in the same file * PM-6441 Fix Unit tests * PM-6441 Refactor UserVerification on Fido2Authenticator and Client services to be of an enum type so we can see which specific preference the RP sent and to be passed into the user verification mediator service to perform the correct flow depending on that. Also updated Unit tests. * PM-6441 Changed user verification logic a bit so if preference is Preferred and the app has the ability to verify the user then enforce required UV and fix issue on on Discouraged to take into account MP reprompt
This commit is contained in:
committed by
GitHub
parent
e41abf5003
commit
4292542155
@@ -1,20 +1,20 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using Bit.Core.Abstractions;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Domain;
|
||||
using Bit.Core.Models.View;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
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
|
||||
{
|
||||
@@ -37,41 +37,49 @@ namespace Bit.Core.Test.Services
|
||||
/// </summary>
|
||||
public Fido2AuthenticatorGetAssertionTests()
|
||||
{
|
||||
_credentialIds = [
|
||||
_credentialIds = new List<string>
|
||||
{
|
||||
"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 = [
|
||||
};
|
||||
_rawCredentialIds = new List<byte[]>
|
||||
{
|
||||
new byte[] { 0x2a, 0x34, 0x6a, 0x27, 0x02, 0xc5, 0x49, 0x67, 0xae, 0x9e, 0x8a, 0x09, 0x0a, 0x1a, 0x8e, 0xf3 },
|
||||
new byte[] { 0x92, 0x4e, 0x81, 0x2b, 0x54, 0x0e, 0x44, 0x5f, 0xa2, 0xfc, 0xb3, 0x92, 0xa1, 0xbf, 0x9f, 0x27 },
|
||||
new byte[] { 0x54, 0x7d, 0x7a, 0xea, 0x0d, 0x0e, 0x49, 0x3c, 0xbf, 0x86, 0xd8, 0x58, 0x7e, 0x73, 0x0d, 0xc1 },
|
||||
new byte[] { 0xc0, 0x7c, 0x71, 0xc4, 0x03, 0x0f, 0x4e, 0x24, 0xb2, 0x84, 0xc8, 0x53, 0xaa, 0xd7, 0x2e, 0x2b }
|
||||
};
|
||||
_ciphers = new List<CipherView>
|
||||
{
|
||||
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 {
|
||||
allowCredentialDescriptorList: new PublicKeyCredentialDescriptor[]
|
||||
{
|
||||
new PublicKeyCredentialDescriptor
|
||||
{
|
||||
Id = _rawCredentialIds[0],
|
||||
Type = Constants.DefaultFido2CredentialType
|
||||
},
|
||||
new PublicKeyCredentialDescriptor {
|
||||
new PublicKeyCredentialDescriptor
|
||||
{
|
||||
Id = _rawCredentialIds[1],
|
||||
Type = Constants.DefaultFido2CredentialType
|
||||
},
|
||||
],
|
||||
requireUserVerification: false
|
||||
},
|
||||
userVerificationPreference: Fido2UserVerificationPreference.Discouraged
|
||||
);
|
||||
_sutProvider.GetDependency<ICipherService>().GetAllDecryptedAsync().Returns(_ciphers);
|
||||
_userInterface.PickCredentialAsync(Arg.Any<Fido2GetAssertionUserInterfaceCredential[]>()).Returns((_ciphers[0].Id, false));
|
||||
_sutProvider.GetDependency<IUserVerificationMediatorService>().CanPerformUserVerificationPreferredAsync(Arg.Any<Fido2UserVerificationOptions>()).Returns(Task.FromResult(false));
|
||||
_sutProvider.GetDependency<IUserVerificationMediatorService>().ShouldPerformMasterPasswordRepromptAsync(Arg.Any<Fido2UserVerificationOptions>()).Returns(Task.FromResult(false));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -109,7 +117,8 @@ namespace Bit.Core.Test.Services
|
||||
public async Task GetAssertionAsync_AsksForAllCredentials_ParamsContainsAllowedCredentialsList()
|
||||
{
|
||||
// Arrange
|
||||
_params.AllowCredentialDescriptorList = [
|
||||
_params.AllowCredentialDescriptorList = new PublicKeyCredentialDescriptor[]
|
||||
{
|
||||
new PublicKeyCredentialDescriptor {
|
||||
Id = _rawCredentialIds[0],
|
||||
Type = Constants.DefaultFido2CredentialType
|
||||
@@ -118,7 +127,7 @@ namespace Bit.Core.Test.Services
|
||||
Id = _rawCredentialIds[1],
|
||||
Type = Constants.DefaultFido2CredentialType
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// Act
|
||||
await _sutProvider.Sut.GetAssertionAsync(_params, _userInterface);
|
||||
@@ -148,11 +157,11 @@ namespace Bit.Core.Test.Services
|
||||
|
||||
[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.
|
||||
// If UserVerificationPreference is Required, the authorization gesture MUST include user verification.
|
||||
public async Task GetAssertionAsync_RequestsUserVerification_ParamsRequireUserVerification()
|
||||
{
|
||||
// Arrange
|
||||
_params.RequireUserVerification = true;
|
||||
_params.UserVerificationPreference = Fido2UserVerificationPreference.Required;
|
||||
_userInterface.PickCredentialAsync(Arg.Any<Fido2GetAssertionUserInterfaceCredential[]>()).Returns((_ciphers[0].Id, true));
|
||||
|
||||
// Act
|
||||
@@ -160,7 +169,30 @@ namespace Bit.Core.Test.Services
|
||||
|
||||
// Assert
|
||||
await _userInterface.Received().PickCredentialAsync(Arg.Is<Fido2GetAssertionUserInterfaceCredential[]>(
|
||||
(credentials) => credentials.All((c) => c.RequireUserVerification == true)
|
||||
(credentials) => credentials.All((c) => c.UserVerificationPreference == Fido2UserVerificationPreference.Required)
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
// Spec: Prompt the user to select a public key credential source `selectedCredential` from `credentialOptions`.
|
||||
// If UserVerificationPreference is Preferred and MP reprompt is on then the authorization gesture MUST include user verification.
|
||||
// If MP reprompt is off then the authorization gestue MAY include user verification
|
||||
public async Task GetAssertionAsync_RequestsPreferredUserVerification_ParamsPreferUserVerification()
|
||||
{
|
||||
// Arrange
|
||||
_params.UserVerificationPreference = Fido2UserVerificationPreference.Preferred;
|
||||
_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.Any((c) => _ciphers.First(cip => cip.Id == c.CipherId).Reprompt == CipherRepromptType.None && c.UserVerificationPreference == Fido2UserVerificationPreference.Preferred)
|
||||
));
|
||||
|
||||
await _userInterface.Received().PickCredentialAsync(Arg.Is<Fido2GetAssertionUserInterfaceCredential[]>(
|
||||
(credentials) => credentials.Any((c) => _ciphers.First(cip => cip.Id == c.CipherId).Reprompt != CipherRepromptType.None && c.UserVerificationPreference == Fido2UserVerificationPreference.Required)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -169,17 +201,18 @@ namespace Bit.Core.Test.Services
|
||||
// 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.
|
||||
// Deviation: We send the actual preference instead of just a boolean, user presence (not user verification) is therefore required when that value is `discouraged`
|
||||
public async Task GetAssertionAsync_DoesNotRequestUserVerification_ParamsDoNotRequireUserVerification()
|
||||
{
|
||||
// Arrange
|
||||
_params.RequireUserVerification = false;
|
||||
_params.UserVerificationPreference = Fido2UserVerificationPreference.Discouraged;
|
||||
|
||||
// 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))
|
||||
(credentials) => credentials.Select(c => c.UserVerificationPreference == Fido2UserVerificationPreference.Required).SequenceEqual(_ciphers.Select((c) => c.Reprompt == CipherRepromptType.Password))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -199,7 +232,7 @@ namespace Bit.Core.Test.Services
|
||||
public async Task GetAssertionAsync_ThrowsNotAllowed_NoUserVerificationWhenRequired()
|
||||
{
|
||||
// Arrange
|
||||
_params.RequireUserVerification = true;
|
||||
_params.UserVerificationPreference = Fido2UserVerificationPreference.Required;
|
||||
_userInterface.PickCredentialAsync(Arg.Any<Fido2GetAssertionUserInterfaceCredential[]>()).Returns((_selectedCipher.Id, false));
|
||||
|
||||
// Act and assert
|
||||
@@ -212,8 +245,27 @@ namespace Bit.Core.Test.Services
|
||||
{
|
||||
// Arrange
|
||||
_selectedCipher.Reprompt = CipherRepromptType.Password;
|
||||
_params.RequireUserVerification = false;
|
||||
_params.UserVerificationPreference = Fido2UserVerificationPreference.Discouraged;
|
||||
_userInterface.PickCredentialAsync(Arg.Any<Fido2GetAssertionUserInterfaceCredential[]>()).Returns((_selectedCipher.Id, false));
|
||||
_sutProvider.GetDependency<IUserVerificationMediatorService>()
|
||||
.ShouldPerformMasterPasswordRepromptAsync(Arg.Is<Fido2UserVerificationOptions>(opt => opt.ShouldCheckMasterPasswordReprompt))
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// 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_PreferredUserVerificationPreference_CanPerformUserVerification()
|
||||
{
|
||||
// Arrange
|
||||
_selectedCipher.Reprompt = CipherRepromptType.Password;
|
||||
_params.UserVerificationPreference = Fido2UserVerificationPreference.Preferred;
|
||||
_userInterface.PickCredentialAsync(Arg.Any<Fido2GetAssertionUserInterfaceCredential[]>()).Returns((_selectedCipher.Id, false));
|
||||
_sutProvider.GetDependency<IUserVerificationMediatorService>()
|
||||
.CanPerformUserVerificationPreferredAsync(Arg.Any<Fido2UserVerificationOptions>())
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.GetAssertionAsync(_params, _userInterface));
|
||||
@@ -265,7 +317,7 @@ namespace Bit.Core.Test.Services
|
||||
var keyPair = GenerateKeyPair();
|
||||
var rpIdHashMock = RandomBytes(32);
|
||||
_params.Hash = RandomBytes(32);
|
||||
_params.RequireUserVerification = true;
|
||||
_params.UserVerificationPreference = Fido2UserVerificationPreference.Required;
|
||||
_selectedCipher.Login.MainFido2Credential.CounterValue = 9000;
|
||||
_selectedCipher.Login.MainFido2Credential.KeyValue = CoreHelpers.Base64UrlEncode(keyPair.ExportPkcs8PrivateKey());
|
||||
_sutProvider.GetDependency<ICryptoFunctionService>().HashAsync(_params.RpId, CryptoHashAlgorithm.Sha256).Returns(rpIdHashMock);
|
||||
@@ -339,14 +391,14 @@ namespace Bit.Core.Test.Services
|
||||
};
|
||||
}
|
||||
|
||||
private Fido2AuthenticatorGetAssertionParams CreateParams(string? rpId = null, byte[]? hash = null, PublicKeyCredentialDescriptor[]? allowCredentialDescriptorList = null, bool? requireUserPresence = null, bool? requireUserVerification = null)
|
||||
private Fido2AuthenticatorGetAssertionParams CreateParams(string? rpId = null, byte[]? hash = null, PublicKeyCredentialDescriptor[]? allowCredentialDescriptorList = null, bool? requireUserPresence = null, Fido2UserVerificationPreference? userVerificationPreference = null)
|
||||
{
|
||||
return new Fido2AuthenticatorGetAssertionParams
|
||||
{
|
||||
RpId = rpId ?? "bitwarden.com",
|
||||
Hash = hash ?? RandomBytes(32),
|
||||
AllowCredentialDescriptorList = allowCredentialDescriptorList ?? null,
|
||||
RequireUserVerification = requireUserPresence ?? false
|
||||
UserVerificationPreference = userVerificationPreference ?? Fido2UserVerificationPreference.Preferred
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,14 +34,16 @@ namespace Bit.Core.Test.Services
|
||||
|
||||
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 = [
|
||||
_rawCredentialIds = new List<byte[]>
|
||||
{
|
||||
new byte[] { 0x21, 0xd6, 0xaa, 0x04, 0x92, 0xbd, 0x4d, 0xef, 0xbf, 0x81, 0x33, 0xf0, 0x46, 0x92, 0x45, 0x99 },
|
||||
new byte[] { 0xf7, 0x0c, 0x01, 0xca, 0xd1, 0xbf, 0x47, 0x04, 0x86, 0xe1, 0xb0, 0x75, 0x73, 0xaa, 0x17, 0xfa }
|
||||
};
|
||||
_ciphers = new List<CipherView>
|
||||
{
|
||||
CreateCipherView(true, _credentialIds[0], "bitwarden.com", false),
|
||||
CreateCipherView(true, _credentialIds[1], "bitwarden.com", true)
|
||||
];
|
||||
};
|
||||
_selectedCipherView = _ciphers[0];
|
||||
_selectedCipherCredentialId = _credentialIds[0];
|
||||
_selectedCipherRawCredentialId = _rawCredentialIds[0];
|
||||
@@ -56,14 +58,16 @@ namespace Bit.Core.Test.Services
|
||||
Id = _rpId,
|
||||
Name = "Bitwarden"
|
||||
},
|
||||
CredTypesAndPubKeyAlgs = [
|
||||
new PublicKeyCredentialParameters {
|
||||
CredTypesAndPubKeyAlgs = new PublicKeyCredentialParameters[]
|
||||
{
|
||||
new PublicKeyCredentialParameters
|
||||
{
|
||||
Type = Constants.DefaultFido2CredentialType,
|
||||
Alg = (int) Fido2AlgorithmIdentifier.ES256
|
||||
}
|
||||
],
|
||||
},
|
||||
RequireResidentKey = false,
|
||||
RequireUserVerification = false,
|
||||
UserVerificationPreference = Fido2UserVerificationPreference.Discouraged,
|
||||
ExcludeCredentialDescriptorList = null
|
||||
};
|
||||
|
||||
@@ -71,6 +75,8 @@ namespace Bit.Core.Test.Services
|
||||
_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));
|
||||
_sutProvider.GetDependency<IUserVerificationMediatorService>().CanPerformUserVerificationPreferredAsync(Arg.Any<Fido2UserVerificationOptions>()).Returns(Task.FromResult(false));
|
||||
_sutProvider.GetDependency<IUserVerificationMediatorService>().ShouldPerformMasterPasswordRepromptAsync(Arg.Any<Fido2UserVerificationOptions>()).Returns(Task.FromResult(false));
|
||||
|
||||
var cryptoServiceMock = Substitute.For<ICryptoService>();
|
||||
ServiceContainer.Register(typeof(CryptoService), cryptoServiceMock);
|
||||
@@ -80,7 +86,7 @@ namespace Bit.Core.Test.Services
|
||||
{
|
||||
ServiceContainer.Reset();
|
||||
}
|
||||
|
||||
|
||||
#region invalid input parameters
|
||||
|
||||
[Fact]
|
||||
@@ -88,12 +94,14 @@ namespace Bit.Core.Test.Services
|
||||
public async Task MakeCredentialAsync_ThrowsNotSupported_NoSupportedAlgorithm()
|
||||
{
|
||||
// Arrange
|
||||
_params.CredTypesAndPubKeyAlgs = [
|
||||
new PublicKeyCredentialParameters {
|
||||
_params.CredTypesAndPubKeyAlgs = new PublicKeyCredentialParameters[]
|
||||
{
|
||||
new PublicKeyCredentialParameters
|
||||
{
|
||||
Type = Constants.DefaultFido2CredentialType,
|
||||
Alg = -257 // RS256 which we do not support
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotSupportedError>(() => _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface));
|
||||
@@ -109,12 +117,14 @@ namespace Bit.Core.Test.Services
|
||||
public async Task MakeCredentialAsync_InformsUser_ExcludedCredentialFound()
|
||||
{
|
||||
// Arrange
|
||||
_params.ExcludeCredentialDescriptorList = [
|
||||
new PublicKeyCredentialDescriptor {
|
||||
_params.ExcludeCredentialDescriptorList = new PublicKeyCredentialDescriptor[]
|
||||
{
|
||||
new PublicKeyCredentialDescriptor
|
||||
{
|
||||
Type = Constants.DefaultFido2CredentialType,
|
||||
Id = _rawCredentialIds[0]
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// Act
|
||||
try
|
||||
@@ -133,12 +143,14 @@ namespace Bit.Core.Test.Services
|
||||
// Spec: return an error code equivalent to "NotAllowedError" and terminate the operation.
|
||||
public async Task MakeCredentialAsync_ThrowsNotAllowed_ExcludedCredentialFound()
|
||||
{
|
||||
_params.ExcludeCredentialDescriptorList = [
|
||||
new PublicKeyCredentialDescriptor {
|
||||
_params.ExcludeCredentialDescriptorList = new PublicKeyCredentialDescriptor[]
|
||||
{
|
||||
new PublicKeyCredentialDescriptor
|
||||
{
|
||||
Type = Constants.DefaultFido2CredentialType,
|
||||
Id = _rawCredentialIds[0]
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface));
|
||||
}
|
||||
@@ -148,12 +160,14 @@ namespace Bit.Core.Test.Services
|
||||
public async Task MakeCredentialAsync_DoesNotInformAboutExcludedCredential_ExcludedCredentialBelongsToOrganization()
|
||||
{
|
||||
_ciphers[0].OrganizationId = "someOrganizationId";
|
||||
_params.ExcludeCredentialDescriptorList = [
|
||||
new PublicKeyCredentialDescriptor {
|
||||
_params.ExcludeCredentialDescriptorList = new PublicKeyCredentialDescriptor[]
|
||||
{
|
||||
new PublicKeyCredentialDescriptor
|
||||
{
|
||||
Type = Constants.DefaultFido2CredentialType,
|
||||
Id = _rawCredentialIds[0]
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
await _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface);
|
||||
|
||||
@@ -168,7 +182,7 @@ namespace Bit.Core.Test.Services
|
||||
public async Task MakeCredentialAsync_RequestsUserVerification_ParamsRequireUserVerification()
|
||||
{
|
||||
// Arrange
|
||||
_params.RequireUserVerification = true;
|
||||
_params.UserVerificationPreference = Fido2UserVerificationPreference.Required;
|
||||
_userInterface.ConfirmNewCredentialAsync(Arg.Any<Fido2ConfirmNewCredentialParams>()).Returns((_selectedCipherView.Id, true));
|
||||
|
||||
// Act
|
||||
@@ -176,7 +190,23 @@ namespace Bit.Core.Test.Services
|
||||
|
||||
// Assert
|
||||
await _userInterface.Received().ConfirmNewCredentialAsync(Arg.Is<Fido2ConfirmNewCredentialParams>(
|
||||
(p) => p.UserVerification == true
|
||||
(p) => p.UserVerificationPreference == Fido2UserVerificationPreference.Required
|
||||
));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MakeCredentialAsync_RequestsUserVerificationPreferred_ParamsPrefersUserVerification()
|
||||
{
|
||||
// Arrange
|
||||
_params.UserVerificationPreference = Fido2UserVerificationPreference.Preferred;
|
||||
_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.UserVerificationPreference == Fido2UserVerificationPreference.Preferred
|
||||
));
|
||||
}
|
||||
|
||||
@@ -184,14 +214,14 @@ namespace Bit.Core.Test.Services
|
||||
public async Task MakeCredentialAsync_DoesNotRequestUserVerification_ParamsDoNotRequireUserVerification()
|
||||
{
|
||||
// Arrange
|
||||
_params.RequireUserVerification = false;
|
||||
_params.UserVerificationPreference = Fido2UserVerificationPreference.Discouraged;
|
||||
|
||||
// Act
|
||||
await _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface);
|
||||
|
||||
// Assert
|
||||
await _userInterface.Received().ConfirmNewCredentialAsync(Arg.Is<Fido2ConfirmNewCredentialParams>(
|
||||
(p) => p.UserVerification == false
|
||||
(p) => p.UserVerificationPreference == Fido2UserVerificationPreference.Discouraged
|
||||
));
|
||||
}
|
||||
|
||||
@@ -236,7 +266,7 @@ namespace Bit.Core.Test.Services
|
||||
public async Task MakeCredentialAsync_ThrowsNotAllowed_NoUserVerificationWhenRequiredByParams()
|
||||
{
|
||||
// Arrange
|
||||
_params.RequireUserVerification = true;
|
||||
_params.UserVerificationPreference = Fido2UserVerificationPreference.Required;
|
||||
_userInterface.ConfirmNewCredentialAsync(Arg.Any<Fido2ConfirmNewCredentialParams>()).Returns((_encryptedSelectedCipher.Id, false));
|
||||
|
||||
// Act & Assert
|
||||
@@ -247,9 +277,27 @@ namespace Bit.Core.Test.Services
|
||||
public async Task MakeCredentialAsync_ThrowsNotAllowed_NoUserVerificationForCipherWithReprompt()
|
||||
{
|
||||
// Arrange
|
||||
_params.RequireUserVerification = false;
|
||||
_params.UserVerificationPreference = Fido2UserVerificationPreference.Discouraged;
|
||||
_encryptedSelectedCipher.Reprompt = CipherRepromptType.Password;
|
||||
_userInterface.ConfirmNewCredentialAsync(Arg.Any<Fido2ConfirmNewCredentialParams>()).Returns((_encryptedSelectedCipher.Id, false));
|
||||
_sutProvider.GetDependency<IUserVerificationMediatorService>()
|
||||
.ShouldPerformMasterPasswordRepromptAsync(Arg.Is<Fido2UserVerificationOptions>(opt => opt.ShouldCheckMasterPasswordReprompt))
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MakeCredentialAsync_ThrowsNotAllowed_PreferredUserVerificationPreference_CanPerformUserVerification()
|
||||
{
|
||||
// Arrange
|
||||
_params.UserVerificationPreference = Fido2UserVerificationPreference.Preferred;
|
||||
_encryptedSelectedCipher.Reprompt = CipherRepromptType.Password;
|
||||
_userInterface.ConfirmNewCredentialAsync(Arg.Any<Fido2ConfirmNewCredentialParams>()).Returns((_encryptedSelectedCipher.Id, false));
|
||||
_sutProvider.GetDependency<IUserVerificationMediatorService>()
|
||||
.CanPerformUserVerificationPreferredAsync(Arg.Any<Fido2UserVerificationOptions>())
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotAllowedError>(() => _sutProvider.Sut.MakeCredentialAsync(_params, _userInterface));
|
||||
@@ -297,10 +345,10 @@ namespace Bit.Core.Test.Services
|
||||
|
||||
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(new byte[] { 0b01011001 }, flags); // UP = true, AD = true, BS = true, BE = true
|
||||
Assert.Equal(new byte[] { 0, 0, 0, 0 }, counter);
|
||||
Assert.Equal(Fido2AuthenticatorService.AAGUID, aaguid);
|
||||
Assert.Equal([0, 16], credentialIdLength); // 16 bytes because we're using GUIDs
|
||||
Assert.Equal(new byte[] { 0, 16 }, credentialIdLength); // 16 bytes because we're using GUIDs
|
||||
Assert.Equal(credentialIdBytes, credentialId);
|
||||
}
|
||||
|
||||
@@ -339,7 +387,7 @@ namespace Bit.Core.Test.Services
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
Type = CipherType.Login,
|
||||
Key = null,
|
||||
Attachments = [],
|
||||
Attachments = new List<Attachment>(),
|
||||
Login = new Login {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace Bit.Core.Test.Services
|
||||
[InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })]
|
||||
public async Task SilentCredentialDiscoveryAsync_ReturnsEmptyArray_NoCredentialsExist(SutProvider<Fido2AuthenticatorService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ICipherService>().GetAllDecryptedAsync().Returns([]);
|
||||
sutProvider.GetDependency<ICipherService>().GetAllDecryptedAsync().Returns(Task.FromResult(new List<CipherView>()));
|
||||
|
||||
var result = await sutProvider.Sut.SilentCredentialDiscoveryAsync("bitwarden.com");
|
||||
|
||||
@@ -32,11 +32,12 @@ namespace Bit.Core.Test.Services
|
||||
[InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })]
|
||||
public async Task SilentCredentialDiscoveryAsync_ReturnsEmptyArray_OnlyNonDiscoverableCredentialsExist(SutProvider<Fido2AuthenticatorService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ICipherService>().GetAllDecryptedAsync().Returns([
|
||||
sutProvider.GetDependency<ICipherService>().GetAllDecryptedAsync().Returns(Task.FromResult(new List<CipherView>
|
||||
{
|
||||
CreateCipherView("bitwarden.com", false),
|
||||
CreateCipherView("bitwarden.com", false),
|
||||
CreateCipherView("bitwarden.com", false)
|
||||
]);
|
||||
}));
|
||||
|
||||
var result = await sutProvider.Sut.SilentCredentialDiscoveryAsync("bitwarden.com");
|
||||
|
||||
@@ -47,10 +48,11 @@ namespace Bit.Core.Test.Services
|
||||
[InlineCustomAutoData(new[] { typeof(SutProviderCustomization) })]
|
||||
public async Task SilentCredentialDiscoveryAsync_ReturnsEmptyArray_NoCredentialsWithMatchingRpIdExist(SutProvider<Fido2AuthenticatorService> sutProvider)
|
||||
{
|
||||
sutProvider.GetDependency<ICipherService>().GetAllDecryptedAsync().Returns([
|
||||
sutProvider.GetDependency<ICipherService>().GetAllDecryptedAsync().Returns(Task.FromResult(new List<CipherView>
|
||||
{
|
||||
CreateCipherView("a.bitwarden.com", true),
|
||||
CreateCipherView("example.com", true)
|
||||
]);
|
||||
}));
|
||||
|
||||
var result = await sutProvider.Sut.SilentCredentialDiscoveryAsync("bitwarden.com");
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -27,16 +28,18 @@ namespace Bit.Core.Test.Services
|
||||
Challenge = RandomBytes(32),
|
||||
RpId = "bitwarden.com",
|
||||
UserVerification = "required",
|
||||
AllowCredentials = [
|
||||
new PublicKeyCredentialDescriptor {
|
||||
AllowCredentials = new PublicKeyCredentialDescriptor[]
|
||||
{
|
||||
new PublicKeyCredentialDescriptor
|
||||
{
|
||||
Id = RandomBytes(32),
|
||||
Type = Constants.DefaultFido2CredentialType
|
||||
}
|
||||
],
|
||||
},
|
||||
Timeout = 60000,
|
||||
};
|
||||
|
||||
_sutProvider.GetDependency<IStateService>().GetAutofillBlacklistedUrisAsync().Returns([]);
|
||||
_sutProvider.GetDependency<IStateService>().GetAutofillBlacklistedUrisAsync().Returns(Task.FromResult(new List<string>()));
|
||||
_sutProvider.GetDependency<IStateService>().IsAuthenticatedAsync().Returns(true);
|
||||
}
|
||||
|
||||
@@ -100,9 +103,11 @@ namespace Bit.Core.Test.Services
|
||||
{
|
||||
// Arrange
|
||||
_params.Origin = "https://sub.bitwarden.com";
|
||||
_sutProvider.GetDependency<IStateService>().GetAutofillBlacklistedUrisAsync().Returns([
|
||||
_sutProvider.GetDependency<IStateService>().GetAutofillBlacklistedUrisAsync().Returns(Task.FromResult(new List<string>
|
||||
{
|
||||
"sub.bitwarden.com"
|
||||
]);
|
||||
|
||||
}));
|
||||
|
||||
// Act
|
||||
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _sutProvider.Sut.AssertCredentialAsync(_params));
|
||||
@@ -195,7 +200,7 @@ namespace Bit.Core.Test.Services
|
||||
.GetAssertionAsync(
|
||||
Arg.Is<Fido2AuthenticatorGetAssertionParams>(x =>
|
||||
x.RpId == _params.RpId &&
|
||||
x.RequireUserVerification == true &&
|
||||
x.UserVerificationPreference == Fido2UserVerificationPreference.Required &&
|
||||
x.AllowCredentialDescriptorList.Length == 1 &&
|
||||
x.AllowCredentialDescriptorList[0].Id == _params.AllowCredentials[0].Id
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -22,17 +23,20 @@ namespace Bit.Core.Test.Services
|
||||
|
||||
public Fido2ClientCreateCredentialTests()
|
||||
{
|
||||
_params = new Fido2ClientCreateCredentialParams {
|
||||
_params = new Fido2ClientCreateCredentialParams
|
||||
{
|
||||
Origin = "https://bitwarden.com",
|
||||
SameOriginWithAncestors = true,
|
||||
Attestation = "none",
|
||||
Challenge = RandomBytes(32),
|
||||
PubKeyCredParams = [
|
||||
new PublicKeyCredentialParameters {
|
||||
PubKeyCredParams = new PublicKeyCredentialParameters[]
|
||||
{
|
||||
new PublicKeyCredentialParameters
|
||||
{
|
||||
Type = Constants.DefaultFido2CredentialType,
|
||||
Alg = (int) Fido2AlgorithmIdentifier.ES256
|
||||
}
|
||||
],
|
||||
},
|
||||
Rp = new PublicKeyCredentialRpEntity {
|
||||
Id = "bitwarden.com",
|
||||
Name = "Bitwarden"
|
||||
@@ -44,7 +48,7 @@ namespace Bit.Core.Test.Services
|
||||
}
|
||||
};
|
||||
|
||||
_sutProvider.GetDependency<IStateService>().GetAutofillBlacklistedUrisAsync().Returns([]);
|
||||
_sutProvider.GetDependency<IStateService>().GetAutofillBlacklistedUrisAsync().Returns(Task.FromResult(new List<string>()));
|
||||
_sutProvider.GetDependency<IStateService>().IsAuthenticatedAsync().Returns(true);
|
||||
}
|
||||
|
||||
@@ -150,9 +154,10 @@ namespace Bit.Core.Test.Services
|
||||
{
|
||||
// Arrange
|
||||
_params.Origin = "https://sub.bitwarden.com";
|
||||
_sutProvider.GetDependency<IStateService>().GetAutofillBlacklistedUrisAsync().Returns([
|
||||
_sutProvider.GetDependency<IStateService>().GetAutofillBlacklistedUrisAsync().Returns(Task.FromResult(new List<string>
|
||||
{
|
||||
"sub.bitwarden.com"
|
||||
]);
|
||||
}));
|
||||
|
||||
// Act
|
||||
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _sutProvider.Sut.CreateCredentialAsync(_params));
|
||||
@@ -166,7 +171,8 @@ namespace Bit.Core.Test.Services
|
||||
public async Task CreateCredentialAsync_ThrowsNotSupportedError_CredTypesAndPubKeyAlgsIsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
_params.PubKeyCredParams = [
|
||||
_params.PubKeyCredParams = new PublicKeyCredentialParameters[]
|
||||
{
|
||||
new PublicKeyCredentialParameters {
|
||||
Type = "not-supported",
|
||||
Alg = (int) Fido2AlgorithmIdentifier.ES256
|
||||
@@ -175,7 +181,7 @@ namespace Bit.Core.Test.Services
|
||||
Type = Constants.DefaultFido2CredentialType,
|
||||
Alg = -9001
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// Act
|
||||
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _sutProvider.Sut.CreateCredentialAsync(_params));
|
||||
@@ -216,7 +222,7 @@ namespace Bit.Core.Test.Services
|
||||
.MakeCredentialAsync(
|
||||
Arg.Is<Fido2AuthenticatorMakeCredentialParams>(x =>
|
||||
x.RequireResidentKey == true &&
|
||||
x.RequireUserVerification == true &&
|
||||
x.UserVerificationPreference == Fido2UserVerificationPreference.Required &&
|
||||
x.RpEntity.Id == _params.Rp.Id &&
|
||||
x.UserEntity.DisplayName == _params.User.DisplayName
|
||||
),
|
||||
@@ -227,7 +233,7 @@ namespace Bit.Core.Test.Services
|
||||
Assert.Equal(authenticatorResult.AuthData, result.AuthData);
|
||||
Assert.Equal(authenticatorResult.PublicKey, result.PublicKey);
|
||||
Assert.Equal(authenticatorResult.PublicKeyAlgorithm, result.PublicKeyAlgorithm);
|
||||
Assert.Equal(["internal"], result.Transports);
|
||||
Assert.Equal(new string[] { "internal" }, result.Transports);
|
||||
|
||||
var clientDataJSON = JsonSerializer.Deserialize<JsonObject>(Encoding.UTF8.GetString(result.ClientDataJSON));
|
||||
Assert.Equal("webauthn.create", clientDataJSON["type"].GetValue<string>());
|
||||
|
||||
@@ -11,20 +11,24 @@ namespace Bit.Core.Test.Utilities.Fido2
|
||||
public async Task PickCredentialAsync_ThrowsNotAllowed_PrePickedCredentialDoesNotMatch()
|
||||
{
|
||||
// Arrange
|
||||
var userInterface = new Fido2GetAssertionUserInterface("cipherId", false, null, null);
|
||||
var userInterface = new Fido2GetAssertionUserInterface("cipherId", false, null, DefaultHasVaultBeenUnlockedInThisTransaction, DefaultVerifyUserAsync);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<NotAllowedError>(() => userInterface.PickCredentialAsync([CreateCredential("notMatching", false)]));
|
||||
await Assert.ThrowsAsync<NotAllowedError>(() => userInterface.PickCredentialAsync(new Fido2GetAssertionUserInterfaceCredential[] { CreateCredential("notMatching", Fido2UserVerificationPreference.Discouraged) }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PickCredentialAsync_ReturnPrePickedCredential_CredentialsMatch()
|
||||
{
|
||||
// Arrange
|
||||
var userInterface = new Fido2GetAssertionUserInterface("cipherId", false, null, null);
|
||||
var userInterface = new Fido2GetAssertionUserInterface("cipherId", false, null, DefaultHasVaultBeenUnlockedInThisTransaction, DefaultVerifyUserAsync);
|
||||
|
||||
// Act
|
||||
var result = await userInterface.PickCredentialAsync([CreateCredential("cipherId", false), CreateCredential("cipherId2", true)]);
|
||||
var result = await userInterface.PickCredentialAsync(new Fido2GetAssertionUserInterfaceCredential[]
|
||||
{
|
||||
CreateCredential("cipherId", Fido2UserVerificationPreference.Discouraged),
|
||||
CreateCredential("cipherId2", Fido2UserVerificationPreference.Required)
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("cipherId", result.CipherId);
|
||||
@@ -36,11 +40,18 @@ namespace Bit.Core.Test.Utilities.Fido2
|
||||
{
|
||||
// Arrange
|
||||
var called = false;
|
||||
var callback = () => { called = true; return Task.FromResult(true); };
|
||||
var userInterface = new Fido2GetAssertionUserInterface("cipherId", false, null, callback);
|
||||
var userInterface = new Fido2GetAssertionUserInterface("cipherId", false, null, DefaultHasVaultBeenUnlockedInThisTransaction, (_, __) =>
|
||||
{
|
||||
called = true;
|
||||
return Task.FromResult(true);
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await userInterface.PickCredentialAsync([CreateCredential("cipherId", true), CreateCredential("cipherId2", false)]);
|
||||
var result = await userInterface.PickCredentialAsync(new Fido2GetAssertionUserInterfaceCredential[]
|
||||
{
|
||||
CreateCredential("cipherId", Fido2UserVerificationPreference.Required),
|
||||
CreateCredential("cipherId2", Fido2UserVerificationPreference.Discouraged)
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("cipherId", result.CipherId);
|
||||
@@ -53,11 +64,18 @@ namespace Bit.Core.Test.Utilities.Fido2
|
||||
{
|
||||
// Arrange
|
||||
var called = false;
|
||||
var callback = () => { called = true; return Task.FromResult(true); };
|
||||
var userInterface = new Fido2GetAssertionUserInterface("cipherId2", true, null, callback);
|
||||
var userInterface = new Fido2GetAssertionUserInterface("cipherId2", true, null, DefaultHasVaultBeenUnlockedInThisTransaction, (_, __) =>
|
||||
{
|
||||
called = true;
|
||||
return Task.FromResult(true);
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await userInterface.PickCredentialAsync([CreateCredential("cipherId", true), CreateCredential("cipherId2", false)]);
|
||||
var result = await userInterface.PickCredentialAsync(new Fido2GetAssertionUserInterfaceCredential[]
|
||||
{
|
||||
CreateCredential("cipherId", Fido2UserVerificationPreference.Required),
|
||||
CreateCredential("cipherId2", Fido2UserVerificationPreference.Discouraged)
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal("cipherId2", result.CipherId);
|
||||
@@ -65,45 +83,34 @@ namespace Bit.Core.Test.Utilities.Fido2
|
||||
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);
|
||||
var userInterface = new Fido2GetAssertionUserInterface("cipherId", false, callback, DefaultHasVaultBeenUnlockedInThisTransaction, DefaultVerifyUserAsync);
|
||||
|
||||
// Act
|
||||
await userInterface.EnsureUnlockedVaultAsync();
|
||||
|
||||
// Assert
|
||||
Assert.True(called);
|
||||
Assert.True(userInterface.HasVaultBeenUnlockedInThisTransaction);
|
||||
}
|
||||
|
||||
private Fido2GetAssertionUserInterfaceCredential CreateCredential(string cipherId, bool requireUserVerification)
|
||||
private Fido2GetAssertionUserInterfaceCredential CreateCredential(string cipherId, Fido2UserVerificationPreference userVerificationPreference)
|
||||
{
|
||||
return new Fido2GetAssertionUserInterfaceCredential
|
||||
{
|
||||
CipherId = cipherId,
|
||||
RequireUserVerification = requireUserVerification
|
||||
UserVerificationPreference = userVerificationPreference
|
||||
};
|
||||
}
|
||||
|
||||
private bool DefaultHasVaultBeenUnlockedInThisTransaction() => true;
|
||||
|
||||
private Task<bool> DefaultVerifyUserAsync(string _, Fido2UserVerificationPreference __) => Task.FromResult(false);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user