diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index 94dec8015b5d..5c72fb7af0b3 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -116,7 +116,7 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac public string GetMasterPasswordSalt() { - return Email.ToLowerInvariant().Trim(); + return MasterPasswordSalt ?? Email.ToLowerInvariant().Trim(); } public void SetNewId() diff --git a/src/Core/Models/Data/UserKdfInformation.cs b/src/Core/Models/Data/UserKdfInformation.cs index 0e5696e5816c..e5a463481672 100644 --- a/src/Core/Models/Data/UserKdfInformation.cs +++ b/src/Core/Models/Data/UserKdfInformation.cs @@ -8,4 +8,5 @@ public class UserKdfInformation public required int KdfIterations { get; set; } public int? KdfMemory { get; set; } public int? KdfParallelism { get; set; } + public string? MasterPasswordSalt { get; set; } } diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index a5a8c4310b3e..322133ae2424 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -4,8 +4,6 @@ using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; -#nullable enable - namespace Bit.Core.Repositories; public interface IUserRepository : IRepository diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index c364eeb8bc4c..b003d997bac1 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -73,7 +73,8 @@ public UserRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) Kdf = e.Kdf, KdfIterations = e.KdfIterations, KdfMemory = e.KdfMemory, - KdfParallelism = e.KdfParallelism + KdfParallelism = e.KdfParallelism, + MasterPasswordSalt = e.MasterPasswordSalt }).SingleOrDefaultAsync(); } } @@ -307,7 +308,7 @@ public async Task SetV2AccountCryptographicStateAsync( userEntity.SecurityVersion = accountKeysData.SecurityStateData.SecurityVersion; userEntity.SignedPublicKey = accountKeysData.PublicKeyEncryptionKeyPairData.SignedPublicKey; - // Replace existing keypair if it exists + // Replace existing key-pair if it exists var existingKeyPair = await dbContext.UserSignatureKeyPairs .FirstOrDefaultAsync(x => x.UserId == userId); if (existingKeyPair != null) diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadManyResetPasswordDetailsByOrganizationUserIds.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadManyAccountRecoveryDetailsByOrganizationUserIds.sql similarity index 55% rename from src/Sql/dbo/Stored Procedures/OrganizationUser_ReadManyResetPasswordDetailsByOrganizationUserIds.sql rename to src/Sql/dbo/Stored Procedures/OrganizationUser_ReadManyAccountRecoveryDetailsByOrganizationUserIds.sql index 269a297b6e2f..808bc66dbd94 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadManyResetPasswordDetailsByOrganizationUserIds.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadManyAccountRecoveryDetailsByOrganizationUserIds.sql @@ -11,14 +11,17 @@ BEGIN U.[KdfIterations], U.[KdfMemory], U.[KdfParallelism], + U.[MasterPasswordSalt], OU.[ResetPasswordKey], O.[PrivateKey] AS EncryptedPrivateKey - FROM @OrganizationUserIds AS OUIDs - INNER JOIN [dbo].[OrganizationUser] AS OU - ON OUIDs.[Id] = OU.[Id] - INNER JOIN [dbo].[Organization] AS O - ON OU.[OrganizationId] = O.[Id] - INNER JOIN [dbo].[User] U - ON U.[Id] = OU.[UserId] - WHERE OU.[OrganizationId] = @OrganizationId + FROM + @OrganizationUserIds AS OUIDs + INNER JOIN + [dbo].[OrganizationUser] AS OU ON OUIDs.[Id] = OU.[Id] + INNER JOIN + [dbo].[Organization] AS O ON OU.[OrganizationId] = O.[Id] + INNER JOIN + [dbo].[User] U ON U.[Id] = OU.[UserId] + WHERE + OU.[OrganizationId] = @OrganizationId END diff --git a/src/Sql/dbo/Stored Procedures/User_ReadKdfByEmail.sql b/src/Sql/dbo/Stored Procedures/User_ReadKdfByEmail.sql index ee0d87742e8f..cd5d68e78b24 100644 --- a/src/Sql/dbo/Stored Procedures/User_ReadKdfByEmail.sql +++ b/src/Sql/dbo/Stored Procedures/User_ReadKdfByEmail.sql @@ -8,7 +8,8 @@ BEGIN [Kdf], [KdfIterations], [KdfMemory], - [KdfParallelism] + [KdfParallelism], + [MasterPasswordSalt] FROM [dbo].[User] WHERE diff --git a/test/Api.Test/Auth/Models/Response/EmergencyAccessTakeoverResponseModelTests.cs b/test/Api.Test/Auth/Models/Response/EmergencyAccessTakeoverResponseModelTests.cs index 1a46cb195655..5245c5494696 100644 --- a/test/Api.Test/Auth/Models/Response/EmergencyAccessTakeoverResponseModelTests.cs +++ b/test/Api.Test/Auth/Models/Response/EmergencyAccessTakeoverResponseModelTests.cs @@ -33,18 +33,6 @@ public void Constructor_ValidInputs_SetsAllPropertiesCorrectly( Assert.Equal(grantor.GetMasterPasswordSalt(), model.Salt); } - [Theory] - [BitAutoData] - public void Constructor_Salt_EqualsGrantorEmailLowercasedAndTrimmed( - EmergencyAccess emergencyAccess, User grantor) - { - grantor.Email = " TEST@Example.COM "; - - var model = new EmergencyAccessTakeoverResponseModel(emergencyAccess, grantor); - - Assert.Equal("test@example.com", model.Salt); - } - [Theory] [InlineData("user@domain.com", "user@domain.com")] [InlineData("USER@DOMAIN.COM", "user@domain.com")] diff --git a/test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs index 3bb3a70b0dc4..067eb0d68944 100644 --- a/test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs @@ -740,6 +740,103 @@ public async Task UpdateUserKeyAndEncryptedDataV2Async_InvokesUpdateDataActions( Assert.True(actionWasInvoked); } + [Theory, DatabaseData] + public async Task GetKdfInformationByEmailAsync_WithPbkdf2User_ReturnsKdfInformation( + IUserRepository userRepository) + { + // Arrange + var email = $"test+{Guid.NewGuid()}@example.com"; + var salt = "test-salt-value"; + await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = email, + ApiKey = "TEST", + SecurityStamp = "stamp", + MasterPassword = "password_hash", + MasterPasswordSalt = salt, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + }); + + // Act + var result = await userRepository.GetKdfInformationByEmailAsync(email); + + // Assert + Assert.NotNull(result); + Assert.Equal(KdfType.PBKDF2_SHA256, result.Kdf); + Assert.Equal(AuthConstants.PBKDF2_ITERATIONS.Default, result.KdfIterations); + Assert.Null(result.KdfMemory); + Assert.Null(result.KdfParallelism); + Assert.Equal(salt, result.MasterPasswordSalt); + } + + [Theory, DatabaseData] + public async Task GetKdfInformationByEmailAsync_WithArgon2idUser_ReturnsKdfInformationWithMemoryAndParallelism( + IUserRepository userRepository) + { + // Arrange + var email = $"test+{Guid.NewGuid()}@example.com"; + var salt = "argon2-salt-value"; + await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = email, + ApiKey = "TEST", + SecurityStamp = "stamp", + MasterPassword = "password_hash", + MasterPasswordSalt = salt, + Kdf = KdfType.Argon2id, + KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default, + KdfMemory = AuthConstants.ARGON2_MEMORY.Default, + KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default, + }); + + // Act + var result = await userRepository.GetKdfInformationByEmailAsync(email); + + // Assert + Assert.NotNull(result); + Assert.Equal(KdfType.Argon2id, result.Kdf); + Assert.Equal(AuthConstants.ARGON2_ITERATIONS.Default, result.KdfIterations); + Assert.Equal(AuthConstants.ARGON2_MEMORY.Default, result.KdfMemory); + Assert.Equal(AuthConstants.ARGON2_PARALLELISM.Default, result.KdfParallelism); + Assert.Equal(salt, result.MasterPasswordSalt); + } + + [Theory, DatabaseData] + public async Task GetKdfInformationByEmailAsync_WithNoMasterPassword_ReturnsNullSalt( + IUserRepository userRepository) + { + // Arrange + var email = $"test+{Guid.NewGuid()}@example.com"; + await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = email, + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + // Act + var result = await userRepository.GetKdfInformationByEmailAsync(email); + + // Assert + Assert.NotNull(result); + Assert.Null(result.MasterPasswordSalt); + } + + [Theory, DatabaseData] + public async Task GetKdfInformationByEmailAsync_WithNonExistentEmail_ReturnsNull( + IUserRepository userRepository) + { + // Act + var result = await userRepository.GetKdfInformationByEmailAsync($"nonexistent+{Guid.NewGuid()}@example.com"); + + // Assert + Assert.Null(result); + } + private static async Task RunUpdateUserDataAsync(UpdateUserData task, Database database) { if (database.Type == SupportedDatabaseProviders.SqlServer && !database.UseEf) diff --git a/util/Migrator/DbScripts/2026-03-16_00_AlterReadKdfByEmail.sql b/util/Migrator/DbScripts/2026-03-16_00_AlterReadKdfByEmail.sql new file mode 100644 index 000000000000..88a630c88932 --- /dev/null +++ b/util/Migrator/DbScripts/2026-03-16_00_AlterReadKdfByEmail.sql @@ -0,0 +1,18 @@ +CREATE OR ALTER PROCEDURE [dbo].[User_ReadKdfByEmail] + @Email NVARCHAR(256) +AS +BEGIN + SET NOCOUNT ON + + SELECT + [Kdf], + [KdfIterations], + [KdfMemory], + [KdfParallelism], + [MasterPasswordSalt] + FROM + [dbo].[User] + WHERE + [Email] = @Email +END +GO diff --git a/util/Migrator/DbScripts/2026-03-16_01_AlterReadManyAccountRecoveryDetailsByOrganizationUserIds.sql b/util/Migrator/DbScripts/2026-03-16_01_AlterReadManyAccountRecoveryDetailsByOrganizationUserIds.sql new file mode 100644 index 000000000000..27bf95c196b2 --- /dev/null +++ b/util/Migrator/DbScripts/2026-03-16_01_AlterReadManyAccountRecoveryDetailsByOrganizationUserIds.sql @@ -0,0 +1,28 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ReadManyAccountRecoveryDetailsByOrganizationUserIds] + @OrganizationId UNIQUEIDENTIFIER, + @OrganizationUserIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + SELECT + OU.[Id] AS OrganizationUserId, + U.[Kdf], + U.[KdfIterations], + U.[KdfMemory], + U.[KdfParallelism], + U.[MasterPasswordSalt], + OU.[ResetPasswordKey], + O.[PrivateKey] AS EncryptedPrivateKey + FROM + @OrganizationUserIds AS OUIDs + INNER JOIN + [dbo].[OrganizationUser] AS OU ON OUIDs.[Id] = OU.[Id] + INNER JOIN + [dbo].[Organization] AS O ON OU.[OrganizationId] = O.[Id] + INNER JOIN + [dbo].[User] U ON U.[Id] = OU.[UserId] + WHERE + OU.[OrganizationId] = @OrganizationId +END +GO