mirror of
https://github.com/bitwarden/mobile
synced 2025-12-19 09:43:27 +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:
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user