Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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 @@ -74,6 +74,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
@@ -1,4 +1,4 @@
ο»Ώusing Fido2NetLib;
using Fido2NetLib;
using Fido2NetLib.Objects;

namespace Bit.Core.Auth.UserFeatures.WebAuthnLogin.Implementations;
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 = "WebAuthnLoginAssertion_";
private static readonly DistributedCacheEntryOptions _cacheOptions = new()
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(17)
};

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;
}
Comment on lines +21 to +25
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: Race condition in challenge consumption undermines single-use guarantee

Details and fix

The GetAsync followed by RemoveAsync is not atomic. Two concurrent requests presenting the same challenge token can both read the cache entry before either removes it, allowing the challenge to be used twice. This is a classic TOCTOU (time-of-check-time-of-use) issue that directly undermines the W3C section 13.4.3 single-use requirement this PR aims to enforce.

The IDistributedCache interface does not expose an atomic get-and-delete operation. Consider either:

  1. Use the cache value as a lock: Call SetAsync with a "consumed" sentinel value (and a short TTL) before returning true. The check becomes: read the value, and only proceed if it equals the "available" sentinel (not "consumed"). This shrinks the race window but does not eliminate it.

  2. Use a lower-level API with atomic semantics: If the backing store is Redis, use GETDEL or a Lua script via IConnectionMultiplexer directly. If CosmosDB, use an optimistic-concurrency conditional delete (ETag-based). This fully eliminates the race.

  3. Accept and document the residual risk: If the data-protector token that wraps the challenge already limits replay (short TTL, HMAC-bound), document that the cache is a defense-in-depth layer with a known non-atomic window, and note that a future iteration should close it.


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

private static string BuildCacheKey(byte[] challenge)
=> $"{_cacheKeyPrefix}{CoreHelpers.Base64UrlEncode(challenge)}";
}
5 changes: 3 additions & 2 deletions src/Identity/Controllers/AccountsController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
ο»Ώusing System.Text;
ο»Ώusing System.Globalization;
using System.Text;
using Bit.Core;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Api.Request.Accounts;
Expand Down Expand Up @@ -266,7 +267,7 @@ private UserKdfInformation GetDefaultKdf(string email)
// Convert the hash to a number
var hashHex = BitConverter.ToString(hmacHash).Replace("-", string.Empty).ToLowerInvariant();
var hashFirst8Bytes = hashHex.Substring(0, 16);
var hashNumber = long.Parse(hashFirst8Bytes, System.Globalization.NumberStyles.HexNumber);
var hashNumber = long.Parse(hashFirst8Bytes, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
// Find the default KDF value for this hash number
var hashIndex = (int)(Math.Abs(hashNumber) % _defaultKdfResults.Count);
return _defaultKdfResults[hashIndex];
Expand Down
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

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 @@ -98,7 +95,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
9 changes: 7 additions & 2 deletions test/Api.Test/Auth/Controllers/WebAuthnControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,16 @@ public async Task AssertionOptions_UserVerificationFailed_ThrowsBadRequestExcept
}

[Theory, BitAutoData]
public async Task AssertionOptions_UserVerificationSuccess_ReturnsAssertionOptions(SecretVerificationRequestModel requestModel, User user, SutProvider<WebAuthnController> sutProvider)
public async Task AssertionOptions_UserVerificationSuccess_ReturnsAssertionOptions(
SecretVerificationRequestModel requestModel, User user, AssertionOptions assertionOptions,
SutProvider<WebAuthnController> sutProvider)
{
// Arrange
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user);
sutProvider.GetDependency<IUserService>().VerifySecretAsync(user, requestModel.Secret).Returns(true);
sutProvider.GetDependency<IGetWebAuthnLoginCredentialAssertionOptionsCommand>()
.GetWebAuthnLoginCredentialAssertionOptions()
.Returns(assertionOptions);
sutProvider.GetDependency<IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable>>()
.Protect(Arg.Any<WebAuthnLoginAssertionOptionsTokenable>()).Returns("token");

Expand All @@ -177,6 +182,7 @@ public async Task AssertionOptions_UserVerificationSuccess_ReturnsAssertionOptio
Assert.NotNull(result);
Assert.IsType<WebAuthnLoginAssertionOptionsResponseModel>(result);
}

#endregion

[Theory, BitAutoData]
Expand Down Expand Up @@ -467,7 +473,6 @@ public async Task Put_UpdateCredential_Success(User user, WebAuthnCredential cre
sutProvider.GetDependency<IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable>>()
.Unprotect(requestModel.Token)
.Returns(token);

sutProvider.GetDependency<IAssertWebAuthnLoginCredentialCommand>()
.AssertWebAuthnLoginCredential(assertionOptions, requestModel.DeviceResponse)
.Returns((user, credential));
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 ChallengeNotConsumed_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 = $"WebAuthnLoginAssertion_{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 = $"WebAuthnLoginAssertion_{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
Expand Up @@ -1088,4 +1088,5 @@ private int GetExpectedKdfIndex(string email, byte[] defaultKey, List<UserKdfInf
var hashNumber = long.Parse(hashFirst8Bytes, System.Globalization.NumberStyles.HexNumber);
return (int)(Math.Abs(hashNumber) % defaultKdfResults.Count);
}

}
Loading
Loading