mirror of
https://github.com/bitwarden/mobile.git
synced 2024-11-25 12:05:59 +01:00
[PM-5731] feat: implement credential assertion in client
This commit is contained in:
parent
e9d1792dd7
commit
3c848a3dcc
@ -192,7 +192,7 @@ namespace Bit.Core.Services
|
||||
|
||||
var signature = GenerateSignature(
|
||||
authData: authenticatorData,
|
||||
clientDataHash: assertionParams.ClientDataHash,
|
||||
clientDataHash: assertionParams.Hash,
|
||||
privateKey: selectedFido2Credential.KeyBytes
|
||||
);
|
||||
|
||||
|
@ -18,8 +18,7 @@ namespace Bit.Core.Services
|
||||
IStateService stateService,
|
||||
IEnvironmentService environmentService,
|
||||
ICryptoFunctionService cryptoFunctionService,
|
||||
IFido2AuthenticatorService fido2AuthenticatorService
|
||||
)
|
||||
IFido2AuthenticatorService fido2AuthenticatorService)
|
||||
{
|
||||
_stateService = stateService;
|
||||
_environmentService = environmentService;
|
||||
@ -133,7 +132,74 @@ namespace Bit.Core.Services
|
||||
}
|
||||
}
|
||||
|
||||
public Task<Fido2ClientAssertCredentialResult> AssertCredentialAsync(Fido2ClientAssertCredentialParams assertCredentialParams) => throw new NotImplementedException();
|
||||
public async Task<Fido2ClientAssertCredentialResult> AssertCredentialAsync(Fido2ClientAssertCredentialParams assertCredentialParams)
|
||||
{
|
||||
var blockedUris = await _stateService.GetAutofillBlacklistedUrisAsync();
|
||||
var domain = CoreHelpers.GetHostname(assertCredentialParams.Origin);
|
||||
if (blockedUris.Contains(domain))
|
||||
{
|
||||
throw new Fido2ClientException(
|
||||
Fido2ClientException.ErrorCode.UriBlockedError,
|
||||
"Origin is blocked by the user");
|
||||
}
|
||||
|
||||
if (!await _stateService.IsAuthenticatedAsync())
|
||||
{
|
||||
throw new Fido2ClientException(
|
||||
Fido2ClientException.ErrorCode.InvalidStateError,
|
||||
"No user is logged in");
|
||||
}
|
||||
|
||||
if (assertCredentialParams.Origin == _environmentService.GetWebVaultUrl())
|
||||
{
|
||||
throw new Fido2ClientException(
|
||||
Fido2ClientException.ErrorCode.NotAllowedError,
|
||||
"Saving Bitwarden credentials in a Bitwarden vault is not allowed");
|
||||
}
|
||||
|
||||
if (!assertCredentialParams.Origin.StartsWith("https://"))
|
||||
{
|
||||
throw new Fido2ClientException(
|
||||
Fido2ClientException.ErrorCode.SecurityError,
|
||||
"Origin is not a valid https origin");
|
||||
}
|
||||
|
||||
if (!Fido2DomainUtils.IsValidRpId(assertCredentialParams.RpId, assertCredentialParams.Origin))
|
||||
{
|
||||
throw new Fido2ClientException(
|
||||
Fido2ClientException.ErrorCode.SecurityError,
|
||||
"RP ID cannot be used with this origin");
|
||||
}
|
||||
|
||||
var clientDataJSON = JsonSerializer.Serialize(new {
|
||||
type = "webauthn.get",
|
||||
challenge = CoreHelpers.Base64UrlEncode(assertCredentialParams.Challenge),
|
||||
origin = assertCredentialParams.Origin,
|
||||
crossOrigin = !assertCredentialParams.SameOriginWithAncestors,
|
||||
});
|
||||
var clientDataJSONBytes = Encoding.UTF8.GetBytes(clientDataJSON);
|
||||
var clientDataHash = await _cryptoFunctionService.HashAsync(clientDataJSONBytes, CryptoHashAlgorithm.Sha256);
|
||||
var getAssertionParams = MapToGetAssertionParams(assertCredentialParams, clientDataHash);
|
||||
|
||||
try {
|
||||
var getAssertionResult = await _fido2AuthenticatorService.GetAssertionAsync(getAssertionParams);
|
||||
|
||||
return new Fido2ClientAssertCredentialResult {
|
||||
AuthenticatorData = getAssertionResult.AuthenticatorData,
|
||||
ClientDataJSON = clientDataJSONBytes,
|
||||
Id = CoreHelpers.Base64UrlEncode(getAssertionResult.SelectedCredential.Id),
|
||||
RawId = getAssertionResult.SelectedCredential.Id,
|
||||
Signature = getAssertionResult.Signature,
|
||||
UserHandle = getAssertionResult.SelectedCredential.UserHandle
|
||||
};
|
||||
} catch (InvalidStateError) {
|
||||
throw new Fido2ClientException(Fido2ClientException.ErrorCode.InvalidStateError, "Unknown invalid state encountered");
|
||||
} catch (Exception) {
|
||||
throw new Fido2ClientException(Fido2ClientException.ErrorCode.UnknownError, $"An unknown error occurred");
|
||||
}
|
||||
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private Fido2AuthenticatorMakeCredentialParams MapToMakeCredentialParams(
|
||||
Fido2ClientCreateCredentialParams createCredentialParams,
|
||||
@ -160,5 +226,23 @@ namespace Bit.Core.Services
|
||||
Extensions = createCredentialParams.Extensions
|
||||
};
|
||||
}
|
||||
|
||||
private Fido2AuthenticatorGetAssertionParams MapToGetAssertionParams(
|
||||
Fido2ClientAssertCredentialParams assertCredentialParams,
|
||||
byte[] cliendDataHash)
|
||||
{
|
||||
var requireUserVerification = assertCredentialParams.UserVerification == "required" ||
|
||||
assertCredentialParams.UserVerification == "preferred" ||
|
||||
assertCredentialParams.UserVerification == null;
|
||||
|
||||
return new Fido2AuthenticatorGetAssertionParams {
|
||||
RpId = assertCredentialParams.RpId,
|
||||
Challenge = assertCredentialParams.Challenge,
|
||||
AllowCredentialDescriptorList = assertCredentialParams.AllowCredentials,
|
||||
RequireUserPresence = true,
|
||||
RequireUserVerification = requireUserVerification,
|
||||
Hash = cliendDataHash
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,7 @@
|
||||
public string RpId { get; set; }
|
||||
|
||||
/** The hash of the serialized client data, provided by the client. */
|
||||
public byte[] ClientDataHash { get; set; }
|
||||
public byte[] Hash { get; set; }
|
||||
|
||||
public PublicKeyCredentialDescriptor[] AllowCredentialDescriptorList { get; set; }
|
||||
|
||||
@ -20,6 +20,11 @@
|
||||
/// </summary>
|
||||
public bool RequireUserPresence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The challenge to be signed by the authenticator.
|
||||
/// </summary>
|
||||
public byte[] Challenge { get; set; }
|
||||
|
||||
public object Extensions { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -11,7 +11,13 @@ namespace Bit.Core.Utilities.Fido2
|
||||
public class Fido2ClientAssertCredentialParams
|
||||
{
|
||||
/// <summary>
|
||||
/// S challenge that the selected authenticator signs, along with other data, when producing an authentication
|
||||
/// A value which is true if and only if the caller’s environment settings object is same-origin with its ancestors.
|
||||
/// It is false if caller is cross-origin.
|
||||
/// </summary>
|
||||
public bool SameOriginWithAncestors { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The challenge that the selected authenticator signs, along with other data, when producing an authentication
|
||||
/// assertion.
|
||||
/// </summary>
|
||||
public required byte[] Challenge { get; set; }
|
||||
|
@ -16,5 +16,27 @@ namespace Bit.Core.Utilities.Fido2
|
||||
/// The credential identifier.
|
||||
/// </summary>
|
||||
public required byte[] RawId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The JSON-compatible serialization of client datapassed to the authenticator by the client in
|
||||
/// order to generate this assertion.
|
||||
/// </summary>
|
||||
public required byte[] ClientDataJSON { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The authenticator data returned by the authenticator.
|
||||
/// </summary>
|
||||
public required byte[] AuthenticatorData { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The raw signature returned from the authenticator.
|
||||
/// </summary>
|
||||
public required byte[] Signature { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The user handle returned from the authenticator, or null if the authenticator did not
|
||||
/// return a user handle.
|
||||
/// </summary>
|
||||
public byte[]? UserHandle { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -12,7 +12,6 @@ namespace Bit.Core.Utilities.Fido2
|
||||
/// </summary>
|
||||
public required string Origin { get; set; }
|
||||
|
||||
// TODO: Check if we actually need this
|
||||
/// <summary>
|
||||
/// A value which is true if and only if the caller’s environment settings object is same-origin with its ancestors.
|
||||
/// It is false if caller is cross-origin.
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user