1
0
mirror of https://github.com/bitwarden/server synced 2026-02-25 08:53:21 +00:00

feat(auth-validator): [Auth/PM-22975] Client Version Validator (#6588)

* feat(auth-validator): [PM-22975] Client Version Validator - Implementation.

* test(auth-validator): [PM-22975] Client Version Validator - Added tests.
This commit is contained in:
Patrick-Pimentel-Bitwarden
2026-02-23 10:00:10 -05:00
committed by GitHub
parent b5554c6030
commit 3dbd17f61d
27 changed files with 732 additions and 83 deletions

View File

@@ -32,7 +32,7 @@ public interface ICurrentContext
Guid? OrganizationId { get; set; }
IdentityClientType IdentityClientType { get; set; }
string ClientId { get; set; }
Version ClientVersion { get; set; }
Version? ClientVersion { get; set; }
bool ClientVersionIsPrerelease { get; set; }
Task BuildAsync(HttpContext httpContext, GlobalSettings globalSettings);

View File

@@ -4,6 +4,7 @@ using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Utilities;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Identity;
@@ -216,6 +217,42 @@ public class User : ITableObject<Guid>, IStorableSubscriber, IRevisable, ITwoFac
return SecurityVersion ?? 1;
}
/// <summary>
/// Evaluates user state to determine if they are currently in a v2 encryption state.
/// </summary>
/// <returns>If the shape of their private key is v2 as well as has the proper security version then true, otherwise false</returns>
public bool HasV2Encryption()
{
return HasV2KeyShape() && IsSecurityVersionTwo();
}
private bool HasV2KeyShape()
{
if (string.IsNullOrEmpty(PrivateKey))
{
return false;
}
try
{
return EncryptionParsing.GetEncryptionType(PrivateKey) == EncryptionType.XChaCha20Poly1305_B64;
}
catch (ArgumentException)
{
// Invalid encryption string format - treat as not v2
return false;
}
}
/// <summary>
/// This technically is correct but all versions after 1 are considered v2 encryption. Leaving for now with
/// KM's blessing that when a new version comes along they will handle migration.
/// </summary>
private bool IsSecurityVersionTwo()
{
return SecurityVersion == 2;
}
/// <summary>
/// Serializes the C# object to the User.TwoFactorProviders property in JSON format.
/// </summary>

View File

@@ -11,8 +11,11 @@ public enum EncryptionType : byte
XChaCha20Poly1305_B64 = 7,
// asymmetric
[Obsolete("PM-29656 - Should probably be removed as it is not known to exist in the real world")]
Rsa2048_OaepSha256_B64 = 3,
Rsa2048_OaepSha1_B64 = 4,
[Obsolete("PM-29656 - Should probably be removed as it is not known to exist in the real world")]
Rsa2048_OaepSha256_HmacSha256_B64 = 5,
[Obsolete("PM-29656 - Should probably be removed as it is not known to exist in the real world")]
Rsa2048_OaepSha1_HmacSha256_B64 = 6
}

View File

@@ -0,0 +1,6 @@
namespace Bit.Core.KeyManagement;
public static class Constants
{
public static readonly Version MinimumClientVersionForV2Encryption = new("2025.11.0");
}

View File

@@ -6,6 +6,7 @@ using Bit.Core.Entities;
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;
@@ -159,7 +160,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
}
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");
}
@@ -231,7 +232,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
{
// 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;
}
@@ -259,7 +260,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
{
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.");
}
@@ -268,7 +269,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
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.");
}
@@ -281,24 +282,4 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
throw new InvalidOperationException("No signed security state provider for V2 user");
}
}
/// <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.");
}
}

View File

@@ -0,0 +1,28 @@
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)
{
ArgumentNullException.ThrowIfNull(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.");
}
}

View File

