Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ private static void AddWebAuthnLoginCommands(this IServiceCollection services)
services.AddScoped<ICreateWebAuthnLoginCredentialCommand, CreateWebAuthnLoginCredentialCommand>();
services.AddScoped<IGetWebAuthnLoginCredentialAssertionOptionsCommand, GetWebAuthnLoginCredentialAssertionOptionsCommand>();
services.AddScoped<IAssertWebAuthnLoginCredentialCommand, AssertWebAuthnLoginCredentialCommand>();
services.AddScoped<IWebAuthnChallengeCacheProvider, WebAuthnChallengeCacheProvider>();
}

private static void AddTwoFactorCommandsQueries(this IServiceCollection services)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
ο»Ώnamespace Bit.Core.Auth.UserFeatures.WebAuthnLogin;

/// <summary>
/// Enforces single-use semantics for WebAuthn assertion challenges per the W3C WebAuthn
/// specification (Β§13.4.3). Tracks used challenges so that each challenge can only be
/// validated once.
/// </summary>
public interface IWebAuthnChallengeCacheProvider
{
/// <summary>
/// Attempts to mark a challenge as used. Returns <c>true</c> if this is the first use
/// (challenge was not in cache and has now been saved). Returns <c>false</c> if the
/// challenge was already marked as used (found in cache).
/// </summary>
/// <param name="challenge">The challenge bytes from <see cref="Fido2NetLib.AssertionOptions.Challenge"/>.</param>
Task<bool> TryMarkChallengeAsUsedAsync(byte[] challenge);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,27 @@ internal class AssertWebAuthnLoginCredentialCommand : IAssertWebAuthnLoginCreden
private readonly IFido2 _fido2;
private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository;
private readonly IUserRepository _userRepository;
private readonly IWebAuthnChallengeCacheProvider _webAuthnChallengeCache;

public AssertWebAuthnLoginCredentialCommand(IFido2 fido2, IWebAuthnCredentialRepository webAuthnCredentialRepository, IUserRepository userRepository)
public AssertWebAuthnLoginCredentialCommand(
IFido2 fido2,
IWebAuthnCredentialRepository webAuthnCredentialRepository,
IUserRepository userRepository,
IWebAuthnChallengeCacheProvider webAuthnChallengeCache)
{
_fido2 = fido2;
_webAuthnCredentialRepository = webAuthnCredentialRepository;
_userRepository = userRepository;
_webAuthnChallengeCache = webAuthnChallengeCache;
}

public async Task<(User, WebAuthnCredential)> AssertWebAuthnLoginCredential(AssertionOptions options, AuthenticatorAssertionRawResponse assertionResponse)
{
if (!await _webAuthnChallengeCache.TryMarkChallengeAsUsedAsync(options.Challenge))
{
throw new BadRequestException("Invalid credential.");
}

if (!GuidUtilities.TryParseBytes(assertionResponse.Response.UserHandle, out var userId))
{
throw new BadRequestException("Invalid credential.");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
ο»Ώusing Bit.Core.Utilities;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;

namespace Bit.Core.Auth.UserFeatures.WebAuthnLogin.Implementations;

internal class WebAuthnChallengeCacheProvider(
[FromKeyedServices("persistent")] IDistributedCache distributedCache) : IWebAuthnChallengeCacheProvider
{
private const string _cacheKeyPrefix = "WebAuthnAssertion_";
private static readonly DistributedCacheEntryOptions _cacheOptions = new()
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(17)
Comment thread
ike-kottlowski marked this conversation as resolved.
Outdated
};

private readonly IDistributedCache _distributedCache = distributedCache;

public async Task<bool> TryMarkChallengeAsUsedAsync(byte[] challenge)
{
var cacheKey = BuildCacheKey(challenge);
var cached = await _distributedCache.GetAsync(cacheKey);
if (cached != null)
{
return false;
}

await _distributedCache.SetAsync(cacheKey, [1], _cacheOptions);
return true;
}

private static string BuildCacheKey(byte[] challenge)
=> $"{_cacheKeyPrefix}{CoreHelpers.Base64UrlEncode(challenge)}";
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
ο»Ώ// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
Comment thread
ike-kottlowski marked this conversation as resolved.

using System.Security.Claims;
ο»Ώusing System.Security.Claims;
using System.Text.Json;
using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
Expand Down Expand Up @@ -101,7 +98,7 @@ public async Task ValidateAsync(ExtensionGrantValidationContext context)
token.TokenIsValid(WebAuthnLoginAssertionOptionsScope.Authentication);
var deviceResponse = JsonSerializer.Deserialize<AuthenticatorAssertionRawResponse>(rawDeviceResponse);

if (!verified)
if (!verified || deviceResponse == null)
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest);
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
ο»Ώusing System.Text;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin.Implementations;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
Expand All @@ -19,10 +20,27 @@ namespace Bit.Core.Test.Auth.UserFeatures.WebAuthnLogin;
[SutProviderCustomize]
public class AssertWebAuthnLoginCredentialCommandTests
{
[Theory, BitAutoData]
internal async Task ChallengeNotCacheable_ThrowsBadRequestException(SutProvider<AssertWebAuthnLoginCredentialCommand> sutProvider, AssertionOptions options, AuthenticatorAssertionRawResponse response)
{
// Arrange
sutProvider.GetDependency<IWebAuthnChallengeCacheProvider>()
.TryMarkChallengeAsUsedAsync(options.Challenge)
.Returns(false);

// Act
var result = async () => await sutProvider.Sut.AssertWebAuthnLoginCredential(options, response);

// Assert
await Assert.ThrowsAsync<BadRequestException>(result);
}

[Theory, BitAutoData]
internal async Task InvalidUserHandle_ThrowsBadRequestException(SutProvider<AssertWebAuthnLoginCredentialCommand> sutProvider, AssertionOptions options, AuthenticatorAssertionRawResponse response)
{
// Arrange
sutProvider.GetDependency<IWebAuthnChallengeCacheProvider>()
.TryMarkChallengeAsUsedAsync(options.Challenge).Returns(true);
response.Response.UserHandle = Encoding.UTF8.GetBytes("invalid-user-handle");

// Act
Expand All @@ -36,6 +54,8 @@ internal async Task InvalidUserHandle_ThrowsBadRequestException(SutProvider<Asse
internal async Task UserNotFound_ThrowsBadRequestException(SutProvider<AssertWebAuthnLoginCredentialCommand> sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response)
{
// Arrange
sutProvider.GetDependency<IWebAuthnChallengeCacheProvider>()
.TryMarkChallengeAsUsedAsync(options.Challenge).Returns(true);
response.Response.UserHandle = user.Id.ToByteArray();
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).ReturnsNull();

Expand All @@ -50,6 +70,8 @@ internal async Task UserNotFound_ThrowsBadRequestException(SutProvider<AssertWeb
internal async Task NoMatchingCredentialExists_ThrowsBadRequestException(SutProvider<AssertWebAuthnLoginCredentialCommand> sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response)
{
// Arrange
sutProvider.GetDependency<IWebAuthnChallengeCacheProvider>()
.TryMarkChallengeAsUsedAsync(options.Challenge).Returns(true);
response.Response.UserHandle = user.Id.ToByteArray();
sutProvider.GetDependency<IUserRepository>().GetByIdAsync(user.Id).Returns(user);
sutProvider.GetDependency<IWebAuthnCredentialRepository>().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { });
Expand All @@ -65,6 +87,8 @@ internal async Task NoMatchingCredentialExists_ThrowsBadRequestException(SutProv
internal async Task AssertionFails_ThrowsBadRequestException(SutProvider<AssertWebAuthnLoginCredentialCommand> sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response, WebAuthnCredential credential, AssertionVerificationResult assertionResult)
{
// Arrange
sutProvider.GetDependency<IWebAuthnChallengeCacheProvider>()
.TryMarkChallengeAsUsedAsync(options.Challenge).Returns(true);
var credentialId = Guid.NewGuid().ToByteArray();
credential.CredentialId = CoreHelpers.Base64UrlEncode(credentialId);
response.Id = credentialId;
Expand All @@ -86,6 +110,8 @@ internal async Task AssertionFails_ThrowsBadRequestException(SutProvider<AssertW
internal async Task AssertionSucceeds_ReturnsUserAndCredential(SutProvider<AssertWebAuthnLoginCredentialCommand> sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response, WebAuthnCredential credential, AssertionVerificationResult assertionResult)
{
// Arrange
sutProvider.GetDependency<IWebAuthnChallengeCacheProvider>()
.TryMarkChallengeAsUsedAsync(options.Challenge).Returns(true);
var credentialId = Guid.NewGuid().ToByteArray();
credential.CredentialId = CoreHelpers.Base64UrlEncode(credentialId);
response.Id = credentialId;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
ο»Ώusing Bit.Core.Auth.UserFeatures.WebAuthnLogin.Implementations;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Caching.Distributed;
using NSubstitute;
using Xunit;

namespace Bit.Core.Test.Auth.UserFeatures.WebAuthnLogin;

[SutProviderCustomize]
public class WebAuthnChallengeCacheProviderTests
{
[Theory, BitAutoData]
internal async Task TryMarkChallengeAsUsedAsync_FirstUse_SavesAndReturnsTrue(
SutProvider<WebAuthnChallengeCacheProvider> sutProvider)
{
// Arrange
var challenge = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
var expectedKey = $"WebAuthnAssertion_{CoreHelpers.Base64UrlEncode(challenge)}";

sutProvider.GetDependency<IDistributedCache>()
.GetAsync(expectedKey, Arg.Any<CancellationToken>())
.Returns((byte[])null);

// Act
var result = await sutProvider.Sut.TryMarkChallengeAsUsedAsync(challenge);

// Assert
Assert.True(result);
await sutProvider.GetDependency<IDistributedCache>()
.Received(1)
.SetAsync(
expectedKey,
Arg.Is<byte[]>(b => b.Length == 1 && b[0] == 1),
Arg.Is<DistributedCacheEntryOptions>(o =>
o.AbsoluteExpirationRelativeToNow == TimeSpan.FromMinutes(17)),
Arg.Any<CancellationToken>());
}

[Theory, BitAutoData]
internal async Task TryMarkChallengeAsUsedAsync_AlreadyUsed_ReturnsFalseAndDoesNotSave(
SutProvider<WebAuthnChallengeCacheProvider> sutProvider)
{
// Arrange
var challenge = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
var expectedKey = $"WebAuthnAssertion_{CoreHelpers.Base64UrlEncode(challenge)}";

sutProvider.GetDependency<IDistributedCache>()
.GetAsync(expectedKey, Arg.Any<CancellationToken>())
.Returns(new byte[] { 1 });

// Act
var result = await sutProvider.Sut.TryMarkChallengeAsUsedAsync(challenge);

// Assert
Assert.False(result);
await sutProvider.GetDependency<IDistributedCache>()
.DidNotReceive()
.SetAsync(
Arg.Any<string>(),
Arg.Any<byte[]>(),
Arg.Any<DistributedCacheEntryOptions>(),
Arg.Any<CancellationToken>());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
ο»Ώusing System.Buffers.Binary;
using System.Formats.Cbor;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Fido2NetLib;
using Fido2NetLib.Objects;

namespace Bit.Identity.IntegrationTest.RequestValidation.VaultAccess;

/// <summary>
/// Minimal in-memory WebAuthn authenticator for integration tests. Generates valid
/// ECDSA P-256 assertions that pass Fido2NetLib verification end-to-end.
/// </summary>
internal sealed class FakeWebAuthnAuthenticator : IDisposable
{
private readonly ECDsa _keyPair;

public byte[] CredentialId { get; } = RandomNumberGenerator.GetBytes(32);
public uint SignatureCounter { get; private set; }

public FakeWebAuthnAuthenticator()
{
_keyPair = ECDsa.Create(ECCurve.NamedCurves.nistP256);
}

/// <summary>
/// Returns the credential's public key as a COSE_Key CBOR map (what Fido2NetLib expects
/// to see in the server-stored public key blob).
/// </summary>
public byte[] GetCosePublicKey()
{
var parameters = _keyPair.ExportParameters(includePrivateParameters: false);
var writer = new CborWriter(CborConformanceMode.Ctap2Canonical);
writer.WriteStartMap(5);
// Per CTAP2 canonical ordering: keys sorted ascending as signed integers, with
// non-negative keys before negative keys.
writer.WriteInt32(1); writer.WriteInt32(2); // kty = EC2
writer.WriteInt32(3); writer.WriteInt32(-7); // alg = ES256
writer.WriteInt32(-1); writer.WriteInt32(1); // crv = P-256
writer.WriteInt32(-2); writer.WriteByteString(parameters.Q.X!);
writer.WriteInt32(-3); writer.WriteByteString(parameters.Q.Y!);
writer.WriteEndMap();
return writer.Encode();
}

/// <summary>
/// Produce a valid assertion for the given challenge and relying-party context.
/// </summary>
public AuthenticatorAssertionRawResponse MakeAssertion(
byte[] challenge,
string rpId,
string origin,
byte[] userHandle)
{
// clientDataJSON per WebAuthn spec
var clientData = new
{
type = "webauthn.get",
challenge = Base64UrlEncode(challenge),
origin,
crossOrigin = false,
};
var clientDataJson = JsonSerializer.SerializeToUtf8Bytes(clientData);

// authenticatorData: rpIdHash (32) || flags (1) || signCount (4, big-endian)
var rpIdHash = SHA256.HashData(Encoding.UTF8.GetBytes(rpId));
const byte flags = 0x05; // UP (0x01) | UV (0x04)
SignatureCounter++;
var counterBytes = new byte[4];
BinaryPrimitives.WriteUInt32BigEndian(counterBytes, SignatureCounter);

var authenticatorData = new byte[rpIdHash.Length + 1 + counterBytes.Length];
Buffer.BlockCopy(rpIdHash, 0, authenticatorData, 0, rpIdHash.Length);
authenticatorData[rpIdHash.Length] = flags;
Buffer.BlockCopy(counterBytes, 0, authenticatorData, rpIdHash.Length + 1, counterBytes.Length);

// Signature covers authenticatorData || SHA256(clientDataJson), encoded as DER
var clientDataHash = SHA256.HashData(clientDataJson);
var toSign = new byte[authenticatorData.Length + clientDataHash.Length];
Buffer.BlockCopy(authenticatorData, 0, toSign, 0, authenticatorData.Length);
Buffer.BlockCopy(clientDataHash, 0, toSign, authenticatorData.Length, clientDataHash.Length);

var signature = _keyPair.SignData(toSign, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);

return new AuthenticatorAssertionRawResponse
{
Id = CredentialId,
RawId = CredentialId,
Type = PublicKeyCredentialType.PublicKey,
Extensions = new AuthenticationExtensionsClientOutputs(),
Response = new AuthenticatorAssertionRawResponse.AssertionResponse
{
AuthenticatorData = authenticatorData,
Signature = signature,
ClientDataJson = clientDataJson,
UserHandle = userHandle,
},
};
}

private static string Base64UrlEncode(byte[] input)
=> Convert.ToBase64String(input)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');

public void Dispose() => _keyPair.Dispose();
}
Loading
Loading