mirror of
https://github.com/bitwarden/server
synced 2026-01-06 10:34:01 +00:00
feat(auth-validator): [PM-22975] Client Version Validator - initial implementation
This commit is contained in:
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 MinimumClientVersion = new Version("2025.11.0");
|
||||||
|
}
|
||||||
@@ -26,5 +26,7 @@ public static class KeyManagementServiceCollectionExtensions
|
|||||||
private static void AddKeyManagementQueries(this IServiceCollection services)
|
private static void AddKeyManagementQueries(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddScoped<IUserAccountKeysQuery, UserAccountKeysQuery>();
|
services.AddScoped<IUserAccountKeysQuery, UserAccountKeysQuery>();
|
||||||
|
services.AddScoped<IIsV2EncryptionUserQuery, IsV2EncryptionUserQuery>();
|
||||||
|
services.AddScoped<IGetMinimumClientVersionForUserQuery, GetMinimumClientVersionForUserQuery>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.KeyManagement.Queries.Interfaces;
|
||||||
|
|
||||||
|
namespace Bit.Core.KeyManagement.Queries;
|
||||||
|
|
||||||
|
public class GetMinimumClientVersionForUserQuery(IIsV2EncryptionUserQuery isV2EncryptionUserQuery)
|
||||||
|
: IGetMinimumClientVersionForUserQuery
|
||||||
|
{
|
||||||
|
public async Task<Version?> Run(User? user)
|
||||||
|
{
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await isV2EncryptionUserQuery.Run(user))
|
||||||
|
{
|
||||||
|
return Constants.MinimumClientVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using Bit.Core.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Core.KeyManagement.Queries.Interfaces;
|
||||||
|
|
||||||
|
public interface IGetMinimumClientVersionForUserQuery
|
||||||
|
{
|
||||||
|
Task<Version?> Run(User? user);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using Bit.Core.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Core.KeyManagement.Queries.Interfaces;
|
||||||
|
|
||||||
|
public interface IIsV2EncryptionUserQuery
|
||||||
|
{
|
||||||
|
Task<bool> Run(User user);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
33
src/Core/KeyManagement/Queries/IsV2EncryptionUserQuery.cs
Normal file
33
src/Core/KeyManagement/Queries/IsV2EncryptionUserQuery.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.KeyManagement.Queries.Interfaces;
|
||||||
|
using Bit.Core.KeyManagement.Repositories;
|
||||||
|
using Bit.Core.KeyManagement.Utilities;
|
||||||
|
|
||||||
|
namespace Bit.Core.KeyManagement.Queries;
|
||||||
|
|
||||||
|
public class IsV2EncryptionUserQuery(IUserSignatureKeyPairRepository userSignatureKeyPairRepository)
|
||||||
|
: IIsV2EncryptionUserQuery
|
||||||
|
{
|
||||||
|
public async Task<bool> Run(User user)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
|
|
||||||
|
var hasSignatureKeyPair = await userSignatureKeyPairRepository.GetByUserIdAsync(user.Id) != null;
|
||||||
|
var isPrivateKeyEncryptionV2 =
|
||||||
|
!string.IsNullOrWhiteSpace(user.PrivateKey) &&
|
||||||
|
EncryptionParsing.GetEncryptionType(user.PrivateKey) == EncryptionType.XChaCha20Poly1305_B64;
|
||||||
|
|
||||||
|
return hasSignatureKeyPair switch
|
||||||
|
{
|
||||||
|
// Valid v2 user
|
||||||
|
true when isPrivateKeyEncryptionV2 => true,
|
||||||
|
// Valid v1 user
|
||||||
|
false when !isPrivateKeyEncryptionV2 => false,
|
||||||
|
_ => throw new InvalidOperationException(
|
||||||
|
"User is in an invalid state for key rotation. User has a signature key pair, but the private key is not in v2 format, or vice versa.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
// FIXME: Update this file to be null safe and then delete the line below
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
using Bit.Core.Auth.Repositories;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.KeyManagement.Models.Data;
|
||||||
|
using Bit.Core.KeyManagement.Repositories;
|
||||||
|
using Bit.Core.KeyManagement.Utilities;
|
||||||
|
using Bit.Core.Platform.Push;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Tools.Repositories;
|
||||||
|
using Bit.Core.Vault.Repositories;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
|
||||||
|
namespace Bit.Core.KeyManagement.UserKey.Implementations;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
||||||
|
{
|
||||||
|
private readonly IUserService _userService;
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly ICipherRepository _cipherRepository;
|
||||||
|
private readonly IFolderRepository _folderRepository;
|
||||||
|
private readonly ISendRepository _sendRepository;
|
||||||
|
private readonly IEmergencyAccessRepository _emergencyAccessRepository;
|
||||||
|
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||||
|
private readonly IDeviceRepository _deviceRepository;
|
||||||
|
private readonly IPushNotificationService _pushService;
|
||||||
|
private readonly IdentityErrorDescriber _identityErrorDescriber;
|
||||||
|
private readonly IWebAuthnCredentialRepository _credentialRepository;
|
||||||
|
private readonly IPasswordHasher<User> _passwordHasher;
|
||||||
|
private readonly IUserSignatureKeyPairRepository _userSignatureKeyPairRepository;
|
||||||
|
private readonly IFeatureService _featureService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Instantiates a new <see cref="RotateUserAccountKeysCommand"/>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userService">Master password hash validation</param>
|
||||||
|
/// <param name="userRepository">Updates user keys and re-encrypted data if needed</param>
|
||||||
|
/// <param name="cipherRepository">Provides a method to update re-encrypted cipher data</param>
|
||||||
|
/// <param name="folderRepository">Provides a method to update re-encrypted folder data</param>
|
||||||
|
/// <param name="sendRepository">Provides a method to update re-encrypted send data</param>
|
||||||
|
/// <param name="emergencyAccessRepository">Provides a method to update re-encrypted emergency access data</param>
|
||||||
|
/// <param name="organizationUserRepository">Provides a method to update re-encrypted organization user data</param>
|
||||||
|
/// <param name="deviceRepository">Provides a method to update re-encrypted device keys</param>
|
||||||
|
/// <param name="passwordHasher">Hashes the new master password</param>
|
||||||
|
/// <param name="pushService">Logs out user from other devices after successful rotation</param>
|
||||||
|
/// <param name="errors">Provides a password mismatch error if master password hash validation fails</param>
|
||||||
|
/// <param name="credentialRepository">Provides a method to update re-encrypted WebAuthn keys</param>
|
||||||
|
/// <param name="userSignatureKeyPairRepository">Provides a method to update re-encrypted signature keys</param>
|
||||||
|
public RotateUserAccountKeysCommand(IUserService userService, IUserRepository userRepository,
|
||||||
|
ICipherRepository cipherRepository, IFolderRepository folderRepository, ISendRepository sendRepository,
|
||||||
|
IEmergencyAccessRepository emergencyAccessRepository, IOrganizationUserRepository organizationUserRepository,
|
||||||
|
IDeviceRepository deviceRepository,
|
||||||
|
IPasswordHasher<User> passwordHasher,
|
||||||
|
IPushNotificationService pushService, IdentityErrorDescriber errors, IWebAuthnCredentialRepository credentialRepository,
|
||||||
|
IUserSignatureKeyPairRepository userSignatureKeyPairRepository,
|
||||||
|
IFeatureService featureService)
|
||||||
|
{
|
||||||
|
_userService = userService;
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_cipherRepository = cipherRepository;
|
||||||
|
_folderRepository = folderRepository;
|
||||||
|
_sendRepository = sendRepository;
|
||||||
|
_emergencyAccessRepository = emergencyAccessRepository;
|
||||||
|
_organizationUserRepository = organizationUserRepository;
|
||||||
|
_deviceRepository = deviceRepository;
|
||||||
|
_pushService = pushService;
|
||||||
|
_identityErrorDescriber = errors;
|
||||||
|
_credentialRepository = credentialRepository;
|
||||||
|
_passwordHasher = passwordHasher;
|
||||||
|
_userSignatureKeyPairRepository = userSignatureKeyPairRepository;
|
||||||
|
_featureService = featureService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IdentityResult> RotateUserAccountKeysAsync(User user, RotateUserAccountKeysData model)
|
||||||
|
{
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await _userService.CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash))
|
||||||
|
{
|
||||||
|
return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch());
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
user.RevisionDate = user.AccountRevisionDate = now;
|
||||||
|
user.LastKeyRotationDate = now;
|
||||||
|
user.SecurityStamp = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
List<UpdateEncryptedDataForKeyRotation> saveEncryptedDataActions = [];
|
||||||
|
|
||||||
|
await UpdateAccountKeysAsync(model, user, saveEncryptedDataActions);
|
||||||
|
UpdateUnlockMethods(model, user, saveEncryptedDataActions);
|
||||||
|
UpdateUserData(model, user, saveEncryptedDataActions);
|
||||||
|
|
||||||
|
await _userRepository.UpdateUserKeyAndEncryptedDataV2Async(user, saveEncryptedDataActions);
|
||||||
|
await _pushService.PushLogOutAsync(user.Id);
|
||||||
|
return IdentityResult.Success;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RotateV2AccountKeysAsync(RotateUserAccountKeysData model, User user, List<UpdateEncryptedDataForKeyRotation> saveEncryptedDataActions)
|
||||||
|
{
|
||||||
|
ValidateV2Encryption(model);
|
||||||
|
await ValidateVerifyingKeyUnchangedAsync(model, user);
|
||||||
|
|
||||||
|
saveEncryptedDataActions.Add(_userSignatureKeyPairRepository.UpdateForKeyRotation(user.Id, model.AccountKeys.SignatureKeyPairData));
|
||||||
|
user.SignedPublicKey = model.AccountKeys.PublicKeyEncryptionKeyPairData.SignedPublicKey;
|
||||||
|
user.SecurityState = model.AccountKeys.SecurityStateData!.SecurityState;
|
||||||
|
user.SecurityVersion = model.AccountKeys.SecurityStateData.SecurityVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpgradeV1ToV2Keys(RotateUserAccountKeysData model, User user, List<UpdateEncryptedDataForKeyRotation> saveEncryptedDataActions)
|
||||||
|
{
|
||||||
|
ValidateV2Encryption(model);
|
||||||
|
saveEncryptedDataActions.Add(_userSignatureKeyPairRepository.SetUserSignatureKeyPair(user.Id, model.AccountKeys.SignatureKeyPairData));
|
||||||
|
user.SignedPublicKey = model.AccountKeys.PublicKeyEncryptionKeyPairData.SignedPublicKey;
|
||||||
|
user.SecurityState = model.AccountKeys.SecurityStateData!.SecurityState;
|
||||||
|
user.SecurityVersion = model.AccountKeys.SecurityStateData.SecurityVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAccountKeysAsync(RotateUserAccountKeysData model, User user, List<UpdateEncryptedDataForKeyRotation> saveEncryptedDataActions)
|
||||||
|
{
|
||||||
|
ValidatePublicKeyEncryptionKeyPairUnchanged(model, user);
|
||||||
|
|
||||||
|
if (IsV2EncryptionUserAsync(user))
|
||||||
|
{
|
||||||
|
await RotateV2AccountKeysAsync(model, user, saveEncryptedDataActions);
|
||||||
|
}
|
||||||
|
else if (model.AccountKeys.SignatureKeyPairData != null)
|
||||||
|
{
|
||||||
|
UpgradeV1ToV2Keys(model, user, saveEncryptedDataActions);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (EncryptionParsing.GetEncryptionType(model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey) != EncryptionType.AesCbc256_HmacSha256_B64)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The provided account private key was not wrapped with AES-256-CBC-HMAC");
|
||||||
|
}
|
||||||
|
// V1 user to V1 user rotation needs to further changes, the private key was re-encrypted.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private key is re-wrapped with new user key by client
|
||||||
|
user.PrivateKey = model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateUserData(RotateUserAccountKeysData model, User user, List<UpdateEncryptedDataForKeyRotation> saveEncryptedDataActions)
|
||||||
|
{
|
||||||
|
// The revision date has to be updated so that de-synced clients don't accidentally post over the re-encrypted data
|
||||||
|
// with an old-user key-encrypted copy
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (model.Ciphers.Any())
|
||||||
|
{
|
||||||
|
var ciphersWithUpdatedDate = model.Ciphers.ToList().Select(c => { c.RevisionDate = now; return c; });
|
||||||
|
saveEncryptedDataActions.Add(_cipherRepository.UpdateForKeyRotation(user.Id, ciphersWithUpdatedDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.Folders.Any())
|
||||||
|
{
|
||||||
|
var foldersWithUpdatedDate = model.Folders.ToList().Select(f => { f.RevisionDate = now; return f; });
|
||||||
|
saveEncryptedDataActions.Add(_folderRepository.UpdateForKeyRotation(user.Id, foldersWithUpdatedDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.Sends.Any())
|
||||||
|
{
|
||||||
|
var sendsWithUpdatedDate = model.Sends.ToList().Select(s => { s.RevisionDate = now; return s; });
|
||||||
|
saveEncryptedDataActions.Add(_sendRepository.UpdateForKeyRotation(user.Id, sendsWithUpdatedDate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void UpdateUnlockMethods(RotateUserAccountKeysData model, User user, List<UpdateEncryptedDataForKeyRotation> saveEncryptedDataActions)
|
||||||
|
{
|
||||||
|
if (!model.MasterPasswordUnlockData.ValidateForUser(user))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The provided master password unlock data is not valid for this user.");
|
||||||
|
}
|
||||||
|
// Update master password authentication & unlock
|
||||||
|
user.Key = model.MasterPasswordUnlockData.MasterKeyEncryptedUserKey;
|
||||||
|
user.MasterPassword = _passwordHasher.HashPassword(user, model.MasterPasswordUnlockData.MasterKeyAuthenticationHash);
|
||||||
|
user.MasterPasswordHint = model.MasterPasswordUnlockData.MasterPasswordHint;
|
||||||
|
|
||||||
|
if (model.EmergencyAccesses.Any())
|
||||||
|
{
|
||||||
|
saveEncryptedDataActions.Add(_emergencyAccessRepository.UpdateForKeyRotation(user.Id, model.EmergencyAccesses));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.OrganizationUsers.Any())
|
||||||
|
{
|
||||||
|
saveEncryptedDataActions.Add(_organizationUserRepository.UpdateForKeyRotation(user.Id, model.OrganizationUsers));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.WebAuthnKeys.Any())
|
||||||
|
{
|
||||||
|
saveEncryptedDataActions.Add(_credentialRepository.UpdateKeysForRotationAsync(user.Id, model.WebAuthnKeys));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.DeviceKeys.Any())
|
||||||
|
{
|
||||||
|
saveEncryptedDataActions.Add(_deviceRepository.UpdateKeysForRotationAsync(user.Id, model.DeviceKeys));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsV2EncryptionUserAsync(User user)
|
||||||
|
{
|
||||||
|
// Returns whether the user is a V2 user based on the private key's encryption type.
|
||||||
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
|
var isPrivateKeyEncryptionV2 = EncryptionParsing.GetEncryptionType(user.PrivateKey) == EncryptionType.XChaCha20Poly1305_B64;
|
||||||
|
return isPrivateKeyEncryptionV2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ValidateVerifyingKeyUnchangedAsync(RotateUserAccountKeysData model, User user)
|
||||||
|
{
|
||||||
|
var currentSignatureKeyPair = await _userSignatureKeyPairRepository.GetByUserIdAsync(user.Id) ?? throw new InvalidOperationException("User does not have a signature key pair.");
|
||||||
|
if (model.AccountKeys.SignatureKeyPairData.VerifyingKey != currentSignatureKeyPair!.VerifyingKey)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The provided verifying key does not match the user's current verifying key.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidatePublicKeyEncryptionKeyPairUnchanged(RotateUserAccountKeysData model, User user)
|
||||||
|
{
|
||||||
|
var publicKey = model.AccountKeys.PublicKeyEncryptionKeyPairData.PublicKey;
|
||||||
|
if (publicKey != user.PublicKey)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The provided account public key does not match the user's current public key, and changing the account asymmetric key pair is currently not supported during key rotation.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateV2Encryption(RotateUserAccountKeysData model)
|
||||||
|
{
|
||||||
|
if (model.AccountKeys.SignatureKeyPairData == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Signature key pair data is required for V2 encryption.");
|
||||||
|
}
|
||||||
|
if (EncryptionParsing.GetEncryptionType(model.AccountKeys.SignatureKeyPairData.WrappedSigningKey) != EncryptionType.XChaCha20Poly1305_B64)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The provided signing key data is not wrapped with XChaCha20-Poly1305.");
|
||||||
|
}
|
||||||
|
if (string.IsNullOrEmpty(model.AccountKeys.SignatureKeyPairData.VerifyingKey))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The provided signature key pair data does not contain a valid verifying key.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EncryptionParsing.GetEncryptionType(model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey) != EncryptionType.XChaCha20Poly1305_B64)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("The provided private key encryption key is not wrapped with XChaCha20-Poly1305.");
|
||||||
|
}
|
||||||
|
if (string.IsNullOrEmpty(model.AccountKeys.PublicKeyEncryptionKeyPairData.SignedPublicKey))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("No signed public key provided, but the user already has a signature key pair.");
|
||||||
|
}
|
||||||
|
if (model.AccountKeys.SecurityStateData == null || string.IsNullOrEmpty(model.AccountKeys.SecurityStateData.SecurityState))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("No signed security state provider for V2 user");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsing moved to Bit.Core.KeyManagement.Utilities.EncryptionParsing
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ using Bit.Core.Entities;
|
|||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.KeyManagement.Models.Data;
|
using Bit.Core.KeyManagement.Models.Data;
|
||||||
using Bit.Core.KeyManagement.Repositories;
|
using Bit.Core.KeyManagement.Repositories;
|
||||||
|
using Bit.Core.KeyManagement.Utilities;
|
||||||
using Bit.Core.Platform.Push;
|
using Bit.Core.Platform.Push;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
@@ -137,7 +138,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
|||||||
}
|
}
|
||||||
else
|
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");
|
throw new InvalidOperationException("The provided account private key was not wrapped with AES-256-CBC-HMAC");
|
||||||
}
|
}
|
||||||
@@ -209,7 +210,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
|||||||
{
|
{
|
||||||
// Returns whether the user is a V2 user based on the private key's encryption type.
|
// Returns whether the user is a V2 user based on the private key's encryption type.
|
||||||
ArgumentNullException.ThrowIfNull(user);
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
var isPrivateKeyEncryptionV2 = GetEncryptionType(user.PrivateKey) == EncryptionType.XChaCha20Poly1305_B64;
|
var isPrivateKeyEncryptionV2 = EncryptionParsing.GetEncryptionType(user.PrivateKey) == EncryptionType.XChaCha20Poly1305_B64;
|
||||||
return isPrivateKeyEncryptionV2;
|
return isPrivateKeyEncryptionV2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +238,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
|||||||
{
|
{
|
||||||
throw new InvalidOperationException("Signature key pair data is required for V2 encryption.");
|
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.");
|
throw new InvalidOperationException("The provided signing key data is not wrapped with XChaCha20-Poly1305.");
|
||||||
}
|
}
|
||||||
@@ -246,7 +247,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
|||||||
throw new InvalidOperationException("The provided signature key pair data does not contain a valid verifying key.");
|
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.");
|
throw new InvalidOperationException("The provided private key encryption key is not wrapped with XChaCha20-Poly1305.");
|
||||||
}
|
}
|
||||||
@@ -260,23 +261,5 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// Parsing moved to Bit.Core.KeyManagement.Utilities.EncryptionParsing
|
||||||
/// 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.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
46
src/Core/KeyManagement/Utilities/EncryptionParsing.cs
Normal file
46
src/Core/KeyManagement/Utilities/EncryptionParsing.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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.
|
||||||
|
/// Accepts formats like "Header.iv|ct|mac" or "Header" COSE format.
|
||||||
|
/// </summary>
|
||||||
|
public static EncryptionType GetEncryptionType(string encString)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(encString))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Encrypted string cannot be null or empty.", nameof(encString));
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = encString.Split('.');
|
||||||
|
if (parts.Length == 1)
|
||||||
|
{
|
||||||
|
// No header detected; assume AES CBC variants based on number of pieces
|
||||||
|
var splitParts = encString.Split('|');
|
||||||
|
if (splitParts.Length == 3)
|
||||||
|
{
|
||||||
|
return EncryptionType.AesCbc128_HmacSha256_B64;
|
||||||
|
}
|
||||||
|
|
||||||
|
return EncryptionType.AesCbc256_B64;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parse header as numeric, then as enum name, else fail
|
||||||
|
if (byte.TryParse(parts[0], out var encryptionTypeNumber))
|
||||||
|
{
|
||||||
|
return (EncryptionType)encryptionTypeNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Enum.TryParse(parts[0], out EncryptionType parsed))
|
||||||
|
{
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ArgumentException("Invalid encryption type header.", nameof(encString));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
private readonly IUserRepository _userRepository;
|
private readonly IUserRepository _userRepository;
|
||||||
private readonly IAuthRequestRepository _authRequestRepository;
|
private readonly IAuthRequestRepository _authRequestRepository;
|
||||||
private readonly IMailService _mailService;
|
private readonly IMailService _mailService;
|
||||||
|
private readonly IClientVersionValidator _clientVersionValidator;
|
||||||
|
|
||||||
protected ICurrentContext CurrentContext { get; }
|
protected ICurrentContext CurrentContext { get; }
|
||||||
protected IPolicyService PolicyService { get; }
|
protected IPolicyService PolicyService { get; }
|
||||||
@@ -68,7 +69,8 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
IPolicyRequirementQuery policyRequirementQuery,
|
IPolicyRequirementQuery policyRequirementQuery,
|
||||||
IAuthRequestRepository authRequestRepository,
|
IAuthRequestRepository authRequestRepository,
|
||||||
IMailService mailService,
|
IMailService mailService,
|
||||||
IUserAccountKeysQuery userAccountKeysQuery
|
IUserAccountKeysQuery userAccountKeysQuery,
|
||||||
|
IClientVersionValidator clientVersionValidator
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
@@ -89,6 +91,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
_authRequestRepository = authRequestRepository;
|
_authRequestRepository = authRequestRepository;
|
||||||
_mailService = mailService;
|
_mailService = mailService;
|
||||||
_accountKeysQuery = userAccountKeysQuery;
|
_accountKeysQuery = userAccountKeysQuery;
|
||||||
|
_clientVersionValidator = clientVersionValidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
|
protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
|
||||||
@@ -259,6 +262,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
return
|
return
|
||||||
[
|
[
|
||||||
() => ValidateMasterPasswordAsync(context, validatorContext),
|
() => ValidateMasterPasswordAsync(context, validatorContext),
|
||||||
|
() => ValidateClientVersionAsync(context, validatorContext),
|
||||||
() => ValidateTwoFactorAsync(context, request, validatorContext),
|
() => ValidateTwoFactorAsync(context, request, validatorContext),
|
||||||
() => ValidateSsoAsync(context, request, validatorContext),
|
() => ValidateSsoAsync(context, request, validatorContext),
|
||||||
() => ValidateNewDeviceAsync(context, request, validatorContext),
|
() => ValidateNewDeviceAsync(context, request, validatorContext),
|
||||||
@@ -272,6 +276,7 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
return
|
return
|
||||||
[
|
[
|
||||||
() => ValidateMasterPasswordAsync(context, validatorContext),
|
() => ValidateMasterPasswordAsync(context, validatorContext),
|
||||||
|
() => ValidateClientVersionAsync(context, validatorContext),
|
||||||
() => ValidateSsoAsync(context, request, validatorContext),
|
() => ValidateSsoAsync(context, request, validatorContext),
|
||||||
() => ValidateTwoFactorAsync(context, request, validatorContext),
|
() => ValidateTwoFactorAsync(context, request, validatorContext),
|
||||||
() => ValidateNewDeviceAsync(context, request, validatorContext),
|
() => ValidateNewDeviceAsync(context, request, validatorContext),
|
||||||
@@ -323,6 +328,24 @@ public abstract class BaseRequestValidator<T> where T : class
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates whether the client version is compatible for the user attempting to authenticate.
|
||||||
|
/// New authentications only; refresh/device grants are handled elsewhere.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>true if the scheme successfully passed validation, otherwise false.</returns>
|
||||||
|
private async Task<bool> ValidateClientVersionAsync(T context, CustomValidatorRequestContext validatorContext)
|
||||||
|
{
|
||||||
|
var ok = await _clientVersionValidator.ValidateAsync(validatorContext.User, validatorContext);
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetValidationErrorResult(context, validatorContext);
|
||||||
|
await LogFailedLoginEvent(validatorContext.User, EventType.User_FailedLogIn);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates the user's Master Password hash.
|
/// Validates the user's Master Password hash.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.KeyManagement.Queries.Interfaces;
|
||||||
|
using Bit.Core.Models.Api;
|
||||||
|
using Duende.IdentityServer.Validation;
|
||||||
|
|
||||||
|
namespace Bit.Identity.IdentityServer.RequestValidators;
|
||||||
|
|
||||||
|
public interface IClientVersionValidator
|
||||||
|
{
|
||||||
|
Task<bool> ValidateAsync(User user, CustomValidatorRequestContext requestContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ClientVersionValidator(ICurrentContext currentContext,
|
||||||
|
IGetMinimumClientVersionForUserQuery getMinimumClientVersionForUserQuery)
|
||||||
|
: IClientVersionValidator
|
||||||
|
{
|
||||||
|
private static readonly string UpgradeMessage = "Please update your app to continue using Bitwarden";
|
||||||
|
|
||||||
|
public async Task<bool> ValidateAsync(User? user, CustomValidatorRequestContext requestContext)
|
||||||
|
{
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientVersion = currentContext.ClientVersion;
|
||||||
|
var minVersion = await getMinimumClientVersionForUserQuery.Run(user);
|
||||||
|
|
||||||
|
// Fail-open if headers are missing or no restriction
|
||||||
|
if (minVersion == null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientVersion < minVersion)
|
||||||
|
{
|
||||||
|
requestContext.ValidationErrorResult = new ValidationResult
|
||||||
|
{
|
||||||
|
Error = "invalid_grant",
|
||||||
|
ErrorDescription = UpgradeMessage,
|
||||||
|
IsError = true
|
||||||
|
};
|
||||||
|
requestContext.CustomResponse = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "ErrorModel", new ErrorResponseModel(UpgradeMessage) }
|
||||||
|
};
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -49,7 +49,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
|||||||
IPolicyRequirementQuery policyRequirementQuery,
|
IPolicyRequirementQuery policyRequirementQuery,
|
||||||
IAuthRequestRepository authRequestRepository,
|
IAuthRequestRepository authRequestRepository,
|
||||||
IMailService mailService,
|
IMailService mailService,
|
||||||
IUserAccountKeysQuery userAccountKeysQuery)
|
IUserAccountKeysQuery userAccountKeysQuery,
|
||||||
|
IClientVersionValidator clientVersionValidator)
|
||||||
: base(
|
: base(
|
||||||
userManager,
|
userManager,
|
||||||
userService,
|
userService,
|
||||||
@@ -68,7 +69,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
|
|||||||
policyRequirementQuery,
|
policyRequirementQuery,
|
||||||
authRequestRepository,
|
authRequestRepository,
|
||||||
mailService,
|
mailService,
|
||||||
userAccountKeysQuery)
|
userAccountKeysQuery,
|
||||||
|
clientVersionValidator)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_updateInstallationCommand = updateInstallationCommand;
|
_updateInstallationCommand = updateInstallationCommand;
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
|
|||||||
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
|
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
|
||||||
IPolicyRequirementQuery policyRequirementQuery,
|
IPolicyRequirementQuery policyRequirementQuery,
|
||||||
IMailService mailService,
|
IMailService mailService,
|
||||||
IUserAccountKeysQuery userAccountKeysQuery)
|
IUserAccountKeysQuery userAccountKeysQuery,
|
||||||
|
IClientVersionValidator clientVersionValidator)
|
||||||
: base(
|
: base(
|
||||||
userManager,
|
userManager,
|
||||||
userService,
|
userService,
|
||||||
@@ -62,7 +63,8 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
|
|||||||
policyRequirementQuery,
|
policyRequirementQuery,
|
||||||
authRequestRepository,
|
authRequestRepository,
|
||||||
mailService,
|
mailService,
|
||||||
userAccountKeysQuery)
|
userAccountKeysQuery,
|
||||||
|
clientVersionValidator)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
|
|||||||
IPolicyRequirementQuery policyRequirementQuery,
|
IPolicyRequirementQuery policyRequirementQuery,
|
||||||
IAuthRequestRepository authRequestRepository,
|
IAuthRequestRepository authRequestRepository,
|
||||||
IMailService mailService,
|
IMailService mailService,
|
||||||
IUserAccountKeysQuery userAccountKeysQuery)
|
IUserAccountKeysQuery userAccountKeysQuery,
|
||||||
|
IClientVersionValidator clientVersionValidator)
|
||||||
: base(
|
: base(
|
||||||
userManager,
|
userManager,
|
||||||
userService,
|
userService,
|
||||||
@@ -71,7 +72,8 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
|
|||||||
policyRequirementQuery,
|
policyRequirementQuery,
|
||||||
authRequestRepository,
|
authRequestRepository,
|
||||||
mailService,
|
mailService,
|
||||||
userAccountKeysQuery)
|
userAccountKeysQuery,
|
||||||
|
clientVersionValidator)
|
||||||
{
|
{
|
||||||
_assertionOptionsDataProtector = assertionOptionsDataProtector;
|
_assertionOptionsDataProtector = assertionOptionsDataProtector;
|
||||||
_assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand;
|
_assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddTransient<IAuthorizationCodeStore, AuthorizationCodeStore>();
|
services.AddTransient<IAuthorizationCodeStore, AuthorizationCodeStore>();
|
||||||
services.AddTransient<IUserDecryptionOptionsBuilder, UserDecryptionOptionsBuilder>();
|
services.AddTransient<IUserDecryptionOptionsBuilder, UserDecryptionOptionsBuilder>();
|
||||||
services.AddTransient<IDeviceValidator, DeviceValidator>();
|
services.AddTransient<IDeviceValidator, DeviceValidator>();
|
||||||
|
services.AddTransient<IClientVersionValidator, ClientVersionValidator>();
|
||||||
services.AddTransient<ITwoFactorAuthenticationValidator, TwoFactorAuthenticationValidator>();
|
services.AddTransient<ITwoFactorAuthenticationValidator, TwoFactorAuthenticationValidator>();
|
||||||
services.AddTransient<ILoginApprovingClientTypes, LoginApprovingClientTypes>();
|
services.AddTransient<ILoginApprovingClientTypes, LoginApprovingClientTypes>();
|
||||||
services.AddTransient<ISendAuthenticationMethodValidator<ResourcePassword>, SendPasswordRequestValidator>();
|
services.AddTransient<ISendAuthenticationMethodValidator<ResourcePassword>, SendPasswordRequestValidator>();
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ using System.Security.Claims;
|
|||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using AspNetCoreRateLimit;
|
using AspNetCoreRateLimit;
|
||||||
using Azure.Messaging.ServiceBus;
|
using Azure.Messaging.ServiceBus;
|
||||||
using Bit.Core;
|
|
||||||
using Bit.Core.AdminConsole.AbilitiesCache;
|
using Bit.Core.AdminConsole.AbilitiesCache;
|
||||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||||
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
|
||||||
@@ -85,6 +84,7 @@ using Microsoft.Extensions.Hosting;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using StackExchange.Redis;
|
using StackExchange.Redis;
|
||||||
|
using Constants = Bit.Core.Constants;
|
||||||
using NoopRepos = Bit.Core.Repositories.Noop;
|
using NoopRepos = Bit.Core.Repositories.Noop;
|
||||||
using Role = Bit.Core.Entities.Role;
|
using Role = Bit.Core.Entities.Role;
|
||||||
using TableStorageRepos = Bit.Core.Repositories.TableStorage;
|
using TableStorageRepos = Bit.Core.Repositories.TableStorage;
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.KeyManagement.Queries;
|
||||||
|
using Bit.Core.KeyManagement.Queries.Interfaces;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.KeyManagement.Queries;
|
||||||
|
|
||||||
|
public class GetMinimumClientVersionForUserQueryTests
|
||||||
|
{
|
||||||
|
private class FakeIsV2Query : IIsV2EncryptionUserQuery
|
||||||
|
{
|
||||||
|
private readonly bool _isV2;
|
||||||
|
public FakeIsV2Query(bool isV2) { _isV2 = isV2; }
|
||||||
|
public Task<bool> Run(User user) => Task.FromResult(_isV2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Run_ReturnsMinVersion_ForV2User()
|
||||||
|
{
|
||||||
|
var sut = new GetMinimumClientVersionForUserQuery(new FakeIsV2Query(true));
|
||||||
|
var version = await sut.Run(new User());
|
||||||
|
Assert.Equal(Core.KeyManagement.Constants.MinimumClientVersion, version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Run_ReturnsNull_ForV1User()
|
||||||
|
{
|
||||||
|
var sut = new GetMinimumClientVersionForUserQuery(new FakeIsV2Query(false));
|
||||||
|
var version = await sut.Run(new User());
|
||||||
|
Assert.Null(version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.KeyManagement.Entities;
|
||||||
|
using Bit.Core.KeyManagement.Enums;
|
||||||
|
using Bit.Core.KeyManagement.Models.Data;
|
||||||
|
using Bit.Core.KeyManagement.Queries;
|
||||||
|
using Bit.Core.KeyManagement.Repositories;
|
||||||
|
using Bit.Core.KeyManagement.UserKey;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Core.Test.KeyManagement.Queries;
|
||||||
|
|
||||||
|
public class IsV2EncryptionUserQueryTests
|
||||||
|
{
|
||||||
|
private class FakeSigRepo : IUserSignatureKeyPairRepository
|
||||||
|
{
|
||||||
|
private readonly bool _hasKeys;
|
||||||
|
public FakeSigRepo(bool hasKeys) { _hasKeys = hasKeys; }
|
||||||
|
public Task<SignatureKeyPairData?> GetByUserIdAsync(Guid userId)
|
||||||
|
=> Task.FromResult(_hasKeys ? new SignatureKeyPairData(SignatureAlgorithm.Ed25519, "7.cose_signing", "vk") : null);
|
||||||
|
|
||||||
|
// Unused in tests
|
||||||
|
public Task<IEnumerable<UserSignatureKeyPair>> GetManyAsync(IEnumerable<Guid> ids) => throw new NotImplementedException();
|
||||||
|
public Task<UserSignatureKeyPair> GetByIdAsync(Guid id) => throw new NotImplementedException();
|
||||||
|
public Task<UserSignatureKeyPair> CreateAsync(UserSignatureKeyPair obj) => throw new NotImplementedException();
|
||||||
|
public Task ReplaceAsync(UserSignatureKeyPair obj) => throw new NotImplementedException();
|
||||||
|
public Task UpsertAsync(UserSignatureKeyPair obj) => throw new NotImplementedException();
|
||||||
|
public Task DeleteAsync(UserSignatureKeyPair obj) => throw new NotImplementedException();
|
||||||
|
public Task DeleteAsync(Guid id) => throw new NotImplementedException();
|
||||||
|
public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid grantorId, SignatureKeyPairData signatureKeyPair) => throw new NotImplementedException();
|
||||||
|
public UpdateEncryptedDataForKeyRotation SetUserSignatureKeyPair(Guid userId, SignatureKeyPairData signatureKeyPair) => throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Run_ReturnsTrue_ForV2State()
|
||||||
|
{
|
||||||
|
var user = new User { Id = Guid.NewGuid(), PrivateKey = "7.cose" };
|
||||||
|
var sut = new IsV2EncryptionUserQuery(new FakeSigRepo(true));
|
||||||
|
|
||||||
|
var result = await sut.Run(user);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Run_ReturnsFalse_ForV1State()
|
||||||
|
{
|
||||||
|
var user = new User { Id = Guid.NewGuid(), PrivateKey = "2.iv|ct|mac" };
|
||||||
|
var sut = new IsV2EncryptionUserQuery(new FakeSigRepo(false));
|
||||||
|
|
||||||
|
var result = await sut.Run(user);
|
||||||
|
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Run_ThrowsForInvalidMixedState()
|
||||||
|
{
|
||||||
|
var user = new User { Id = Guid.NewGuid(), PrivateKey = "7.cose" };
|
||||||
|
var sut = new IsV2EncryptionUserQuery(new FakeSigRepo(false));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(async () => await sut.Run(user));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
119
test/Identity.IntegrationTest/Login/ClientVersionGateTests.cs
Normal file
119
test/Identity.IntegrationTest/Login/ClientVersionGateTests.cs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Bit.Core.Auth.Models.Api.Request.Accounts;
|
||||||
|
using Bit.Core.KeyManagement.Enums;
|
||||||
|
using Bit.Core.Test.Auth.AutoFixture;
|
||||||
|
using Bit.IntegrationTestCommon.Factories;
|
||||||
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Identity.IntegrationTest.Login;
|
||||||
|
|
||||||
|
public class ClientVersionGateTests : IClassFixture<IdentityApplicationFactory>
|
||||||
|
{
|
||||||
|
private readonly IdentityApplicationFactory _factory;
|
||||||
|
|
||||||
|
public ClientVersionGateTests(IdentityApplicationFactory factory)
|
||||||
|
{
|
||||||
|
_factory = factory;
|
||||||
|
ReinitializeDbForTests(_factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory, BitAutoData, RegisterFinishRequestModelCustomize]
|
||||||
|
public async Task TokenEndpoint_GrantTypePassword_V2User_OnOldClientVersion_Blocked(RegisterFinishRequestModel requestModel)
|
||||||
|
{
|
||||||
|
var localFactory = new IdentityApplicationFactory();
|
||||||
|
var server = localFactory.Server;
|
||||||
|
var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel);
|
||||||
|
|
||||||
|
// Make user V2: set private key to COSE and add signature key pair
|
||||||
|
var db = localFactory.GetDatabaseContext();
|
||||||
|
var efUser = await db.Users.FirstAsync(u => u.Email == user.Email);
|
||||||
|
efUser.PrivateKey = "7.cose";
|
||||||
|
db.UserSignatureKeyPairs.Add(new Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair
|
||||||
|
{
|
||||||
|
Id = Core.Utilities.CoreHelpers.GenerateComb(),
|
||||||
|
UserId = efUser.Id,
|
||||||
|
SignatureAlgorithm = SignatureAlgorithm.Ed25519,
|
||||||
|
SigningKey = "7.cose_signing",
|
||||||
|
VerifyingKey = "vk"
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var context = await server.PostAsync("/connect/token",
|
||||||
|
new FormUrlEncodedContent(new Dictionary<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)
|
||||||
|
{
|
||||||
|
var localFactory = new IdentityApplicationFactory();
|
||||||
|
var server = localFactory.Server;
|
||||||
|
var user = await localFactory.RegisterNewIdentityFactoryUserAsync(requestModel);
|
||||||
|
|
||||||
|
// Make user V2
|
||||||
|
var db = localFactory.GetDatabaseContext();
|
||||||
|
var efUser = await db.Users.FirstAsync(u => u.Email == user.Email);
|
||||||
|
efUser.PrivateKey = "7.cose";
|
||||||
|
db.UserSignatureKeyPairs.Add(new Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair
|
||||||
|
{
|
||||||
|
Id = Core.Utilities.CoreHelpers.GenerateComb(),
|
||||||
|
UserId = efUser.Id,
|
||||||
|
SignatureAlgorithm = SignatureAlgorithm.Ed25519,
|
||||||
|
SigningKey = "7.cose_signing",
|
||||||
|
VerifyingKey = "vk"
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var context = await server.PostAsync("/connect/token",
|
||||||
|
new FormUrlEncodedContent(new Dictionary<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Bit.Core.Context;
|
||||||
|
using Bit.Core.Entities;
|
||||||
|
using Bit.Core.KeyManagement.Queries.Interfaces;
|
||||||
|
using Bit.Identity.IdentityServer.RequestValidators;
|
||||||
|
using NSubstitute;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Bit.Identity.Test.IdentityServer.RequestValidators;
|
||||||
|
|
||||||
|
public class ClientVersionValidatorTests
|
||||||
|
{
|
||||||
|
private static ICurrentContext MakeContext(Version version)
|
||||||
|
{
|
||||||
|
var ctx = Substitute.For<ICurrentContext>();
|
||||||
|
ctx.ClientVersion = version;
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IGetMinimumClientVersionForUserQuery MakeMinQuery(Version? v)
|
||||||
|
{
|
||||||
|
var q = Substitute.For<IGetMinimumClientVersionForUserQuery>();
|
||||||
|
q.Run(Arg.Any<User>()).Returns(Task.FromResult(v));
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Allows_When_NoMinVersion()
|
||||||
|
{
|
||||||
|
var sut = new ClientVersionValidator(MakeContext(new Version("2025.1.0")), MakeMinQuery(null));
|
||||||
|
var ok = await sut.ValidateAsync(new User(), new Bit.Identity.IdentityServer.CustomValidatorRequestContext());
|
||||||
|
Assert.True(ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Blocks_When_ClientTooOld()
|
||||||
|
{
|
||||||
|
var sut = new ClientVersionValidator(MakeContext(new Version("2025.10.0")), MakeMinQuery(new Version("2025.11.0")));
|
||||||
|
var ctx = new Bit.Identity.IdentityServer.CustomValidatorRequestContext();
|
||||||
|
var ok = await sut.ValidateAsync(new User(), ctx);
|
||||||
|
Assert.False(ok);
|
||||||
|
Assert.NotNull(ctx.ValidationErrorResult);
|
||||||
|
Assert.True(ctx.ValidationErrorResult.IsError);
|
||||||
|
Assert.Equal("invalid_grant", ctx.ValidationErrorResult.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Allows_When_ClientMeetsMin()
|
||||||
|
{
|
||||||
|
var sut = new ClientVersionValidator(MakeContext(new Version("2025.11.0")), MakeMinQuery(new Version("2025.11.0")));
|
||||||
|
var ok = await sut.ValidateAsync(new User(), new Bit.Identity.IdentityServer.CustomValidatorRequestContext());
|
||||||
|
Assert.True(ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user