mirror of
https://github.com/bitwarden/server
synced 2026-02-24 00:23:05 +00:00
feat(auth-validator): [Auth/PM-22975] Client Version Validator (#6588)
* feat(auth-validator): [PM-22975] Client Version Validator - Implementation. * test(auth-validator): [PM-22975] Client Version Validator - Added tests.
This commit is contained in:
committed by
GitHub
parent
b5554c6030
commit
3dbd17f61d
@@ -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);
|
||||
|
||||
@@ -4,6 +4,7 @@ using Bit.Core.Auth.Enums;
|
||||
using Bit.Core.Auth.Models;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.KeyManagement.Utilities;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
@@ -216,6 +217,42 @@ public class User : ITableObject<Guid>, IStorableSubscriber, IRevisable, ITwoFac
|
||||
return SecurityVersion ?? 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates user state to determine if they are currently in a v2 encryption state.
|
||||
/// </summary>
|
||||
/// <returns>If the shape of their private key is v2 as well as has the proper security version then true, otherwise false</returns>
|
||||
public bool HasV2Encryption()
|
||||
{
|
||||
return HasV2KeyShape() && IsSecurityVersionTwo();
|
||||
}
|
||||
|
||||
private bool HasV2KeyShape()
|
||||
{
|
||||
if (string.IsNullOrEmpty(PrivateKey))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return EncryptionParsing.GetEncryptionType(PrivateKey) == EncryptionType.XChaCha20Poly1305_B64;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Invalid encryption string format - treat as not v2
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This technically is correct but all versions after 1 are considered v2 encryption. Leaving for now with
|
||||
/// KM's blessing that when a new version comes along they will handle migration.
|
||||
/// </summary>
|
||||
private bool IsSecurityVersionTwo()
|
||||
{
|
||||
return SecurityVersion == 2;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the C# object to the User.TwoFactorProviders property in JSON format.
|
||||
/// </summary>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
6
src/Core/KeyManagement/Constants.cs
Normal file
6
src/Core/KeyManagement/Constants.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Bit.Core.KeyManagement;
|
||||
|
||||
public static class Constants
|
||||
{
|
||||
public static readonly Version MinimumClientVersionForV2Encryption = new("2025.11.0");
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.KeyManagement.Repositories;
|
||||
using Bit.Core.KeyManagement.Utilities;
|
||||
using Bit.Core.Platform.Push;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
@@ -159,7 +160,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
||||
}
|
||||
else
|
||||
{
|
||||
if (GetEncryptionType(model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey) != EncryptionType.AesCbc256_HmacSha256_B64)
|
||||
if (EncryptionParsing.GetEncryptionType(model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey) != EncryptionType.AesCbc256_HmacSha256_B64)
|
||||
{
|
||||
throw new InvalidOperationException("The provided account private key was not wrapped with AES-256-CBC-HMAC");
|
||||
}
|
||||
@@ -231,7 +232,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
||||
{
|
||||
// Returns whether the user is a V2 user based on the private key's encryption type.
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
var isPrivateKeyEncryptionV2 = GetEncryptionType(user.PrivateKey) == EncryptionType.XChaCha20Poly1305_B64;
|
||||
var isPrivateKeyEncryptionV2 = EncryptionParsing.GetEncryptionType(user.PrivateKey) == EncryptionType.XChaCha20Poly1305_B64;
|
||||
return isPrivateKeyEncryptionV2;
|
||||
}
|
||||
|
||||
@@ -259,7 +260,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
||||
{
|
||||
throw new InvalidOperationException("Signature key pair data is required for V2 encryption.");
|
||||
}
|
||||
if (GetEncryptionType(model.AccountKeys.SignatureKeyPairData.WrappedSigningKey) != EncryptionType.XChaCha20Poly1305_B64)
|
||||
if (EncryptionParsing.GetEncryptionType(model.AccountKeys.SignatureKeyPairData.WrappedSigningKey) != EncryptionType.XChaCha20Poly1305_B64)
|
||||
{
|
||||
throw new InvalidOperationException("The provided signing key data is not wrapped with XChaCha20-Poly1305.");
|
||||
}
|
||||
@@ -268,7 +269,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
||||
throw new InvalidOperationException("The provided signature key pair data does not contain a valid verifying key.");
|
||||
}
|
||||
|
||||
if (GetEncryptionType(model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey) != EncryptionType.XChaCha20Poly1305_B64)
|
||||
if (EncryptionParsing.GetEncryptionType(model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey) != EncryptionType.XChaCha20Poly1305_B64)
|
||||
{
|
||||
throw new InvalidOperationException("The provided private key encryption key is not wrapped with XChaCha20-Poly1305.");
|
||||
}
|
||||
@@ -281,24 +282,4 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
||||
throw new InvalidOperationException("No signed security state provider for V2 user");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to convert an encryption type string to an enum value.
|
||||
/// </summary>
|
||||
private static EncryptionType GetEncryptionType(string encString)
|
||||
{
|
||||
var parts = encString.Split('.');
|
||||
if (parts.Length == 1)
|
||||
{
|
||||
throw new ArgumentException("Invalid encryption type string.");
|
||||
}
|
||||
if (byte.TryParse(parts[0], out var encryptionTypeNumber))
|
||||
{
|
||||
if (Enum.IsDefined(typeof(EncryptionType), encryptionTypeNumber))
|
||||
{
|
||||
return (EncryptionType)encryptionTypeNumber;
|
||||
}
|
||||
}
|
||||
throw new ArgumentException("Invalid encryption type string.");
|
||||
}
|
||||
}
|
||||
28
src/Core/KeyManagement/Utilities/EncryptionParsing.cs
Normal file
28
src/Core/KeyManagement/Utilities/EncryptionParsing.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Bit.Core.Enums;
|
||||
|
||||
namespace Bit.Core.KeyManagement.Utilities;
|
||||
|
||||
public static class EncryptionParsing
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper method to convert an encryption type string to an enum value.
|
||||
/// </summary>
|
||||
public static EncryptionType GetEncryptionType(string? encString)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(encString);
|
||||
|
||||
var parts = encString.Split('.');
|
||||
if (parts.Length == 1)
|
||||
{
|
||||
throw new ArgumentException("Invalid encryption type string.");
|
||||
}
|
||||
if (byte.TryParse(parts[0], out var encryptionTypeNumber))
|
||||
{
|
||||
if (Enum.IsDefined(typeof(EncryptionType), encryptionTypeNumber))
|
||||
{
|
||||
return (EncryptionType)encryptionTypeNumber;
|
||||
}
|
||||
}
|
||||
throw new ArgumentException("Invalid encryption type string.");
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IAuthRequestRepository _authRequestRepository;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly IClientVersionValidator _clientVersionValidator;
|
||||
|
||||
protected ICurrentContext CurrentContext { get; }
|
||||
protected IPolicyService PolicyService { get; }
|
||||
@@ -69,7 +70,8 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IAuthRequestRepository authRequestRepository,
|
||||
IMailService mailService,
|
||||
IUserAccountKeysQuery userAccountKeysQuery
|
||||
IUserAccountKeysQuery userAccountKeysQuery,
|
||||
IClientVersionValidator clientVersionValidator
|
||||
)
|
||||
{
|
||||
_userManager = userManager;
|
||||
@@ -91,6 +93,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
_authRequestRepository = authRequestRepository;
|
||||
_mailService = mailService;
|
||||
_accountKeysQuery = userAccountKeysQuery;
|
||||
_clientVersionValidator = clientVersionValidator;
|
||||
}
|
||||
|
||||
protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
|
||||
@@ -135,7 +138,13 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
// validation to perform the recovery as part of scheme validation based on the request.
|
||||
return
|
||||
[
|
||||
() => ValidateMasterPasswordAsync(context, validatorContext),
|
||||
() => ValidateGrantSpecificContext(context, validatorContext),
|
||||
// Now check the version number of the client. Do this after ValidateContextAsync so that
|
||||
// we prevent account enumeration. If we were to do this before ValidateContextAsync, then attackers
|
||||
// could use a known invalid client version and make a request for a user (before we know if they have
|
||||
// demonstrated ownership of the account via correct credentials) and identify if they exist by getting
|
||||
// an error response back from the validator saying the user is not compatible with the client.
|
||||
() => ValidateClientVersionAsync(context, validatorContext),
|
||||
() => ValidateTwoFactorAsync(context, request, validatorContext),
|
||||
() => ValidateSsoAsync(context, request, validatorContext),
|
||||
() => ValidateNewDeviceAsync(context, request, validatorContext),
|
||||
@@ -148,7 +157,13 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
// The typical validation scenario.
|
||||
return
|
||||
[
|
||||
() => ValidateMasterPasswordAsync(context, validatorContext),
|
||||
() => ValidateGrantSpecificContext(context, validatorContext),
|
||||
// Now check the version number of the client. Do this after ValidateContextAsync so that
|
||||
// we prevent account enumeration. If we were to do this before ValidateContextAsync, then attackers
|
||||
// could use a known invalid client version and make a request for a user (before we know if they have
|
||||
// demonstrated ownership of the account via correct credentials) and identify if they exist by getting
|
||||
// an error response back from the validator saying the user is not compatible with the client.
|
||||
() => ValidateClientVersionAsync(context, validatorContext),
|
||||
() => ValidateSsoAsync(context, request, validatorContext),
|
||||
() => ValidateTwoFactorAsync(context, request, validatorContext),
|
||||
() => ValidateNewDeviceAsync(context, request, validatorContext),
|
||||
@@ -201,12 +216,29 @@ public abstract class BaseRequestValidator<T> where T : class
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the user's Master Password hash.
|
||||
/// Validates whether the client version is compatible for the user attempting to authenticate.
|
||||
/// </summary>
|
||||
/// <returns>true if the scheme successfully passed validation, otherwise false.</returns>
|
||||
private async Task<bool> ValidateClientVersionAsync(T context, CustomValidatorRequestContext validatorContext)
|
||||
{
|
||||
var ok = _clientVersionValidator.Validate(validatorContext.User, validatorContext);
|
||||
if (ok)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
SetValidationErrorResult(context, validatorContext);
|
||||
await LogFailedLoginEvent(validatorContext.User, EventType.User_FailedLogIn);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the user's master password, webauthen, or custom token request via the appropriate context validator.
|
||||
/// </summary>
|
||||
/// <param name="context">The current request context.</param>
|
||||
/// <param name="validatorContext"><see cref="Bit.Identity.IdentityServer.CustomValidatorRequestContext" /></param>
|
||||
/// <returns>true if the scheme successfully passed validation, otherwise false.</returns>
|
||||
private async Task<bool> ValidateMasterPasswordAsync(T context, CustomValidatorRequestContext validatorContext)
|
||||
private async Task<bool> ValidateGrantSpecificContext(T context, CustomValidatorRequestContext validatorContext)
|
||||
{
|
||||
var valid = await ValidateContextAsync(context, validatorContext);
|
||||
var user = validatorContext.User;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.KeyManagement;
|
||||
using Bit.Core.Models.Api;
|
||||
using Duende.IdentityServer.Validation;
|
||||
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators;
|
||||
|
||||
public interface IClientVersionValidator
|
||||
{
|
||||
bool Validate(User user, CustomValidatorRequestContext requestContext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This validator will use the Client Version on a request, which currently maps
|
||||
/// to the "Bitwarden-Client-Version" header, to determine if a user meets minimum
|
||||
/// required client version for issuing tokens on an old client. This is done to
|
||||
/// incentivize users to get on an updated client when their password encryption
|
||||
/// method has already been updated.
|
||||
///
|
||||
/// If the header is omitted, then the validator returns that this request is invalid.
|
||||
/// </summary>
|
||||
public class ClientVersionValidator(
|
||||
ICurrentContext currentContext)
|
||||
: IClientVersionValidator
|
||||
{
|
||||
private const string _upgradeMessage = "Please update your app to continue using Bitwarden";
|
||||
private const string _noUserMessage = "No user found while trying to validate client version";
|
||||
private const string _versionHeaderMissing = "No client version header found, required to prevent encryption errors. Please confirm your client is supplying the header: \"Bitwarden-Client-Version\"";
|
||||
|
||||
public bool Validate(User? user, CustomValidatorRequestContext requestContext)
|
||||
{
|
||||
// Do this nullish check because the base request validator currently is not
|
||||
// strict null checking. Once that gets fixed then we can see about making
|
||||
// the user not nullish checked. If they are null then the validator should fail.
|
||||
if (user == null)
|
||||
{
|
||||
FillRequestContextWithErrorData(requestContext, "no_user", _noUserMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
Version? clientVersion = currentContext.ClientVersion;
|
||||
|
||||
// Deny access if the client version headers are missing.
|
||||
// We want to establish a strict contract with clients that if they omit this header,
|
||||
// then the server cannot guarantee that a client won't do harm to a user's data
|
||||
// with stale encryption architecture.
|
||||
if (clientVersion == null)
|
||||
{
|
||||
FillRequestContextWithErrorData(requestContext, "version_header_missing", _versionHeaderMissing);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Determine the minimum version client that a user needs. If no V2 encryption detected then
|
||||
// no validation needs to occur, which is why min version number can be null.
|
||||
Version? minVersion = user.HasV2Encryption() ? Constants.MinimumClientVersionForV2Encryption : null;
|
||||
|
||||
// If min version is null then we know that the user had an encryption
|
||||
// configuration that doesn't require a minimum version. Allowing through.
|
||||
if (minVersion == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (clientVersion < minVersion)
|
||||
{
|
||||
FillRequestContextWithErrorData(requestContext, "invalid_client_version", _upgradeMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void FillRequestContextWithErrorData(
|
||||
CustomValidatorRequestContext requestContext,
|
||||
string errorId,
|
||||
string errorMessage)
|
||||
{
|
||||
requestContext.ValidationErrorResult = new ValidationResult
|
||||
{
|
||||
Error = errorId,
|
||||
ErrorDescription = errorMessage,
|
||||
IsError = true
|
||||
};
|
||||
requestContext.CustomResponse = new Dictionary<string, object>
|
||||
{
|
||||
{ "ErrorModel", new ErrorResponseModel(errorMessage) }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,8 @@ using Bit.Core.Settings;
|
||||
using Duende.IdentityModel;
|
||||
using Duende.IdentityServer.Extensions;
|
||||
using Duende.IdentityServer.Validation;
|
||||
using HandlebarsDotNet;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Identity.IdentityServer.RequestValidators;
|
||||
|
||||
public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenRequestValidationContext>,
|
||||
@@ -50,7 +47,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IAuthRequestRepository authRequestRepository,
|
||||
IMailService mailService,
|
||||
IUserAccountKeysQuery userAccountKeysQuery)
|
||||
IUserAccountKeysQuery userAccountKeysQuery,
|
||||
IClientVersionValidator clientVersionValidator)
|
||||
: base(
|
||||
userManager,
|
||||
userService,
|
||||
@@ -70,7 +68,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
||||
policyRequirementQuery,
|
||||
authRequestRepository,
|
||||
mailService,
|
||||
userAccountKeysQuery)
|
||||
userAccountKeysQuery,
|
||||
clientVersionValidator)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_updateInstallationCommand = updateInstallationCommand;
|
||||
|
||||
@@ -44,7 +44,8 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
|
||||
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IMailService mailService,
|
||||
IUserAccountKeysQuery userAccountKeysQuery)
|
||||
IUserAccountKeysQuery userAccountKeysQuery,
|
||||
IClientVersionValidator clientVersionValidator)
|
||||
: base(
|
||||
userManager,
|
||||
userService,
|
||||
@@ -64,7 +65,8 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
|
||||
policyRequirementQuery,
|
||||
authRequestRepository,
|
||||
mailService,
|
||||
userAccountKeysQuery)
|
||||
userAccountKeysQuery,
|
||||
clientVersionValidator)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_currentContext = currentContext;
|
||||
@@ -74,7 +76,6 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
|
||||
|
||||
public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
|
||||
{
|
||||
|
||||
var user = await _userManager.FindByEmailAsync(context.UserName.ToLowerInvariant());
|
||||
// We want to keep this device around incase the device is new for the user
|
||||
var requestDevice = DeviceValidator.GetDeviceFromRequest(context.Request);
|
||||
|
||||
@@ -53,7 +53,8 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IAuthRequestRepository authRequestRepository,
|
||||
IMailService mailService,
|
||||
IUserAccountKeysQuery userAccountKeysQuery)
|
||||
IUserAccountKeysQuery userAccountKeysQuery,
|
||||
IClientVersionValidator clientVersionValidator)
|
||||
: base(
|
||||
userManager,
|
||||
userService,
|
||||
@@ -73,7 +74,8 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
|
||||
policyRequirementQuery,
|
||||
authRequestRepository,
|
||||
mailService,
|
||||
userAccountKeysQuery)
|
||||
userAccountKeysQuery,
|
||||
clientVersionValidator)
|
||||
{
|
||||
_assertionOptionsDataProtector = assertionOptionsDataProtector;
|
||||
_assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand;
|
||||
|
||||
@@ -25,6 +25,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddTransient<IAuthorizationCodeStore, AuthorizationCodeStore>();
|
||||
services.AddTransient<IUserDecryptionOptionsBuilder, UserDecryptionOptionsBuilder>();
|
||||
services.AddTransient<IDeviceValidator, DeviceValidator>();
|
||||
services.AddTransient<IClientVersionValidator, ClientVersionValidator>();
|
||||
services.AddTransient<ITwoFactorAuthenticationValidator, TwoFactorAuthenticationValidator>();
|
||||
services.AddTransient<ISsoRequestValidator, SsoRequestValidator>();
|
||||
services.AddTransient<ILoginApprovingClientTypes, LoginApprovingClientTypes>();
|
||||
|
||||
@@ -6,7 +6,6 @@ using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using AspNetCoreRateLimit;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.AbilitiesCache;
|
||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
@@ -83,6 +82,7 @@ using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi;
|
||||
using StackExchange.Redis;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using Constants = Bit.Core.Constants;
|
||||
using NoopRepos = Bit.Core.Repositories.Noop;
|
||||
using Role = Bit.Core.Entities.Role;
|
||||
using TableStorageRepos = Bit.Core.Repositories.TableStorage;
|
||||
|
||||
@@ -3,6 +3,7 @@ using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
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 @@ public class ApiApplicationFactory : WebApplicationFactoryBase<Startup>
|
||||
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);
|
||||
|
||||
24
test/Common/Constants/TestEncryptionConstants.cs
Normal file
24
test/Common/Constants/TestEncryptionConstants.cs
Normal file
@@ -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";
|
||||
}
|
||||
@@ -3,7 +3,6 @@ using System.Text;
|
||||
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 class GlobalSettingsBuilder : ISpecimenBuilder
|
||||
var dataProtector = Substitute.For<IDataProtector>();
|
||||
dataProtector.Unprotect(Arg.Any<byte[]>())
|
||||
.Returns(data =>
|
||||
Encoding.UTF8.GetBytes(Constants.DatabaseFieldProtectedPrefix +
|
||||
Encoding.UTF8.GetBytes(Core.Constants.DatabaseFieldProtectedPrefix +
|
||||
Encoding.UTF8.GetString((byte[])data[0])));
|
||||
|
||||
var dataProtectionProvider = Substitute.For<IDataProtectionProvider>();
|
||||
dataProtectionProvider.CreateProtector(Constants.DatabaseFieldProtectorPurpose)
|
||||
dataProtectionProvider.CreateProtector(Core.Constants.DatabaseFieldProtectorPurpose)
|
||||
.Returns(dataProtector);
|
||||
|
||||
return dataProtectionProvider;
|
||||
|
||||
@@ -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<ArgumentNullException>(() => 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<ArgumentException>(() => 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||
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 @@ public class EventsApplicationFactory : WebApplicationFactoryBase<Startup>
|
||||
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);
|
||||
|
||||
@@ -15,7 +15,10 @@ using Bit.Core.Enums;
|
||||
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 class IdentityServerSsoTests
|
||||
var user = await factory.Services.GetRequiredService<IUserRepository>().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 class IdentityServerSsoTests
|
||||
Name = "Thing",
|
||||
UserId = user.Id,
|
||||
EncryptedPrivateKey = expectedPrivateKey,
|
||||
EncryptedPublicKey = "2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==",
|
||||
EncryptedPublicKey = TestEncryptionConstants.AES256_CBC_HMAC_Encstring,
|
||||
EncryptedUserKey = expectedUserKey,
|
||||
});
|
||||
|
||||
@@ -540,21 +543,70 @@ public class IdentityServerSsoTests
|
||||
}, challenge, trustedDeviceEnabled);
|
||||
|
||||
await configureFactory(factory);
|
||||
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "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 = "<unreadable>";
|
||||
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 @@ public class IdentityServerSsoTests
|
||||
{
|
||||
var factory = new IdentityApplicationFactory();
|
||||
|
||||
// Bypass client version gating to isolate SSO test behavior
|
||||
factory.SubstituteService<IClientVersionValidator>(svc =>
|
||||
{
|
||||
svc.Validate(Arg.Any<User>(), Arg.Any<CustomValidatorRequestContext>())
|
||||
.Returns(true);
|
||||
});
|
||||
|
||||
var authorizationCode = new AuthorizationCode
|
||||
{
|
||||
ClientId = "web",
|
||||
@@ -584,6 +643,7 @@ public class IdentityServerSsoTests
|
||||
|
||||
factory.SubstituteService<IAuthorizationCodeStore>(service =>
|
||||
{
|
||||
// Return our pre-built authorization code regardless of handle representation
|
||||
service.GetAuthorizationCodeAsync("test_code")
|
||||
.Returns(authorizationCode);
|
||||
});
|
||||
@@ -597,10 +657,10 @@ public class IdentityServerSsoTests
|
||||
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<IOrganizationRepository>();
|
||||
|
||||
@@ -9,11 +9,14 @@ using Bit.Core.Enums;
|
||||
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<IdentityApplicationFactory>
|
||||
public IdentityServerTests(IdentityApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
|
||||
// Bypass client version gating to isolate SSO test behavior
|
||||
_factory.SubstituteService<IClientVersionValidator>(svc =>
|
||||
{
|
||||
svc.Validate(Arg.Any<User>(), Arg.Any<CustomValidatorRequestContext>())
|
||||
.Returns(true);
|
||||
});
|
||||
|
||||
ReinitializeDbForTests(_factory);
|
||||
}
|
||||
|
||||
|
||||
@@ -387,10 +387,10 @@ public class IdentityServerTwoFactorTests : IClassFixture<IdentityApplicationFac
|
||||
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 @@ public class IdentityServerTwoFactorTests : IClassFixture<IdentityApplicationFac
|
||||
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<IUserService>();
|
||||
|
||||
149
test/Identity.IntegrationTest/Login/ClientVersionGateTests.cs
Normal file
149
test/Identity.IntegrationTest/Login/ClientVersionGateTests.cs
Normal file
@@ -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<IdentityApplicationFactory>
|
||||
{
|
||||
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<string, string>
|
||||
{
|
||||
{ "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<JsonDocument>(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<string, string>
|
||||
{
|
||||
{ "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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -613,10 +613,10 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ public class BaseRequestValidatorTests
|
||||
private readonly IAuthRequestRepository _authRequestRepository;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
|
||||
private readonly IClientVersionValidator _clientVersionValidator;
|
||||
|
||||
private readonly BaseRequestValidatorTestWrapper _sut;
|
||||
|
||||
@@ -82,6 +83,7 @@ public class BaseRequestValidatorTests
|
||||
_authRequestRepository = Substitute.For<IAuthRequestRepository>();
|
||||
_mailService = Substitute.For<IMailService>();
|
||||
_userAccountKeysQuery = Substitute.For<IUserAccountKeysQuery>();
|
||||
_clientVersionValidator = Substitute.For<IClientVersionValidator>();
|
||||
|
||||
_sut = new BaseRequestValidatorTestWrapper(
|
||||
_userManager,
|
||||
@@ -102,7 +104,13 @@ public class BaseRequestValidatorTests
|
||||
_policyRequirementQuery,
|
||||
_authRequestRepository,
|
||||
_mailService,
|
||||
_userAccountKeysQuery);
|
||||
_userAccountKeysQuery,
|
||||
_clientVersionValidator);
|
||||
|
||||
// Default client version validator behavior: allow to pass unless a test overrides.
|
||||
_clientVersionValidator
|
||||
.Validate(Arg.Any<User>(), Arg.Any<CustomValidatorRequestContext>())
|
||||
.Returns(true);
|
||||
}
|
||||
|
||||
/* Logic path
|
||||
@@ -1266,6 +1274,38 @@ public class BaseRequestValidatorTests
|
||||
"TwoFactorRecoveryRequested flag should be set for audit/logging");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task ValidateAsync_ClientVersionValidator_IsInvoked(
|
||||
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
|
||||
[AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext,
|
||||
GrantValidationResult grantResult)
|
||||
{
|
||||
// Arrange
|
||||
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<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed)
|
||||
.Returns(Task.FromResult(true));
|
||||
|
||||
// Act
|
||||
await _sut.ValidateAsync(context);
|
||||
|
||||
// Assert
|
||||
_clientVersionValidator.Received(1)
|
||||
.Validate(requestContext.User, requestContext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests that when SSO validation returns a custom response, (e.g., with organization identifier),
|
||||
/// that custom response is properly propagated to the result.
|
||||
|
||||
@@ -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<ICurrentContext>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,8 @@ IBaseRequestValidatorTestWrapper
|
||||
IPolicyRequirementQuery policyRequirementQuery,
|
||||
IAuthRequestRepository authRequestRepository,
|
||||
IMailService mailService,
|
||||
IUserAccountKeysQuery userAccountKeysQuery) :
|
||||
IUserAccountKeysQuery userAccountKeysQuery,
|
||||
IClientVersionValidator clientVersionValidator) :
|
||||
base(
|
||||
userManager,
|
||||
userService,
|
||||
@@ -87,7 +88,8 @@ IBaseRequestValidatorTestWrapper
|
||||
policyRequirementQuery,
|
||||
authRequestRepository,
|
||||
mailService,
|
||||
userAccountKeysQuery)
|
||||
userAccountKeysQuery,
|
||||
clientVersionValidator)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ using Bit.Core.Enums;
|
||||
using Bit.Core.KeyManagement.Models.Api.Request;
|
||||
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;
|
||||
@@ -27,6 +29,7 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase<Startup>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// A dictionary to store registration tokens for email verification. We cannot substitute the IMailService more than once, so
|
||||
@@ -50,6 +53,16 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase<Startup>
|
||||
});
|
||||
});
|
||||
|
||||
if (UseMockClientVersionValidator)
|
||||
{
|
||||
// Bypass client version gating to isolate tests from client version behavior
|
||||
SubstituteService<IClientVersionValidator>(svc =>
|
||||
{
|
||||
svc.Validate(Arg.Any<User>(), Arg.Any<CustomValidatorRequestContext>())
|
||||
.Returns(true);
|
||||
});
|
||||
}
|
||||
|
||||
base.ConfigureWebHost(builder);
|
||||
}
|
||||
|
||||
@@ -296,7 +309,7 @@ public class IdentityApplicationFactory : WebApplicationFactoryBase<Startup>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ctx?.Response?.Body == null)
|
||||
if (ctx?.Response.Body == null)
|
||||
{
|
||||
return "<no body>";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user