Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
1c4fd6c
feat(auth-validator): [PM-22975] Client Version Validator - initial iโ€ฆ
Patrick-Pimentel-Bitwarden Nov 17, 2025
22e9e5b
mend
Patrick-Pimentel-Bitwarden Nov 17, 2025
1af2fba
Merge branch 'main' into auth/pm-22975/client-version-validator
Patrick-Pimentel-Bitwarden Nov 17, 2025
47a26bb
fix(auth-validator): [PM-22975] Client Version Validator - Minor toucโ€ฆ
Patrick-Pimentel-Bitwarden Nov 20, 2025
a82b31c
fix(auth-validator): [PM-22975] Client Version Validator - Fixing somโ€ฆ
Patrick-Pimentel-Bitwarden Nov 20, 2025
7d71ee2
fix(auth-validator): [PM-22975] Client Version Validator - Finished fโ€ฆ
Patrick-Pimentel-Bitwarden Nov 20, 2025
851f963
test(auth-validator): [PM-22975] Client Version Validator - Fixed tesโ€ฆ
Patrick-Pimentel-Bitwarden Nov 20, 2025
756ae5e
fix(auth-validator): [PM-22975] Client Version Validator - Fixed namiโ€ฆ
Patrick-Pimentel-Bitwarden Nov 20, 2025
7874ec7
Merge branch 'main' into auth/pm-22975/client-version-validator
Patrick-Pimentel-Bitwarden Nov 20, 2025
91af02b
test(auth-validator): [PM-22975] Client Version Validator - Fixed tests.
Patrick-Pimentel-Bitwarden Nov 20, 2025
59d9d7b
Merge branch 'main' into auth/pm-22975/client-version-validator
Patrick-Pimentel-Bitwarden Nov 21, 2025
7897485
fix(auth-validator): [PM-22975] Client Version Validator - Renamed vaโ€ฆ
Patrick-Pimentel-Bitwarden Nov 21, 2025
d0c5333
Merge branch 'main' into auth/pm-22975/client-version-validator
Patrick-Pimentel-Bitwarden Nov 24, 2025
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
6 changes: 6 additions & 0 deletions src/Core/KeyManagement/Constants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
๏ปฟnamespace Bit.Core.KeyManagement;

