diff --git a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs index 96f990c29981..86a30883fa96 100644 --- a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs +++ b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs @@ -3,6 +3,8 @@ using Bit.Core.KeyManagement.Commands.Interfaces; using Bit.Core.KeyManagement.Kdf; using Bit.Core.KeyManagement.Kdf.Implementations; +using Bit.Core.KeyManagement.MasterPassword; +using Bit.Core.KeyManagement.MasterPassword.Interfaces; using Bit.Core.KeyManagement.Queries; using Bit.Core.KeyManagement.Queries.Interfaces; using Microsoft.AspNetCore.Authorization; @@ -30,11 +32,15 @@ private static void AddKeyManagementCommands(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } private static void AddKeyManagementQueries(this IServiceCollection services) { services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Core/KeyManagement/MasterPassword/Interfaces/ISetInitialMasterPasswordCommand.cs b/src/Core/KeyManagement/MasterPassword/Interfaces/ISetInitialMasterPasswordCommand.cs new file mode 100644 index 000000000000..2cde381b72cd --- /dev/null +++ b/src/Core/KeyManagement/MasterPassword/Interfaces/ISetInitialMasterPasswordCommand.cs @@ -0,0 +1,25 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.KeyManagement.MasterPassword.Interfaces; + +/// +/// Validates the provided data against the user, applies the initial master password state +/// to the user object in memory, and persists the changes to the database. +/// +/// +/// Use for in-memory mutation only (no persistence). +/// +public interface ISetInitialMasterPasswordCommand +{ + /// + /// Validates against , mutates the user + /// with the initial master password state, and persists the result. + /// + /// The user to set the initial master password for. + /// The initial master password data to validate and apply. + /// + /// Thrown when the data is not valid for the user (see ). + /// + Task RunAsync(User user, SetInitialMasterPasswordData data); +} diff --git a/src/Core/KeyManagement/MasterPassword/Interfaces/ISetInitialMasterPasswordQuery.cs b/src/Core/KeyManagement/MasterPassword/Interfaces/ISetInitialMasterPasswordQuery.cs new file mode 100644 index 000000000000..ae41c1392ac4 --- /dev/null +++ b/src/Core/KeyManagement/MasterPassword/Interfaces/ISetInitialMasterPasswordQuery.cs @@ -0,0 +1,25 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.KeyManagement.MasterPassword.Interfaces; + +/// +/// Validates the provided data against the user and applies the initial master password state +/// to the user object in memory. Does not persist to the database. +/// +/// +/// Use to also persist the changes. +/// +public interface ISetInitialMasterPasswordQuery +{ + /// + /// Validates against and mutates the user + /// in memory with the initial master password state. + /// + /// The user to apply the initial master password to. + /// The initial master password data to validate and apply. + /// + /// Thrown when the data is not valid for the user (see ). + /// + Task RunAsync(User user, SetInitialMasterPasswordData data); +} diff --git a/src/Core/KeyManagement/MasterPassword/Interfaces/IUpdateMasterPasswordCommand.cs b/src/Core/KeyManagement/MasterPassword/Interfaces/IUpdateMasterPasswordCommand.cs new file mode 100644 index 000000000000..4472c4108730 --- /dev/null +++ b/src/Core/KeyManagement/MasterPassword/Interfaces/IUpdateMasterPasswordCommand.cs @@ -0,0 +1,26 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.KeyManagement.MasterPassword.Interfaces; + +/// +/// Validates the provided data against the user, applies the updated master password state +/// to the user object in memory, and persists the changes to the database. +/// +/// +/// KDF settings must remain unchanged. Use +/// to change KDF settings. Use for in-memory mutation only (no persistence). +/// +public interface IUpdateMasterPasswordCommand +{ + /// + /// Validates against , mutates the user + /// with the updated master password state, and persists the result. + /// + /// The user to update the master password for. + /// The updated master password data to validate and apply. + /// + /// Thrown when the data is not valid for the user (see ). + /// + Task RunAsync(User user, UpdateMasterPasswordData data); +} diff --git a/src/Core/KeyManagement/MasterPassword/Interfaces/IUpdateMasterPasswordQuery.cs b/src/Core/KeyManagement/MasterPassword/Interfaces/IUpdateMasterPasswordQuery.cs new file mode 100644 index 000000000000..787e533b4e9f --- /dev/null +++ b/src/Core/KeyManagement/MasterPassword/Interfaces/IUpdateMasterPasswordQuery.cs @@ -0,0 +1,26 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.KeyManagement.MasterPassword.Interfaces; + +/// +/// Validates the provided data against the user and applies the updated master password state +/// to the user object in memory. Does not persist to the database. +/// +/// +/// KDF settings must remain unchanged. Use +/// to change KDF settings. Use to also persist the changes. +/// +public interface IUpdateMasterPasswordQuery +{ + /// + /// Validates against and mutates the user + /// in memory with the updated master password state. + /// + /// The user to apply the updated master password to. + /// The updated master password data to validate and apply. + /// + /// Thrown when the data is not valid for the user (see ). + /// + Task RunAsync(User user, UpdateMasterPasswordData data); +} diff --git a/src/Core/KeyManagement/MasterPassword/SetInitialMasterPasswordCommand.cs b/src/Core/KeyManagement/MasterPassword/SetInitialMasterPasswordCommand.cs new file mode 100644 index 000000000000..c9f45b802e33 --- /dev/null +++ b/src/Core/KeyManagement/MasterPassword/SetInitialMasterPasswordCommand.cs @@ -0,0 +1,26 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.MasterPassword.Interfaces; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Repositories; + +namespace Bit.Core.KeyManagement.MasterPassword; + +/// +public class SetInitialMasterPasswordCommand : ISetInitialMasterPasswordCommand +{ + private readonly ISetInitialMasterPasswordQuery _query; + private readonly IUserRepository _userRepository; + + public SetInitialMasterPasswordCommand(ISetInitialMasterPasswordQuery query, IUserRepository userRepository) + { + _query = query; + _userRepository = userRepository; + } + + /// + public async Task RunAsync(User user, SetInitialMasterPasswordData data) + { + await _query.RunAsync(user, data); + await _userRepository.ReplaceAsync(user); + } +} diff --git a/src/Core/KeyManagement/MasterPassword/SetInitialMasterPasswordQuery.cs b/src/Core/KeyManagement/MasterPassword/SetInitialMasterPasswordQuery.cs new file mode 100644 index 000000000000..80ed81e60a95 --- /dev/null +++ b/src/Core/KeyManagement/MasterPassword/SetInitialMasterPasswordQuery.cs @@ -0,0 +1,40 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.MasterPassword.Interfaces; +using Bit.Core.KeyManagement.Models.Data; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.KeyManagement.MasterPassword; + +/// +public class SetInitialMasterPasswordQuery : ISetInitialMasterPasswordQuery +{ + private readonly IPasswordHasher _passwordHasher; + + public SetInitialMasterPasswordQuery(IPasswordHasher passwordHasher) + { + _passwordHasher = passwordHasher; + } + + /// + public Task RunAsync(User user, SetInitialMasterPasswordData data) + { + data.ValidateForUser(user); + + user.MasterPassword = _passwordHasher.HashPassword(user, + data.MasterPasswordAuthentication.MasterPasswordAuthenticationHash); + user.MasterPasswordHint = data.MasterPasswordHint; + user.MasterPasswordSalt = data.MasterPasswordAuthentication.Salt; + user.Key = data.MasterPasswordUnlock.MasterKeyWrappedUserKey; + user.Kdf = data.MasterPasswordAuthentication.Kdf.KdfType; + user.KdfIterations = data.MasterPasswordAuthentication.Kdf.Iterations; + user.KdfMemory = data.MasterPasswordAuthentication.Kdf.Memory; + user.KdfParallelism = data.MasterPasswordAuthentication.Kdf.Parallelism; + + var now = DateTime.UtcNow; + user.RevisionDate = now; + user.AccountRevisionDate = now; + user.LastPasswordChangeDate = now; + + return Task.CompletedTask; + } +} diff --git a/src/Core/KeyManagement/MasterPassword/UpdateMasterPasswordCommand.cs b/src/Core/KeyManagement/MasterPassword/UpdateMasterPasswordCommand.cs new file mode 100644 index 000000000000..d7f72c584197 --- /dev/null +++ b/src/Core/KeyManagement/MasterPassword/UpdateMasterPasswordCommand.cs @@ -0,0 +1,26 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.MasterPassword.Interfaces; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Repositories; + +namespace Bit.Core.KeyManagement.MasterPassword; + +/// +public class UpdateMasterPasswordCommand : IUpdateMasterPasswordCommand +{ + private readonly IUpdateMasterPasswordQuery _query; + private readonly IUserRepository _userRepository; + + public UpdateMasterPasswordCommand(IUpdateMasterPasswordQuery query, IUserRepository userRepository) + { + _query = query; + _userRepository = userRepository; + } + + /// + public async Task RunAsync(User user, UpdateMasterPasswordData data) + { + await _query.RunAsync(user, data); + await _userRepository.ReplaceAsync(user); + } +} diff --git a/src/Core/KeyManagement/MasterPassword/UpdateMasterPasswordQuery.cs b/src/Core/KeyManagement/MasterPassword/UpdateMasterPasswordQuery.cs new file mode 100644 index 000000000000..5e11673a1cf7 --- /dev/null +++ b/src/Core/KeyManagement/MasterPassword/UpdateMasterPasswordQuery.cs @@ -0,0 +1,35 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.MasterPassword.Interfaces; +using Bit.Core.KeyManagement.Models.Data; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.KeyManagement.MasterPassword; + +/// +public class UpdateMasterPasswordQuery : IUpdateMasterPasswordQuery +{ + private readonly IPasswordHasher _passwordHasher; + + public UpdateMasterPasswordQuery(IPasswordHasher passwordHasher) + { + _passwordHasher = passwordHasher; + } + + /// + public Task RunAsync(User user, UpdateMasterPasswordData data) + { + data.ValidateForUser(user); + + user.MasterPassword = _passwordHasher.HashPassword(user, + data.MasterPasswordAuthentication.MasterPasswordAuthenticationHash); + user.MasterPasswordHint = data.MasterPasswordHint; + user.Key = data.MasterPasswordUnlock.MasterKeyWrappedUserKey; + + var now = DateTime.UtcNow; + user.RevisionDate = now; + user.AccountRevisionDate = now; + user.LastPasswordChangeDate = now; + + return Task.CompletedTask; + } +} diff --git a/src/Core/KeyManagement/Models/Data/SetInitialMasterPasswordData.cs b/src/Core/KeyManagement/Models/Data/SetInitialMasterPasswordData.cs new file mode 100644 index 000000000000..db4e029e12af --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/SetInitialMasterPasswordData.cs @@ -0,0 +1,53 @@ +using Bit.Core.Entities; + +namespace Bit.Core.KeyManagement.Models.Data; + +/// +/// Data for setting an initial master password on a user account that has no existing master password. +/// See for updating an existing master password. +/// +public class SetInitialMasterPasswordData +{ + public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; init; } + public required MasterPasswordUnlockData MasterPasswordUnlock { get; init; } + public string? MasterPasswordHint { get; init; } + + /// + /// Validates that the provided data is consistent with a user account that has no existing master password. + /// + /// + /// Verifies that the user has no existing master password, user key, or master password salt, + /// and that the provided salts match the user's current salt (email fallback). + /// + /// + /// Thrown when the user already has a master password, user key, or master password salt set, + /// or when the provided salt does not match the user's current salt. + /// + public void ValidateForUser(User user) + { + try + { + if (user.MasterPassword != null) + { + throw new InvalidOperationException("User already has a master password."); + } + + if (user.Key != null) + { + throw new InvalidOperationException("User already has a user key."); + } + + if (user.MasterPasswordSalt != null) + { + throw new InvalidOperationException("User already has a master password salt."); + } + + MasterPasswordAuthentication.ValidateSaltUnchangedForUser(user); + MasterPasswordUnlock.ValidateSaltUnchangedForUser(user); + } + catch + { + throw new InvalidOperationException("The provided master password data is not valid for this user."); + } + } +} diff --git a/src/Core/KeyManagement/Models/Data/UpdateMasterPasswordData.cs b/src/Core/KeyManagement/Models/Data/UpdateMasterPasswordData.cs new file mode 100644 index 000000000000..1d80b6346bcd --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/UpdateMasterPasswordData.cs @@ -0,0 +1,39 @@ +using Bit.Core.Entities; + +namespace Bit.Core.KeyManagement.Models.Data; + +/// +/// Data for updating a master password on a user account that already has one. +/// KDF settings must remain unchanged — use to change KDF. +/// See for setting an initial master password. +/// +public class UpdateMasterPasswordData +{ + public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; init; } + public required MasterPasswordUnlockData MasterPasswordUnlock { get; init; } + public string? MasterPasswordHint { get; init; } + + /// + /// Validates that the provided data is consistent with the user's current KDF and salt configuration. + /// + /// + /// KDF settings and salt must be unchanged, as this operation only updates the master password. + /// + /// + /// Thrown when the KDF settings or salt in the provided data do not match the user's current values. + /// + public void ValidateForUser(User user) + { + try + { + MasterPasswordAuthentication.ValidateSaltUnchangedForUser(user); + MasterPasswordAuthentication.Kdf.ValidateUnchangedForUser(user); + MasterPasswordUnlock.ValidateSaltUnchangedForUser(user); + MasterPasswordUnlock.Kdf.ValidateUnchangedForUser(user); + } + catch + { + throw new InvalidOperationException("The provided master password data is not valid for this user."); + } + } +} diff --git a/test/Core.Test/KeyManagement/MasterPassword/Models/Data/SetInitialMasterPasswordDataTests.cs b/test/Core.Test/KeyManagement/MasterPassword/Models/Data/SetInitialMasterPasswordDataTests.cs new file mode 100644 index 000000000000..753f569278b6 --- /dev/null +++ b/test/Core.Test/KeyManagement/MasterPassword/Models/Data/SetInitialMasterPasswordDataTests.cs @@ -0,0 +1,126 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.MasterPassword.Models.Data; + +public class SetInitialMasterPasswordDataTests +{ + private const string _mockAuthHash = "mockAuthenticationHash"; + private const string _mockMasterKeyWrappedUserKey = "mockMasterKeyWrappedUserKey"; + + private static KdfSettings ValidKdf => + new() { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000, Memory = null, Parallelism = null }; + + private static void SetupValidUser(User user) + { + user.Email = "test@example.com"; + user.MasterPassword = null; + user.Key = null; + user.MasterPasswordSalt = null; + } + + private static SetInitialMasterPasswordData CreateValidModel(string salt) => + new() + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Kdf = ValidKdf, + MasterPasswordAuthenticationHash = _mockAuthHash, + Salt = salt + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Kdf = ValidKdf, + MasterKeyWrappedUserKey = _mockMasterKeyWrappedUserKey, + Salt = salt + } + }; + + [Theory] + [BitAutoData] + public void ValidateForUser_ValidData_DoesNotThrow(User user) + { + SetupValidUser(user); + var model = CreateValidModel(user.Email); + + model.ValidateForUser(user); + } + + [Theory] + [BitAutoData] + public void ValidateForUser_UserAlreadyHasMasterPassword_ThrowsInvalidOperationException(User user) + { + SetupValidUser(user); + user.MasterPassword = "existing-hash"; + var model = CreateValidModel(user.Email); + + Assert.Throws(() => model.ValidateForUser(user)); + } + + [Theory] + [BitAutoData] + public void ValidateForUser_UserAlreadyHasKey_ThrowsInvalidOperationException(User user) + { + SetupValidUser(user); + user.Key = "existing-key"; + var model = CreateValidModel(user.Email); + + Assert.Throws(() => model.ValidateForUser(user)); + } + + [Theory] + [BitAutoData] + public void ValidateForUser_UserAlreadyHasSalt_ThrowsInvalidOperationException(User user) + { + SetupValidUser(user); + user.MasterPasswordSalt = "existing-salt"; + var model = CreateValidModel(user.Email); + + Assert.Throws(() => model.ValidateForUser(user)); + } + + [Theory] + [BitAutoData] + public void ValidateForUser_AuthenticationSaltMismatch_ThrowsInvalidOperationException(User user) + { + SetupValidUser(user); + var validModel = CreateValidModel(user.Email); + + var model = new SetInitialMasterPasswordData + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Kdf = ValidKdf, + MasterPasswordAuthenticationHash = _mockAuthHash, + Salt = "wrong@example.com" + }, + MasterPasswordUnlock = validModel.MasterPasswordUnlock + }; + + Assert.Throws(() => model.ValidateForUser(user)); + } + + [Theory] + [BitAutoData] + public void ValidateForUser_UnlockSaltMismatch_ThrowsInvalidOperationException(User user) + { + SetupValidUser(user); + var validModel = CreateValidModel(user.Email); + + var model = new SetInitialMasterPasswordData + { + MasterPasswordAuthentication = validModel.MasterPasswordAuthentication, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Kdf = ValidKdf, + MasterKeyWrappedUserKey = _mockMasterKeyWrappedUserKey, + Salt = "wrong@example.com" + } + }; + + Assert.Throws(() => model.ValidateForUser(user)); + } +} diff --git a/test/Core.Test/KeyManagement/MasterPassword/Models/Data/UpdateMasterPasswordDataTests.cs b/test/Core.Test/KeyManagement/MasterPassword/Models/Data/UpdateMasterPasswordDataTests.cs new file mode 100644 index 000000000000..65cde8a3d31e --- /dev/null +++ b/test/Core.Test/KeyManagement/MasterPassword/Models/Data/UpdateMasterPasswordDataTests.cs @@ -0,0 +1,137 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.MasterPassword.Models.Data; + +public class UpdateMasterPasswordDataTests +{ + private const string _mockAuthHash = "mockAuthenticationHash"; + private const string _mockMasterKeyWrappedUserKey = "mockMasterKeyWrappedUserKey"; + + private static KdfSettings ValidKdf => + new() { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000, Memory = null, Parallelism = null }; + + private static void SetupValidUser(User user) + { + user.Email = "test@example.com"; + user.MasterPasswordSalt = null; + user.Kdf = ValidKdf.KdfType; + user.KdfIterations = ValidKdf.Iterations; + user.KdfMemory = ValidKdf.Memory; + user.KdfParallelism = ValidKdf.Parallelism; + } + + private static UpdateMasterPasswordData CreateValidModel(string salt, KdfSettings kdf) => + new() + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = _mockAuthHash, + Salt = salt + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = _mockMasterKeyWrappedUserKey, + Salt = salt + } + }; + + [Theory] + [BitAutoData] + public void ValidateForUser_ValidData_DoesNotThrow(User user) + { + SetupValidUser(user); + var model = CreateValidModel(user.Email, ValidKdf); + + model.ValidateForUser(user); + } + + [Theory] + [BitAutoData] + public void ValidateForUser_AuthenticationSaltMismatch_ThrowsInvalidOperationException(User user) + { + SetupValidUser(user); + var validModel = CreateValidModel(user.Email, ValidKdf); + + var model = new UpdateMasterPasswordData + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Kdf = ValidKdf, + MasterPasswordAuthenticationHash = _mockAuthHash, + Salt = "wrong@example.com" + }, + MasterPasswordUnlock = validModel.MasterPasswordUnlock + }; + + Assert.Throws(() => model.ValidateForUser(user)); + } + + [Theory] + [BitAutoData] + public void ValidateForUser_UnlockSaltMismatch_ThrowsInvalidOperationException(User user) + { + SetupValidUser(user); + var validModel = CreateValidModel(user.Email, ValidKdf); + + var model = new UpdateMasterPasswordData + { + MasterPasswordAuthentication = validModel.MasterPasswordAuthentication, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Kdf = ValidKdf, + MasterKeyWrappedUserKey = _mockMasterKeyWrappedUserKey, + Salt = "wrong@example.com" + } + }; + + Assert.Throws(() => model.ValidateForUser(user)); + } + + [Theory] + [BitAutoData] + public void ValidateForUser_AuthenticationKdfMismatch_ThrowsInvalidOperationException(User user) + { + SetupValidUser(user); + var validModel = CreateValidModel(user.Email, ValidKdf); + + var model = new UpdateMasterPasswordData + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Kdf = new KdfSettings { KdfType = KdfType.Argon2id, Iterations = 3, Memory = 64, Parallelism = 4 }, + MasterPasswordAuthenticationHash = _mockAuthHash, + Salt = validModel.MasterPasswordAuthentication.Salt + }, + MasterPasswordUnlock = validModel.MasterPasswordUnlock + }; + + Assert.Throws(() => model.ValidateForUser(user)); + } + + [Theory] + [BitAutoData] + public void ValidateForUser_UnlockKdfMismatch_ThrowsInvalidOperationException(User user) + { + SetupValidUser(user); + var validModel = CreateValidModel(user.Email, ValidKdf); + + var model = new UpdateMasterPasswordData + { + MasterPasswordAuthentication = validModel.MasterPasswordAuthentication, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Kdf = new KdfSettings { KdfType = KdfType.Argon2id, Iterations = 3, Memory = 64, Parallelism = 4 }, + MasterKeyWrappedUserKey = _mockMasterKeyWrappedUserKey, + Salt = validModel.MasterPasswordUnlock.Salt + } + }; + + Assert.Throws(() => model.ValidateForUser(user)); + } +} diff --git a/test/Core.Test/KeyManagement/MasterPassword/SetInitialMasterPasswordCommandTests.cs b/test/Core.Test/KeyManagement/MasterPassword/SetInitialMasterPasswordCommandTests.cs new file mode 100644 index 000000000000..466436faa019 --- /dev/null +++ b/test/Core.Test/KeyManagement/MasterPassword/SetInitialMasterPasswordCommandTests.cs @@ -0,0 +1,75 @@ +#nullable enable + +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.MasterPassword; +using Bit.Core.KeyManagement.MasterPassword.Interfaces; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.MasterPassword; + +[SutProviderCustomize] +public class SetInitialMasterPasswordCommandTests +{ + private static SetInitialMasterPasswordData CreateData(string salt) => + new() + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }, + MasterPasswordAuthenticationHash = "client-auth-hash", + Salt = salt + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }, + MasterKeyWrappedUserKey = "master-key-wrapped-user-key", + Salt = salt + } + }; + + [Theory] + [BitAutoData] + public async Task RunAsync_DelegatesToQueryThenPersists( + SutProvider sutProvider, User user) + { + var data = CreateData(user.Email); + + await sutProvider.Sut.RunAsync(user, data); + + await sutProvider.GetDependency().Received(1).RunAsync(user, data); + await sutProvider.GetDependency().Received(1).ReplaceAsync(user); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_QueryThrows_DoesNotCallRepository( + SutProvider sutProvider, User user) + { + var data = CreateData(user.Email); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("Validation failed."))); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(user, data)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_PersistsUserReturnedByQuery( + SutProvider sutProvider, User user) + { + var data = CreateData(user.Email); + + await sutProvider.Sut.RunAsync(user, data); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(u => u.Id == user.Id)); + } +} diff --git a/test/Core.Test/KeyManagement/MasterPassword/SetInitialMasterPasswordQueryTests.cs b/test/Core.Test/KeyManagement/MasterPassword/SetInitialMasterPasswordQueryTests.cs new file mode 100644 index 000000000000..fc661ed3f1eb --- /dev/null +++ b/test/Core.Test/KeyManagement/MasterPassword/SetInitialMasterPasswordQueryTests.cs @@ -0,0 +1,137 @@ +#nullable enable + +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.MasterPassword; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.MasterPassword; + +[SutProviderCustomize] +public class SetInitialMasterPasswordQueryTests +{ + private static KdfSettings ValidKdf => + new() { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000, Memory = null, Parallelism = null }; + + private static void SetupValidUser(User user) + { + user.Email = "test@example.com"; + user.MasterPassword = null; + user.Key = null; + user.MasterPasswordSalt = null; + } + + private static SetInitialMasterPasswordData CreateValidData(string salt) => + new() + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Kdf = ValidKdf, + MasterPasswordAuthenticationHash = "client-auth-hash", + Salt = salt + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Kdf = ValidKdf, + MasterKeyWrappedUserKey = "master-key-wrapped-user-key", + Salt = salt + }, + MasterPasswordHint = "hint" + }; + + [Theory] + [BitAutoData] + public async Task RunAsync_ValidData_MutatesUserCorrectly( + SutProvider sutProvider, User user) + { + SetupValidUser(user); + var data = CreateValidData(user.Email); + var hashedPassword = "server-hashed-password"; + sutProvider.GetDependency>() + .HashPassword(user, data.MasterPasswordAuthentication.MasterPasswordAuthenticationHash) + .Returns(hashedPassword); + + await sutProvider.Sut.RunAsync(user, data); + + Assert.Equal(hashedPassword, user.MasterPassword); + Assert.Equal("hint", user.MasterPasswordHint); + Assert.Equal(user.Email, user.MasterPasswordSalt); + Assert.Equal("master-key-wrapped-user-key", user.Key); + Assert.Equal(KdfType.PBKDF2_SHA256, user.Kdf); + Assert.Equal(600000, user.KdfIterations); + Assert.Null(user.KdfMemory); + Assert.Null(user.KdfParallelism); + Assert.NotEqual(default, user.RevisionDate); + Assert.NotEqual(default, user.AccountRevisionDate); + Assert.NotNull(user.LastPasswordChangeDate); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_UserAlreadyHasMasterPassword_ThrowsInvalidOperationException( + SutProvider sutProvider, User user) + { + SetupValidUser(user); + user.MasterPassword = "existing-hash"; + var data = CreateValidData(user.Email); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(user, data)); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_UserAlreadyHasKey_ThrowsInvalidOperationException( + SutProvider sutProvider, User user) + { + SetupValidUser(user); + user.Key = "existing-key"; + var data = CreateValidData(user.Email); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(user, data)); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_UserAlreadyHasSalt_ThrowsInvalidOperationException( + SutProvider sutProvider, User user) + { + SetupValidUser(user); + user.MasterPasswordSalt = "existing-salt"; + var data = CreateValidData(user.Email); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(user, data)); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_SaltMismatch_ThrowsInvalidOperationException( + SutProvider sutProvider, User user) + { + SetupValidUser(user); + var data = CreateValidData("wrong@example.com"); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(user, data)); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_DoesNotCallRepository( + SutProvider sutProvider, User user) + { + SetupValidUser(user); + var data = CreateValidData(user.Email); + sutProvider.GetDependency>() + .HashPassword(Arg.Any(), Arg.Any()) + .Returns("hashed"); + + await sutProvider.Sut.RunAsync(user, data); + + // Queries must not persist — verify no repository interactions + // (SutProvider will inject no IUserRepository; this test confirms no persistence dependency) + } +} diff --git a/test/Core.Test/KeyManagement/MasterPassword/UpdateMasterPasswordCommandTests.cs b/test/Core.Test/KeyManagement/MasterPassword/UpdateMasterPasswordCommandTests.cs new file mode 100644 index 000000000000..c918c9273eae --- /dev/null +++ b/test/Core.Test/KeyManagement/MasterPassword/UpdateMasterPasswordCommandTests.cs @@ -0,0 +1,75 @@ +#nullable enable + +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.MasterPassword; +using Bit.Core.KeyManagement.MasterPassword.Interfaces; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.MasterPassword; + +[SutProviderCustomize] +public class UpdateMasterPasswordCommandTests +{ + private static UpdateMasterPasswordData CreateData(string salt) => + new() + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }, + MasterPasswordAuthenticationHash = "client-auth-hash", + Salt = salt + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }, + MasterKeyWrappedUserKey = "new-master-key-wrapped-user-key", + Salt = salt + } + }; + + [Theory] + [BitAutoData] + public async Task RunAsync_DelegatesToQueryThenPersists( + SutProvider sutProvider, User user) + { + var data = CreateData(user.Email); + + await sutProvider.Sut.RunAsync(user, data); + + await sutProvider.GetDependency().Received(1).RunAsync(user, data); + await sutProvider.GetDependency().Received(1).ReplaceAsync(user); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_QueryThrows_DoesNotCallRepository( + SutProvider sutProvider, User user) + { + var data = CreateData(user.Email); + sutProvider.GetDependency() + .RunAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("Validation failed."))); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(user, data)); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_PersistsUserReturnedByQuery( + SutProvider sutProvider, User user) + { + var data = CreateData(user.Email); + + await sutProvider.Sut.RunAsync(user, data); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(u => u.Id == user.Id)); + } +} diff --git a/test/Core.Test/KeyManagement/MasterPassword/UpdateMasterPasswordQueryTests.cs b/test/Core.Test/KeyManagement/MasterPassword/UpdateMasterPasswordQueryTests.cs new file mode 100644 index 000000000000..64eae5e8a371 --- /dev/null +++ b/test/Core.Test/KeyManagement/MasterPassword/UpdateMasterPasswordQueryTests.cs @@ -0,0 +1,116 @@ +#nullable enable + +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.MasterPassword; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.MasterPassword; + +[SutProviderCustomize] +public class UpdateMasterPasswordQueryTests +{ + private static KdfSettings ValidKdf => + new() { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000, Memory = null, Parallelism = null }; + + private static void SetupValidUser(User user) + { + user.Email = "test@example.com"; + user.MasterPasswordSalt = null; + user.Kdf = ValidKdf.KdfType; + user.KdfIterations = ValidKdf.Iterations; + user.KdfMemory = ValidKdf.Memory; + user.KdfParallelism = ValidKdf.Parallelism; + } + + private static UpdateMasterPasswordData CreateValidData(string salt, KdfSettings kdf) => + new() + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "client-auth-hash", + Salt = salt + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = "new-master-key-wrapped-user-key", + Salt = salt + }, + MasterPasswordHint = "new-hint" + }; + + [Theory] + [BitAutoData] + public async Task RunAsync_ValidData_MutatesUserCorrectly( + SutProvider sutProvider, User user) + { + SetupValidUser(user); + var originalKdf = user.Kdf; + var originalKdfIterations = user.KdfIterations; + var originalSalt = user.MasterPasswordSalt; + var data = CreateValidData(user.Email, ValidKdf); + var hashedPassword = "server-hashed-password"; + sutProvider.GetDependency>() + .HashPassword(user, data.MasterPasswordAuthentication.MasterPasswordAuthenticationHash) + .Returns(hashedPassword); + + await sutProvider.Sut.RunAsync(user, data); + + Assert.Equal(hashedPassword, user.MasterPassword); + Assert.Equal("new-hint", user.MasterPasswordHint); + Assert.Equal("new-master-key-wrapped-user-key", user.Key); + Assert.NotEqual(default, user.RevisionDate); + Assert.NotEqual(default, user.AccountRevisionDate); + Assert.NotNull(user.LastPasswordChangeDate); + // KDF and salt must not change + Assert.Equal(originalKdf, user.Kdf); + Assert.Equal(originalKdfIterations, user.KdfIterations); + Assert.Equal(originalSalt, user.MasterPasswordSalt); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_KdfChanged_ThrowsInvalidOperationException( + SutProvider sutProvider, User user) + { + SetupValidUser(user); + var differentKdf = new KdfSettings { KdfType = KdfType.Argon2id, Iterations = 3, Memory = 64, Parallelism = 4 }; + var data = CreateValidData(user.Email, differentKdf); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(user, data)); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_SaltMismatch_ThrowsInvalidOperationException( + SutProvider sutProvider, User user) + { + SetupValidUser(user); + var data = CreateValidData("wrong@example.com", ValidKdf); + + await Assert.ThrowsAsync(() => sutProvider.Sut.RunAsync(user, data)); + } + + [Theory] + [BitAutoData] + public async Task RunAsync_DoesNotCallRepository( + SutProvider sutProvider, User user) + { + SetupValidUser(user); + var data = CreateValidData(user.Email, ValidKdf); + sutProvider.GetDependency>() + .HashPassword(Arg.Any(), Arg.Any()) + .Returns("hashed"); + + await sutProvider.Sut.RunAsync(user, data); + + // Queries must not persist — verify no repository interactions + } +}