diff --git a/src/Core/KeyManagement/Constants.cs b/src/Core/KeyManagement/Constants.cs new file mode 100644 index 000000000000..2bc44134be6e --- /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 0e551c5d0e20..9b63dfdaf9a3 100644 --- a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs +++ b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs @@ -26,5 +26,7 @@ private static void AddKeyManagementCommands(this IServiceCollection services) 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 000000000000..fe6020ab5a91 --- /dev/null +++ b/src/Core/KeyManagement/Queries/GetMinimumClientVersionForUserQuery.cs @@ -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 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 000000000000..01deb460f198 --- /dev/null +++ b/src/Core/KeyManagement/Queries/Interfaces/IGetMinimumClientVersionForUserQuery.cs @@ -0,0 +1,8 @@ +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 000000000000..38c0e10b4404 --- /dev/null +++ b/src/Core/KeyManagement/Queries/Interfaces/IIsV2EncryptionUserQuery.cs @@ -0,0 +1,8 @@ +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 000000000000..ea64d5a20aa5 --- /dev/null +++ b/src/Core/KeyManagement/Queries/IsV2EncryptionUserQuery.cs @@ -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 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 similarity index 91% rename from src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs rename to src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountKeysCommand.cs index c1e7905d7857..6e5708f667d8 100644 --- a/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs +++ b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountKeysCommand.cs @@ -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; @@ -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"); } @@ -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; } @@ -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."); } @@ -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."); } @@ -260,23 +261,5 @@ private static void ValidateV2Encryption(RotateUserAccountKeysData model) } } - /// - /// 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 000000000000..ffe8cb3134fb --- /dev/null +++ b/src/Core/KeyManagement/Utilities/EncryptionParsing.cs @@ -0,0 +1,26 @@ +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. + /// + public 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."); + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index 224c7a1866ab..3a49c0fdb8c5 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 BaseRequestValidator( IPolicyRequirementQuery policyRequirementQuery, IAuthRequestRepository authRequestRepository, IMailService mailService, - IUserAccountKeysQuery userAccountKeysQuery + IUserAccountKeysQuery userAccountKeysQuery, + IClientVersionValidator clientVersionValidator ) { _userManager = userManager; @@ -89,6 +91,7 @@ IUserAccountKeysQuery userAccountKeysQuery _authRequestRepository = authRequestRepository; _mailService = mailService; _accountKeysQuery = userAccountKeysQuery; + _clientVersionValidator = clientVersionValidator; } protected async Task ValidateAsync(T context, ValidatedTokenRequest request, @@ -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) @@ -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. + 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) @@ -258,7 +273,8 @@ private Func>[] 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), @@ -271,7 +287,8 @@ private Func>[] 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), @@ -324,12 +341,30 @@ private static async Task ProcessValidatorsAsync(params Func>[] } /// - /// 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. + /// + /// 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, webauthen, or custom token request via the appropriate context validator. /// /// The current request context. /// /// true if the scheme successfully passed validation, otherwise false. - private async Task ValidateMasterPasswordAsync(T context, CustomValidatorRequestContext validatorContext) + private async Task ValidateGrantSpecificContext(T context, CustomValidatorRequestContext validatorContext) { var valid = await ValidateContextAsync(context, validatorContext); var user = validatorContext.User; diff --git a/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs b/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs new file mode 100644 index 000000000000..5896061e1312 --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs @@ -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 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_client_version", + 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 64156ea5f3a0..4a5befc42bdf 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -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, @@ -49,7 +46,8 @@ public CustomTokenRequestValidator( IPolicyRequirementQuery policyRequirementQuery, IAuthRequestRepository authRequestRepository, IMailService mailService, - IUserAccountKeysQuery userAccountKeysQuery) + IUserAccountKeysQuery userAccountKeysQuery, + IClientVersionValidator clientVersionValidator) : base( userManager, userService, @@ -68,7 +66,8 @@ public CustomTokenRequestValidator( policyRequirementQuery, authRequestRepository, mailService, - userAccountKeysQuery) + userAccountKeysQuery, + clientVersionValidator) { _userManager = userManager; _updateInstallationCommand = updateInstallationCommand; diff --git a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs index d69d521ef7a3..c8e9b13c8ce4 100644 --- a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs @@ -43,7 +43,8 @@ public ResourceOwnerPasswordValidator( IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder, IPolicyRequirementQuery policyRequirementQuery, IMailService mailService, - IUserAccountKeysQuery userAccountKeysQuery) + IUserAccountKeysQuery userAccountKeysQuery, + IClientVersionValidator clientVersionValidator) : base( userManager, userService, @@ -62,7 +63,8 @@ public ResourceOwnerPasswordValidator( policyRequirementQuery, authRequestRepository, mailService, - userAccountKeysQuery) + userAccountKeysQuery, + clientVersionValidator) { _userManager = userManager; _currentContext = currentContext; @@ -72,7 +74,6 @@ public ResourceOwnerPasswordValidator( 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); diff --git a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs index 294df1c18d37..d0290106388e 100644 --- a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs @@ -52,7 +52,8 @@ public WebAuthnGrantValidator( IPolicyRequirementQuery policyRequirementQuery, IAuthRequestRepository authRequestRepository, IMailService mailService, - IUserAccountKeysQuery userAccountKeysQuery) + IUserAccountKeysQuery userAccountKeysQuery, + IClientVersionValidator clientVersionValidator) : base( userManager, userService, @@ -71,7 +72,8 @@ public WebAuthnGrantValidator( policyRequirementQuery, authRequestRepository, mailService, - userAccountKeysQuery) + userAccountKeysQuery, + clientVersionValidator) { _assertionOptionsDataProtector = assertionOptionsDataProtector; _assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand; diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs index e9056d030e70..9a5188db25dd 100644 --- a/src/Identity/Utilities/ServiceCollectionExtensions.cs +++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs @@ -25,6 +25,7 @@ public static IIdentityServerBuilder AddCustomIdentityServerServices(this IServi services.AddTransient(); 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 9caa37b9979e..25d76c161eb6 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -7,7 +7,6 @@ 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; @@ -86,6 +85,7 @@ 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/Api.IntegrationTest/Factories/ApiApplicationFactory.cs b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs index 173580ad8c5e..08bd60253700 100644 --- a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs +++ b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs @@ -66,10 +66,10 @@ await _identityApplicationFactory.RegisterNewIdentityFactoryUserAsync( KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, UserAsymmetricKeys = new KeysRequestModel() { - PublicKey = "public_key", - EncryptedPrivateKey = "private_key" + PublicKey = "pk_test", + EncryptedPrivateKey = "2.iv|ct|mac" // v1-format so parsing succeeds and user is treated as v1 }, - UserSymmetricKey = "sym_key", + UserSymmetricKey = "2.iv|ct|mac", }); return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash); diff --git a/test/Core.Test/KeyManagement/Queries/GetMinimumClientVersionForUserQueryTests.cs b/test/Core.Test/KeyManagement/Queries/GetMinimumClientVersionForUserQueryTests.cs new file mode 100644 index 000000000000..1a04e60ca536 --- /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 000000000000..a3e91bb6efec --- /dev/null +++ b/test/Core.Test/KeyManagement/Queries/IsV2EncryptionUserQueryTests.cs @@ -0,0 +1,63 @@ +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 000000000000..642148b685d1 --- /dev/null +++ b/test/Identity.IntegrationTest/Login/ClientVersionGateTests.cs @@ -0,0 +1,117 @@ +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/BaseRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs index e78c7d161c09..6ef2df23013e 100644 --- a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs @@ -55,6 +55,7 @@ public class BaseRequestValidatorTests private readonly IAuthRequestRepository _authRequestRepository; private readonly IMailService _mailService; private readonly IUserAccountKeysQuery _userAccountKeysQuery; + private readonly IClientVersionValidator _clientVersionValidator; private readonly BaseRequestValidatorTestWrapper _sut; @@ -78,6 +79,7 @@ public BaseRequestValidatorTests() _authRequestRepository = Substitute.For(); _mailService = Substitute.For(); _userAccountKeysQuery = Substitute.For(); + _clientVersionValidator = Substitute.For(); _sut = new BaseRequestValidatorTestWrapper( _userManager, @@ -97,7 +99,13 @@ public BaseRequestValidatorTests() _policyRequirementQuery, _authRequestRepository, _mailService, - _userAccountKeysQuery); + _userAccountKeysQuery, + _clientVersionValidator); + + // Default client version validator behavior: allow to pass unless a test overrides. + _clientVersionValidator + .ValidateAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(true)); } private void SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(bool recoveryCodeSupportEnabled) @@ -1243,6 +1251,41 @@ await _userRepository.Received(1).ReplaceAsync(Arg.Is(u => } } + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task ValidateAsync_ClientVersionValidator_IsInvoked_ForFeatureFlagStates( + bool featureFlagValue, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) + { + // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); + var context = CreateContext(tokenRequest, requestContext, grantResult); + _sut.isValid = true; // ensure initial context validation passes + + // Force a grant type that will evaluate SSO after client version validation + context.ValidatedTokenRequest.GrantType = "password"; + + // Make client version validation succeed but ensure it's invoked + _clientVersionValidator + .ValidateAsync(requestContext.User, requestContext) + .Returns(Task.FromResult(true)); + + // Ensure SSO requirement triggers an early stop after version validation to avoid success path setup + _policyService.AnyPoliciesApplicableToUserAsync( + Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) + .Returns(Task.FromResult(true)); + + // Act + await _sut.ValidateAsync(context); + + // Assert + await _clientVersionValidator.Received(1) + .ValidateAsync(requestContext.User, requestContext); + } + private BaseRequestValidationContextFake CreateContext( ValidatedTokenRequest tokenRequest, CustomValidatorRequestContext requestContext, diff --git a/test/Identity.Test/IdentityServer/RequestValidators/ClientVersionValidatorTests.cs b/test/Identity.Test/IdentityServer/RequestValidators/ClientVersionValidatorTests.cs new file mode 100644 index 000000000000..65a99042464d --- /dev/null +++ b/test/Identity.Test/IdentityServer/RequestValidators/ClientVersionValidatorTests.cs @@ -0,0 +1,53 @@ +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_client_version", 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); + } +} diff --git a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs index ec3e791d5b41..bd8fcd4bda1e 100644 --- a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs +++ b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs @@ -66,7 +66,8 @@ public BaseRequestValidatorTestWrapper( IPolicyRequirementQuery policyRequirementQuery, IAuthRequestRepository authRequestRepository, IMailService mailService, - IUserAccountKeysQuery userAccountKeysQuery) : + IUserAccountKeysQuery userAccountKeysQuery, + IClientVersionValidator clientVersionValidator) : base( userManager, userService, @@ -85,7 +86,8 @@ public BaseRequestValidatorTestWrapper( policyRequirementQuery, authRequestRepository, mailService, - userAccountKeysQuery) + userAccountKeysQuery, + clientVersionValidator) { }