@@ -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; }
@@ -69,7 +70,8 @@ public abstract class BaseRequestValidator<T> where T : class
IPolicyRequirementQuery policyRequirementQuery,
IAuthRequestRepository authRequestRepository,
IMailService mailService,
IUserAccountKeysQuery userAccountKeysQuery
IUserAccountKeysQuery userAccountKeysQuery,
IClientVersionValidator clientVersionValidator
)
{
_userManager = userManager;
@@ -91,6 +93,7 @@ public abstract class BaseRequestValidator<T> where T : class
_authRequestRepository = authRequestRepository;
_mailService = mailService;
_accountKeysQuery = userAccountKeysQuery;
_clientVersionValidator = clientVersionValidator;
}
protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
@@ -135,7 +138,13 @@ public abstract class BaseRequestValidator<T> where T : class
// validation to perform the recovery as part of scheme validation based on the request.
return
[
() => ValidateMasterPasswordAsync(context, validatorContext),
() => ValidateGrantSpecificContext(context, validatorContext),
// 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.
() => ValidateClientVersionAsync(context, validatorContext),
() => ValidateTwoFactorAsync(context, request, validatorContext),
() => ValidateSsoAsync(context, request, validatorContext),
() => ValidateNewDeviceAsync(context, request, validatorContext),
@@ -148,7 +157,13 @@ public abstract class BaseRequestValidator<T> where T : class
// The typical validation scenario.
return
[
() => ValidateMasterPasswordAsync(context, validatorContext),
() => ValidateGrantSpecificContext(context, validatorContext),
// 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.
() => ValidateClientVersionAsync(context, validatorContext),
() => ValidateSsoAsync(context, request, validatorContext),
() => ValidateTwoFactorAsync(context, request, validatorContext),
() => ValidateNewDeviceAsync(context, request, validatorContext),
@@ -201,12 +216,29 @@ public abstract class BaseRequestValidator<T> where T : class
}
/// <summary>
/// Validates the user's Master Password hash.
/// Validates whether the client version is compatible for the user attempting to authenticate.
/// </summary>
/// <returns>true if the scheme successfully passed validation, otherwise false.</returns>
private async Task<bool> ValidateClientVersionAsync(T context, CustomValidatorRequestContext validatorContext)
{
var ok = _clientVersionValidator.Validate(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;

View File

@@ -0,0 +1,92 @@
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.KeyManagement;
using Bit.Core.Models.Api;
using Duende.IdentityServer.Validation;
namespace Bit.Identity.IdentityServer.RequestValidators;
public interface IClientVersionValidator
{
bool Validate(User user, CustomValidatorRequestContext requestContext);
}
/// <summary>
/// This validator will use the Client Version on a request, which currently maps
/// to the "Bitwarden-Client-Version" header, to determine if a user meets minimum
/// required client version for issuing tokens on an old client. This is done to
/// incentivize users to get on an updated client when their password encryption
/// method has already been updated.
///
/// If the header is omitted, then the validator returns that this request is invalid.
/// </summary>
public class ClientVersionValidator(
ICurrentContext currentContext)
: IClientVersionValidator
{
private const string _upgradeMessage = "Please update your app to continue using Bitwarden";
private const string _noUserMessage = "No user found while trying to validate client version";
private const string _versionHeaderMissing = "No client version header found, required to prevent encryption errors. Please confirm your client is supplying the header: \"Bitwarden-Client-Version\"";
public bool Validate(User? user, CustomValidatorRequestContext requestContext)
{
// Do this nullish check because the base request validator currently is not
// strict null checking. Once that gets fixed then we can see about making
// the user not nullish checked. If they are null then the validator should fail.
if (user == null)
{
FillRequestContextWithErrorData(requestContext, "no_user", _noUserMessage);
return false;
}
Version? clientVersion = currentContext.ClientVersion;
// Deny access if the client version headers are missing.
// We want to establish a strict contract with clients that if they omit this header,
// then the server cannot guarantee that a client won't do harm to a user's data
// with stale encryption architecture.
if (clientVersion == null)
{
FillRequestContextWithErrorData(requestContext, "version_header_missing", _versionHeaderMissing);
return false;
}
// Determine the minimum version client that a user needs. If no V2 encryption detected then
// no validation needs to occur, which is why min version number can be null.
Version? minVersion = user.HasV2Encryption() ? Constants.MinimumClientVersionForV2Encryption : null;
// If min version is null then we know that the user had an encryption
// configuration that doesn't require a minimum version. Allowing through.
if (minVersion == null)
{
return true;
}
if (clientVersion < minVersion)
{
FillRequestContextWithErrorData(requestContext, "invalid_client_version", _upgradeMessage);
return false;
}
return true;
}
private void FillRequestContextWithErrorData(
CustomValidatorRequestContext requestContext,
string errorId,
string errorMessage)
{
requestContext.ValidationErrorResult = new ValidationResult
{
Error = errorId,
ErrorDescription = errorMessage,
IsError = true
};
requestContext.CustomResponse = new Dictionary<string, object>
{
{ "ErrorModel", new ErrorResponseModel(errorMessage) }
};
}
}

View File

@@ -16,11 +16,8 @@ using Bit.Core.Settings;
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>,
@@ -50,7 +47,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
IPolicyRequirementQuery policyRequirementQuery,
IAuthRequestRepository authRequestRepository,
IMailService mailService,
IUserAccountKeysQuery userAccountKeysQuery)
IUserAccountKeysQuery userAccountKeysQuery,
IClientVersionValidator clientVersionValidator)
: base(
userManager,
userService,
@@ -70,7 +68,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
policyRequirementQuery,
authRequestRepository,
mailService,
userAccountKeysQuery)
userAccountKeysQuery,
clientVersionValidator)
{
_userManager = userManager;
_updateInstallationCommand = updateInstallationCommand;

View File

@@ -44,7 +44,8 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IPolicyRequirementQuery policyRequirementQuery,
IMailService mailService,
IUserAccountKeysQuery userAccountKeysQuery)
IUserAccountKeysQuery userAccountKeysQuery,
IClientVersionValidator clientVersionValidator)
: base(
userManager,
userService,
@@ -64,7 +65,8 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
policyRequirementQuery,
authRequestRepository,
mailService,
userAccountKeysQuery)
userAccountKeysQuery,
clientVersionValidator)
{
_userManager = userManager;
_currentContext = currentContext;
@@ -74,7 +76,6 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
{
var user = await _userManager.FindByEmailAsync(context.UserName.ToLowerInvariant());
// We want to keep this device around incase the device is new for the user
var requestDevice = DeviceValidator.GetDeviceFromRequest(context.Request);

View File

@@ -53,7 +53,8 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
IPolicyRequirementQuery policyRequirementQuery,
IAuthRequestRepository authRequestRepository,
IMailService mailService,
IUserAccountKeysQuery userAccountKeysQuery)
IUserAccountKeysQuery userAccountKeysQuery,
IClientVersionValidator clientVersionValidator)
: base(
userManager,
userService,
@@ -73,7 +74,8 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
policyRequirementQuery,
authRequestRepository,
mailService,
userAccountKeysQuery)
userAccountKeysQuery,
clientVersionValidator)
{
_assertionOptionsDataProtector = assertionOptionsDataProtector;
_assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand;

View File

@@ -25,6 +25,7 @@ public static class ServiceCollectionExtensions
services.AddTransient<IAuthorizationCodeStore, AuthorizationCodeStore>();
services.AddTransient<IUserDecryptionOptionsBuilder, UserDecryptionOptionsBuilder>();
services.AddTransient<IDeviceValidator, DeviceValidator>();
services.AddTransient<IClientVersionValidator, ClientVersionValidator>();
services.AddTransient<ITwoFactorAuthenticationValidator, TwoFactorAuthenticationValidator>();
services.AddTransient<ISsoRequestValidator, SsoRequestValidator>();
services.AddTransient<ILoginApprovingClientTypes, LoginApprovingClientTypes>();

View File

@@ -6,7 +6,6 @@ using System.Reflection;
using System.Security.Claims;
using System.Security.Cryptography.X509Certificates;
using AspNetCoreRateLimit;
using Bit.Core;
using Bit.Core.AdminConsole.AbilitiesCache;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
@@ -83,6 +82,7 @@ using Microsoft.Extensions.Options;
using Microsoft.OpenApi;
using StackExchange.Redis;
using Swashbuckle.AspNetCore.SwaggerGen;
using Constants = Bit.Core.Constants;
using NoopRepos = Bit.Core.Repositories.Noop;
using Role = Bit.Core.Entities.Role;
using TableStorageRepos = Bit.Core.Repositories.TableStorage;