diff --git a/src/Core/Context/ICurrentContext.cs b/src/Core/Context/ICurrentContext.cs index d527cdd36382..3de09f944181 100644 --- a/src/Core/Context/ICurrentContext.cs +++ b/src/Core/Context/ICurrentContext.cs @@ -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); diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index 669e32bcbe8b..789dc5ba94fe 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -4,6 +4,7 @@ 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; @@ -51,7 +52,7 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac public string? Key { get; set; } /// /// The raw public key, without a signature from the user's signature key. - /// + /// public string? PublicKey { get; set; } /// /// User key wrapped private key. @@ -211,6 +212,42 @@ public int GetSecurityVersion() return SecurityVersion ?? 1; } + /// + /// Evaluates user state to determine if they are currently in a v2 encryption state. + /// + /// If the shape of their private key is v2 as well as has the proper security version then true, otherwise false + 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; + } + } + + /// + /// 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. + /// + private bool IsSecurityVersionTwo() + { + return SecurityVersion == 2; + } + /// /// Serializes the C# object to the User.TwoFactorProviders property in JSON format. /// diff --git a/src/Core/Enums/EncryptionType.cs b/src/Core/Enums/EncryptionType.cs index 52231e047cf7..d910e22e56c0 100644 --- a/src/Core/Enums/EncryptionType.cs +++ b/src/Core/Enums/EncryptionType.cs @@ -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 } diff --git a/src/Core/KeyManagement/Constants.cs b/src/Core/KeyManagement/Constants.cs new file mode 100644 index 000000000000..f1e3e1a2685c --- /dev/null +++ b/src/Core/KeyManagement/Constants.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.KeyManagement; + +public static class Constants +{ + public static readonly Version MinimumClientVersionForV2Encryption = new("2025.11.0"); +} 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..3e392e6c3702 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."); } @@ -259,24 +260,4 @@ private static void ValidateV2Encryption(RotateUserAccountKeysData model) throw new InvalidOperationException("No signed security state provider for V2 user"); } } - - /// - /// 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."); - } } diff --git a/src/Core/KeyManagement/Utilities/EncryptionParsing.cs b/src/Core/KeyManagement/Utilities/EncryptionParsing.cs new file mode 100644 index 000000000000..96a3117cf78f --- /dev/null +++ b/src/Core/KeyManagement/Utilities/EncryptionParsing.cs @@ -0,0 +1,28 @@ +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) + { + 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."); + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index e07446d49ffa..6ab52363234d 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -41,6 +41,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; } @@ -70,7 +71,8 @@ public BaseRequestValidator( IPolicyRequirementQuery policyRequirementQuery, IAuthRequestRepository authRequestRepository, IMailService mailService, - IUserAccountKeysQuery userAccountKeysQuery + IUserAccountKeysQuery userAccountKeysQuery, + IClientVersionValidator clientVersionValidator ) { _userManager = userManager; @@ -92,6 +94,7 @@ IUserAccountKeysQuery userAccountKeysQuery _authRequestRepository = authRequestRepository; _mailService = mailService; _accountKeysQuery = userAccountKeysQuery; + _clientVersionValidator = clientVersionValidator; } protected async Task ValidateAsync(T context, ValidatedTokenRequest request, @@ -136,7 +139,13 @@ 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), + // 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), @@ -149,7 +158,13 @@ private Func>[] DetermineValidationOrder(T context, ValidatedTokenReq // 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), @@ -202,12 +217,29 @@ 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. + /// + /// true if the scheme successfully passed validation, otherwise false. + private async Task 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; + } + + /// + /// 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..0936b4e8ff14 --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs @@ -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); +} + +/// +/// 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. +/// +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 + { + { "ErrorModel", new ErrorResponseModel(errorMessage) } + }; + } +} + + diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index 38a4813ecda2..fbf6f3796dcb 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, @@ -50,7 +47,8 @@ public CustomTokenRequestValidator( IPolicyRequirementQuery policyRequirementQuery, IAuthRequestRepository authRequestRepository, IMailService mailService, - IUserAccountKeysQuery userAccountKeysQuery) + IUserAccountKeysQuery userAccountKeysQuery, + IClientVersionValidator clientVersionValidator) : base( userManager, userService, @@ -70,7 +68,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 ea2c021f63a0..53d28c13cc44 100644 --- a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs @@ -44,7 +44,8 @@ public ResourceOwnerPasswordValidator( IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder, IPolicyRequirementQuery policyRequirementQuery, IMailService mailService, - IUserAccountKeysQuery userAccountKeysQuery) + IUserAccountKeysQuery userAccountKeysQuery, + IClientVersionValidator clientVersionValidator) : base( userManager, userService, @@ -64,7 +65,8 @@ public ResourceOwnerPasswordValidator( policyRequirementQuery, authRequestRepository, mailService, - userAccountKeysQuery) + userAccountKeysQuery, + clientVersionValidator) { _userManager = userManager; _currentContext = currentContext; @@ -74,7 +76,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 e4cd60827e40..9a23db1248a8 100644 --- a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs @@ -53,7 +53,8 @@ public WebAuthnGrantValidator( IPolicyRequirementQuery policyRequirementQuery, IAuthRequestRepository authRequestRepository, IMailService mailService, - IUserAccountKeysQuery userAccountKeysQuery) + IUserAccountKeysQuery userAccountKeysQuery, + IClientVersionValidator clientVersionValidator) : base( userManager, userService, @@ -73,7 +74,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 7e64975c9550..6b12e0ea4ed4 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(); diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 8f5dfdf3f4cb..06d4c5d16e32 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -6,7 +6,6 @@ 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; @@ -81,6 +80,7 @@ using Microsoft.OpenApi.Models; 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; diff --git a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs index 173580ad8c5e..7cc827bc6eea 100644 --- a/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs +++ b/test/Api.IntegrationTest/Factories/ApiApplicationFactory.cs @@ -3,6 +3,7 @@ using Bit.Core.Enums; using Bit.IntegrationTestCommon; using Bit.IntegrationTestCommon.Factories; +using Bit.Test.Common.Constants; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.TestHost; using Xunit; @@ -66,10 +67,10 @@ await _identityApplicationFactory.RegisterNewIdentityFactoryUserAsync( KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, UserAsymmetricKeys = new KeysRequestModel() { - PublicKey = "public_key", - EncryptedPrivateKey = "private_key" + PublicKey = TestEncryptionConstants.PublicKey, + EncryptedPrivateKey = TestEncryptionConstants.AES256_CBC_HMAC_Encstring }, - UserSymmetricKey = "sym_key", + UserSymmetricKey = TestEncryptionConstants.AES256_CBC_HMAC_Encstring, }); return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash); diff --git a/test/Common/Constants/TestEncryptionConstants.cs b/test/Common/Constants/TestEncryptionConstants.cs new file mode 100644 index 000000000000..08022fa83d5e --- /dev/null +++ b/test/Common/Constants/TestEncryptionConstants.cs @@ -0,0 +1,24 @@ +namespace Bit.Test.Common.Constants; + +public static class TestEncryptionConstants +{ + + // Simple stubs for different encrypted string versions + [Obsolete] + public const string AES256_CBC_B64_Encstring = "0.stub"; + public const string AES256_CBC_HMAC_EmptySuffix = "2."; + // Intended for use as a V1 encrypted string, accepted by validators + public const string AES256_CBC_HMAC_Encstring = "2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA=="; + public const string RSA2048_OAEPSHA1_B64_Encstring = "4.stub"; + public const string XCHACHA20POLY1305_B64_Encstring = "7.stub"; + + // Public key test placeholder + public const string PublicKey = "pk_test"; + + // V2-style values used across tests + // Private key indicating v2 (used in multiple tests to mark v2 state) + public const string V2PrivateKey = "7.cose"; + // Wrapped signing key and verifying key values from real tests + public const string V2WrappedSigningKey = "7.cose_signing"; + public const string V2VerifyingKey = "vk"; +} diff --git a/test/Core.Test/AutoFixture/GlobalSettingsFixtures.cs b/test/Core.Test/AutoFixture/GlobalSettingsFixtures.cs index 020b097077e2..e3d36fdc7152 100644 --- a/test/Core.Test/AutoFixture/GlobalSettingsFixtures.cs +++ b/test/Core.Test/AutoFixture/GlobalSettingsFixtures.cs @@ -3,7 +3,6 @@ using AutoFixture; using AutoFixture.Kernel; using AutoFixture.Xunit2; -using Bit.Core; using Bit.Core.Test.Helpers.Factories; using Microsoft.AspNetCore.DataProtection; using NSubstitute; @@ -36,11 +35,11 @@ public object Create(object request, ISpecimenContext context) var dataProtector = Substitute.For(); dataProtector.Unprotect(Arg.Any()) .Returns(data => - Encoding.UTF8.GetBytes(Constants.DatabaseFieldProtectedPrefix + + Encoding.UTF8.GetBytes(Core.Constants.DatabaseFieldProtectedPrefix + Encoding.UTF8.GetString((byte[])data[0]))); var dataProtectionProvider = Substitute.For(); - dataProtectionProvider.CreateProtector(Constants.DatabaseFieldProtectorPurpose) + dataProtectionProvider.CreateProtector(Core.Constants.DatabaseFieldProtectorPurpose) .Returns(dataProtector); return dataProtectionProvider; diff --git a/test/Core.Test/KeyManagement/Utilities/EncryptionParsingTests.cs b/test/Core.Test/KeyManagement/Utilities/EncryptionParsingTests.cs new file mode 100644 index 000000000000..a4837c91d012 --- /dev/null +++ b/test/Core.Test/KeyManagement/Utilities/EncryptionParsingTests.cs @@ -0,0 +1,40 @@ +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Utilities; +using Bit.Test.Common.Constants; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.Utilities; + +public class EncryptionParsingTests +{ + [Fact] + public void GetEncryptionType_WithNull_ThrowsArgumentNullException() + { + Assert.Throws(() => EncryptionParsing.GetEncryptionType(null)); + } + + [Theory] + [InlineData("2")] // missing '.' separator + [InlineData("abc.def")] // non-numeric prefix + [InlineData("8.any")] // undefined enum value + [InlineData("255.any")] // out of defined enum range + public void GetEncryptionType_WithInvalidString_ThrowsArgumentException(string input) + { + Assert.Throws(() => EncryptionParsing.GetEncryptionType(input)); + } + + [Theory] + [InlineData(TestEncryptionConstants.AES256_CBC_B64_Encstring, EncryptionType.AesCbc256_B64)] + [InlineData(TestEncryptionConstants.AES256_CBC_HMAC_Encstring, EncryptionType.AesCbc256_HmacSha256_B64)] + [InlineData(TestEncryptionConstants.RSA2048_OAEPSHA1_B64_Encstring, EncryptionType.Rsa2048_OaepSha1_B64)] + [InlineData(TestEncryptionConstants.V2PrivateKey, EncryptionType.XChaCha20Poly1305_B64)] + [InlineData(TestEncryptionConstants.V2WrappedSigningKey, EncryptionType.XChaCha20Poly1305_B64)] + [InlineData(TestEncryptionConstants.AES256_CBC_HMAC_EmptySuffix, EncryptionType.AesCbc256_HmacSha256_B64)] // empty suffix still valid + [InlineData(TestEncryptionConstants.XCHACHA20POLY1305_B64_Encstring, EncryptionType.XChaCha20Poly1305_B64)] + public void GetEncryptionType_WithValidString_ReturnsExpected(string input, EncryptionType expected) + { + var result = EncryptionParsing.GetEncryptionType(input); + Assert.Equal(expected, result); + } +} + diff --git a/test/Events.IntegrationTest/EventsApplicationFactory.cs b/test/Events.IntegrationTest/EventsApplicationFactory.cs index 7d692c442a6b..3530d0aa6cb3 100644 --- a/test/Events.IntegrationTest/EventsApplicationFactory.cs +++ b/test/Events.IntegrationTest/EventsApplicationFactory.cs @@ -3,6 +3,7 @@ using Bit.Core.Enums; using Bit.IntegrationTestCommon; using Bit.IntegrationTestCommon.Factories; +using Bit.Test.Common.Constants; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; @@ -58,10 +59,10 @@ await _identityApplicationFactory.RegisterNewIdentityFactoryUserAsync( KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, UserAsymmetricKeys = new KeysRequestModel() { - PublicKey = "public_key", - EncryptedPrivateKey = "private_key" + PublicKey = TestEncryptionConstants.PublicKey, + EncryptedPrivateKey = TestEncryptionConstants.AES256_CBC_HMAC_Encstring }, - UserSymmetricKey = "sym_key", + UserSymmetricKey = TestEncryptionConstants.AES256_CBC_HMAC_Encstring, }); return await _identityApplicationFactory.TokenFromPasswordAsync(email, masterPasswordHash); diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs index 1ed2cac17ac8..1c7b035874ad 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerSsoTests.cs @@ -15,7 +15,10 @@ using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Utilities; +using Bit.Identity.IdentityServer; +using Bit.Identity.IdentityServer.RequestValidators; using Bit.IntegrationTestCommon.Factories; +using Bit.Test.Common.Constants; using Bit.Test.Common.Helpers; using Duende.IdentityModel; using Duende.IdentityServer.Models; @@ -310,8 +313,8 @@ public async Task SsoLogin_TrustedDeviceEncryptionAndNoMasterPassword_DeviceAlre var user = await factory.Services.GetRequiredService().GetByEmailAsync(TestEmail); Assert.NotNull(user); - const string expectedPrivateKey = "2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA=="; - const string expectedUserKey = "2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA=="; + const string expectedPrivateKey = TestEncryptionConstants.AES256_CBC_HMAC_Encstring; + const string expectedUserKey = TestEncryptionConstants.AES256_CBC_HMAC_Encstring; var device = await deviceRepository.CreateAsync(new Device { @@ -320,7 +323,7 @@ public async Task SsoLogin_TrustedDeviceEncryptionAndNoMasterPassword_DeviceAlre Name = "Thing", UserId = user.Id, EncryptedPrivateKey = expectedPrivateKey, - EncryptedPublicKey = "2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==", + EncryptedPublicKey = TestEncryptionConstants.AES256_CBC_HMAC_Encstring, EncryptedUserKey = expectedUserKey, }); @@ -540,21 +543,70 @@ private static async Task RunSuccessTestAsync(Func + var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent( + new Dictionary + { + { "scope", "api offline_access" }, + { "client_id", "web" }, + { "deviceType", "10" }, + { "deviceIdentifier", "test_id" }, + { "deviceName", "firefox" }, + { "twoFactorToken", "TEST" }, + { "twoFactorProvider", "5" }, // RememberMe Provider + { "twoFactorRemember", "0" }, + { "grant_type", "authorization_code" }, + { "code", "test_code" }, + { "code_verifier", challenge }, + { "redirect_uri", "https://localhost:8080/sso-connector.html" } + })); + + // If this fails, surface detailed error information to aid debugging + if (context.Response.StatusCode != StatusCodes.Status200OK) { - { "scope", "api offline_access" }, - { "client_id", "web" }, - { "deviceType", "10" }, - { "deviceIdentifier", "test_id" }, - { "deviceName", "firefox" }, - { "twoFactorToken", "TEST"}, - { "twoFactorProvider", "5" }, // RememberMe Provider - { "twoFactorRemember", "0" }, - { "grant_type", "authorization_code" }, - { "code", "test_code" }, - { "code_verifier", challenge }, - { "redirect_uri", "https://localhost:8080/sso-connector.html" } - })); + string contentType = context.Response.ContentType ?? string.Empty; + string rawBody = ""; + try + { + if (context.Response.Body.CanSeek) + { + context.Response.Body.Position = 0; + } + using var reader = new StreamReader(context.Response.Body, leaveOpen: true); + rawBody = await reader.ReadToEndAsync(); + } + catch + { + // leave rawBody as unreadable + } + + string? error = null; + string? errorDesc = null; + string? errorModelMsg = null; + try + { + using var doc = JsonDocument.Parse(rawBody); + var root = doc.RootElement; + if (root.TryGetProperty("error", out var e)) error = e.GetString(); + if (root.TryGetProperty("error_description", out var ed)) errorDesc = ed.GetString(); + if (root.TryGetProperty("ErrorModel", out var em) && em.ValueKind == JsonValueKind.Object) + { + if (em.TryGetProperty("Message", out var msg) && msg.ValueKind == JsonValueKind.String) + { + errorModelMsg = msg.GetString(); + } + } + } + catch + { + // Not JSON, continue with raw body + } + + var message = + $"Unexpected status {context.Response.StatusCode}." + + $" error='{error}' error_description='{errorDesc}' ErrorModel.Message='{errorModelMsg}'" + + $" ContentType='{contentType}' RawBody='{rawBody}'"; + Assert.Fail(message); + } // Only calls that result in a 200 OK should call this helper Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); @@ -570,6 +622,13 @@ private static async Task CreateFactoryAsync( { var factory = new IdentityApplicationFactory(); + // Bypass client version gating to isolate SSO test behavior + factory.SubstituteService(svc => + { + svc.Validate(Arg.Any(), Arg.Any()) + .Returns(true); + }); + var authorizationCode = new AuthorizationCode { ClientId = "web", @@ -584,6 +643,7 @@ private static async Task CreateFactoryAsync( factory.SubstituteService(service => { + // Return our pre-built authorization code regardless of handle representation service.GetAuthorizationCodeAsync("test_code") .Returns(authorizationCode); }); @@ -597,10 +657,10 @@ private static async Task CreateFactoryAsync( KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, UserAsymmetricKeys = new KeysRequestModel() { - PublicKey = "public_key", - EncryptedPrivateKey = "private_key" + PublicKey = TestEncryptionConstants.PublicKey, + EncryptedPrivateKey = TestEncryptionConstants.AES256_CBC_HMAC_Encstring // v1-format so parsing succeeds and user is treated as v1 }, - UserSymmetricKey = "sym_key", + UserSymmetricKey = TestEncryptionConstants.AES256_CBC_HMAC_Encstring, }); var organizationRepository = factory.Services.GetRequiredService(); diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs index 9f5fc2aaeaf0..a4e6c6798e1f 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTests.cs @@ -9,11 +9,14 @@ using Bit.Core.Platform.Installations; using Bit.Core.Repositories; using Bit.Core.Test.Auth.AutoFixture; +using Bit.Identity.IdentityServer; +using Bit.Identity.IdentityServer.RequestValidators; using Bit.IntegrationTestCommon.Factories; using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Helpers; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; +using NSubstitute; using Xunit; namespace Bit.Identity.IntegrationTest.Endpoints; @@ -36,6 +39,14 @@ public class IdentityServerTests : IClassFixture public IdentityServerTests(IdentityApplicationFactory factory) { _factory = factory; + + // Bypass client version gating to isolate SSO test behavior + _factory.SubstituteService(svc => + { + svc.Validate(Arg.Any(), Arg.Any()) + .Returns(true); + }); + ReinitializeDbForTests(_factory); } diff --git a/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs b/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs index a04b8acf1908..d0d35f5d7bfe 100644 --- a/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs +++ b/test/Identity.IntegrationTest/Endpoints/IdentityServerTwoFactorTests.cs @@ -387,10 +387,10 @@ private async Task CreateUserAsync( KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, UserAsymmetricKeys = new KeysRequestModel() { - PublicKey = "public_key", - EncryptedPrivateKey = "private_key" + PublicKey = Bit.Test.Common.Constants.TestEncryptionConstants.PublicKey, + EncryptedPrivateKey = Bit.Test.Common.Constants.TestEncryptionConstants.AES256_CBC_HMAC_Encstring }, - UserSymmetricKey = "sym_key", + UserSymmetricKey = Bit.Test.Common.Constants.TestEncryptionConstants.AES256_CBC_HMAC_Encstring, }); Assert.NotNull(user); @@ -441,10 +441,10 @@ private async Task CreateSsoOrganizationAndUserAsync KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, UserAsymmetricKeys = new KeysRequestModel() { - PublicKey = "public_key", - EncryptedPrivateKey = "private_key" + PublicKey = Bit.Test.Common.Constants.TestEncryptionConstants.PublicKey, + EncryptedPrivateKey = Bit.Test.Common.Constants.TestEncryptionConstants.AES256_CBC_HMAC_Encstring }, - UserSymmetricKey = "sym_key", + UserSymmetricKey = Bit.Test.Common.Constants.TestEncryptionConstants.AES256_CBC_HMAC_Encstring, }); var userService = factory.GetService(); diff --git a/test/Identity.IntegrationTest/Login/ClientVersionGateTests.cs b/test/Identity.IntegrationTest/Login/ClientVersionGateTests.cs new file mode 100644 index 000000000000..a05e187c895a --- /dev/null +++ b/test/Identity.IntegrationTest/Login/ClientVersionGateTests.cs @@ -0,0 +1,149 @@ +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 Bit.Test.Common.Constants; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Bit.Identity.IntegrationTest.Login; + +public class ClientVersionGateTests : IClassFixture +{ + private readonly IdentityApplicationFactory _factory; + private const string DefaultEncryptedString = "2.3Uk+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98sp4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg="; + + public ClientVersionGateTests(IdentityApplicationFactory factory) + { + _factory = factory; + ReinitializeDbForTests(_factory); + } + + [Theory, BitAutoData, RegisterFinishRequestModelCustomize] + public async Task TokenEndpoint_GrantTypePassword_V2User_OnOldClientVersion_Blocked(RegisterFinishRequestModel requestModel) + { + NormalizeEncryptedStrings(requestModel); + var localFactory = new IdentityApplicationFactory + { + UseMockClientVersionValidator = false + }; + 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 = TestEncryptionConstants.V2PrivateKey; + efUser.SecurityVersion = 2; + db.UserSignatureKeyPairs.Add(new Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair + { + Id = Core.Utilities.CoreHelpers.GenerateComb(), + UserId = efUser.Id, + SignatureAlgorithm = SignatureAlgorithm.Ed25519, + SigningKey = TestEncryptionConstants.V2WrappedSigningKey, + VerifyingKey = TestEncryptionConstants.V2VerifyingKey, + }); + 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) + { + NormalizeEncryptedStrings(requestModel); + var localFactory = new IdentityApplicationFactory + { + UseMockClientVersionValidator = false + }; + 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 = TestEncryptionConstants.V2PrivateKey; + efUser.SecurityVersion = 2; + db.UserSignatureKeyPairs.Add(new Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair + { + Id = Core.Utilities.CoreHelpers.GenerateComb(), + UserId = efUser.Id, + SignatureAlgorithm = SignatureAlgorithm.Ed25519, + SigningKey = TestEncryptionConstants.V2WrappedSigningKey, + VerifyingKey = TestEncryptionConstants.V2VerifyingKey, + }); + 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(); + } + + private static void NormalizeEncryptedStrings(RegisterFinishRequestModel requestModel) + { + var accountKeys = requestModel.UserAsymmetricKeys.AccountKeys; + if (accountKeys == null) + { + return; + } + + accountKeys.UserKeyEncryptedAccountPrivateKey = DefaultEncryptedString; + if (accountKeys.PublicKeyEncryptionKeyPair != null) + { + accountKeys.PublicKeyEncryptionKeyPair.WrappedPrivateKey = DefaultEncryptedString; + } + if (accountKeys.SignatureKeyPair != null) + { + accountKeys.SignatureKeyPair.WrappedSigningKey = DefaultEncryptedString; + accountKeys.SignatureKeyPair.SignatureAlgorithm = "ed25519"; + } + } +} diff --git a/test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs b/test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs index 91123b3a60e9..54fb80eed04c 100644 --- a/test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs @@ -613,10 +613,10 @@ await factory.RegisterNewIdentityFactoryUserAsync( KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, UserAsymmetricKeys = new KeysRequestModel { - PublicKey = "public_key", - EncryptedPrivateKey = "private_key" + PublicKey = Bit.Test.Common.Constants.TestEncryptionConstants.PublicKey, + EncryptedPrivateKey = Bit.Test.Common.Constants.TestEncryptionConstants.AES256_CBC_HMAC_Encstring }, - UserSymmetricKey = "sym_key", + UserSymmetricKey = Bit.Test.Common.Constants.TestEncryptionConstants.AES256_CBC_HMAC_Encstring, }); } diff --git a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs index 677382b1384b..156e675a3371 100644 --- a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs @@ -57,6 +57,7 @@ public class BaseRequestValidatorTests private readonly IAuthRequestRepository _authRequestRepository; private readonly IMailService _mailService; private readonly IUserAccountKeysQuery _userAccountKeysQuery; + private readonly IClientVersionValidator _clientVersionValidator; private readonly BaseRequestValidatorTestWrapper _sut; @@ -81,6 +82,7 @@ public BaseRequestValidatorTests() _authRequestRepository = Substitute.For(); _mailService = Substitute.For(); _userAccountKeysQuery = Substitute.For(); + _clientVersionValidator = Substitute.For(); _sut = new BaseRequestValidatorTestWrapper( _userManager, @@ -101,7 +103,13 @@ public BaseRequestValidatorTests() _policyRequirementQuery, _authRequestRepository, _mailService, - _userAccountKeysQuery); + _userAccountKeysQuery, + _clientVersionValidator); + + // Default client version validator behavior: allow to pass unless a test overrides. + _clientVersionValidator + .Validate(Arg.Any(), Arg.Any()) + .Returns(true); } /* Logic path @@ -1160,6 +1168,41 @@ await _userRepository.Received(1).ReplaceAsync(Arg.Is(u => "TwoFactorRecoveryRequested flag should be set for audit/logging"); } + [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 + _featureService.IsEnabled(FeatureFlagKeys.RedirectOnSsoRequired).Returns(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 + .Validate(requestContext.User, requestContext) + .Returns(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 + _clientVersionValidator.Received(1) + .Validate(requestContext.User, requestContext); + } + /// /// Tests that when RedirectOnSsoRequired is DISABLED, the legacy SSO validation path is used. /// This validates the deprecated RequireSsoLoginAsync method is called and SSO requirement diff --git a/test/Identity.Test/IdentityServer/RequestValidators/ClientVersionValidatorTests.cs b/test/Identity.Test/IdentityServer/RequestValidators/ClientVersionValidatorTests.cs new file mode 100644 index 000000000000..51b1c73a73cc --- /dev/null +++ b/test/Identity.Test/IdentityServer/RequestValidators/ClientVersionValidatorTests.cs @@ -0,0 +1,127 @@ +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Identity.IdentityServer.RequestValidators; +using Bit.Test.Common.Constants; +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 User MakeValidV2User() + { + return new User + { + PrivateKey = TestEncryptionConstants.V2PrivateKey, + SecurityVersion = 2 + }; + } + + [Fact] + public void Allows_When_ClientMeetsMinimumVersion() + { + // Arrange + var sut = new ClientVersionValidator(MakeContext(new Version("2025.11.0"))); + var ctx = new Bit.Identity.IdentityServer.CustomValidatorRequestContext(); + var user = MakeValidV2User(); + + // Act + var ok = sut.Validate(user, ctx); + + // Assert + Assert.True(ok); + } + + [Fact] + public void Blocks_When_ClientTooOld() + { + // Arrange + var sut = new ClientVersionValidator(MakeContext(new Version("2025.10.0"))); + var ctx = new Bit.Identity.IdentityServer.CustomValidatorRequestContext(); + var user = MakeValidV2User(); + + // Act + var ok = sut.Validate(user, ctx); + + // Assert + Assert.False(ok); + Assert.NotNull(ctx.ValidationErrorResult); + Assert.True(ctx.ValidationErrorResult.IsError); + Assert.Equal("invalid_client_version", ctx.ValidationErrorResult.Error); + } + + [Fact] + public void Blocks_When_NullUser() + { + // Arrange + var sut = new ClientVersionValidator(MakeContext(new Version("2025.11.0"))); + var ctx = new Bit.Identity.IdentityServer.CustomValidatorRequestContext(); + User? user = null; + + // Act + var ok = sut.Validate(user, ctx); + + // Assert + Assert.False(ok); + Assert.NotNull(ctx.ValidationErrorResult); + Assert.True(ctx.ValidationErrorResult.IsError); + Assert.Equal("no_user", ctx.ValidationErrorResult.Error); + } + + [Fact] + public void Allows_When_NoPrivateKey() + { + // Arrange + var sut = new ClientVersionValidator(MakeContext(new Version("2025.11.0"))); + var ctx = new Bit.Identity.IdentityServer.CustomValidatorRequestContext(); + var user = MakeValidV2User(); + user.PrivateKey = null; + + // Act + var ok = sut.Validate(user, ctx); + + // Assert + Assert.True(ok); + } + + [Fact] + public void Allows_When_NoSecurityVersion() + { + // Arrange + var sut = new ClientVersionValidator(MakeContext(new Version("2025.11.0"))); + var ctx = new Bit.Identity.IdentityServer.CustomValidatorRequestContext(); + + var user = MakeValidV2User(); + user.SecurityVersion = null; + // Act + var ok = sut.Validate(user, ctx); + // Assert + Assert.True(ok); + } + + [Fact] + public void Blocks_When_ClientVersionHeaderMissing() + { + // Arrange + var sut = new ClientVersionValidator(MakeContext(null)); + var ctx = new Bit.Identity.IdentityServer.CustomValidatorRequestContext(); + var user = MakeValidV2User(); + + // Act + var ok = sut.Validate(user, ctx); + + // Assert + Assert.False(ok); + Assert.NotNull(ctx.ValidationErrorResult); + Assert.True(ctx.ValidationErrorResult.IsError); + Assert.Equal("version_header_missing", ctx.ValidationErrorResult.Error); + } +} diff --git a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs index b336e4c3c135..58a006e75646 100644 --- a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs +++ b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs @@ -67,7 +67,8 @@ public BaseRequestValidatorTestWrapper( IPolicyRequirementQuery policyRequirementQuery, IAuthRequestRepository authRequestRepository, IMailService mailService, - IUserAccountKeysQuery userAccountKeysQuery) : + IUserAccountKeysQuery userAccountKeysQuery, + IClientVersionValidator clientVersionValidator) : base( userManager, userService, @@ -87,7 +88,8 @@ public BaseRequestValidatorTestWrapper( policyRequirementQuery, authRequestRepository, mailService, - userAccountKeysQuery) + userAccountKeysQuery, + clientVersionValidator) { } diff --git a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs index 3c0b55190864..298fe7e4add6 100644 --- a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs +++ b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs @@ -3,12 +3,15 @@ using System.Collections.Concurrent; using System.Net.Http.Json; +using System.Text; using System.Text.Json; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Services; using Bit.Identity; +using Bit.Identity.IdentityServer; +using Bit.Identity.IdentityServer.RequestValidators; using Bit.Test.Common.Helpers; using LinqToDB; using Microsoft.AspNetCore.Hosting; @@ -23,6 +26,8 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase public const string DefaultDeviceIdentifier = "92b9d953-b9b6-4eaf-9d3e-11d57144dfeb"; public const string DefaultUserEmail = "DefaultEmail@bitwarden.com"; public const string DefaultUserPasswordHash = "default_password_hash"; + private const string DefaultEncryptedString = "2.3Uk+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98sp4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg="; + public bool UseMockClientVersionValidator { get; set; } = true; /// /// A dictionary to store registration tokens for email verification. We cannot substitute the IMailService more than once, so @@ -46,6 +51,16 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) }); }); + if (UseMockClientVersionValidator) + { + // Bypass client version gating to isolate tests from client version behavior + SubstituteService(svc => + { + svc.Validate(Arg.Any(), Arg.Any()) + .Returns(true); + }); + } + base.ConfigureWebHost(builder); } @@ -211,8 +226,11 @@ public async Task RegisterNewIdentityFactoryUserAsync( requestModel.EmailVerificationToken = RegistrationTokens[requestModel.Email]; var postRegisterFinishHttpContext = await PostRegisterFinishAsync(requestModel); - - Assert.Equal(StatusCodes.Status200OK, postRegisterFinishHttpContext.Response.StatusCode); + if (postRegisterFinishHttpContext.Response.StatusCode != StatusCodes.Status200OK) + { + var body = await ReadResponseBodyAsync(postRegisterFinishHttpContext); + Assert.Fail($"register/finish failed (status {postRegisterFinishHttpContext.Response.StatusCode}). Body: {body}"); + } var database = GetDatabaseContext(); var user = await database.Users @@ -222,4 +240,32 @@ public async Task RegisterNewIdentityFactoryUserAsync( return user; } + + private static async Task ReadResponseBodyAsync(HttpContext ctx) + { + try + { + if (ctx?.Response.Body == null) + { + return ""; + } + var stream = ctx.Response.Body; + if (stream.CanSeek) + { + stream.Seek(0, SeekOrigin.Begin); + } + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + var text = await reader.ReadToEndAsync(); + if (stream.CanSeek) + { + stream.Seek(0, SeekOrigin.Begin); + } + return string.IsNullOrWhiteSpace(text) ? "" : text; + } + catch (Exception ex) + { + return $""; + } + } + }