diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index fec9b80d8e..5d687efe16 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -51,7 +51,7 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac public string? Key { get; set; } /// /// The raw public key, without a signature from the user's signature key. - /// + /// public string? PublicKey { get; set; } /// /// User key wrapped private key. @@ -211,6 +211,11 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac return SecurityVersion ?? 1; } + public bool IsSecurityVersionTwo() + { + return SecurityVersion == 2; + } + /// /// Serializes the C# object to the User.TwoFactorProviders property in JSON format. /// diff --git a/src/Core/KeyManagement/Constants.cs b/src/Core/KeyManagement/Constants.cs index f5976752fb..f1e3e1a268 100644 --- a/src/Core/KeyManagement/Constants.cs +++ b/src/Core/KeyManagement/Constants.cs @@ -2,5 +2,5 @@ public static class Constants { - public static readonly Version MinimumClientVersionForV2Encryption = new Version("2025.11.0"); + public static readonly Version MinimumClientVersionForV2Encryption = new("2025.11.0"); } diff --git a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs index 9b63dfdaf9..f18d1c73ba 100644 --- a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs +++ b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs @@ -26,7 +26,6 @@ public static class KeyManagementServiceCollectionExtensions private static void AddKeyManagementQueries(this IServiceCollection services) { services.AddScoped(); - services.AddScoped(); services.AddScoped(); } } diff --git a/src/Core/KeyManagement/Queries/GetMinimumClientVersionForUserQuery.cs b/src/Core/KeyManagement/Queries/GetMinimumClientVersionForUserQuery.cs index 4b59a4f3d7..b39fa11320 100644 --- a/src/Core/KeyManagement/Queries/GetMinimumClientVersionForUserQuery.cs +++ b/src/Core/KeyManagement/Queries/GetMinimumClientVersionForUserQuery.cs @@ -3,21 +3,21 @@ using Bit.Core.KeyManagement.Queries.Interfaces; namespace Bit.Core.KeyManagement.Queries; -public class GetMinimumClientVersionForUserQuery(IIsV2EncryptionUserQuery isV2EncryptionUserQuery) +public class GetMinimumClientVersionForUserQuery() : IGetMinimumClientVersionForUserQuery { - public async Task Run(User? user) + public Task Run(User? user) { if (user == null) { - return null; + return Task.FromResult(null); } - if (await isV2EncryptionUserQuery.Run(user)) + if (user.IsSecurityVersionTwo()) { - return Constants.MinimumClientVersionForV2Encryption; + return Task.FromResult(Constants.MinimumClientVersionForV2Encryption)!; } - return null; + return Task.FromResult(null); } } diff --git a/src/Core/KeyManagement/Queries/Interfaces/IIsV2EncryptionUserQuery.cs b/src/Core/KeyManagement/Queries/Interfaces/IIsV2EncryptionUserQuery.cs deleted file mode 100644 index 38c0e10b44..0000000000 --- a/src/Core/KeyManagement/Queries/Interfaces/IIsV2EncryptionUserQuery.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Bit.Core.Entities; - -namespace Bit.Core.KeyManagement.Queries.Interfaces; - -public interface IIsV2EncryptionUserQuery -{ - Task Run(User user); -} diff --git a/src/Core/KeyManagement/Queries/IsV2EncryptionUserQuery.cs b/src/Core/KeyManagement/Queries/IsV2EncryptionUserQuery.cs deleted file mode 100644 index ea64d5a20a..0000000000 --- a/src/Core/KeyManagement/Queries/IsV2EncryptionUserQuery.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Bit.Core.Entities; -using Bit.Core.Enums; -using Bit.Core.KeyManagement.Queries.Interfaces; -using Bit.Core.KeyManagement.Repositories; -using Bit.Core.KeyManagement.Utilities; - -namespace Bit.Core.KeyManagement.Queries; - -public class IsV2EncryptionUserQuery(IUserSignatureKeyPairRepository userSignatureKeyPairRepository) - : IIsV2EncryptionUserQuery -{ - public async Task Run(User user) - { - ArgumentNullException.ThrowIfNull(user); - - var hasSignatureKeyPair = await userSignatureKeyPairRepository.GetByUserIdAsync(user.Id) != null; - var isPrivateKeyEncryptionV2 = - !string.IsNullOrWhiteSpace(user.PrivateKey) && - EncryptionParsing.GetEncryptionType(user.PrivateKey) == EncryptionType.XChaCha20Poly1305_B64; - - return hasSignatureKeyPair switch - { - // Valid v2 user - true when isPrivateKeyEncryptionV2 => true, - // Valid v1 user - false when !isPrivateKeyEncryptionV2 => false, - _ => throw new InvalidOperationException( - "User is in an invalid state for key rotation. User has a signature key pair, but the private key is not in v2 format, or vice versa.") - }; - } -} diff --git a/src/Core/KeyManagement/Utilities/EncryptionParsing.cs b/src/Core/KeyManagement/Utilities/EncryptionParsing.cs index ffe8cb3134..96a3117cf7 100644 --- a/src/Core/KeyManagement/Utilities/EncryptionParsing.cs +++ b/src/Core/KeyManagement/Utilities/EncryptionParsing.cs @@ -7,8 +7,10 @@ public static class EncryptionParsing /// /// Helper method to convert an encryption type string to an enum value. /// - public static EncryptionType GetEncryptionType(string encString) + public static EncryptionType GetEncryptionType(string? encString) { + ArgumentNullException.ThrowIfNull(encString); + var parts = encString.Split('.'); if (parts.Length == 1) { diff --git a/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs b/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs index a9de0550cc..558ad041c2 100644 --- a/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/ClientVersionValidator.cs @@ -11,6 +11,16 @@ public interface IClientVersionValidator Task ValidateAsync(User user, CustomValidatorRequestContext requestContext); } +/// +/// This validator will use the Client Version on a request, which currently maps +/// to the "Bitwarden-Client-Version" header, to determine if a user meets minimum +/// required client version for issuing tokens on an old client. This is done to +/// incentivize users getting on an updated client when their password encryption +/// method has already been updated. Currently this validator looks for the version +/// defined by MinimumClientVersionForV2Encryption. +/// +/// If the header is omitted, then the validator returns that this request is valid. +/// public class ClientVersionValidator( ICurrentContext currentContext, IGetMinimumClientVersionForUserQuery getMinimumClientVersionForUserQuery) diff --git a/test/Core.Test/KeyManagement/Queries/GetMinimumClientVersionForUserQueryTests.cs b/test/Core.Test/KeyManagement/Queries/GetMinimumClientVersionForUserQueryTests.cs index 77410c25a0..db8a76e06b 100644 --- a/test/Core.Test/KeyManagement/Queries/GetMinimumClientVersionForUserQueryTests.cs +++ b/test/Core.Test/KeyManagement/Queries/GetMinimumClientVersionForUserQueryTests.cs @@ -1,32 +1,30 @@ using Bit.Core.Entities; using Bit.Core.KeyManagement.Queries; -using Bit.Core.KeyManagement.Queries.Interfaces; using Xunit; namespace Bit.Core.Test.KeyManagement.Queries; public class GetMinimumClientVersionForUserQueryTests { - private class FakeIsV2Query : IIsV2EncryptionUserQuery - { - private readonly bool _isV2; - public FakeIsV2Query(bool isV2) { _isV2 = isV2; } - public Task Run(User user) => Task.FromResult(_isV2); - } - [Fact] public async Task Run_ReturnsMinVersion_ForV2User() { - var sut = new GetMinimumClientVersionForUserQuery(new FakeIsV2Query(true)); - var version = await sut.Run(new User()); + var sut = new GetMinimumClientVersionForUserQuery(); + var version = await sut.Run(new User + { + SecurityVersion = 2 + }); Assert.Equal(Core.KeyManagement.Constants.MinimumClientVersionForV2Encryption, version); } [Fact] public async Task Run_ReturnsNull_ForV1User() { - var sut = new GetMinimumClientVersionForUserQuery(new FakeIsV2Query(false)); - var version = await sut.Run(new User()); + var sut = new GetMinimumClientVersionForUserQuery(); + var version = await sut.Run(new User + { + SecurityVersion = 1 + }); Assert.Null(version); } } diff --git a/test/Core.Test/KeyManagement/Queries/IsV2EncryptionUserQueryTests.cs b/test/Core.Test/KeyManagement/Queries/IsV2EncryptionUserQueryTests.cs deleted file mode 100644 index 30ec47c2fe..0000000000 --- a/test/Core.Test/KeyManagement/Queries/IsV2EncryptionUserQueryTests.cs +++ /dev/null @@ -1,64 +0,0 @@ -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 Bit.Test.Common.Constants; -using Xunit; - -namespace Bit.Core.Test.KeyManagement.Queries; - -public class IsV2EncryptionUserQueryTests -{ - private class FakeUserSignatureKeyPairRepository : IUserSignatureKeyPairRepository - { - private readonly bool _hasKeys; - public FakeUserSignatureKeyPairRepository(bool hasKeys) { _hasKeys = hasKeys; } - public Task GetByUserIdAsync(Guid userId) - => Task.FromResult(_hasKeys ? new SignatureKeyPairData(SignatureAlgorithm.Ed25519, TestEncryptionConstants.V2WrappedSigningKey, TestEncryptionConstants.V2VerifyingKey) : null); - - // Unused in tests - public Task> GetManyAsync(IEnumerable ids) => throw new NotImplementedException(); - public Task GetByIdAsync(Guid id) => throw new NotImplementedException(); - public Task CreateAsync(UserSignatureKeyPair obj) => throw new NotImplementedException(); - public Task ReplaceAsync(UserSignatureKeyPair obj) => throw new NotImplementedException(); - public Task UpsertAsync(UserSignatureKeyPair obj) => throw new NotImplementedException(); - public Task DeleteAsync(UserSignatureKeyPair obj) => throw new NotImplementedException(); - public Task DeleteAsync(Guid id) => throw new NotImplementedException(); - public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid grantorId, SignatureKeyPairData signatureKeyPair) => throw new NotImplementedException(); - public UpdateEncryptedDataForKeyRotation SetUserSignatureKeyPair(Guid userId, SignatureKeyPairData signatureKeyPair) => throw new NotImplementedException(); - } - - [Fact] - public async Task Run_ReturnsTrue_ForV2State() - { - var user = new User { Id = Guid.NewGuid(), PrivateKey = TestEncryptionConstants.V2PrivateKey }; - var sut = new IsV2EncryptionUserQuery(new FakeUserSignatureKeyPairRepository(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 = TestEncryptionConstants.V1EncryptedBase64 }; - var sut = new IsV2EncryptionUserQuery(new FakeUserSignatureKeyPairRepository(false)); - - var result = await sut.Run(user); - - Assert.False(result); - } - - [Fact] - public async Task Run_ThrowsForInvalidMixedState() - { - var user = new User { Id = Guid.NewGuid(), PrivateKey = TestEncryptionConstants.V2PrivateKey }; - var sut = new IsV2EncryptionUserQuery(new FakeUserSignatureKeyPairRepository(false)); - - await Assert.ThrowsAsync(async () => await sut.Run(user)); - } -}