mirror of
https://github.com/bitwarden/mobile
synced 2026-01-01 08:03:37 +00:00
[PM-5731] feat: implement credential assertion in client
This commit is contained in:
@@ -259,7 +259,7 @@ namespace Bit.Core.Test.Services
|
||||
// Arrange
|
||||
var keyPair = GenerateKeyPair();
|
||||
var rpIdHashMock = RandomBytes(32);
|
||||
_params.ClientDataHash = RandomBytes(32);
|
||||
_params.Hash = RandomBytes(32);
|
||||
_params.RequireUserVerification = true;
|
||||
_selectedCipher.Login.MainFido2Credential.CounterValue = 9000;
|
||||
_selectedCipher.Login.MainFido2Credential.KeyValue = CoreHelpers.Base64UrlEncode(keyPair.ExportPkcs8PrivateKey());
|
||||
@@ -283,7 +283,7 @@ namespace Bit.Core.Test.Services
|
||||
Assert.Equal(rpIdHashMock, rpIdHash);
|
||||
Assert.Equal(new byte[] { 0b00000101 }, flags); // UP = true, UV = true
|
||||
Assert.Equal(new byte[] { 0, 0, 0x23, 0x29 }, counter); // 9001 in binary big-endian format
|
||||
Assert.True(keyPair.VerifyData(authData.Concat(_params.ClientDataHash).ToArray(), result.Signature, HashAlgorithmName.SHA256), "Signature verification failed");
|
||||
Assert.True(keyPair.VerifyData(authData.Concat(_params.Hash).ToArray(), result.Signature, HashAlgorithmName.SHA256), "Signature verification failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -357,11 +357,11 @@ namespace Bit.Core.Test.Services
|
||||
};
|
||||
}
|
||||
|
||||
private Fido2AuthenticatorGetAssertionParams CreateParams(string? rpId = null, byte[]? clientDataHash = 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, bool? requireUserVerification = null)
|
||||
{
|
||||
return new Fido2AuthenticatorGetAssertionParams {
|
||||
RpId = rpId ?? "bitwarden.com",
|
||||
ClientDataHash = clientDataHash ?? RandomBytes(32),
|
||||
Hash = hash ?? RandomBytes(32),
|
||||
AllowCredentialDescriptorList = allowCredentialDescriptorList ?? null,
|
||||
RequireUserPresence = requireUserPresence ?? true,
|
||||
RequireUserVerification = requireUserPresence ?? false
|
||||
|
||||
222
test/Core.Test/Services/Fido2ClientAssertCredentialTests.cs
Normal file
222
test/Core.Test/Services/Fido2ClientAssertCredentialTests.cs
Normal file
@@ -0,0 +1,222 @@
|
||||
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 = "public-key"
|
||||
}
|
||||
],
|
||||
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>())
|
||||
.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>())
|
||||
.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>())
|
||||
.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.RequireUserPresence == true &&
|
||||
x.RequireUserVerification == true &&
|
||||
x.AllowCredentialDescriptorList.Length == 1 &&
|
||||
x.AllowCredentialDescriptorList[0].Id == _params.AllowCredentials[0].Id
|
||||
));
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ namespace Bit.Core.Test.Services
|
||||
|
||||
[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();
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user