1
0
mirror of https://github.com/bitwarden/mobile synced 2025-12-21 18:53:29 +00:00

[PM-5731] feat: implement fido2 client createCredential

This commit is contained in:
Andreas Coroiu
2024-02-08 14:36:36 +01:00
parent 2075f8168c
commit b8c5ef501a
9 changed files with 378 additions and 16 deletions

View File

@@ -1,8 +1,15 @@
using System;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Bit.Core.Abstractions;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Core.Utilities.Fido2;
using Bit.Test.Common.AutoFixture;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Bit.Core.Test.Services
@@ -36,6 +43,9 @@ namespace Bit.Core.Test.Services
DisplayName = "User"
}
};
_sutProvider.GetDependency<IStateService>().GetAutofillBlacklistedUrisAsync().Returns([]);
_sutProvider.GetDependency<IStateService>().IsAuthenticatedAsync().Returns(true);
}
public void Dispose()
@@ -46,10 +56,13 @@ namespace Bit.Core.Test.Services
// Spec: If sameOriginWithAncestors is false, return a "NotAllowedError" DOMException.
public async Task CreateCredentialAsync_ThrowsNotAllowedError_SameOriginWithAncestorsIsFalse()
{
// Arrange
_params.SameOriginWithAncestors = false;
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _sutProvider.Sut.CreateCredentialAsync(_params));
// Assert
Assert.Equal(Fido2ClientException.ErrorCode.NotAllowedError, exception.Code);
}
@@ -57,10 +70,13 @@ namespace Bit.Core.Test.Services
// Spec: If the length of options.user.id is not between 1 and 64 bytes (inclusive) then return a TypeError.
public async Task CreateCredentialAsync_ThrowsTypeError_UserIdIsTooSmall()
{
// Arrange
_params.User.Id = RandomBytes(0);
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _sutProvider.Sut.CreateCredentialAsync(_params));
// Assert
Assert.Equal(Fido2ClientException.ErrorCode.TypeError, exception.Code);
}
@@ -68,13 +84,216 @@ namespace Bit.Core.Test.Services
// Spec: If the length of options.user.id is not between 1 and 64 bytes (inclusive) then return a TypeError.
public async Task CreateCredentialAsync_ThrowsTypeError_UserIdIsTooLarge()
{
// Arrange
_params.User.Id = RandomBytes(65);
// Act
var exception = await Assert.ThrowsAsync<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_UserIdIsTooLarge() => throw new NotImplementedException();
[Fact]
// Spec: Let effectiveDomain be the callerOrigins effective domain. If effective domain is not a valid domain,
// then return a DOMException whose name is "SecurityError" and terminate this algorithm.
public async Task CreateCredentialAsync_ThrowsSecurityError_OriginIsNotValidDomain()
{
// Arrange
_params.Origin = "invalid-domain-name";
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _sutProvider.Sut.CreateCredentialAsync(_params));
// Assert
Assert.Equal(Fido2ClientException.ErrorCode.SecurityError, exception.Code);
}
[Fact]
// Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain,
// return a DOMException whose name is "SecurityError", and terminate this algorithm.
public async Task CreateCredentialAsync_ThrowsSecurityError_RpIdIsNotValidForOrigin()
{
// Arrange
_params.Origin = "https://passwordless.dev";
_params.Rp.Id = "bitwarden.com";
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _sutProvider.Sut.CreateCredentialAsync(_params));
// Assert
Assert.Equal(Fido2ClientException.ErrorCode.SecurityError, exception.Code);
}
[Fact]
// Spec: The origin's scheme must be https.
public async Task CreateCredentialAsync_ThrowsSecurityError_OriginIsNotHttps()
{
// Arrange
_params.Origin = "http://bitwarden.com";
_params.Rp.Id = "bitwarden.com";
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _sutProvider.Sut.CreateCredentialAsync(_params));
// Assert
Assert.Equal(Fido2ClientException.ErrorCode.SecurityError, exception.Code);
}
[Fact]
// Spec: If the origin's hostname is a blocked uri, then return UriBlockedError.
public async Task CreateCredentialAsync_ThrowsUriBlockedError_OriginIsBlocked()
{
// Arrange
_params.Origin = "https://sub.bitwarden.com";
_sutProvider.GetDependency<IStateService>().GetAutofillBlacklistedUrisAsync().Returns([
"sub.bitwarden.com"
]);
// Act
var exception = await Assert.ThrowsAsync<Fido2ClientException>(() => _sutProvider.Sut.CreateCredentialAsync(_params));
// Assert
Assert.Equal(Fido2ClientException.ErrorCode.UriBlockedError, exception.Code);
}
[Fact]
// Spec: If credTypesAndPubKeyAlgs is empty, return a DOMException whose name is "NotSupportedError", and terminate this algorithm.
public async Task CreateCredentialAsync_ThrowsNotSupportedError_CredTypesAndPubKeyAlgsIsEmpty()
{
// Arrange
_params.PubKeyCredParams = [
new PublicKeyCredentialParameters {
Type = "not-supported",
Alg = -7
},
new PublicKeyCredentialParameters {
Type = "public-key",
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 = -7,
};
_sutProvider.GetDependency<IFido2AuthenticatorService>()
.MakeCredentialAsync(Arg.Any<Fido2AuthenticatorMakeCredentialParams>())
.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
));
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>())
.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>())
.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)
{