From eb942e8bbb8e57f7c08de522285eafdbfd337f89 Mon Sep 17 00:00:00 2001 From: enmande <3836813+enmande@users.noreply.github.com> Date: Tue, 12 May 2026 10:20:24 -0400 Subject: [PATCH 01/14] test(change-kdf-cmd): Update tests for MasterPasswordService internals. --- .../Kdf/ChangeKdfCommandTests.cs | 172 +++++++++++------- 1 file changed, 111 insertions(+), 61 deletions(-) diff --git a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs index 991935b92896..013152f152bb 100644 --- a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs +++ b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs @@ -1,17 +1,20 @@ -#nullable enable +#nullable enable +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Kdf.Implementations; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Platform.Push; -using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Identity; using NSubstitute; +using NSubstitute.ExceptionExtensions; +using OneOf; using Xunit; namespace Bit.Core.Test.KeyManagement.Kdf; @@ -21,14 +24,67 @@ public class ChangeKdfCommandTests { [Theory] [BitAutoData] - public async Task ChangeKdfAsync_ChangesKdfAsync(SutProvider sutProvider, User user) + public async Task ChangeKdfAsync_ServiceReturnsUser_ReturnsIdentityResultSuccess( + SutProvider sutProvider, User user, KdfSettings kdf) { - sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(true)); - sutProvider.GetDependency().UpdatePasswordHash(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(IdentityResult.Success)); + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "auth-hash", + Salt = user.GetMasterPasswordSalt() + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrapped-key", + Salt = user.GetMasterPasswordSalt() + }; + sutProvider.GetDependency().CheckPasswordAsync(user, Arg.Any()) + .Returns(true); + sutProvider.GetDependency() + .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Any()) + .Returns(OneOf.FromT0(user)); + + var result = await sutProvider.Sut.ChangeKdfAsync(user, "current-password", authenticationData, unlockData); + + Assert.True(result.Succeeded); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_ServiceReturnsErrors_ReturnsIdentityResultFailed( + SutProvider sutProvider, User user, KdfSettings kdf) + { + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "auth-hash", + Salt = user.GetMasterPasswordSalt() + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrapped-key", + Salt = user.GetMasterPasswordSalt() + }; + var serviceError = new IdentityError { Code = "SomeError", Description = "Service error" }; + sutProvider.GetDependency().CheckPasswordAsync(user, Arg.Any()) + .Returns(true); + sutProvider.GetDependency() + .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Any()) + .Returns(OneOf.FromT1(new[] { serviceError })); + + var result = await sutProvider.Sut.ChangeKdfAsync(user, "current-password", authenticationData, unlockData); + + Assert.False(result.Succeeded); + Assert.Contains(result.Errors, e => e.Code == "SomeError"); + } - var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 }; + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_ChangesKdfAsync(SutProvider sutProvider, User user) + { + var kdf = new KdfSettings { KdfType = KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 }; var authenticationData = new MasterPasswordAuthenticationData { Kdf = kdf, @@ -41,23 +97,25 @@ public async Task ChangeKdfAsync_ChangesKdfAsync(SutProvider s MasterKeyWrappedUserKey = "masterKeyWrappedUserKey", Salt = user.GetMasterPasswordSalt() }; + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()) + .Returns(true); + sutProvider.GetDependency() + .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Any()) + .Returns(OneOf.FromT0(user)); await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); - await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(u => - u.Id == user.Id - && u.Kdf == Enums.KdfType.Argon2id - && u.KdfIterations == 4 - && u.KdfMemory == 512 - && u.KdfParallelism == 4 - )); + await sutProvider.GetDependency().Received(1) + .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Is(d => + d.MasterPasswordAuthentication == authenticationData && + d.MasterPasswordUnlock == unlockData)); } [Theory] [BitAutoData] public async Task ChangeKdfAsync_UserIsNull_ThrowsArgumentNullException(SutProvider sutProvider) { - var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 }; + var kdf = new KdfSettings { KdfType = KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 }; var authenticationData = new MasterPasswordAuthenticationData { Kdf = kdf, @@ -83,7 +141,7 @@ public async Task ChangeKdfAsync_WrongPassword_ReturnsPasswordMismatch(SutProvid sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(false)); - var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 }; + var kdf = new KdfSettings { KdfType = KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 }; var authenticationData = new MasterPasswordAuthenticationData { Kdf = kdf, @@ -110,7 +168,7 @@ public async Task { var constantKdf = new KdfSettings { - KdfType = Enums.KdfType.Argon2id, + KdfType = KdfType.Argon2id, Iterations = 5, Memory = 1024, Parallelism = 4 @@ -128,24 +186,17 @@ public async Task Salt = user.GetMasterPasswordSalt() }; sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(true)); - sutProvider.GetDependency() - .UpdatePasswordHash(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(IdentityResult.Success)); + .Returns(true); sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(false); + sutProvider.GetDependency() + .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Any()) + .Returns(OneOf.FromT0(user)); await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); - await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(u => - u.Id == user.Id - && u.Kdf == constantKdf.KdfType - && u.KdfIterations == constantKdf.Iterations - && u.KdfMemory == constantKdf.Memory - && u.KdfParallelism == constantKdf.Parallelism - && u.Key == "new-wrapped-key" - )); - await sutProvider.GetDependency().Received(1).UpdatePasswordHash(user, - authenticationData.MasterPasswordAuthenticationHash, validatePassword: true, refreshStamp: true); + await sutProvider.GetDependency().Received(1) + .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Is(d => + d.RefreshStamp == true)); await sutProvider.GetDependency().Received(1).PushLogOutAsync(user.Id); sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange); } @@ -158,7 +209,7 @@ public async Task { var constantKdf = new KdfSettings { - KdfType = Enums.KdfType.Argon2id, + KdfType = KdfType.Argon2id, Iterations = 5, Memory = 1024, Parallelism = 4 @@ -176,24 +227,17 @@ public async Task Salt = user.GetMasterPasswordSalt() }; sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(true)); - sutProvider.GetDependency() - .UpdatePasswordHash(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(IdentityResult.Success)); + .Returns(true); sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); + sutProvider.GetDependency() + .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Any()) + .Returns(OneOf.FromT0(user)); await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); - await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(u => - u.Id == user.Id - && u.Kdf == constantKdf.KdfType - && u.KdfIterations == constantKdf.Iterations - && u.KdfMemory == constantKdf.Memory - && u.KdfParallelism == constantKdf.Parallelism - && u.Key == "new-wrapped-key" - )); - await sutProvider.GetDependency().Received(1).UpdatePasswordHash(user, - authenticationData.MasterPasswordAuthenticationHash, validatePassword: true, refreshStamp: false); + await sutProvider.GetDependency().Received(1) + .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Is(d => + d.RefreshStamp == false)); await sutProvider.GetDependency().Received(1) .PushLogOutAsync(user.Id, false, PushNotificationLogOutReason.KdfChange); await sutProvider.GetDependency().Received(1).PushSyncSettingsAsync(user.Id); @@ -212,7 +256,7 @@ public async Task ChangeKdfAsync_KdfNotEqualBetweenAuthAndUnlock_ThrowsBadReques { Kdf = new KdfSettings { - KdfType = Enums.KdfType.Argon2id, + KdfType = KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 @@ -222,7 +266,7 @@ public async Task ChangeKdfAsync_KdfNotEqualBetweenAuthAndUnlock_ThrowsBadReques }; var unlockData = new MasterPasswordUnlockData { - Kdf = new KdfSettings { KdfType = Enums.KdfType.PBKDF2_SHA256, Iterations = 100000 }, + Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 100000 }, MasterKeyWrappedUserKey = "new-wrapped-key", Salt = user.GetMasterPasswordSalt() }; @@ -236,7 +280,10 @@ public async Task ChangeKdfAsync_AuthDataSaltMismatch_Throws(SutProvider().CheckPasswordAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(true)); + .Returns(true); + sutProvider.GetDependency() + .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Any()) + .ThrowsAsync(new BadRequestException("Salt mismatch.")); var authenticationData = new MasterPasswordAuthenticationData { @@ -260,7 +307,10 @@ public async Task ChangeKdfAsync_UnlockDataSaltMismatch_Throws(SutProvider().CheckPasswordAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(true)); + .Returns(true); + sutProvider.GetDependency() + .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Any()) + .ThrowsAsync(new BadRequestException("Salt mismatch.")); var authenticationData = new MasterPasswordAuthenticationData { @@ -280,16 +330,17 @@ await Assert.ThrowsAsync(async () => [Theory] [BitAutoData] - public async Task ChangeKdfAsync_UpdatePasswordHashFails_ReturnsFailure(SutProvider sutProvider, - User user) + public async Task ChangeKdfAsync_ServiceReturnsFailure_ReturnsFailedIdentityResult( + SutProvider sutProvider, User user) { sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(true)); - var failedResult = IdentityResult.Failed(new IdentityError { Code = "TestFail", Description = "Test fail" }); - sutProvider.GetDependency().UpdatePasswordHash(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(failedResult)); + .Returns(true); + var failureError = new IdentityError { Code = "TestFail", Description = "Test fail" }; + sutProvider.GetDependency() + .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Any()) + .Returns(OneOf.FromT1(new[] { failureError })); - var kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 }; + var kdf = new KdfSettings { KdfType = KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 }; var authenticationData = new MasterPasswordAuthenticationData { Kdf = kdf, @@ -316,10 +367,9 @@ public async Task ChangeKdfAsync_InvalidKdfSettings_ThrowsBadRequestException( sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); - // Create invalid KDF settings (iterations too low for PBKDF2) var invalidKdf = new KdfSettings { - KdfType = Enums.KdfType.PBKDF2_SHA256, + KdfType = KdfType.PBKDF2_SHA256, Iterations = 1000, // This is below the minimum of 600,000 Memory = null, Parallelism = null @@ -355,7 +405,7 @@ public async Task ChangeKdfAsync_InvalidArgon2Settings_ThrowsBadRequestException // Create invalid Argon2 KDF settings (memory too high) var invalidKdf = new KdfSettings { - KdfType = Enums.KdfType.Argon2id, + KdfType = KdfType.Argon2id, Iterations = 3, // Valid Memory = 2048, // This is above the maximum of 1024 Parallelism = 4 // Valid From dcdf3d1da7353074fb18d5dea3eec6cc1896aa52 Mon Sep 17 00:00:00 2001 From: enmande <3836813+enmande@users.noreply.github.com> Date: Tue, 12 May 2026 10:43:27 -0400 Subject: [PATCH 02/14] feat(change-kdf-cmd): Add MasterPasswordService integration. --- .../Kdf/Implementations/ChangeKdfCommand.cs | 59 ++++++------------- .../Kdf/ChangeKdfCommandTests.cs | 6 +- 2 files changed, 22 insertions(+), 43 deletions(-) diff --git a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs index 17cb20723ec3..587dbbde78a7 100644 --- a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs +++ b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs @@ -1,13 +1,13 @@ -using Bit.Core.Entities; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Platform.Push; -using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Logging; namespace Bit.Core.KeyManagement.Kdf.Implementations; @@ -16,20 +16,18 @@ public class ChangeKdfCommand : IChangeKdfCommand { private readonly IUserService _userService; private readonly IPushNotificationService _pushService; - private readonly IUserRepository _userRepository; + private readonly IMasterPasswordService _masterPasswordService; private readonly IdentityErrorDescriber _identityErrorDescriber; - private readonly ILogger _logger; private readonly IFeatureService _featureService; public ChangeKdfCommand(IUserService userService, IPushNotificationService pushService, - IUserRepository userRepository, IdentityErrorDescriber describer, ILogger logger, + IMasterPasswordService masterPasswordService, IdentityErrorDescriber describer, IFeatureService featureService) { _userService = userService; _pushService = pushService; - _userRepository = userRepository; + _masterPasswordService = masterPasswordService; _identityErrorDescriber = describer; - _logger = logger; _featureService = featureService; } @@ -44,7 +42,8 @@ public async Task ChangeKdfAsync(User user, string masterPasswor // Validate to prevent user account from becoming un-decryptable from invalid parameters // - // Prevent a de-synced salt value from creating an un-decryptable unlock method + // Prevent a de-synced salt value from creating an un-decryptable unlock method. + // Also checked in the MasterPasswordService via UpdateExistingPasswordAndKdfData.ValidateDataForUser. authenticationData.ValidateSaltUnchangedForUser(user); unlockData.ValidateSaltUnchangedForUser(user); @@ -62,42 +61,20 @@ public async Task ChangeKdfAsync(User user, string masterPasswor var logoutOnKdfChange = !_featureService.IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange); - // Update the user with the new KDF settings - // This updates the authentication data and unlock data for the user separately. Currently these still - // use shared values for KDF settings and salt. - // The authentication hash, and the unlock data each are dependent on: - // - The master password (entered by the user every time) - // - The KDF settings (iterations, memory, parallelism) - // - The salt - // These combinations - (password, authentication hash, KDF settings, salt) and (password, unlock data, KDF settings, salt) - // must remain consistent to unlock correctly. + var data = new UpdateExistingPasswordAndKdfData + { + MasterPasswordAuthentication = authenticationData, + MasterPasswordUnlock = unlockData, + ValidatePassword = false, // password already verified by CheckPasswordAsync above + RefreshStamp = logoutOnKdfChange + }; - // Authentication - // Note: This mutates the user but does not yet save it to DB. That is done atomically, later. - // This entire operation MUST be atomic to prevent a user from being locked out of their account. - // Salt is ensured to be the same as unlock data, and the value stored in the account and not updated. - // KDF is ensured to be the same as unlock data above and updated below. - var result = await _userService.UpdatePasswordHash(user, authenticationData.MasterPasswordAuthenticationHash, - refreshStamp: logoutOnKdfChange); - if (!result.Succeeded) + var result = await _masterPasswordService.SaveUpdateExistingMasterPasswordAndKdfAsync(user, data); + if (result.TryPickT1(out var errors, out _)) { - _logger.LogWarning("Change KDF failed for user {userId}.", user.Id); - return result; + return IdentityResult.Failed(errors); } - // Salt is ensured to be the same as authentication data, and the value stored in the account, and is not updated. - // Kdf - These will be seperated in the future, but for now are ensured to be the same as authentication data above. - user.Key = unlockData.MasterKeyWrappedUserKey; - user.Kdf = unlockData.Kdf.KdfType; - user.KdfIterations = unlockData.Kdf.Iterations; - user.KdfMemory = unlockData.Kdf.Memory; - user.KdfParallelism = unlockData.Kdf.Parallelism; - - var now = DateTime.UtcNow; - user.RevisionDate = user.AccountRevisionDate = now; - user.LastKdfChangeDate = now; - - await _userRepository.ReplaceAsync(user); if (logoutOnKdfChange) { await _pushService.PushLogOutAsync(user.Id); diff --git a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs index 013152f152bb..5e5d960b5cf1 100644 --- a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs +++ b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs @@ -25,8 +25,9 @@ public class ChangeKdfCommandTests [Theory] [BitAutoData] public async Task ChangeKdfAsync_ServiceReturnsUser_ReturnsIdentityResultSuccess( - SutProvider sutProvider, User user, KdfSettings kdf) + SutProvider sutProvider, User user) { + var kdf = new KdfSettings { KdfType = KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 }; var authenticationData = new MasterPasswordAuthenticationData { Kdf = kdf, @@ -53,8 +54,9 @@ public async Task ChangeKdfAsync_ServiceReturnsUser_ReturnsIdentityResultSuccess [Theory] [BitAutoData] public async Task ChangeKdfAsync_ServiceReturnsErrors_ReturnsIdentityResultFailed( - SutProvider sutProvider, User user, KdfSettings kdf) + SutProvider sutProvider, User user) { + var kdf = new KdfSettings { KdfType = KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 }; var authenticationData = new MasterPasswordAuthenticationData { Kdf = kdf, From d65c1de3e64c304a93e248774b6f8b70cb7d769f Mon Sep 17 00:00:00 2001 From: enmande <3836813+enmande@users.noreply.github.com> Date: Tue, 12 May 2026 10:58:01 -0400 Subject: [PATCH 03/14] test(change-kdf-cmd): Add not-ValidatePassword assertion for KDF change. --- test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs index 5e5d960b5cf1..bc8b3e1daa8a 100644 --- a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs +++ b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs @@ -110,7 +110,8 @@ public async Task ChangeKdfAsync_ChangesKdfAsync(SutProvider s await sutProvider.GetDependency().Received(1) .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Is(d => d.MasterPasswordAuthentication == authenticationData && - d.MasterPasswordUnlock == unlockData)); + d.MasterPasswordUnlock == unlockData && + d.ValidatePassword == false)); } [Theory] From e54f074eae5ffe603271319d90351aa2eabbcd35 Mon Sep 17 00:00:00 2001 From: enmande <3836813+enmande@users.noreply.github.com> Date: Thu, 14 May 2026 11:05:52 -0400 Subject: [PATCH 04/14] feat(hint): Pass user hint on KDF rotation. --- src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs | 3 ++- test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs index 587dbbde78a7..1de347537028 100644 --- a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs +++ b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs @@ -66,7 +66,8 @@ public async Task ChangeKdfAsync(User user, string masterPasswor MasterPasswordAuthentication = authenticationData, MasterPasswordUnlock = unlockData, ValidatePassword = false, // password already verified by CheckPasswordAsync above - RefreshStamp = logoutOnKdfChange + RefreshStamp = logoutOnKdfChange, + MasterPasswordHint = user.MasterPasswordHint, // KDF rotation does not change the hint; carry existing value through }; var result = await _masterPasswordService.SaveUpdateExistingMasterPasswordAndKdfAsync(user, data); diff --git a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs index bc8b3e1daa8a..0aea89c3a3c3 100644 --- a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs +++ b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs @@ -111,7 +111,8 @@ await sutProvider.GetDependency().Received(1) .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Is(d => d.MasterPasswordAuthentication == authenticationData && d.MasterPasswordUnlock == unlockData && - d.ValidatePassword == false)); + d.ValidatePassword == false && + d.MasterPasswordHint == user.MasterPasswordHint)); } [Theory] From 530983ecc3e66b3b7cf8c1eb178708e8b05a17fd Mon Sep 17 00:00:00 2001 From: enmande <3836813+enmande@users.noreply.github.com> Date: Thu, 14 May 2026 12:27:06 -0400 Subject: [PATCH 05/14] fix(update-kdf): KDF update does not update LastPasswordChangeDate. --- .../Interfaces/IMasterPasswordService.cs | 8 +++++--- .../UserMasterPassword/MasterPasswordService.cs | 5 +++-- .../UserMasterPassword/MasterPasswordServiceTests.cs | 1 - 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs index 46f7c47c1c8e..5ca778b95ef3 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs @@ -255,11 +255,13 @@ public interface IMasterPasswordService Task> PrepareUpdateExistingMasterPasswordAsync(User user, UpdateExistingPasswordData updateExistingData); /// - /// Applies a new master password and updated KDF parameters over the user's existing ones - /// and persists the updated user to the database. KDF validation is intentionally skipped. + /// Updates the user's KDF parameters and persists the updated user to the database. + /// The user's master password authentication hash is re-derived + /// from the existing password using the new KDF parameters. + /// /// - /// Use when: rotating KDF parameters. + /// Use when: rotating KDF parameters. /// /// /// diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs index 12d3cb228263..40a1ee38044b 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs @@ -199,9 +199,10 @@ public async Task> SaveUpdateExistingMasterPassword // Always override the master password hint, even if it's null user.MasterPasswordHint = updateExistingData.MasterPasswordHint; - // Update time markers on the user + // Update time markers on the user. + // LastPasswordChangeDate is intentionally not set: KDF rotation re-derives the authentication + // hash from the same password using new KDF parameters — the user's password has not changed. var now = _timeProvider.GetUtcNow().UtcDateTime; - user.LastPasswordChangeDate = now; user.LastKdfChangeDate = now; user.RevisionDate = user.AccountRevisionDate = now; diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs index 121751efc274..6e0788748128 100644 --- a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs @@ -782,7 +782,6 @@ public async Task SaveUpdateExistingMasterPasswordAndKdf_Success(User user) Assert.Equal(data.MasterPasswordUnlock.Kdf.KdfType, user.Kdf); Assert.Equal(data.MasterPasswordUnlock.Kdf.Iterations, user.KdfIterations); Assert.Equal("test-hint", user.MasterPasswordHint); - Assert.Equal(expectedTime, user.LastPasswordChangeDate); Assert.Equal(expectedTime, user.LastKdfChangeDate); Assert.Equal(expectedTime, user.RevisionDate); Assert.Equal(user.RevisionDate, user.AccountRevisionDate); From 80733d19b052c6745fb76059aa280ce47d315364 Mon Sep 17 00:00:00 2001 From: enmande <3836813+enmande@users.noreply.github.com> Date: Thu, 14 May 2026 12:46:50 -0400 Subject: [PATCH 06/14] refactor(update-kdf): Rename UpdateExistingKdfConfiguration. Master Password hash will be updated as a necessary side-effect of KDF configuration change, but should not be represented in the method naming to prevent confusion. This is for KDF-update flows only. --- ... => UpdateExistingKdfConfigurationData.cs} | 8 ++-- .../Data/UpdateExistingPasswordData.cs | 2 +- .../Interfaces/IMasterPasswordService.cs | 16 ++++---- .../MasterPasswordService.cs | 4 +- .../Kdf/Implementations/ChangeKdfCommand.cs | 6 +-- ...pdateExistingKdfConfigurationDataTests.cs} | 10 ++--- .../MasterPasswordServiceTests.cs | 40 +++++++++---------- .../Kdf/ChangeKdfCommandTests.cs | 22 +++++----- 8 files changed, 53 insertions(+), 55 deletions(-) rename src/Core/Auth/UserFeatures/UserMasterPassword/Data/{UpdateExistingPasswordAndKdfData.cs => UpdateExistingKdfConfigurationData.cs} (93%) rename test/Core.Test/Auth/UserFeatures/UserMasterPassword/Data/{UpdateExistingPasswordAndKdfDataTests.cs => UpdateExistingKdfConfigurationDataTests.cs} (92%) diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordAndKdfData.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingKdfConfigurationData.cs similarity index 93% rename from src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordAndKdfData.cs rename to src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingKdfConfigurationData.cs index ed9812a890cc..98f049ee6df0 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordAndKdfData.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingKdfConfigurationData.cs @@ -1,20 +1,20 @@ -using Bit.Core.Entities; +using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Models.Data; namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; /// -/// Input data for updating a user's existing master password hash together with new KDF parameters. +/// Input data for updating a user's KDF configuration together with the re-derived authentication hash. /// Salt is validated as unchanged; KDF validation is intentionally skipped since KDF is being replaced. /// /// /// Use when: constructing a call to -/// +/// /// (KDF rotation flows). For hash-only updates, use instead. /// /// -public class UpdateExistingPasswordAndKdfData +public class UpdateExistingKdfConfigurationData { public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; } public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordData.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordData.cs index 0f3d9c3934c5..8e2002470656 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordData.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordData.cs @@ -13,7 +13,7 @@ namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; /// Use when: constructing a call to /// or /// . -/// For KDF rotation, use instead. +/// For KDF rotation, use instead. /// /// public class UpdateExistingPasswordData diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs index 5ca778b95ef3..43faf2eeaf31 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs @@ -256,13 +256,11 @@ public interface IMasterPasswordService /// /// Updates the user's KDF parameters and persists the updated user to the database. - /// The user's master password authentication hash is re-derived - /// from the existing password using the new KDF parameters. - + /// The user's master password is not changed; the + /// authentication hash is re-derived from the existing password using the new KDF parameters. /// /// - /// Use when: rotating KDF parameters. - /// + /// Use when: updating KDF parameters. /// /// /// /// Constraints: @@ -277,18 +275,18 @@ public interface IMasterPasswordService /// /// The user object to mutate and persist. Must already have a master password; /// must not be a Key Connector user. Salt must be unchanged. Validated via - /// . + /// . /// /// - /// Cryptographic and authentication data for the updated password and KDF parameters, - /// including MasterPasswordUnlock, MasterPasswordAuthentication, + /// Cryptographic and authentication data for KDF configuration, including + /// MasterPasswordUnlock, MasterPasswordAuthentication, /// and control flags ValidatePassword and RefreshStamp. /// /// /// On success, the modified . On failure, an array of /// describing validation failures. /// - Task> SaveUpdateExistingMasterPasswordAndKdfAsync(User user, UpdateExistingPasswordAndKdfData updateExistingData); + Task> SaveUpdateExistingKdfConfigurationAsync(User user, UpdateExistingKdfConfigurationData updateExistingData); /// /// Applies a new master password over the user's existing one and persists the updated user diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs index 40a1ee38044b..cc8a1c94b0a7 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/MasterPasswordService.cs @@ -171,9 +171,9 @@ public async Task> PrepareUpdateExistingMasterPassw return user; } - public async Task> SaveUpdateExistingMasterPasswordAndKdfAsync( + public async Task> SaveUpdateExistingKdfConfigurationAsync( User user, - UpdateExistingPasswordAndKdfData updateExistingData) + UpdateExistingKdfConfigurationData updateExistingData) { EnsureUserIsHydrated(user); updateExistingData.ValidateDataForUser(user); diff --git a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs index 1de347537028..c43ee46bdabf 100644 --- a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs +++ b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs @@ -43,7 +43,7 @@ public async Task ChangeKdfAsync(User user, string masterPasswor // Validate to prevent user account from becoming un-decryptable from invalid parameters // // Prevent a de-synced salt value from creating an un-decryptable unlock method. - // Also checked in the MasterPasswordService via UpdateExistingPasswordAndKdfData.ValidateDataForUser. + // Also checked in the MasterPasswordService via UpdateExistingKdfConfigurationData.ValidateDataForUser. authenticationData.ValidateSaltUnchangedForUser(user); unlockData.ValidateSaltUnchangedForUser(user); @@ -61,7 +61,7 @@ public async Task ChangeKdfAsync(User user, string masterPasswor var logoutOnKdfChange = !_featureService.IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange); - var data = new UpdateExistingPasswordAndKdfData + var data = new UpdateExistingKdfConfigurationData { MasterPasswordAuthentication = authenticationData, MasterPasswordUnlock = unlockData, @@ -70,7 +70,7 @@ public async Task ChangeKdfAsync(User user, string masterPasswor MasterPasswordHint = user.MasterPasswordHint, // KDF rotation does not change the hint; carry existing value through }; - var result = await _masterPasswordService.SaveUpdateExistingMasterPasswordAndKdfAsync(user, data); + var result = await _masterPasswordService.SaveUpdateExistingKdfConfigurationAsync(user, data); if (result.TryPickT1(out var errors, out _)) { return IdentityResult.Failed(errors); diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordAndKdfDataTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingKdfConfigurationDataTests.cs similarity index 92% rename from test/Core.Test/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordAndKdfDataTests.cs rename to test/Core.Test/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingKdfConfigurationDataTests.cs index 40c8e3946a24..341dc03a4d76 100644 --- a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingPasswordAndKdfDataTests.cs +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingKdfConfigurationDataTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -7,7 +7,7 @@ namespace Bit.Core.Test.Auth.UserFeatures.UserMasterPassword.Data; -public class UpdateExistingPasswordAndKdfDataTests +public class UpdateExistingKdfConfigurationDataTests { private static User BuildValidUser() { @@ -24,7 +24,7 @@ private static User BuildValidUser() }; } - private static UpdateExistingPasswordAndKdfData BuildData(User user, string? saltOverride = null) + private static UpdateExistingKdfConfigurationData BuildData(User user, string? saltOverride = null) { var salt = saltOverride ?? user.GetMasterPasswordSalt(); var newKdf = new KdfSettings @@ -34,7 +34,7 @@ private static UpdateExistingPasswordAndKdfData BuildData(User user, string? sal Memory = 64, Parallelism = 4 }; - return new UpdateExistingPasswordAndKdfData + return new UpdateExistingKdfConfigurationData { MasterPasswordUnlock = new MasterPasswordUnlockData { @@ -100,7 +100,7 @@ public void ValidateDataForUser_Throws_WhenSaltMismatch_ValidatesBothFieldsIndep var user = BuildValidUser(); var correctSalt = user.GetMasterPasswordSalt(); var newKdf = new KdfSettings { KdfType = KdfType.Argon2id, Iterations = 3, Memory = 64, Parallelism = 4 }; - var data = new UpdateExistingPasswordAndKdfData + var data = new UpdateExistingKdfConfigurationData { MasterPasswordUnlock = new MasterPasswordUnlockData { diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs index 6e0788748128..7cf055b8357b 100644 --- a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs @@ -106,7 +106,7 @@ private static UpdateExistingPasswordData BuildUpdateExistingPasswordData(User u }; } - private static UpdateExistingPasswordAndKdfData BuildUpdateExistingPasswordAndKdfData(User user, + private static UpdateExistingKdfConfigurationData BuildUpdateExistingKdfConfigurationData(User user, KdfSettings? newKdf = null, string? hint = null, bool validatePassword = false, bool refreshStamp = false) { var salt = user.GetMasterPasswordSalt(); @@ -117,7 +117,7 @@ private static UpdateExistingPasswordAndKdfData BuildUpdateExistingPasswordAndKd Memory = 64, Parallelism = 4 }; - return new UpdateExistingPasswordAndKdfData + return new UpdateExistingKdfConfigurationData { MasterPasswordUnlock = new MasterPasswordUnlockData { @@ -768,12 +768,12 @@ public async Task SaveUpdateExistingMasterPasswordAndKdf_Success(User user) user.MasterPassword = "existing-hash"; user.UsesKeyConnector = false; - var data = BuildUpdateExistingPasswordAndKdfData(user, hint: "test-hint"); + var data = BuildUpdateExistingKdfConfigurationData(user, hint: "test-hint"); sutProvider.GetDependency>() .HashPassword(Arg.Any(), Arg.Any()) .Returns("new-hash"); - var result = await sutProvider.Sut.SaveUpdateExistingMasterPasswordAndKdfAsync(user, data); + var result = await sutProvider.Sut.SaveUpdateExistingKdfConfigurationAsync(user, data); var expectedTime = sutProvider.GetDependency().GetUtcNow().UtcDateTime; @@ -806,12 +806,12 @@ public async Task SaveUpdateExistingMasterPasswordAndKdf_RotatesPbkdf2ToArgon2id Memory = 64, Parallelism = 4 }; - var data = BuildUpdateExistingPasswordAndKdfData(user, newKdf: newKdf); + var data = BuildUpdateExistingKdfConfigurationData(user, newKdf: newKdf); sutProvider.GetDependency>() .HashPassword(Arg.Any(), Arg.Any()) .Returns("new-hash"); - var result = await sutProvider.Sut.SaveUpdateExistingMasterPasswordAndKdfAsync(user, data); + var result = await sutProvider.Sut.SaveUpdateExistingKdfConfigurationAsync(user, data); Assert.True(result.IsT0); Assert.Equal(KdfType.Argon2id, user.Kdf); @@ -838,12 +838,12 @@ public async Task SaveUpdateExistingMasterPasswordAndKdf_RotatesArgon2idToPbkdf2 Memory = null, Parallelism = null }; - var data = BuildUpdateExistingPasswordAndKdfData(user, newKdf: newKdf); + var data = BuildUpdateExistingKdfConfigurationData(user, newKdf: newKdf); sutProvider.GetDependency>() .HashPassword(Arg.Any(), Arg.Any()) .Returns("new-hash"); - var result = await sutProvider.Sut.SaveUpdateExistingMasterPasswordAndKdfAsync(user, data); + var result = await sutProvider.Sut.SaveUpdateExistingKdfConfigurationAsync(user, data); Assert.True(result.IsT0); Assert.Equal(KdfType.PBKDF2_SHA256, user.Kdf); @@ -865,9 +865,9 @@ public async Task SaveUpdateExistingMasterPasswordAndKdf_WhenValidationFails_Ret user.MasterPassword = "existing-hash"; user.UsesKeyConnector = false; - var data = BuildUpdateExistingPasswordAndKdfData(user, validatePassword: true); + var data = BuildUpdateExistingKdfConfigurationData(user, validatePassword: true); - var result = await sutProvider.Sut.SaveUpdateExistingMasterPasswordAndKdfAsync(user, data); + var result = await sutProvider.Sut.SaveUpdateExistingKdfConfigurationAsync(user, data); Assert.True(result.IsT1); Assert.NotEmpty(result.AsT1); @@ -889,7 +889,7 @@ public async Task SaveUpdateExistingMasterPasswordAndKdf_ThrowsWhenSaltChanged(U Memory = 64, Parallelism = 4 }; - var data = new UpdateExistingPasswordAndKdfData + var data = new UpdateExistingKdfConfigurationData { MasterPasswordUnlock = new MasterPasswordUnlockData { @@ -908,7 +908,7 @@ public async Task SaveUpdateExistingMasterPasswordAndKdf_ThrowsWhenSaltChanged(U }; await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveUpdateExistingMasterPasswordAndKdfAsync(user, data)); + () => sutProvider.Sut.SaveUpdateExistingKdfConfigurationAsync(user, data)); } [Theory, BitAutoData] @@ -917,10 +917,10 @@ public async Task SaveUpdateExistingMasterPasswordAndKdf_ThrowsWhenNoExistingPas var sutProvider = CreateSutProvider(); user.MasterPassword = null; - var data = BuildUpdateExistingPasswordAndKdfData(user); + var data = BuildUpdateExistingKdfConfigurationData(user); await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveUpdateExistingMasterPasswordAndKdfAsync(user, data)); + () => sutProvider.Sut.SaveUpdateExistingKdfConfigurationAsync(user, data)); } [Theory, BitAutoData] @@ -930,10 +930,10 @@ public async Task SaveUpdateExistingMasterPasswordAndKdf_ThrowsWhenUserNotHydrat user.Id = default; user.MasterPassword = "existing-hash"; - var data = BuildUpdateExistingPasswordAndKdfData(user); + var data = BuildUpdateExistingKdfConfigurationData(user); await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveUpdateExistingMasterPasswordAndKdfAsync(user, data)); + () => sutProvider.Sut.SaveUpdateExistingKdfConfigurationAsync(user, data)); } [Theory, BitAutoData] @@ -943,10 +943,10 @@ public async Task SaveUpdateExistingMasterPasswordAndKdf_ThrowsForKeyConnectorUs user.MasterPassword = "existing-hash"; user.UsesKeyConnector = true; - var data = BuildUpdateExistingPasswordAndKdfData(user); + var data = BuildUpdateExistingKdfConfigurationData(user); await Assert.ThrowsAsync( - () => sutProvider.Sut.SaveUpdateExistingMasterPasswordAndKdfAsync(user, data)); + () => sutProvider.Sut.SaveUpdateExistingKdfConfigurationAsync(user, data)); } [Theory] @@ -966,12 +966,12 @@ public async Task SaveUpdateExistingMasterPasswordAndKdf_SecurityStampRotation_H KdfIterations = 600000, SecurityStamp = "original-stamp" }; - var data = BuildUpdateExistingPasswordAndKdfData(user, refreshStamp: refreshStamp); + var data = BuildUpdateExistingKdfConfigurationData(user, refreshStamp: refreshStamp); sutProvider.GetDependency>() .HashPassword(Arg.Any(), Arg.Any()) .Returns("hash"); - await sutProvider.Sut.SaveUpdateExistingMasterPasswordAndKdfAsync(user, data); + await sutProvider.Sut.SaveUpdateExistingKdfConfigurationAsync(user, data); Assert.Equal(refreshStamp, user.SecurityStamp != "original-stamp"); } diff --git a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs index 0aea89c3a3c3..5feaad533847 100644 --- a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs +++ b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs @@ -43,7 +43,7 @@ public async Task ChangeKdfAsync_ServiceReturnsUser_ReturnsIdentityResultSuccess sutProvider.GetDependency().CheckPasswordAsync(user, Arg.Any()) .Returns(true); sutProvider.GetDependency() - .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Any()) + .SaveUpdateExistingKdfConfigurationAsync(user, Arg.Any()) .Returns(OneOf.FromT0(user)); var result = await sutProvider.Sut.ChangeKdfAsync(user, "current-password", authenticationData, unlockData); @@ -73,7 +73,7 @@ public async Task ChangeKdfAsync_ServiceReturnsErrors_ReturnsIdentityResultFaile sutProvider.GetDependency().CheckPasswordAsync(user, Arg.Any()) .Returns(true); sutProvider.GetDependency() - .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Any()) + .SaveUpdateExistingKdfConfigurationAsync(user, Arg.Any()) .Returns(OneOf.FromT1(new[] { serviceError })); var result = await sutProvider.Sut.ChangeKdfAsync(user, "current-password", authenticationData, unlockData); @@ -102,13 +102,13 @@ public async Task ChangeKdfAsync_ChangesKdfAsync(SutProvider s sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()) .Returns(true); sutProvider.GetDependency() - .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Any()) + .SaveUpdateExistingKdfConfigurationAsync(user, Arg.Any()) .Returns(OneOf.FromT0(user)); await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); await sutProvider.GetDependency().Received(1) - .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Is(d => + .SaveUpdateExistingKdfConfigurationAsync(user, Arg.Is(d => d.MasterPasswordAuthentication == authenticationData && d.MasterPasswordUnlock == unlockData && d.ValidatePassword == false && @@ -193,13 +193,13 @@ public async Task .Returns(true); sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(false); sutProvider.GetDependency() - .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Any()) + .SaveUpdateExistingKdfConfigurationAsync(user, Arg.Any()) .Returns(OneOf.FromT0(user)); await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); await sutProvider.GetDependency().Received(1) - .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Is(d => + .SaveUpdateExistingKdfConfigurationAsync(user, Arg.Is(d => d.RefreshStamp == true)); await sutProvider.GetDependency().Received(1).PushLogOutAsync(user.Id); sutProvider.GetDependency().Received(1).IsEnabled(FeatureFlagKeys.NoLogoutOnKdfChange); @@ -234,13 +234,13 @@ public async Task .Returns(true); sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); sutProvider.GetDependency() - .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Any()) + .SaveUpdateExistingKdfConfigurationAsync(user, Arg.Any()) .Returns(OneOf.FromT0(user)); await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); await sutProvider.GetDependency().Received(1) - .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Is(d => + .SaveUpdateExistingKdfConfigurationAsync(user, Arg.Is(d => d.RefreshStamp == false)); await sutProvider.GetDependency().Received(1) .PushLogOutAsync(user.Id, false, PushNotificationLogOutReason.KdfChange); @@ -286,7 +286,7 @@ public async Task ChangeKdfAsync_AuthDataSaltMismatch_Throws(SutProvider().CheckPasswordAsync(Arg.Any(), Arg.Any()) .Returns(true); sutProvider.GetDependency() - .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Any()) + .SaveUpdateExistingKdfConfigurationAsync(user, Arg.Any()) .ThrowsAsync(new BadRequestException("Salt mismatch.")); var authenticationData = new MasterPasswordAuthenticationData @@ -313,7 +313,7 @@ public async Task ChangeKdfAsync_UnlockDataSaltMismatch_Throws(SutProvider().CheckPasswordAsync(Arg.Any(), Arg.Any()) .Returns(true); sutProvider.GetDependency() - .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Any()) + .SaveUpdateExistingKdfConfigurationAsync(user, Arg.Any()) .ThrowsAsync(new BadRequestException("Salt mismatch.")); var authenticationData = new MasterPasswordAuthenticationData @@ -341,7 +341,7 @@ public async Task ChangeKdfAsync_ServiceReturnsFailure_ReturnsFailedIdentityResu .Returns(true); var failureError = new IdentityError { Code = "TestFail", Description = "Test fail" }; sutProvider.GetDependency() - .SaveUpdateExistingMasterPasswordAndKdfAsync(user, Arg.Any()) + .SaveUpdateExistingKdfConfigurationAsync(user, Arg.Any()) .Returns(OneOf.FromT1(new[] { failureError })); var kdf = new KdfSettings { KdfType = KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 }; From 4af1648808c0c76a8b9aa56eb4a7133ba2705ffd Mon Sep 17 00:00:00 2001 From: enmande <3836813+enmande@users.noreply.github.com> Date: Thu, 14 May 2026 13:15:52 -0400 Subject: [PATCH 07/14] test(kdf-command): Remove an mpservice assertion on salt. Mismatched salt should fail fast at the command level (existing). A defense-in-depth check also resides at ValidateDataForUser inside the MasterPasswordService. --- .../Interfaces/IMasterPasswordService.cs | 3 ++- .../KeyManagement/Kdf/ChangeKdfCommandTests.cs | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs index 43faf2eeaf31..6d3e0ca4c701 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs @@ -260,7 +260,8 @@ public interface IMasterPasswordService /// authentication hash is re-derived from the existing password using the new KDF parameters. /// /// - /// Use when: updating KDF parameters. /// + /// Use when: updating KDF parameters. + /// /// /// /// Constraints: diff --git a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs index 5feaad533847..dfbb23a73801 100644 --- a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs +++ b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs @@ -285,9 +285,6 @@ public async Task ChangeKdfAsync_AuthDataSaltMismatch_Throws(SutProvider().CheckPasswordAsync(Arg.Any(), Arg.Any()) .Returns(true); - sutProvider.GetDependency() - .SaveUpdateExistingKdfConfigurationAsync(user, Arg.Any()) - .ThrowsAsync(new BadRequestException("Salt mismatch.")); var authenticationData = new MasterPasswordAuthenticationData { @@ -303,6 +300,9 @@ public async Task ChangeKdfAsync_AuthDataSaltMismatch_Throws(SutProvider(async () => await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData)); + await sutProvider.GetDependency() + .Received(0) + .SaveUpdateExistingKdfConfigurationAsync(Arg.Any(), Arg.Any()); } [Theory] @@ -312,9 +312,6 @@ public async Task ChangeKdfAsync_UnlockDataSaltMismatch_Throws(SutProvider().CheckPasswordAsync(Arg.Any(), Arg.Any()) .Returns(true); - sutProvider.GetDependency() - .SaveUpdateExistingKdfConfigurationAsync(user, Arg.Any()) - .ThrowsAsync(new BadRequestException("Salt mismatch.")); var authenticationData = new MasterPasswordAuthenticationData { @@ -330,6 +327,9 @@ public async Task ChangeKdfAsync_UnlockDataSaltMismatch_Throws(SutProvider(async () => await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData)); + await sutProvider.GetDependency() + .Received(0) + .SaveUpdateExistingKdfConfigurationAsync(Arg.Any(), Arg.Any()); } [Theory] From 36bfc44f494bf4a5d8a44ee5d2f89df479689209 Mon Sep 17 00:00:00 2001 From: enmande <3836813+enmande@users.noreply.github.com> Date: Thu, 14 May 2026 13:25:50 -0400 Subject: [PATCH 08/14] refactor(mp-service-test): Reflect Update KDF rename in test methods. --- .../MasterPasswordServiceTests.cs | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs index 7cf055b8357b..0facfe6cc406 100644 --- a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs @@ -759,10 +759,10 @@ await Assert.ThrowsAsync( () => sutProvider.Sut.PrepareSetInitialOrUpdateExistingMasterPasswordAsync(user, data)); } - // --- SaveUpdateExistingMasterPasswordAndKdf --- + // --- SaveUpdateExistingKdfConfiguration --- [Theory, BitAutoData] - public async Task SaveUpdateExistingMasterPasswordAndKdf_Success(User user) + public async Task SaveUpdateExistingKdfConfiguration_Success(User user) { var sutProvider = CreateSutProvider(); user.MasterPassword = "existing-hash"; @@ -772,6 +772,7 @@ public async Task SaveUpdateExistingMasterPasswordAndKdf_Success(User user) sutProvider.GetDependency>() .HashPassword(Arg.Any(), Arg.Any()) .Returns("new-hash"); + var originalLastPasswordChangeDate = user.LastPasswordChangeDate; var result = await sutProvider.Sut.SaveUpdateExistingKdfConfigurationAsync(user, data); @@ -783,13 +784,19 @@ public async Task SaveUpdateExistingMasterPasswordAndKdf_Success(User user) Assert.Equal(data.MasterPasswordUnlock.Kdf.Iterations, user.KdfIterations); Assert.Equal("test-hint", user.MasterPasswordHint); Assert.Equal(expectedTime, user.LastKdfChangeDate); + // LastPasswordChangeDate marks a user's action to change the password; + // the fact that the hash-of-hash which is stored in the database changes + // as a result of KDF update does not affect a change to this date; + // doing so would confuse the data. LastKdfChangeDate neatly separates + // this concern. + Assert.Equal(originalLastPasswordChangeDate, user.LastPasswordChangeDate); Assert.Equal(expectedTime, user.RevisionDate); Assert.Equal(user.RevisionDate, user.AccountRevisionDate); await sutProvider.GetDependency().Received().ReplaceAsync(user); } [Theory, BitAutoData] - public async Task SaveUpdateExistingMasterPasswordAndKdf_RotatesPbkdf2ToArgon2id(User user) + public async Task SaveUpdateExistingKdfConfiguration_RotatesPbkdf2ToArgon2id(User user) { var sutProvider = CreateSutProvider(); user.MasterPassword = "existing-hash"; @@ -821,7 +828,7 @@ public async Task SaveUpdateExistingMasterPasswordAndKdf_RotatesPbkdf2ToArgon2id } [Theory, BitAutoData] - public async Task SaveUpdateExistingMasterPasswordAndKdf_RotatesArgon2idToPbkdf2(User user) + public async Task SaveUpdateExistingKdfConfiguration_RotatesArgon2idToPbkdf2(User user) { var sutProvider = CreateSutProvider(); user.MasterPassword = "existing-hash"; @@ -853,7 +860,7 @@ public async Task SaveUpdateExistingMasterPasswordAndKdf_RotatesArgon2idToPbkdf2 } [Theory, BitAutoData] - public async Task SaveUpdateExistingMasterPasswordAndKdf_WhenValidationFails_ReturnsErrorsAndDoesNotPersist(User user) + public async Task SaveUpdateExistingKdfConfiguration_WhenValidationFails_ReturnsErrorsAndDoesNotPersist(User user) { var error = new IdentityError { Code = "pwd-invalid", Description = "Password is too weak." }; var validator = Substitute.For>(); @@ -875,7 +882,7 @@ public async Task SaveUpdateExistingMasterPasswordAndKdf_WhenValidationFails_Ret } [Theory, BitAutoData] - public async Task SaveUpdateExistingMasterPasswordAndKdf_ThrowsWhenSaltChanged(User user) + public async Task SaveUpdateExistingKdfConfiguration_ThrowsWhenSaltChanged(User user) { var sutProvider = CreateSutProvider(); user.MasterPassword = "existing-hash"; @@ -912,7 +919,7 @@ await Assert.ThrowsAsync( } [Theory, BitAutoData] - public async Task SaveUpdateExistingMasterPasswordAndKdf_ThrowsWhenNoExistingPassword(User user) + public async Task SaveUpdateExistingKdfConfiguration_ThrowsWhenNoExistingPassword(User user) { var sutProvider = CreateSutProvider(); user.MasterPassword = null; @@ -924,7 +931,7 @@ await Assert.ThrowsAsync( } [Theory, BitAutoData] - public async Task SaveUpdateExistingMasterPasswordAndKdf_ThrowsWhenUserNotHydrated(User user) + public async Task SaveUpdateExistingKdfConfiguration_ThrowsWhenUserNotHydrated(User user) { var sutProvider = CreateSutProvider(); user.Id = default; @@ -937,7 +944,7 @@ await Assert.ThrowsAsync( } [Theory, BitAutoData] - public async Task SaveUpdateExistingMasterPasswordAndKdf_ThrowsForKeyConnectorUser(User user) + public async Task SaveUpdateExistingKdfConfiguration_ThrowsForKeyConnectorUser(User user) { var sutProvider = CreateSutProvider(); user.MasterPassword = "existing-hash"; @@ -952,7 +959,7 @@ await Assert.ThrowsAsync( [Theory] [InlineData(true)] [InlineData(false)] - public async Task SaveUpdateExistingMasterPasswordAndKdf_SecurityStampRotation_HonorsRefreshStampFlag(bool refreshStamp) + public async Task SaveUpdateExistingKdfConfiguration_SecurityStampRotation_HonorsRefreshStampFlag(bool refreshStamp) { var sutProvider = CreateSutProvider(); var user = new User From 212a60beffb72ad33dc1d55edb566b6870805cf1 Mon Sep 17 00:00:00 2001 From: enmande <3836813+enmande@users.noreply.github.com> Date: Thu, 14 May 2026 13:59:20 -0400 Subject: [PATCH 09/14] test(change-kdf): Remove redundant test. --- .../Kdf/ChangeKdfCommandTests.cs | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs index dfbb23a73801..ddeca6af6ff0 100644 --- a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs +++ b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs @@ -332,37 +332,6 @@ await sutProvider.GetDependency() .SaveUpdateExistingKdfConfigurationAsync(Arg.Any(), Arg.Any()); } - [Theory] - [BitAutoData] - public async Task ChangeKdfAsync_ServiceReturnsFailure_ReturnsFailedIdentityResult( - SutProvider sutProvider, User user) - { - sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()) - .Returns(true); - var failureError = new IdentityError { Code = "TestFail", Description = "Test fail" }; - sutProvider.GetDependency() - .SaveUpdateExistingKdfConfigurationAsync(user, Arg.Any()) - .Returns(OneOf.FromT1(new[] { failureError })); - - var kdf = new KdfSettings { KdfType = KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 }; - var authenticationData = new MasterPasswordAuthenticationData - { - Kdf = kdf, - MasterPasswordAuthenticationHash = "newMasterPassword", - Salt = user.GetMasterPasswordSalt() - }; - var unlockData = new MasterPasswordUnlockData - { - Kdf = kdf, - MasterKeyWrappedUserKey = "masterKeyWrappedUserKey", - Salt = user.GetMasterPasswordSalt() - }; - - var result = await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); - - Assert.False(result.Succeeded); - } - [Theory] [BitAutoData] public async Task ChangeKdfAsync_InvalidKdfSettings_ThrowsBadRequestException( From c27b015efd0e5d109b746ff33afe71bb0c9bfc27 Mon Sep 17 00:00:00 2001 From: enmande <3836813+enmande@users.noreply.github.com> Date: Thu, 14 May 2026 14:12:11 -0400 Subject: [PATCH 10/14] chore: lint. --- .../Data/UpdateExistingKdfConfigurationData.cs | 2 +- src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs | 2 +- .../Data/UpdateExistingKdfConfigurationDataTests.cs | 2 +- .../UserMasterPassword/MasterPasswordServiceTests.cs | 2 +- test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs | 3 +-- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingKdfConfigurationData.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingKdfConfigurationData.cs index 98f049ee6df0..2077c40d073b 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingKdfConfigurationData.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingKdfConfigurationData.cs @@ -1,4 +1,4 @@ -using Bit.Core.Entities; +using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Models.Data; diff --git a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs index c43ee46bdabf..71af12a9a6d5 100644 --- a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs +++ b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs @@ -1,4 +1,4 @@ -using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingKdfConfigurationDataTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingKdfConfigurationDataTests.cs index 341dc03a4d76..0d4ba54073de 100644 --- a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingKdfConfigurationDataTests.cs +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/Data/UpdateExistingKdfConfigurationDataTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs index 0facfe6cc406..43f28a22c475 100644 --- a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordServiceTests.cs @@ -789,7 +789,7 @@ public async Task SaveUpdateExistingKdfConfiguration_Success(User user) // as a result of KDF update does not affect a change to this date; // doing so would confuse the data. LastKdfChangeDate neatly separates // this concern. - Assert.Equal(originalLastPasswordChangeDate, user.LastPasswordChangeDate); + Assert.Equal(originalLastPasswordChangeDate, user.LastPasswordChangeDate); Assert.Equal(expectedTime, user.RevisionDate); Assert.Equal(user.RevisionDate, user.AccountRevisionDate); await sutProvider.GetDependency().Received().ReplaceAsync(user); diff --git a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs index ddeca6af6ff0..a14c78981e62 100644 --- a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs +++ b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs @@ -1,4 +1,4 @@ -#nullable enable +#nullable enable using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; @@ -13,7 +13,6 @@ using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Identity; using NSubstitute; -using NSubstitute.ExceptionExtensions; using OneOf; using Xunit; From d6e86c6e2547acbd3b50dbe2dd687007f786a150 Mon Sep 17 00:00:00 2001 From: enmande <3836813+enmande@users.noreply.github.com> Date: Thu, 14 May 2026 14:48:52 -0400 Subject: [PATCH 11/14] fix(change-kdf): Preserve ValidatePassword policy. --- src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs | 2 +- test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs index 71af12a9a6d5..000bc5a347a7 100644 --- a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs +++ b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs @@ -65,7 +65,7 @@ public async Task ChangeKdfAsync(User user, string masterPasswor { MasterPasswordAuthentication = authenticationData, MasterPasswordUnlock = unlockData, - ValidatePassword = false, // password already verified by CheckPasswordAsync above + ValidatePassword = true, RefreshStamp = logoutOnKdfChange, MasterPasswordHint = user.MasterPasswordHint, // KDF rotation does not change the hint; carry existing value through }; diff --git a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs index a14c78981e62..099beb774648 100644 --- a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs +++ b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs @@ -110,7 +110,7 @@ await sutProvider.GetDependency().Received(1) .SaveUpdateExistingKdfConfigurationAsync(user, Arg.Is(d => d.MasterPasswordAuthentication == authenticationData && d.MasterPasswordUnlock == unlockData && - d.ValidatePassword == false && + d.ValidatePassword == true && d.MasterPasswordHint == user.MasterPasswordHint)); } From 8474b19253dc1d26c4786c32df122428c55ea20a Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 21 May 2026 15:05:16 -0400 Subject: [PATCH 12/14] PM-35395 - refactor(change-kdf-model): Require new payload, drop legacy fields Removes the deprecated NewMasterPasswordHash/Key fields and the MasterPasswordPayloadVariantValidator path from ChangeKdfRequestModel. AuthenticationData and UnlockData become C# required + [Required]; rejection of null/missing values is delegated to System.Text.Json and the ASP.NET ModelStateValidationFilter. Validate() adds an early return when either cross-field input is null, so it remains safe to invoke after [Required] has already populated ModelState (the framework runs IValidatableObject.Validate regardless of data-annotation outcome). Tests for the dropped variant-validator path are removed; a new Validate_EitherFieldNull_ReturnsNoErrors test pins the defensive guard. --- .../Request/Accounts/ChangeKdfRequestModel.cs | 41 ++---- .../Accounts/ChangeKdfRequestModelTests.cs | 132 ++++-------------- 2 files changed, 43 insertions(+), 130 deletions(-) diff --git a/src/Api/Auth/Models/Request/Accounts/ChangeKdfRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/ChangeKdfRequestModel.cs index 6496f45ca0ab..bb5bc6a5a648 100644 --- a/src/Api/Auth/Models/Request/Accounts/ChangeKdfRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/ChangeKdfRequestModel.cs @@ -1,51 +1,36 @@ using System.ComponentModel.DataAnnotations; -using Bit.Core.Auth.Utilities; using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.Utilities; namespace Bit.Api.Auth.Models.Request.Accounts; /// -/// Dual-shape request: validation accepts either the legacy -/// (, ) or new -/// (, ) payload so the wire contract -/// can stabilize ahead of caller wiring. PostKdf currently honors only the new shape; -/// legacy-shape dispatch arrives with ChangeKdfCommand's dual-path refactor. All legacy -/// fields are removed in PM-33141. +/// Request model for changing the KDF settings for a user's account. /// public class ChangeKdfRequestModel : IValidatableObject { + // The current master password hash; proves user has access to the MP [Required] public required string MasterPasswordHash { get; set; } - [Obsolete("To be removed in PM-33141")] - [StringLength(300)] - public string? NewMasterPasswordHash { get; set; } - [Obsolete("To be removed in PM-33141")] - public string? Key { get; set; } - // Should be made required in PM-33141 - public MasterPasswordAuthenticationDataRequestModel? AuthenticationData { get; set; } - // Should be made required in PM-33141 - public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; } + [Required] + public required MasterPasswordAuthenticationDataRequestModel AuthenticationData { get; set; } + [Required] + public required MasterPasswordUnlockDataRequestModel UnlockData { get; set; } public IEnumerable Validate(ValidationContext validationContext) { - var hasNewPayloads = AuthenticationData is not null && UnlockData is not null; - var hasLegacyPayloads = NewMasterPasswordHash is not null && Key is not null; - - foreach (var validationResult in MasterPasswordPayloadVariantValidator.ValidatePresence( - hasNewPayloads, hasLegacyPayloads)) + // [Required] on AuthenticationData/UnlockData reports null-field errors via ModelState; + // this method only runs cross-field validation when both are present. + if (AuthenticationData == null || UnlockData == null) { - yield return validationResult; + yield break; } - if (hasNewPayloads) + foreach (var validationResult in KdfSettingsValidator.ValidateAuthenticationAndUnlockData( + AuthenticationData.ToData(), UnlockData.ToData())) { - foreach (var validationResult in KdfSettingsValidator.ValidateAuthenticationAndUnlockData( - AuthenticationData!.ToData(), UnlockData!.ToData())) - { - yield return validationResult; - } + yield return validationResult; } } } diff --git a/test/Api.Test/Auth/Models/Request/Accounts/ChangeKdfRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/Accounts/ChangeKdfRequestModelTests.cs index 91b43b4d21bb..6c592874cec7 100644 --- a/test/Api.Test/Auth/Models/Request/Accounts/ChangeKdfRequestModelTests.cs +++ b/test/Api.Test/Auth/Models/Request/Accounts/ChangeKdfRequestModelTests.cs @@ -46,46 +46,35 @@ public void Validate_NewPayloadsOnly_NoErrors( } [Theory] - [BitAutoData] - public void Validate_NewPayloadsOnly_WithMismatchedKdfSettings_ReturnsKdfValidationError( + [BitAutoData(true, false)] + [BitAutoData(false, true)] + [BitAutoData(true, true)] + public void Validate_EitherFieldNull_ReturnsNoErrors(bool authNull, bool unlockNull, string masterPasswordHash) { - var authKdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; - var unlockKdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 650000 }; - - var model = new ChangeKdfRequestModel - { - MasterPasswordHash = masterPasswordHash, - AuthenticationData = new MasterPasswordAuthenticationDataRequestModel - { - Kdf = authKdf, - MasterPasswordAuthenticationHash = "authHash", - Salt = "salt" - }, - UnlockData = new MasterPasswordUnlockDataRequestModel - { - Kdf = unlockKdf, - MasterKeyWrappedUserKey = "wrappedKey", - Salt = "salt" - } - }; - - var result = model.Validate(new ValidationContext(model)).ToList(); - - Assert.Single(result); - Assert.Contains("must have the same KDF configuration", result[0].ErrorMessage); - } + // Defensive guard: [Required] reports null-field errors via ModelState, so Validate() + // must not throw or report when either cross-field input is null. + var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; - [Theory] - [BitAutoData] - public void Validate_LegacyPayloadsOnly_NoErrors( - string masterPasswordHash, string newHash, string key) - { var model = new ChangeKdfRequestModel { MasterPasswordHash = masterPasswordHash, - NewMasterPasswordHash = newHash, - Key = key + AuthenticationData = authNull + ? null + : new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + UnlockData = unlockNull + ? null + : new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } }; var result = model.Validate(new ValidationContext(model)).ToList(); @@ -95,62 +84,24 @@ public void Validate_LegacyPayloadsOnly_NoErrors( [Theory] [BitAutoData] - public void Validate_BothNewAndLegacyPayloads_NoErrors( - string masterPasswordHash, string newHash, string key) + public void Validate_NewPayloadsOnly_WithMismatchedKdfSettings_ReturnsKdfValidationError( + string masterPasswordHash) { - var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + var authKdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + var unlockKdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 650000 }; var model = new ChangeKdfRequestModel { MasterPasswordHash = masterPasswordHash, - NewMasterPasswordHash = newHash, - Key = key, AuthenticationData = new MasterPasswordAuthenticationDataRequestModel { - Kdf = kdf, + Kdf = authKdf, MasterPasswordAuthenticationHash = "authHash", Salt = "salt" }, UnlockData = new MasterPasswordUnlockDataRequestModel { - Kdf = kdf, - MasterKeyWrappedUserKey = "wrappedKey", - Salt = "salt" - } - }; - - var result = model.Validate(new ValidationContext(model)).ToList(); - - Assert.Empty(result); - } - - [Theory] - [BitAutoData] - public void Validate_NeitherNewNorLegacyPayloads_ReturnsError(string masterPasswordHash) - { - var model = new ChangeKdfRequestModel - { - MasterPasswordHash = masterPasswordHash - }; - - var result = model.Validate(new ValidationContext(model)).ToList(); - - Assert.Single(result); - Assert.Contains("Must provide either", result[0].ErrorMessage); - } - - [Theory] - [BitAutoData] - public void Validate_OnlyUnlockData_ReturnsError(string masterPasswordHash) - { - var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; - - var model = new ChangeKdfRequestModel - { - MasterPasswordHash = masterPasswordHash, - UnlockData = new MasterPasswordUnlockDataRequestModel - { - Kdf = kdf, + Kdf = unlockKdf, MasterKeyWrappedUserKey = "wrappedKey", Salt = "salt" } @@ -159,29 +110,6 @@ public void Validate_OnlyUnlockData_ReturnsError(string masterPasswordHash) var result = model.Validate(new ValidationContext(model)).ToList(); Assert.Single(result); - Assert.Contains("Must provide either", result[0].ErrorMessage); - } - - [Theory] - [BitAutoData] - public void Validate_OnlyAuthenticationData_ReturnsError(string masterPasswordHash) - { - var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; - - var model = new ChangeKdfRequestModel - { - MasterPasswordHash = masterPasswordHash, - AuthenticationData = new MasterPasswordAuthenticationDataRequestModel - { - Kdf = kdf, - MasterPasswordAuthenticationHash = "authHash", - Salt = "salt" - } - }; - - var result = model.Validate(new ValidationContext(model)).ToList(); - - Assert.Single(result); - Assert.Contains("Must provide either", result[0].ErrorMessage); + Assert.Contains("must have the same KDF configuration", result[0].ErrorMessage); } } From f564c1775f160536aa03b23109f7941e22e6ccfe Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 21 May 2026 15:05:42 -0400 Subject: [PATCH 13/14] PM-35395 - refactor(change-kdf-controller): Drop redundant null-guard, align tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the manual null check from PostKdf — null/missing payload fields are now rejected by [Required] + ModelStateValidationFilterAttribute before the action runs. Drops two unit tests that asserted the old custom error message (framework-layer behavior, no longer reachable at the controller layer). Updates the integration test PostKdf_AuthenticationDataOrUnlockDataNull to assert the default [Required] messages, and adds PostKdf_MissingRequiredJsonKey to lock in the `required` C# keyword contract for the wire payload. --- .../Auth/Controllers/AccountsController.cs | 5 --- .../Controllers/AccountsControllerTest.cs | 34 ++++++++++++++++++- .../Controllers/AccountsControllerTests.cs | 28 --------------- 3 files changed, 33 insertions(+), 34 deletions(-) diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 8924caba53f9..0e719aedb151 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -323,11 +323,6 @@ public async Task PostKdf([FromBody] ChangeKdfRequestModel model) throw new UnauthorizedAccessException(); } - if (model.AuthenticationData == null || model.UnlockData == null) - { - throw new BadRequestException("AuthenticationData and UnlockData must be provided."); - } - var result = await _changeKdfCommand.ChangeKdfAsync(user, model.MasterPasswordHash, model.AuthenticationData.ToData(), model.UnlockData.ToData()); if (result.Succeeded) { diff --git a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs index bace8350cd7f..fd858983f1b6 100644 --- a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs @@ -239,6 +239,31 @@ public async Task PostKdf_Unauthorized_ReturnsUnauthorized() Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + [Theory] + [InlineData("authenticationData")] + [InlineData("unlockData")] + [InlineData("masterPasswordHash")] + public async Task PostKdf_MissingRequiredJsonKey_BadRequest(string fieldToOmit) + { + await _loginHelper.LoginAsync(_ownerEmail); + + var payload = new Dictionary + { + ["masterPasswordHash"] = _masterPasswordHash, + ["authenticationData"] = BuildAuthData(_defaultKdfRequest, _ownerEmail), + ["unlockData"] = BuildUnlockData(_defaultKdfRequest, _ownerEmail) + }; + payload.Remove(fieldToOmit); + + using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/kdf"); + message.Content = JsonContent.Create(payload); + var response = await _client.SendAsync(message); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains($"missing required properties including: '{fieldToOmit}'", content); + } + [Theory] [InlineData(false, true)] [InlineData(true, false)] @@ -260,7 +285,14 @@ public async Task PostKdf_AuthenticationDataOrUnlockDataNull_BadRequest(bool aut Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(); - Assert.Contains("AuthenticationData and UnlockData must be provided.", content); + if (authenticationDataNull) + { + Assert.Contains("The AuthenticationData field is required.", content); + } + if (unlockDataNull) + { + Assert.Contains("The UnlockData field is required.", content); + } } [Fact] diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index 703dcd47b2c4..36016405cc11 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -696,34 +696,6 @@ public async Task PostKdf_UserNotFound_ShouldFail(ChangeKdfRequestModel model) await Assert.ThrowsAsync(() => _sut.PostKdf(model)); } - [Theory] - [BitAutoData] - public async Task PostKdf_WithNullAuthenticationData_ShouldFail( - User user, ChangeKdfRequestModel model) - { - _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); - model.AuthenticationData = null; - - // Act - var exception = await Assert.ThrowsAsync(() => _sut.PostKdf(model)); - - Assert.Contains("AuthenticationData and UnlockData must be provided.", exception.Message); - } - - [Theory] - [BitAutoData] - public async Task PostKdf_WithNullUnlockData_ShouldFail( - User user, ChangeKdfRequestModel model) - { - _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); - model.UnlockData = null; - - // Act - var exception = await Assert.ThrowsAsync(() => _sut.PostKdf(model)); - - Assert.Contains("AuthenticationData and UnlockData must be provided.", exception.Message); - } - [Theory] [BitAutoData] public async Task PostKdf_ChangeKdfFailed_ShouldFail( From c22cc13843aac3a970b98ca1ad3d8293775fbd77 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 21 May 2026 15:08:25 -0400 Subject: [PATCH 14/14] PM-35395 - Update test names --- .../Models/Request/Accounts/ChangeKdfRequestModelTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Api.Test/Auth/Models/Request/Accounts/ChangeKdfRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/Accounts/ChangeKdfRequestModelTests.cs index 6c592874cec7..67d56577d06e 100644 --- a/test/Api.Test/Auth/Models/Request/Accounts/ChangeKdfRequestModelTests.cs +++ b/test/Api.Test/Auth/Models/Request/Accounts/ChangeKdfRequestModelTests.cs @@ -12,7 +12,7 @@ public class ChangeKdfRequestModelTests [Theory] [BitAutoData(KdfType.PBKDF2_SHA256, 600000, null, null)] [BitAutoData(KdfType.Argon2id, 3, 64, 4)] - public void Validate_NewPayloadsOnly_NoErrors( + public void Validate_SubmitWithAuthnAndUnlockData_NoErrors( KdfType kdfType, int iterations, int? memory, int? parallelism) { var kdf = new KdfRequestModel @@ -84,7 +84,7 @@ public void Validate_EitherFieldNull_ReturnsNoErrors(bool authNull, bool unlockN [Theory] [BitAutoData] - public void Validate_NewPayloadsOnly_WithMismatchedKdfSettings_ReturnsKdfValidationError( + public void Validate_WithMismatchedKdfSettings_ReturnsKdfValidationError( string masterPasswordHash) { var authKdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 };