public static class Constants
{
public static readonly Version MinimumClientVersion = new Version("2025.11.0");
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,7 @@ private static void AddKeyManagementCommands(this IServiceCollection services)
private static void AddKeyManagementQueries(this IServiceCollection services)
{
services.AddScoped<IUserAccountKeysQuery, UserAccountKeysQuery>();
services.AddScoped<IIsV2EncryptionUserQuery, IsV2EncryptionUserQuery>();
services.AddScoped<IGetMinimumClientVersionForUserQuery, GetMinimumClientVersionForUserQuery>();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
๏ปฟusing Bit.Core.Entities;
using Bit.Core.KeyManagement.Queries.Interfaces;

namespace Bit.Core.KeyManagement.Queries;

public class GetMinimumClientVersionForUserQuery(IIsV2EncryptionUserQuery isV2EncryptionUserQuery)
: IGetMinimumClientVersionForUserQuery
{
public async Task<Version?> Run(User? user)
{
if (user == null)
{
return null;
}

if (await isV2EncryptionUserQuery.Run(user))
Copy link
Contributor

Choose a reason for hiding this comment

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

๐Ÿ’ญ Error handling consideration - If isV2EncryptionUserQuery.Run(user) throws an exception (e.g., invalid encryption state from line 27 of IsV2EncryptionUserQuery), should that:

  1. Propagate and fail the login attempt (current behavior)
  2. Be caught here and fail-open (return null allowing login)
  3. Be caught and fail-closed (return the minimum version, blocking login)

The current behavior (1) seems reasonable for detecting corruption, but consider whether it could be weaponized. If a user's account gets into an invalid state, they'll be locked out until support fixes it.

{
return Constants.MinimumClientVersion;
}

return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
๏ปฟusing Bit.Core.Entities;

namespace Bit.Core.KeyManagement.Queries.Interfaces;

public interface IGetMinimumClientVersionForUserQuery
{
Task<Version?> Run(User? user);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
๏ปฟusing Bit.Core.Entities;

namespace Bit.Core.KeyManagement.Queries.Interfaces;

public interface IIsV2EncryptionUserQuery
{
Task<bool> Run(User user);
}
31 changes: 31 additions & 0 deletions src/Core/KeyManagement/Queries/IsV2EncryptionUserQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
๏ปฟusing Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.KeyManagement.Repositories;
using Bit.Core.KeyManagement.Utilities;

namespace Bit.Core.KeyManagement.Queries;

public class IsV2EncryptionUserQuery(IUserSignatureKeyPairRepository userSignatureKeyPairRepository)
: IIsV2EncryptionUserQuery
{
public async Task<bool> Run(User user)
{
ArgumentNullException.ThrowIfNull(user);

var hasSignatureKeyPair = await userSignatureKeyPairRepository.GetByUserIdAsync(user.Id) != null;
var isPrivateKeyEncryptionV2 =
!string.IsNullOrWhiteSpace(user.PrivateKey) &&
EncryptionParsing.GetEncryptionType(user.PrivateKey) == EncryptionType.XChaCha20Poly1305_B64;

return hasSignatureKeyPair switch
{
// Valid v2 user
true when isPrivateKeyEncryptionV2 => true,
// Valid v1 user
false when !isPrivateKeyEncryptionV2 => false,
_ => throw new InvalidOperationException(
Copy link
Contributor

Choose a reason for hiding this comment

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

โŒ Cryptographic state validation error lacks detail - The exception message doesn't specify which invalid state was detected, making debugging difficult. Consider:

_ => throw new InvalidOperationException(
    $"User {user.Id} is in an invalid encryption state: " +
    $"HasSignatureKeyPair={hasSignatureKeyPair}, " +
    $"IsPrivateKeyV2={isPrivateKeyEncryptionV2}. " +
    "Expected both true (v2) or both false (v1).")

This provides actionable information for support/debugging without exposing sensitive data.

"User is in an invalid state for key rotation. User has a signature key pair, but the private key is not in v2 format, or vice versa.")
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Repositories;
using Bit.Core.KeyManagement.Utilities;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
Expand Down Expand Up @@ -137,7 +138,7 @@ public async Task UpdateAccountKeysAsync(RotateUserAccountKeysData model, User u
}
else
{
if (GetEncryptionType(model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey) != EncryptionType.AesCbc256_HmacSha256_B64)
if (EncryptionParsing.GetEncryptionType(model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey) != EncryptionType.AesCbc256_HmacSha256_B64)
{
throw new InvalidOperationException("The provided account private key was not wrapped with AES-256-CBC-HMAC");
}
Expand Down Expand Up @@ -209,7 +210,7 @@ private bool IsV2EncryptionUserAsync(User user)
{
// Returns whether the user is a V2 user based on the private key's encryption type.
ArgumentNullException.ThrowIfNull(user);
var isPrivateKeyEncryptionV2 = GetEncryptionType(user.PrivateKey) == EncryptionType.XChaCha20Poly1305_B64;
var isPrivateKeyEncryptionV2 = EncryptionParsing.GetEncryptionType(user.PrivateKey) == EncryptionType.XChaCha20Poly1305_B64;
return isPrivateKeyEncryptionV2;
}

Expand Down Expand Up @@ -237,7 +238,7 @@ private static void ValidateV2Encryption(RotateUserAccountKeysData model)
{
throw new InvalidOperationException("Signature key pair data is required for V2 encryption.");
}
if (GetEncryptionType(model.AccountKeys.SignatureKeyPairData.WrappedSigningKey) != EncryptionType.XChaCha20Poly1305_B64)
if (EncryptionParsing.GetEncryptionType(model.AccountKeys.SignatureKeyPairData.WrappedSigningKey) != EncryptionType.XChaCha20Poly1305_B64)
{
throw new InvalidOperationException("The provided signing key data is not wrapped with XChaCha20-Poly1305.");
}
Expand All @@ -246,7 +247,7 @@ private static void ValidateV2Encryption(RotateUserAccountKeysData model)
throw new InvalidOperationException("The provided signature key pair data does not contain a valid verifying key.");
}

if (GetEncryptionType(model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey) != EncryptionType.XChaCha20Poly1305_B64)
if (EncryptionParsing.GetEncryptionType(model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey) != EncryptionType.XChaCha20Poly1305_B64)
{
throw new InvalidOperationException("The provided private key encryption key is not wrapped with XChaCha20-Poly1305.");
}
Expand All @@ -260,23 +261,5 @@ private static void ValidateV2Encryption(RotateUserAccountKeysData model)
}
}

/// <summary>
/// Helper method to convert an encryption type string to an enum value.
/// </summary>
private static EncryptionType GetEncryptionType(string encString)
{
var parts = encString.Split('.');
if (parts.Length == 1)
{
throw new ArgumentException("Invalid encryption type string.");
}
if (byte.TryParse(parts[0], out var encryptionTypeNumber))
{
if (Enum.IsDefined(typeof(EncryptionType), encryptionTypeNumber))
{
return (EncryptionType)encryptionTypeNumber;
}
}
throw new ArgumentException("Invalid encryption type string.");
}
// Parsing moved to Bit.Core.KeyManagement.Utilities.EncryptionParsing
}
26 changes: 26 additions & 0 deletions src/Core/KeyManagement/Utilities/EncryptionParsing.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
๏ปฟusing Bit.Core.Enums;

namespace Bit.Core.KeyManagement.Utilities;

public static class EncryptionParsing
{
/// <summary>
/// Helper method to convert an encryption type string to an enum value.
/// </summary>
public static EncryptionType GetEncryptionType(string encString)
{
var parts = encString.Split('.');
if (parts.Length == 1)
Copy link
Contributor

Choose a reason for hiding this comment

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

โš ๏ธ The error message "Invalid encryption type string" is thrown for two different conditions (length check and parsing failure). Consider making these messages more specific for easier debugging:

if (parts.Length == 1)
{
    throw new ArgumentException("Encryption type string must contain a type prefix and data separated by '.'");
}

And for the second case:

throw new ArgumentException($"Unrecognized encryption type: '{parts[0]}'");

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is code copied from KM, I'm hesitant to make more changes to their work.

{
throw new ArgumentException("Invalid encryption type string.");
}
if (byte.TryParse(parts[0], out var encryptionTypeNumber))
{
if (Enum.IsDefined(typeof(EncryptionType), encryptionTypeNumber))
{
return (EncryptionType)encryptionTypeNumber;
}
}
throw new ArgumentException("Invalid encryption type string.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public abstract class BaseRequestValidator<T> where T : class
private readonly IUserRepository _userRepository;
private readonly IAuthRequestRepository _authRequestRepository;
private readonly IMailService _mailService;
private readonly IClientVersionValidator _clientVersionValidator;

protected ICurrentContext CurrentContext { get; }
protected IPolicyService PolicyService { get; }
Expand Down Expand Up @@ -68,7 +69,8 @@ public BaseRequestValidator(
IPolicyRequirementQuery policyRequirementQuery,
IAuthRequestRepository authRequestRepository,
IMailService mailService,
IUserAccountKeysQuery userAccountKeysQuery
IUserAccountKeysQuery userAccountKeysQuery,
IClientVersionValidator clientVersionValidator
)
{
_userManager = userManager;
Expand All @@ -89,6 +91,7 @@ IUserAccountKeysQuery userAccountKeysQuery
_authRequestRepository = authRequestRepository;
_mailService = mailService;
_accountKeysQuery = userAccountKeysQuery;
_clientVersionValidator = clientVersionValidator;
}

protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
Expand All @@ -108,7 +111,8 @@ await BuildSuccessResultAsync(validatorContext.User, context, validatorContext.D
}
else
{
// 1. We need to check if the user's master password hash is correct.
// 1. We need to check if the user is legitimate via the contextually appropriate mechanism
// (webauthn, password, custom token, etc.).
var valid = await ValidateContextAsync(context, validatorContext);
var user = validatorContext.User;
if (!valid)
Expand All @@ -119,6 +123,17 @@ await BuildSuccessResultAsync(validatorContext.User, context, validatorContext.D
return;
}

// 1.5 Now check the version number of the client. Do this after ValidateContextAsync so that
// we prevent account enumeration. If we were to do this before ValidateContextAsync, then attackers
// could use a known invalid client version and make a request for a user (before we know if they have
// demonstrated ownership of the account via correct credentials) and identify if they exist by getting
// an error response back from the validator saying the user is not compatible with the client.
Comment on lines +126 to +130
Copy link
Contributor Author

Choose a reason for hiding this comment

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

๐Ÿ’ญ Could someone double check my explanation here?

var clientVersionValid = await ValidateClientVersionAsync(context, validatorContext);
if (!clientVersionValid)
{
return;
}

// 2. Decide if this user belongs to an organization that requires SSO.
validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType);
if (validatorContext.SsoRequired)
Expand Down Expand Up @@ -258,7 +273,8 @@ private Func<Task<bool>>[] DetermineValidationOrder(T context, ValidatedTokenReq
// validation to perform the recovery as part of scheme validation based on the request.
return
[
() => ValidateMasterPasswordAsync(context, validatorContext),
() => ValidateGrantSpecificContext(context, validatorContext),
() => ValidateClientVersionAsync(context, validatorContext),
() => ValidateTwoFactorAsync(context, request, validatorContext),
() => ValidateSsoAsync(context, request, validatorContext),
() => ValidateNewDeviceAsync(context, request, validatorContext),
Expand All @@ -271,7 +287,8 @@ private Func<Task<bool>>[] DetermineValidationOrder(T context, ValidatedTokenReq
// The typical validation scenario.
return
[
() => ValidateMasterPasswordAsync(context, validatorContext),
() => ValidateGrantSpecificContext(context, validatorContext),
() => ValidateClientVersionAsync(context, validatorContext),
() => ValidateSsoAsync(context, request, validatorContext),
() => ValidateTwoFactorAsync(context, request, validatorContext),
() => ValidateNewDeviceAsync(context, request, validatorContext),
Expand Down Expand Up @@ -324,12 +341,30 @@ private static async Task<bool> ProcessValidatorsAsync(params Func<Task<bool>>[]
}

/// <summary>
/// Validates the user's Master Password hash.
/// Validates whether the client version is compatible for the user attempting to authenticate.
/// New authentications only; refresh/device grants are handled elsewhere.
/// </summary>
/// <returns>true if the scheme successfully passed validation, otherwise false.</returns>
private async Task<bool> ValidateClientVersionAsync(T context, CustomValidatorRequestContext validatorContext)
{
var ok = await _clientVersionValidator.ValidateAsync(validatorContext.User, validatorContext);
if (ok)
{
return true;
}

SetValidationErrorResult(context, validatorContext);
await LogFailedLoginEvent(validatorContext.User, EventType.User_FailedLogIn);
return false;
}

/// <summary>
/// Validates the user's master password, webauthen, or custom token request via the appropriate context validator.
/// </summary>
/// <param name="context">The current request context.</param>
/// <param name="validatorContext"><see cref="Bit.Identity.IdentityServer.CustomValidatorRequestContext" /></param>
/// <returns>true if the scheme successfully passed validation, otherwise false.</returns>
private async Task<bool> ValidateMasterPasswordAsync(T context, CustomValidatorRequestContext validatorContext)
private async Task<bool> ValidateGrantSpecificContext(T context, CustomValidatorRequestContext validatorContext)
{
var valid = await ValidateContextAsync(context, validatorContext);
var user = validatorContext.User;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
๏ปฟusing Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.Models.Api;
using Duende.IdentityServer.Validation;

namespace Bit.Identity.IdentityServer.RequestValidators;

public interface IClientVersionValidator
{
Task<bool> ValidateAsync(User user, CustomValidatorRequestContext requestContext);
}

public class ClientVersionValidator(
ICurrentContext currentContext,
IGetMinimumClientVersionForUserQuery getMinimumClientVersionForUserQuery)
: IClientVersionValidator
{
private static readonly string UpgradeMessage = "Please update your app to continue using Bitwarden";

public async Task<bool> ValidateAsync(User? user, CustomValidatorRequestContext requestContext)
Copy link
Contributor

Choose a reason for hiding this comment

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

๐Ÿ’ญ The null check for user returns true (allowing authentication to proceed). Consider whether this is the correct behavior:

  • Current behavior: If user is null (shouldn't happen in practice), validation passes
  • Alternative: Fail validation if user is null since we can't determine their encryption version

Which behavior is intentional here? If the current fail-open behavior is intentional for defense-in-depth, consider adding a comment explaining the reasoning.

{
if (user == null)
{
return true;
}

var clientVersion = currentContext.ClientVersion;
var minVersion = await getMinimumClientVersionForUserQuery.Run(user);

// Fail-open if headers are missing or no restriction
if (minVersion == null)
{
return true;
}

if (clientVersion < minVersion)
{
requestContext.ValidationErrorResult = new ValidationResult
{
Error = "invalid_client_version",
ErrorDescription = UpgradeMessage,
IsError = true
};
requestContext.CustomResponse = new Dictionary<string, object>
{
{ "ErrorModel", new ErrorResponseModel(UpgradeMessage) }
};
return false;
}

return true;
}
}


Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,8 @@
using Duende.IdentityModel;
using Duende.IdentityServer.Extensions;
using Duende.IdentityServer.Validation;
using HandlebarsDotNet;
using Microsoft.AspNetCore.Identity;

#nullable enable

namespace Bit.Identity.IdentityServer.RequestValidators;

public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenRequestValidationContext>,
Expand Down Expand Up @@ -49,7 +46,8 @@ public CustomTokenRequestValidator(
IPolicyRequirementQuery policyRequirementQuery,
IAuthRequestRepository authRequestRepository,
IMailService mailService,
IUserAccountKeysQuery userAccountKeysQuery)
IUserAccountKeysQuery userAccountKeysQuery,
IClientVersionValidator clientVersionValidator)
: base(
userManager,
userService,
Expand All @@ -68,7 +66,8 @@ public CustomTokenRequestValidator(
policyRequirementQuery,
authRequestRepository,
mailService,
userAccountKeysQuery)
userAccountKeysQuery,
clientVersionValidator)
{
_userManager = userManager;
_updateInstallationCommand = updateInstallationCommand;
Expand Down
Loading
Loading