From 1c4fd6ca24b503dfd5091927a62c8cf0fc9b5d6e Mon Sep 17 00:00:00 2001 From: Patrick Pimentel Date: Mon, 17 Nov 2025 15:46:02 -0500 Subject: [PATCH] feat(auth-validator): [PM-22975] Client Version Validator - initial implementation --- src/Core/KeyManagement/Constants.cs | 6 + ...eyManagementServiceCollectionExtensions.cs | 2 + .../GetMinimumClientVersionForUserQuery.cs | 25 ++ .../IGetMinimumClientVersionForUserQuery.cs | 10 + .../Interfaces/IIsV2EncryptionUserQuery.cs | 10 + .../Queries/IsV2EncryptionUserQuery.cs | 33 +++ .../RotateUserAccountKeysCommand.cs | 265 ++++++++++++++++++ .../RotateUserAccountkeysCommand.cs | 29 +- .../Utilities/EncryptionParsing.cs | 46 +++ .../RequestValidators/BaseRequestValidator.cs | 25 +- .../ClientVersionValidator.cs | 55 ++++ .../CustomTokenRequestValidator.cs | 6 +- .../ResourceOwnerPasswordValidator.cs | 6 +- .../WebAuthnGrantValidator.cs | 6 +- .../Utilities/ServiceCollectionExtensions.cs | 1 + .../Utilities/ServiceCollectionExtensions.cs | 2 +- ...etMinimumClientVersionForUserQueryTests.cs | 34 +++ .../Queries/IsV2EncryptionUserQueryTests.cs | 65 +++++ .../Login/ClientVersionGateTests.cs | 119 ++++++++ .../ClientVersionValidatorTests.cs | 55 ++++ 20 files changed, 769 insertions(+), 31 deletions(-) create mode 100644 src/Core/KeyManagement/Constants.cs create mode 100644 src/Core/KeyManagement/Queries/GetMinimumClientVersionForUserQuery.cs create mode 100644 src/Core/KeyManagement/Queries/Interfaces/IGetMinimumClientVersionForUserQuery.cs create mode 100644 src/Core/KeyManagement/Queries/Interfaces/IIsV2EncryptionUserQuery.cs create mode 100644 src/Core/KeyManagement/Queries/IsV2EncryptionUserQuery.cs create mode 100644 src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountKeysCommand.cs create mode 100644 src/Core/KeyManagement/Utilities/EncryptionParsing.cs create mode 100644 src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs create mode 100644 test/Core.Test/KeyManagement/Queries/GetMinimumClientVersionForUserQueryTests.cs create mode 100644 test/Core.Test/KeyManagement/Queries/IsV2EncryptionUserQueryTests.cs create mode 100644 test/Identity.IntegrationTest/Login/ClientVersionGateTests.cs create mode 100644 test/Identity.Test/IdentityServer/RequestValidators/ClientVersionValidatorTests.cs diff --git a/src/Core/KeyManagement/Constants.cs b/src/Core/KeyManagement/Constants.cs new file mode 100644 index 0000000000..2bc44134be --- /dev/null +++ b/src/Core/KeyManagement/Constants.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.KeyManagement; + +public static class Constants +{ + public static readonly Version MinimumClientVersion = new Version("2025.11.0"); +} diff --git a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs index 0e551c5d0e..9b63dfdaf9 100644 --- a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs +++ b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs @@ -26,5 +26,7 @@ public static class KeyManagementServiceCollectionExtensions private static void AddKeyManagementQueries(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Core/KeyManagement/Queries/GetMinimumClientVersionForUserQuery.cs b/src/Core/KeyManagement/Queries/GetMinimumClientVersionForUserQuery.cs new file mode 100644 index 0000000000..49cc46a54c --- /dev/null +++ b/src/Core/KeyManagement/Queries/GetMinimumClientVersionForUserQuery.cs @@ -0,0 +1,25 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Queries.Interfaces; + +namespace Bit.Core.KeyManagement.Queries; + +public class GetMinimumClientVersionForUserQuery(IIsV2EncryptionUserQuery isV2EncryptionUserQuery) + : IGetMinimumClientVersionForUserQuery +{ + public async Task Run(User? user) + { + if (user == null) + { + return null; + } + + if (await isV2EncryptionUserQuery.Run(user)) + { + return Constants.MinimumClientVersion; + } + + return null; + } +} + + diff --git a/src/Core/KeyManagement/Queries/Interfaces/IGetMinimumClientVersionForUserQuery.cs b/src/Core/KeyManagement/Queries/Interfaces/IGetMinimumClientVersionForUserQuery.cs new file mode 100644 index 0000000000..896a473d69 --- /dev/null +++ b/src/Core/KeyManagement/Queries/Interfaces/IGetMinimumClientVersionForUserQuery.cs @@ -0,0 +1,10 @@ +using Bit.Core.Entities; + +namespace Bit.Core.KeyManagement.Queries.Interfaces; + +public interface IGetMinimumClientVersionForUserQuery +{ + Task Run(User? user); +} + + diff --git a/src/Core/KeyManagement/Queries/Interfaces/IIsV2EncryptionUserQuery.cs b/src/Core/KeyManagement/Queries/Interfaces/IIsV2EncryptionUserQuery.cs new file mode 100644 index 0000000000..6b644a2dc7 --- /dev/null +++ b/src/Core/KeyManagement/Queries/Interfaces/IIsV2EncryptionUserQuery.cs @@ -0,0 +1,10 @@ +using Bit.Core.Entities; + +namespace Bit.Core.KeyManagement.Queries.Interfaces; + +public interface IIsV2EncryptionUserQuery +{ + Task Run(User user); +} + + diff --git a/src/Core/KeyManagement/Queries/IsV2EncryptionUserQuery.cs b/src/Core/KeyManagement/Queries/IsV2EncryptionUserQuery.cs new file mode 100644 index 0000000000..74a203004c --- /dev/null +++ b/src/Core/KeyManagement/Queries/IsV2EncryptionUserQuery.cs @@ -0,0 +1,33 @@ +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 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( + "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.") + }; + } +} + + diff --git a/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountKeysCommand.cs b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountKeysCommand.cs new file mode 100644 index 0000000000..6e5708f667 --- /dev/null +++ b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountKeysCommand.cs @@ -0,0 +1,265 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.Auth.Repositories; +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; +using Bit.Core.Tools.Repositories; +using Bit.Core.Vault.Repositories; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.KeyManagement.UserKey.Implementations; + +/// +public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand +{ + private readonly IUserService _userService; + private readonly IUserRepository _userRepository; + private readonly ICipherRepository _cipherRepository; + private readonly IFolderRepository _folderRepository; + private readonly ISendRepository _sendRepository; + private readonly IEmergencyAccessRepository _emergencyAccessRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IDeviceRepository _deviceRepository; + private readonly IPushNotificationService _pushService; + private readonly IdentityErrorDescriber _identityErrorDescriber; + private readonly IWebAuthnCredentialRepository _credentialRepository; + private readonly IPasswordHasher _passwordHasher; + private readonly IUserSignatureKeyPairRepository _userSignatureKeyPairRepository; + private readonly IFeatureService _featureService; + + /// + /// Instantiates a new + /// + /// Master password hash validation + /// Updates user keys and re-encrypted data if needed + /// Provides a method to update re-encrypted cipher data + /// Provides a method to update re-encrypted folder data + /// Provides a method to update re-encrypted send data + /// Provides a method to update re-encrypted emergency access data + /// Provides a method to update re-encrypted organization user data + /// Provides a method to update re-encrypted device keys + /// Hashes the new master password + /// Logs out user from other devices after successful rotation + /// Provides a password mismatch error if master password hash validation fails + /// Provides a method to update re-encrypted WebAuthn keys + /// Provides a method to update re-encrypted signature keys + public RotateUserAccountKeysCommand(IUserService userService, IUserRepository userRepository, + ICipherRepository cipherRepository, IFolderRepository folderRepository, ISendRepository sendRepository, + IEmergencyAccessRepository emergencyAccessRepository, IOrganizationUserRepository organizationUserRepository, + IDeviceRepository deviceRepository, + IPasswordHasher passwordHasher, + IPushNotificationService pushService, IdentityErrorDescriber errors, IWebAuthnCredentialRepository credentialRepository, + IUserSignatureKeyPairRepository userSignatureKeyPairRepository, + IFeatureService featureService) + { + _userService = userService; + _userRepository = userRepository; + _cipherRepository = cipherRepository; + _folderRepository = folderRepository; + _sendRepository = sendRepository; + _emergencyAccessRepository = emergencyAccessRepository; + _organizationUserRepository = organizationUserRepository; + _deviceRepository = deviceRepository; + _pushService = pushService; + _identityErrorDescriber = errors; + _credentialRepository = credentialRepository; + _passwordHasher = passwordHasher; + _userSignatureKeyPairRepository = userSignatureKeyPairRepository; + _featureService = featureService; + } + + /// + public async Task RotateUserAccountKeysAsync(User user, RotateUserAccountKeysData model) + { + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + if (!await _userService.CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)) + { + return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); + } + + var now = DateTime.UtcNow; + user.RevisionDate = user.AccountRevisionDate = now; + user.LastKeyRotationDate = now; + user.SecurityStamp = Guid.NewGuid().ToString(); + + List saveEncryptedDataActions = []; + + await UpdateAccountKeysAsync(model, user, saveEncryptedDataActions); + UpdateUnlockMethods(model, user, saveEncryptedDataActions); + UpdateUserData(model, user, saveEncryptedDataActions); + + await _userRepository.UpdateUserKeyAndEncryptedDataV2Async(user, saveEncryptedDataActions); + await _pushService.PushLogOutAsync(user.Id); + return IdentityResult.Success; + } + + public async Task RotateV2AccountKeysAsync(RotateUserAccountKeysData model, User user, List saveEncryptedDataActions) + { + ValidateV2Encryption(model); + await ValidateVerifyingKeyUnchangedAsync(model, user); + + saveEncryptedDataActions.Add(_userSignatureKeyPairRepository.UpdateForKeyRotation(user.Id, model.AccountKeys.SignatureKeyPairData)); + user.SignedPublicKey = model.AccountKeys.PublicKeyEncryptionKeyPairData.SignedPublicKey; + user.SecurityState = model.AccountKeys.SecurityStateData!.SecurityState; + user.SecurityVersion = model.AccountKeys.SecurityStateData.SecurityVersion; + } + + public void UpgradeV1ToV2Keys(RotateUserAccountKeysData model, User user, List saveEncryptedDataActions) + { + ValidateV2Encryption(model); + saveEncryptedDataActions.Add(_userSignatureKeyPairRepository.SetUserSignatureKeyPair(user.Id, model.AccountKeys.SignatureKeyPairData)); + user.SignedPublicKey = model.AccountKeys.PublicKeyEncryptionKeyPairData.SignedPublicKey; + user.SecurityState = model.AccountKeys.SecurityStateData!.SecurityState; + user.SecurityVersion = model.AccountKeys.SecurityStateData.SecurityVersion; + } + + public async Task UpdateAccountKeysAsync(RotateUserAccountKeysData model, User user, List saveEncryptedDataActions) + { + ValidatePublicKeyEncryptionKeyPairUnchanged(model, user); + + if (IsV2EncryptionUserAsync(user)) + { + await RotateV2AccountKeysAsync(model, user, saveEncryptedDataActions); + } + else if (model.AccountKeys.SignatureKeyPairData != null) + { + UpgradeV1ToV2Keys(model, user, saveEncryptedDataActions); + } + else + { + 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"); + } + // V1 user to V1 user rotation needs to further changes, the private key was re-encrypted. + } + + // Private key is re-wrapped with new user key by client + user.PrivateKey = model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey; + } + + public void UpdateUserData(RotateUserAccountKeysData model, User user, List saveEncryptedDataActions) + { + // The revision date has to be updated so that de-synced clients don't accidentally post over the re-encrypted data + // with an old-user key-encrypted copy + var now = DateTime.UtcNow; + + if (model.Ciphers.Any()) + { + var ciphersWithUpdatedDate = model.Ciphers.ToList().Select(c => { c.RevisionDate = now; return c; }); + saveEncryptedDataActions.Add(_cipherRepository.UpdateForKeyRotation(user.Id, ciphersWithUpdatedDate)); + } + + if (model.Folders.Any()) + { + var foldersWithUpdatedDate = model.Folders.ToList().Select(f => { f.RevisionDate = now; return f; }); + saveEncryptedDataActions.Add(_folderRepository.UpdateForKeyRotation(user.Id, foldersWithUpdatedDate)); + } + + if (model.Sends.Any()) + { + var sendsWithUpdatedDate = model.Sends.ToList().Select(s => { s.RevisionDate = now; return s; }); + saveEncryptedDataActions.Add(_sendRepository.UpdateForKeyRotation(user.Id, sendsWithUpdatedDate)); + } + } + + void UpdateUnlockMethods(RotateUserAccountKeysData model, User user, List saveEncryptedDataActions) + { + if (!model.MasterPasswordUnlockData.ValidateForUser(user)) + { + throw new InvalidOperationException("The provided master password unlock data is not valid for this user."); + } + // Update master password authentication & unlock + user.Key = model.MasterPasswordUnlockData.MasterKeyEncryptedUserKey; + user.MasterPassword = _passwordHasher.HashPassword(user, model.MasterPasswordUnlockData.MasterKeyAuthenticationHash); + user.MasterPasswordHint = model.MasterPasswordUnlockData.MasterPasswordHint; + + if (model.EmergencyAccesses.Any()) + { + saveEncryptedDataActions.Add(_emergencyAccessRepository.UpdateForKeyRotation(user.Id, model.EmergencyAccesses)); + } + + if (model.OrganizationUsers.Any()) + { + saveEncryptedDataActions.Add(_organizationUserRepository.UpdateForKeyRotation(user.Id, model.OrganizationUsers)); + } + + if (model.WebAuthnKeys.Any()) + { + saveEncryptedDataActions.Add(_credentialRepository.UpdateKeysForRotationAsync(user.Id, model.WebAuthnKeys)); + } + + if (model.DeviceKeys.Any()) + { + saveEncryptedDataActions.Add(_deviceRepository.UpdateKeysForRotationAsync(user.Id, model.DeviceKeys)); + } + } + + 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 = EncryptionParsing.GetEncryptionType(user.PrivateKey) == EncryptionType.XChaCha20Poly1305_B64; + return isPrivateKeyEncryptionV2; + } + + private async Task ValidateVerifyingKeyUnchangedAsync(RotateUserAccountKeysData model, User user) + { + var currentSignatureKeyPair = await _userSignatureKeyPairRepository.GetByUserIdAsync(user.Id) ?? throw new InvalidOperationException("User does not have a signature key pair."); + if (model.AccountKeys.SignatureKeyPairData.VerifyingKey != currentSignatureKeyPair!.VerifyingKey) + { + throw new InvalidOperationException("The provided verifying key does not match the user's current verifying key."); + } + } + + private static void ValidatePublicKeyEncryptionKeyPairUnchanged(RotateUserAccountKeysData model, User user) + { + var publicKey = model.AccountKeys.PublicKeyEncryptionKeyPairData.PublicKey; + if (publicKey != user.PublicKey) + { + throw new InvalidOperationException("The provided account public key does not match the user's current public key, and changing the account asymmetric key pair is currently not supported during key rotation."); + } + } + + private static void ValidateV2Encryption(RotateUserAccountKeysData model) + { + if (model.AccountKeys.SignatureKeyPairData == null) + { + throw new InvalidOperationException("Signature key pair data is required for V2 encryption."); + } + if (EncryptionParsing.GetEncryptionType(model.AccountKeys.SignatureKeyPairData.WrappedSigningKey) != EncryptionType.XChaCha20Poly1305_B64) + { + throw new InvalidOperationException("The provided signing key data is not wrapped with XChaCha20-Poly1305."); + } + if (string.IsNullOrEmpty(model.AccountKeys.SignatureKeyPairData.VerifyingKey)) + { + throw new InvalidOperationException("The provided signature key pair data does not contain a valid verifying key."); + } + + 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."); + } + if (string.IsNullOrEmpty(model.AccountKeys.PublicKeyEncryptionKeyPairData.SignedPublicKey)) + { + throw new InvalidOperationException("No signed public key provided, but the user already has a signature key pair."); + } + if (model.AccountKeys.SecurityStateData == null || string.IsNullOrEmpty(model.AccountKeys.SecurityStateData.SecurityState)) + { + throw new InvalidOperationException("No signed security state provider for V2 user"); + } + } + + // Parsing moved to Bit.Core.KeyManagement.Utilities.EncryptionParsing +} diff --git a/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs index c1e7905d78..6e5708f667 100644 --- a/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs +++ b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs @@ -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; @@ -137,7 +138,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"); } @@ -209,7 +210,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; } @@ -237,7 +238,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."); } @@ -246,7 +247,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."); } @@ -260,23 +261,5 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand } } - /// - /// Helper method to convert an encryption type string to an enum value. - /// - 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 } diff --git a/src/Core/KeyManagement/Utilities/EncryptionParsing.cs b/src/Core/KeyManagement/Utilities/EncryptionParsing.cs new file mode 100644 index 0000000000..4658f4cf59 --- /dev/null +++ b/src/Core/KeyManagement/Utilities/EncryptionParsing.cs @@ -0,0 +1,46 @@ +using Bit.Core.Enums; + +namespace Bit.Core.KeyManagement.Utilities; + +public static class EncryptionParsing +{ + /// + /// Helper method to convert an encryption type string to an enum value. + /// Accepts formats like "Header.iv|ct|mac" or "Header" COSE format. + /// + public static EncryptionType GetEncryptionType(string encString) + { + if (string.IsNullOrWhiteSpace(encString)) + { + throw new ArgumentException("Encrypted string cannot be null or empty.", nameof(encString)); + } + + var parts = encString.Split('.'); + if (parts.Length == 1) + { + // No header detected; assume AES CBC variants based on number of pieces + var splitParts = encString.Split('|'); + if (splitParts.Length == 3) + { + return EncryptionType.AesCbc128_HmacSha256_B64; + } + + return EncryptionType.AesCbc256_B64; + } + + // Try parse header as numeric, then as enum name, else fail + if (byte.TryParse(parts[0], out var encryptionTypeNumber)) + { + return (EncryptionType)encryptionTypeNumber; + } + + if (Enum.TryParse(parts[0], out EncryptionType parsed)) + { + return parsed; + } + + throw new ArgumentException("Invalid encryption type header.", nameof(encString)); + } +} + + diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index 224c7a1866..4226098b4e 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -40,6 +40,7 @@ public abstract class BaseRequestValidator 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; } @@ -68,7 +69,8 @@ public abstract class BaseRequestValidator where T : class IPolicyRequirementQuery policyRequirementQuery, IAuthRequestRepository authRequestRepository, IMailService mailService, - IUserAccountKeysQuery userAccountKeysQuery + IUserAccountKeysQuery userAccountKeysQuery, + IClientVersionValidator clientVersionValidator ) { _userManager = userManager; @@ -89,6 +91,7 @@ public abstract class BaseRequestValidator where T : class _authRequestRepository = authRequestRepository; _mailService = mailService; _accountKeysQuery = userAccountKeysQuery; + _clientVersionValidator = clientVersionValidator; } protected async Task ValidateAsync(T context, ValidatedTokenRequest request, @@ -259,6 +262,7 @@ public abstract class BaseRequestValidator where T : class return [ () => ValidateMasterPasswordAsync(context, validatorContext), + () => ValidateClientVersionAsync(context, validatorContext), () => ValidateTwoFactorAsync(context, request, validatorContext), () => ValidateSsoAsync(context, request, validatorContext), () => ValidateNewDeviceAsync(context, request, validatorContext), @@ -272,6 +276,7 @@ public abstract class BaseRequestValidator where T : class return [ () => ValidateMasterPasswordAsync(context, validatorContext), + () => ValidateClientVersionAsync(context, validatorContext), () => ValidateSsoAsync(context, request, validatorContext), () => ValidateTwoFactorAsync(context, request, validatorContext), () => ValidateNewDeviceAsync(context, request, validatorContext), @@ -323,6 +328,24 @@ public abstract class BaseRequestValidator where T : class return true; } + /// + /// Validates whether the client version is compatible for the user attempting to authenticate. + /// New authentications only; refresh/device grants are handled elsewhere. + /// + /// true if the scheme successfully passed validation, otherwise false. + private async Task 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; + } + /// /// Validates the user's Master Password hash. /// diff --git a/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs b/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs new file mode 100644 index 0000000000..3d35db7f17 --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs @@ -0,0 +1,55 @@ +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 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 ValidateAsync(User? user, CustomValidatorRequestContext requestContext) + { + 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_grant", + ErrorDescription = UpgradeMessage, + IsError = true + }; + requestContext.CustomResponse = new Dictionary + { + { "ErrorModel", new ErrorResponseModel(UpgradeMessage) } + }; + return false; + } + + return true; + } +} + + diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index 64156ea5f3..e7fc4b6498 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -49,7 +49,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient, SendPasswordRequestValidator>(); diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index ef143b042c..1243a3ee60 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -7,7 +7,6 @@ using System.Security.Claims; using System.Security.Cryptography.X509Certificates; using AspNetCoreRateLimit; using Azure.Messaging.ServiceBus; -using Bit.Core; using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; @@ -85,6 +84,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StackExchange.Redis; +using Constants = Bit.Core.Constants; using NoopRepos = Bit.Core.Repositories.Noop; using Role = Bit.Core.Entities.Role; using TableStorageRepos = Bit.Core.Repositories.TableStorage; diff --git a/test/Core.Test/KeyManagement/Queries/GetMinimumClientVersionForUserQueryTests.cs b/test/Core.Test/KeyManagement/Queries/GetMinimumClientVersionForUserQueryTests.cs new file mode 100644 index 0000000000..1a04e60ca5 --- /dev/null +++ b/test/Core.Test/KeyManagement/Queries/GetMinimumClientVersionForUserQueryTests.cs @@ -0,0 +1,34 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Queries; +using Bit.Core.KeyManagement.Queries.Interfaces; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.Queries; + +public class GetMinimumClientVersionForUserQueryTests +{ + private class FakeIsV2Query : IIsV2EncryptionUserQuery + { + private readonly bool _isV2; + public FakeIsV2Query(bool isV2) { _isV2 = isV2; } + public Task Run(User user) => Task.FromResult(_isV2); + } + + [Fact] + public async Task Run_ReturnsMinVersion_ForV2User() + { + var sut = new GetMinimumClientVersionForUserQuery(new FakeIsV2Query(true)); + var version = await sut.Run(new User()); + Assert.Equal(Core.KeyManagement.Constants.MinimumClientVersion, version); + } + + [Fact] + public async Task Run_ReturnsNull_ForV1User() + { + var sut = new GetMinimumClientVersionForUserQuery(new FakeIsV2Query(false)); + var version = await sut.Run(new User()); + Assert.Null(version); + } +} + + diff --git a/test/Core.Test/KeyManagement/Queries/IsV2EncryptionUserQueryTests.cs b/test/Core.Test/KeyManagement/Queries/IsV2EncryptionUserQueryTests.cs new file mode 100644 index 0000000000..67c3601b81 --- /dev/null +++ b/test/Core.Test/KeyManagement/Queries/IsV2EncryptionUserQueryTests.cs @@ -0,0 +1,65 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Entities; +using Bit.Core.KeyManagement.Enums; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.KeyManagement.Queries; +using Bit.Core.KeyManagement.Repositories; +using Bit.Core.KeyManagement.UserKey; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.Queries; + +public class IsV2EncryptionUserQueryTests +{ + private class FakeSigRepo : IUserSignatureKeyPairRepository + { + private readonly bool _hasKeys; + public FakeSigRepo(bool hasKeys) { _hasKeys = hasKeys; } + public Task GetByUserIdAsync(Guid userId) + => Task.FromResult(_hasKeys ? new SignatureKeyPairData(SignatureAlgorithm.Ed25519, "7.cose_signing", "vk") : null); + + // Unused in tests + public Task> GetManyAsync(IEnumerable ids) => throw new NotImplementedException(); + public Task GetByIdAsync(Guid id) => throw new NotImplementedException(); + public Task CreateAsync(UserSignatureKeyPair obj) => throw new NotImplementedException(); + public Task ReplaceAsync(UserSignatureKeyPair obj) => throw new NotImplementedException(); + public Task UpsertAsync(UserSignatureKeyPair obj) => throw new NotImplementedException(); + public Task DeleteAsync(UserSignatureKeyPair obj) => throw new NotImplementedException(); + public Task DeleteAsync(Guid id) => throw new NotImplementedException(); + public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid grantorId, SignatureKeyPairData signatureKeyPair) => throw new NotImplementedException(); + public UpdateEncryptedDataForKeyRotation SetUserSignatureKeyPair(Guid userId, SignatureKeyPairData signatureKeyPair) => throw new NotImplementedException(); + } + + [Fact] + public async Task Run_ReturnsTrue_ForV2State() + { + var user = new User { Id = Guid.NewGuid(), PrivateKey = "7.cose" }; + var sut = new IsV2EncryptionUserQuery(new FakeSigRepo(true)); + + var result = await sut.Run(user); + + Assert.True(result); + } + + [Fact] + public async Task Run_ReturnsFalse_ForV1State() + { + var user = new User { Id = Guid.NewGuid(), PrivateKey = "2.iv|ct|mac" }; + var sut = new IsV2EncryptionUserQuery(new FakeSigRepo(false)); + + var result = await sut.Run(user); + + Assert.False(result); + } + + [Fact] + public async Task Run_ThrowsForInvalidMixedState() + { + var user = new User { Id = Guid.NewGuid(), PrivateKey = "7.cose" }; + var sut = new IsV2EncryptionUserQuery(new FakeSigRepo(false)); + + await Assert.ThrowsAsync(async () => await sut.Run(user)); + } +} + + diff --git a/test/Identity.IntegrationTest/Login/ClientVersionGateTests.cs b/test/Identity.IntegrationTest/Login/ClientVersionGateTests.cs new file mode 100644 index 0000000000..54f005688e --- /dev/null +++ b/test/Identity.IntegrationTest/Login/ClientVersionGateTests.cs @@ -0,0 +1,119 @@ +using System.Text.Json; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.KeyManagement.Enums; +using Bit.Core.Test.Auth.AutoFixture; +using Bit.IntegrationTestCommon.Factories; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Bit.Identity.IntegrationTest.Login; + +public class ClientVersionGateTests : IClassFixture +{ + private readonly IdentityApplicationFactory _factory; + + public ClientVersionGateTests(IdentityApplicationFactory factory) + { + _factory = factory; + ReinitializeDbForTests(_factory); + } + + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_GrantTypePassword_V2User_OnOldClientVersion_Blocked(RegisterFinishRequestModel requestModel) + { + var localFactory = new IdentityApplicationFactory(); + var server = localFactory.Server; + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); + + // Make user V2: set private key to COSE and add signature key pair + var db = localFactory.GetDatabaseContext(); + var efUser = await db.Users.FirstAsync(u => u.Email == user.Email); + efUser.PrivateKey = "7.cose"; + db.UserSignatureKeyPairs.Add(new Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair + { + Id = Core.Utilities.CoreHelpers.GenerateComb(), + UserId = efUser.Id, + SignatureAlgorithm = SignatureAlgorithm.Ed25519, + SigningKey = "7.cose_signing", + VerifyingKey = "vk" + }); + await db.SaveChangesAsync(); + + var context = await server.PostAsync("/connect/token", + new FormUrlEncodedContent(new Dictionary + { + { "scope", "api offline_access" }, + { "client_id", "web" }, + { "deviceType", "2" }, + { "deviceIdentifier", IdentityApplicationFactory.DefaultDeviceIdentifier }, + { "deviceName", "firefox" }, + { "grant_type", "password" }, + { "username", user.Email }, + { "password", requestModel.MasterPasswordHash }, + }), + http => + { + http.Request.Headers.Append("Bitwarden-Client-Version", "2025.10.0"); + }); + + Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); + var errorBody = await Bit.Test.Common.Helpers.AssertHelper.AssertResponseTypeIs(context); + var error = Bit.Test.Common.Helpers.AssertHelper.AssertJsonProperty(errorBody.RootElement, "ErrorModel", JsonValueKind.Object); + var message = Bit.Test.Common.Helpers.AssertHelper.AssertJsonProperty(error, "Message", JsonValueKind.String).GetString(); + Assert.Equal("Please update your app to continue using Bitwarden", message); + } + + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_GrantTypePassword_V2User_OnMinClientVersion_Succeeds(RegisterFinishRequestModel requestModel) + { + var localFactory = new IdentityApplicationFactory(); + var server = localFactory.Server; + var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel); + + // Make user V2 + var db = localFactory.GetDatabaseContext(); + var efUser = await db.Users.FirstAsync(u => u.Email == user.Email); + efUser.PrivateKey = "7.cose"; + db.UserSignatureKeyPairs.Add(new Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair + { + Id = Core.Utilities.CoreHelpers.GenerateComb(), + UserId = efUser.Id, + SignatureAlgorithm = SignatureAlgorithm.Ed25519, + SigningKey = "7.cose_signing", + VerifyingKey = "vk" + }); + await db.SaveChangesAsync(); + + var context = await server.PostAsync("/connect/token", + new FormUrlEncodedContent(new Dictionary + { + { "scope", "api offline_access" }, + { "client_id", "web" }, + { "deviceType", "2" }, + { "deviceIdentifier", IdentityApplicationFactory.DefaultDeviceIdentifier }, + { "deviceName", "firefox" }, + { "grant_type", "password" }, + { "username", user.Email }, + { "password", requestModel.MasterPasswordHash }, + }), + http => + { + http.Request.Headers.Append("Bitwarden-Client-Version", "2025.11.0"); + }); + + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } + + private void ReinitializeDbForTests(IdentityApplicationFactory factory) + { + var databaseContext = factory.GetDatabaseContext(); + databaseContext.Policies.RemoveRange(databaseContext.Policies); + databaseContext.OrganizationUsers.RemoveRange(databaseContext.OrganizationUsers); + databaseContext.Organizations.RemoveRange(databaseContext.Organizations); + databaseContext.Users.RemoveRange(databaseContext.Users); + databaseContext.SaveChanges(); + } +} + + diff --git a/test/Identity.Test/IdentityServer/RequestValidators/ClientVersionValidatorTests.cs b/test/Identity.Test/IdentityServer/RequestValidators/ClientVersionValidatorTests.cs new file mode 100644 index 0000000000..dc491596fe --- /dev/null +++ b/test/Identity.Test/IdentityServer/RequestValidators/ClientVersionValidatorTests.cs @@ -0,0 +1,55 @@ +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Queries.Interfaces; +using Bit.Identity.IdentityServer.RequestValidators; +using NSubstitute; +using Xunit; + +namespace Bit.Identity.Test.IdentityServer.RequestValidators; + +public class ClientVersionValidatorTests +{ + private static ICurrentContext MakeContext(Version version) + { + var ctx = Substitute.For(); + ctx.ClientVersion = version; + return ctx; + } + + private static IGetMinimumClientVersionForUserQuery MakeMinQuery(Version? v) + { + var q = Substitute.For(); + q.Run(Arg.Any()).Returns(Task.FromResult(v)); + return q; + } + + [Fact] + public async Task Allows_When_NoMinVersion() + { + var sut = new ClientVersionValidator(MakeContext(new Version("2025.1.0")), MakeMinQuery(null)); + var ok = await sut.ValidateAsync(new User(), new Bit.Identity.IdentityServer.CustomValidatorRequestContext()); + Assert.True(ok); + } + + [Fact] + public async Task Blocks_When_ClientTooOld() + { + var sut = new ClientVersionValidator(MakeContext(new Version("2025.10.0")), MakeMinQuery(new Version("2025.11.0"))); + var ctx = new Bit.Identity.IdentityServer.CustomValidatorRequestContext(); + var ok = await sut.ValidateAsync(new User(), ctx); + Assert.False(ok); + Assert.NotNull(ctx.ValidationErrorResult); + Assert.True(ctx.ValidationErrorResult.IsError); + Assert.Equal("invalid_grant", ctx.ValidationErrorResult.Error); + } + + [Fact] + public async Task Allows_When_ClientMeetsMin() + { + var sut = new ClientVersionValidator(MakeContext(new Version("2025.11.0")), MakeMinQuery(new Version("2025.11.0"))); + var ok = await sut.ValidateAsync(new User(), new Bit.Identity.IdentityServer.CustomValidatorRequestContext()); + Assert.True(ok); + } +} + +