diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index 99c586e3e9ea..8818a4a6fe97 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -1,6 +1,7 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request.WebAuthn; +using Bit.Api.KeyManagement.Enums; using Bit.Api.KeyManagement.Models.Requests; using Bit.Api.KeyManagement.Models.Responses; using Bit.Api.KeyManagement.Validators; @@ -149,6 +150,34 @@ await _webauthnKeyValidator.ValidateAsync(user, model.AccountUnlockData.PasskeyU throw new BadRequestException(ModelState); } + [HttpPost("key-management/rotate-user-keys")] + public async Task RotateUserKeysAsync([FromBody] RotateUserKeysRequestModel request) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + switch (request.UnlockMethodData.UnlockMethod) + { + case UnlockMethod.MasterPassword: + var dataModel = new MasterPasswordRotateUserAccountKeysData + { + MasterPasswordUnlockData = request.UnlockMethodData.MasterPasswordUnlockData!.ToData(), + BaseData = await ToBaseDataModelAsync(request, user), + }; + await _rotateUserAccountKeysCommand.MasterPasswordRotateUserAccountKeysAsync(user, dataModel); + break; + case UnlockMethod.Tde: + throw new BadRequestException("TDE not implemented"); + case UnlockMethod.KeyConnector: + throw new BadRequestException("Key connector not implemented"); + default: + throw new ArgumentOutOfRangeException(nameof(request.UnlockMethodData.UnlockMethod), "Unrecognized unlock method"); + } + } + [HttpPost("set-key-connector-key")] public async Task PostSetKeyConnectorKeyAsync([FromBody] SetKeyConnectorKeyRequestModel model) { @@ -240,4 +269,24 @@ public async Task GetKeyConnectorC var details = await _keyConnectorConfirmationDetailsQuery.Run(orgSsoIdentifier, user.Id); return new KeyConnectorConfirmationDetailsResponseModel(details); } + + private async Task ToBaseDataModelAsync(RotateUserKeysRequestModel request, User user) + { + return new BaseRotateUserAccountKeysData + { + AccountKeys = request.WrappedAccountCryptographicState.ToAccountKeysData(), + EmergencyAccesses = + await _emergencyAccessValidator.ValidateAsync(user, request.UnlockData.EmergencyAccessUnlockData), + OrganizationUsers = + await _organizationUserValidator.ValidateAsync(user, + request.UnlockData.OrganizationAccountRecoveryUnlockData), + WebAuthnKeys = await _webauthnKeyValidator.ValidateAsync(user, request.UnlockData.PasskeyUnlockData), + DeviceKeys = await _deviceValidator.ValidateAsync(user, request.UnlockData.DeviceKeyUnlockData), + V2UpgradeToken = request.UnlockData.V2UpgradeToken?.ToData(), + + Ciphers = await _cipherValidator.ValidateAsync(user, request.AccountData.Ciphers), + Folders = await _folderValidator.ValidateAsync(user, request.AccountData.Folders), + Sends = await _sendValidator.ValidateAsync(user, request.AccountData.Sends), + }; + } } diff --git a/src/Api/KeyManagement/Enums/UnlockMethod.cs b/src/Api/KeyManagement/Enums/UnlockMethod.cs new file mode 100644 index 000000000000..ab7e753b15f0 --- /dev/null +++ b/src/Api/KeyManagement/Enums/UnlockMethod.cs @@ -0,0 +1,8 @@ +namespace Bit.Api.KeyManagement.Enums; + +public enum UnlockMethod +{ + Tde, + MasterPassword, + KeyConnector, +} diff --git a/src/Api/KeyManagement/Models/Requests/CommonUnlockDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/CommonUnlockDataRequestModel.cs new file mode 100644 index 000000000000..8b1a4a670836 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/CommonUnlockDataRequestModel.cs @@ -0,0 +1,15 @@ +using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.Auth.Models.Request; +using Bit.Api.Auth.Models.Request.WebAuthn; +using Bit.Core.Auth.Models.Api.Request; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class CommonUnlockDataRequestModel +{ + public required IEnumerable EmergencyAccessUnlockData { get; set; } + public required IEnumerable OrganizationAccountRecoveryUnlockData { get; set; } + public required IEnumerable PasskeyUnlockData { get; set; } + public required IEnumerable DeviceKeyUnlockData { get; set; } + public V2UpgradeTokenRequestModel? V2UpgradeToken { get; set; } +} diff --git a/src/Api/KeyManagement/Models/Requests/RotateUserKeysRequestModel.cs b/src/Api/KeyManagement/Models/Requests/RotateUserKeysRequestModel.cs new file mode 100644 index 000000000000..b4bd15a1a30a --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/RotateUserKeysRequestModel.cs @@ -0,0 +1,9 @@ +namespace Bit.Api.KeyManagement.Models.Requests; + +public class RotateUserKeysRequestModel +{ + public required WrappedAccountCryptographicStateRequestModel WrappedAccountCryptographicState { get; set; } + public required CommonUnlockDataRequestModel UnlockData { get; set; } + public required AccountDataRequestModel AccountData { get; set; } + public required UnlockMethodRequestModel UnlockMethodData { get; set; } +} diff --git a/src/Api/KeyManagement/Models/Requests/UnlockMethodRequestModel.cs b/src/Api/KeyManagement/Models/Requests/UnlockMethodRequestModel.cs new file mode 100644 index 000000000000..4a2e259f0429 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/UnlockMethodRequestModel.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.KeyManagement.Enums; +using Bit.Core.KeyManagement.Models.Api.Request; +using Bit.Core.Utilities; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class UnlockMethodRequestModel : IValidatableObject +{ + [Required] + public required UnlockMethod UnlockMethod { get; init; } + + // Master password user + public MasterPasswordUnlockDataRequestModel? MasterPasswordUnlockData { get; init; } + + // Key Connector user. + [EncryptedString] + public string? KeyConnectorKeyWrappedUserKey { get; init; } + + public IEnumerable Validate(ValidationContext validationContext) + { + switch (UnlockMethod) + { + case UnlockMethod.MasterPassword: + if (MasterPasswordUnlockData == null || KeyConnectorKeyWrappedUserKey != null) + { + yield return new ValidationResult("Invalid MasterPassword unlock method request, MasterPasswordUnlockData must be provided and KeyConnectorKeyWrappedUserKey must be null"); + } + break; + case UnlockMethod.Tde: + if (MasterPasswordUnlockData != null || KeyConnectorKeyWrappedUserKey != null) + { + yield return new ValidationResult("Invalid Tde unlock method request, MasterPasswordUnlockData must be null and KeyConnectorKeyWrappedUserKey must be null"); + } + break; + case UnlockMethod.KeyConnector: + if (KeyConnectorKeyWrappedUserKey == null || MasterPasswordUnlockData != null) + { + yield return new ValidationResult("Invalid KeyConnector unlock method request, KeyConnectorKeyWrappedUserKey must be provided and MasterPasswordUnlockData must be null"); + } + break; + default: + yield return new ValidationResult("Unrecognized unlock method"); + break; + } + } +} diff --git a/src/Api/KeyManagement/Models/Requests/WrappedAccountCryptographicStateRequestModel.cs b/src/Api/KeyManagement/Models/Requests/WrappedAccountCryptographicStateRequestModel.cs new file mode 100644 index 000000000000..c322beed1664 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/WrappedAccountCryptographicStateRequestModel.cs @@ -0,0 +1,22 @@ +using Bit.Core.KeyManagement.Models.Api.Request; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Api.KeyManagement.Models.Requests; + +// This request model is meant to be used when the user will be submitting a v2 encryption WrappedAccountCryptographicState payload. +public class WrappedAccountCryptographicStateRequestModel +{ + public required PublicKeyEncryptionKeyPairRequestModel PublicKeyEncryptionKeyPair { get; set; } + public required SignatureKeyPairRequestModel SignatureKeyPair { get; set; } + public required SecurityStateModel SecurityState { get; set; } + + public UserAccountKeysData ToAccountKeysData() + { + return new UserAccountKeysData + { + PublicKeyEncryptionKeyPairData = PublicKeyEncryptionKeyPair.ToPublicKeyEncryptionKeyPairData(), + SignatureKeyPairData = SignatureKeyPair.ToSignatureKeyPairData(), + SecurityStateData = SecurityState.ToSecurityState() + }; + } +} diff --git a/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs b/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs index 4f063a87bd1d..443b3c9b77ac 100644 --- a/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs +++ b/src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs @@ -2,6 +2,7 @@ #nullable disable using Bit.Core.Entities; +using Bit.Core.Exceptions; using Bit.Core.KeyManagement.UserKey.Models.Data; using Microsoft.AspNetCore.Identity; using Microsoft.Data.SqlClient; @@ -21,6 +22,16 @@ public interface IRotateUserAccountKeysCommand /// User must be provided. /// User KDF settings and email must match the model provided settings. Task PasswordChangeAndRotateUserAccountKeysAsync(User user, PasswordChangeAndRotateUserAccountKeysData model); + + /// + /// For a master password user, rotates the user key and updates all encrypted data without changing the master password. + /// + /// Rotation data. All encrypted data must be included or the request will be rejected. + /// Thrown when is null. + /// Thrown when is not a master password user. + /// Thrown when salt does not match MasterPasswordUnlockData. + /// Thrown when KDF settings do not match MasterPasswordUnlockData. + Task MasterPasswordRotateUserAccountKeysAsync(User user, MasterPasswordRotateUserAccountKeysData model); } /// diff --git a/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountKeysCommand.cs b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountKeysCommand.cs index b09796f372a7..7acb6b01e9d6 100644 --- a/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountKeysCommand.cs +++ b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountKeysCommand.cs @@ -100,6 +100,23 @@ public async Task PasswordChangeAndRotateUserAccountKeysAsync(Us return IdentityResult.Success; } + /// + public async Task MasterPasswordRotateUserAccountKeysAsync(User user, MasterPasswordRotateUserAccountKeysData model) + { + ArgumentNullException.ThrowIfNull(user); + + model.ValidateForUser(user); + + List saveEncryptedDataActions = []; + var shouldPersistV2UpgradeToken = + await BaseRotateUserAccountKeysAsync(model.BaseData, user, saveEncryptedDataActions); + user.Key = model.MasterPasswordUnlockData.MasterKeyWrappedUserKey; + + await _userRepository.UpdateUserKeyAndEncryptedDataV2Async(user, saveEncryptedDataActions); + + await HandlePushNotificationAsync(shouldPersistV2UpgradeToken, user); + } + private async Task RotateV2AccountKeysAsync(BaseRotateUserAccountKeysData model, User user, List saveEncryptedDataActions) { ValidateV2Encryption(model); diff --git a/src/Core/KeyManagement/UserKey/Models/Data/MasterPasswordRotateUserAccountKeysData.cs b/src/Core/KeyManagement/UserKey/Models/Data/MasterPasswordRotateUserAccountKeysData.cs new file mode 100644 index 000000000000..95bf5bd54cff --- /dev/null +++ b/src/Core/KeyManagement/UserKey/Models/Data/MasterPasswordRotateUserAccountKeysData.cs @@ -0,0 +1,23 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.KeyManagement.UserKey.Models.Data; + +public class MasterPasswordRotateUserAccountKeysData +{ + public required MasterPasswordUnlockData MasterPasswordUnlockData { get; init; } + public required BaseRotateUserAccountKeysData BaseData { get; init; } + + public void ValidateForUser(User user) + { + var isMasterPasswordUser = user is { Key: not null, MasterPassword: not null }; + if (!isMasterPasswordUser) + { + throw new BadRequestException("User is in an invalid state for master password key rotation."); + } + + MasterPasswordUnlockData.ValidateSaltUnchangedForUser(user); + MasterPasswordUnlockData.Kdf.ValidateUnchangedForUser(user); + } +} diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index 56d17d7244e5..2534a7f651d3 100644 --- a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -1,11 +1,11 @@ #nullable enable using System.Net; +using System.Text.Json; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.KeyManagement.Models.Requests; using Bit.Api.KeyManagement.Models.Responses; using Bit.Api.Tools.Models.Request; -using Bit.Api.Vault.Models; using Bit.Api.Vault.Models.Request; using Bit.Core; using Bit.Core.AdminConsole.Entities; @@ -730,6 +730,75 @@ await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, ssoUser Assert.Equal(organization.Name, result.OrganizationName); } + [Theory] + [BitAutoData] + public async Task RotateUserKeysAsync_NotLoggedIn_Unauthorized( + RotateUserKeysRequestModel request) + { + var response = await _client.PostAsJsonAsync("/accounts/key-management/rotate-user-keys", request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Theory] + [BitAutoData] + public async Task RotateUserKeysAsync_V2Rotation_Success(RotateUserKeysRequestModel request) + { + var user = await SetupUserForKeyRotationAsync(_mockEncryptedType7String, true); + SetupMasterPasswordRotateUserAccount(request, user); + + var response = await _client.PostAsJsonAsync("/accounts/key-management/rotate-user-keys", request); + response.EnsureSuccessStatusCode(); + + var userNewState = await _userRepository.GetByEmailAsync(_ownerEmail); + Assert.NotNull(userNewState); + Assert.Equal(request.UnlockMethodData.MasterPasswordUnlockData!.MasterKeyWrappedUserKey, userNewState.Key); + Assert.Equal(request.WrappedAccountCryptographicState.PublicKeyEncryptionKeyPair.SignedPublicKey, + userNewState.SignedPublicKey); + Assert.Equal(request.WrappedAccountCryptographicState.SecurityState.SecurityState, userNewState.SecurityState); + Assert.Equal(request.WrappedAccountCryptographicState.SecurityState.SecurityVersion, + userNewState.SecurityVersion); + + var signatureKeyPair = await _userSignatureKeyPairRepository.GetByUserIdAsync(userNewState.Id); + Assert.NotNull(signatureKeyPair); + Assert.Equal(SignatureAlgorithm.Ed25519, signatureKeyPair.SignatureAlgorithm); + Assert.Equal(request.WrappedAccountCryptographicState.SignatureKeyPair.WrappedSigningKey, + signatureKeyPair.WrappedSigningKey); + Assert.Equal(request.WrappedAccountCryptographicState.SignatureKeyPair.VerifyingKey, + signatureKeyPair.VerifyingKey); + } + + [Theory] + [BitAutoData] + public async Task RotateUserKeysAsync_V1ToV2Rotation_Success(RotateUserKeysRequestModel request) + { + var user = await SetupUserForKeyRotationAsync(); + SetupMasterPasswordRotateUserAccount(request, user, true); + + var response = await _client.PostAsJsonAsync("/accounts/key-management/rotate-user-keys", request); + response.EnsureSuccessStatusCode(); + + var userNewState = await _userRepository.GetByEmailAsync(_ownerEmail); + Assert.NotNull(userNewState); + Assert.Equal(request.UnlockMethodData.MasterPasswordUnlockData!.MasterKeyWrappedUserKey, userNewState.Key); + Assert.Equal(request.WrappedAccountCryptographicState.PublicKeyEncryptionKeyPair.SignedPublicKey, + userNewState.SignedPublicKey); + Assert.Equal(request.WrappedAccountCryptographicState.SecurityState.SecurityState, userNewState.SecurityState); + Assert.Equal(request.WrappedAccountCryptographicState.SecurityState.SecurityVersion, + userNewState.SecurityVersion); + Assert.NotNull(userNewState.V2UpgradeToken); + Assert.Contains($"\"WrappedUserKey1\":\"{_mockEncryptedType7String}\"", userNewState.V2UpgradeToken); + Assert.Contains($"\"WrappedUserKey2\":\"{_mockEncryptedString}\"", userNewState.V2UpgradeToken); + + var signatureKeyPair = await _userSignatureKeyPairRepository.GetByUserIdAsync(userNewState.Id); + Assert.NotNull(signatureKeyPair); + Assert.Equal(SignatureAlgorithm.Ed25519, signatureKeyPair.SignatureAlgorithm); + Assert.Equal(request.WrappedAccountCryptographicState.SignatureKeyPair.WrappedSigningKey, + signatureKeyPair.WrappedSigningKey); + Assert.Equal(request.WrappedAccountCryptographicState.SignatureKeyPair.VerifyingKey, + signatureKeyPair.VerifyingKey); + } + private async Task<(string, Organization)> SetupKeyConnectorTestAsync(OrganizationUserStatusType userStatusType, string organizationSsoIdentifier = "test-sso-identifier") { @@ -815,41 +884,7 @@ private void SetupRotateUserAccountUnlockData( private void SetupRotateUserAccountData(RotateUserAccountKeysAndDataRequestModel request) { - request.AccountData.Ciphers = - [ - new CipherWithIdRequestModel - { - Id = Guid.NewGuid(), - Type = CipherType.Login, - Name = _mockEncryptedString, - Login = new CipherLoginModel - { - Username = _mockEncryptedString, - Password = _mockEncryptedString, - }, - }, - ]; - - request.AccountData.Folders = - [ - new FolderWithIdRequestModel - { - Id = Guid.NewGuid(), - Name = _mockEncryptedString, - }, - ]; - - request.AccountData.Sends = - [ - new SendWithIdRequestModel - { - Id = Guid.NewGuid(), - Name = _mockEncryptedString, - Key = _mockEncryptedString, - Disabled = false, - DeletionDate = DateTime.UtcNow.AddDays(1), - }, - ]; + request.AccountData = BuildAccountData(); } private void SetupRotateUserAccountKeys( @@ -862,23 +897,9 @@ private void SetupRotateUserAccountKeys( { // V2 crypto: Type 7 encryption with V2 keys and SecurityState request.AccountKeys.UserKeyEncryptedAccountPrivateKey = _mockEncryptedType7String; - request.AccountKeys.PublicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel - { - PublicKey = "publicKey", - WrappedPrivateKey = _mockEncryptedType7String, - SignedPublicKey = "signedPublicKey", - }; - request.AccountKeys.SignatureKeyPair = new SignatureKeyPairRequestModel - { - SignatureAlgorithm = "ed25519", - WrappedSigningKey = _mockEncryptedType7String, - VerifyingKey = "verifyingKey", - }; - request.AccountKeys.SecurityState = new SecurityStateModel - { - SecurityVersion = 2, - SecurityState = "v2", - }; + request.AccountKeys.PublicKeyEncryptionKeyPair = BuildPublicKeyEncryptionKeyPair(); + request.AccountKeys.SignatureKeyPair = BuildSignatureKeyPair(); + request.AccountKeys.SecurityState = BuildSecurityState(); } else { @@ -891,4 +912,99 @@ private void SetupRotateUserAccountKeys( request.AccountUnlockData.V2UpgradeToken = null; } + + private static void SetupMasterPasswordRotateUserAccount(RotateUserKeysRequestModel request, User user, + bool upgradeToken = false) + { + request.UnlockMethodData = new UnlockMethodRequestModel + { + UnlockMethod = Api.KeyManagement.Enums.UnlockMethod.MasterPassword, + MasterPasswordUnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = user.Kdf, + Iterations = user.KdfIterations, + Memory = user.KdfMemory, + Parallelism = user.KdfParallelism + }, + MasterKeyWrappedUserKey = _mockEncryptedType7String, + Salt = user.Email.ToLowerInvariant().Trim() + } + }; + SetupCommonRotate(request, upgradeToken); + } + + private static void SetupCommonRotate(RotateUserKeysRequestModel request, bool upgradeToken = false) + { + if (upgradeToken) + { + request.UnlockData.V2UpgradeToken = new V2UpgradeTokenRequestModel + { + WrappedUserKey1 = _mockEncryptedType7String, + WrappedUserKey2 = _mockEncryptedString + }; + } + else + { + request.UnlockData.V2UpgradeToken = null; + } + + request.WrappedAccountCryptographicState.PublicKeyEncryptionKeyPair = BuildPublicKeyEncryptionKeyPair(); + request.WrappedAccountCryptographicState.SignatureKeyPair = BuildSignatureKeyPair(); + request.WrappedAccountCryptographicState.SecurityState = BuildSecurityState(); + + request.UnlockData.PasskeyUnlockData = []; + request.UnlockData.DeviceKeyUnlockData = []; + request.UnlockData.EmergencyAccessUnlockData = []; + request.UnlockData.OrganizationAccountRecoveryUnlockData = []; + + request.AccountData = BuildAccountData(); + } + + private static AccountDataRequestModel BuildAccountData() => new() + { + Ciphers = + [ + new CipherWithIdRequestModel + { + Id = Guid.NewGuid(), + Type = CipherType.Login, + Name = _mockEncryptedString, + Data = JsonSerializer.Serialize(new { Username = _mockEncryptedString, Password = _mockEncryptedString }), + }, + ], + Folders = [new FolderWithIdRequestModel { Id = Guid.NewGuid(), Name = _mockEncryptedString }], + Sends = + [ + new SendWithIdRequestModel + { + Id = Guid.NewGuid(), + Name = _mockEncryptedString, + Key = _mockEncryptedString, + Disabled = false, + DeletionDate = DateTime.UtcNow.AddDays(1), + }, + ], + }; + + private static PublicKeyEncryptionKeyPairRequestModel BuildPublicKeyEncryptionKeyPair() => new() + { + PublicKey = "publicKey", + WrappedPrivateKey = _mockEncryptedType7String, + SignedPublicKey = "signedPublicKey", + }; + + private static SignatureKeyPairRequestModel BuildSignatureKeyPair() => new() + { + SignatureAlgorithm = "ed25519", + WrappedSigningKey = _mockEncryptedType7String, + VerifyingKey = "verifyingKey", + }; + + private static SecurityStateModel BuildSecurityState() => new() + { + SecurityVersion = 2, + SecurityState = "v2", + }; } diff --git a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index a659006cefaa..c3947f04a686 100644 --- a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -4,6 +4,7 @@ using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.KeyManagement.Controllers; +using Bit.Api.KeyManagement.Enums; using Bit.Api.KeyManagement.Models.Requests; using Bit.Api.KeyManagement.Validators; using Bit.Api.Tools.Models.Request; @@ -12,6 +13,7 @@ using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Data; using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Commands.Interfaces; using Bit.Core.KeyManagement.Models.Api.Request; @@ -41,6 +43,20 @@ public class AccountsKeyManagementControllerTests "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="; private static readonly string _mockEncryptedType7String = "7.AOs41Hd8OQiCPXjyJKCiDA=="; + + public static IEnumerable UnimplementedUnlockMethods => new List + { + //TDE + new object[] { new UnlockMethodRequestModel { UnlockMethod = UnlockMethod.Tde, KeyConnectorKeyWrappedUserKey = null, MasterPasswordUnlockData = null }}, + //Key connector + new object[] { new UnlockMethodRequestModel + { + UnlockMethod = UnlockMethod.KeyConnector, + KeyConnectorKeyWrappedUserKey = "wrapped-user-key", MasterPasswordUnlockData = null + } }, + + }; + [Theory] [BitAutoData] public async Task RegenerateKeysAsync_FeatureFlagOff_Throws( @@ -632,4 +648,102 @@ public async Task GetKeyConnectorConfirmationDetailsAsync_Success( await sutProvider.GetDependency().Received(1) .Run(orgSsoIdentifier, expectedUser.Id); } + + [Theory] + [BitAutoData] + public async Task RotateUserKeysAsync_WhenUserIsNull_Throws(SutProvider sutProvider, RotateUserKeysRequestModel request) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsNull(); + + await Assert.ThrowsAsync(() => + sutProvider.Sut.RotateUserKeysAsync(request)); + } + + [Theory] + [BitMemberAutoData(nameof(UnimplementedUnlockMethods))] + public async Task RotateUserKeysAsync_UnimplementedUnlockMethod_Throws(UnlockMethodRequestModel unlockMethod, + SutProvider sutProvider, RotateUserKeysRequestModel request, User user) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + request.UnlockMethodData = unlockMethod; + + + await Assert.ThrowsAsync(() => + sutProvider.Sut.RotateUserKeysAsync(request)); + } + + [Theory] + [BitAutoData] + public async Task RotateUserKeysAsync_MasterPassword_Success( + SutProvider sutProvider, RotateUserKeysRequestModel request, User user) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).Returns(user); + request = SetupValidRotateUserKeysRequest(request); + + await sutProvider.Sut.RotateUserKeysAsync(request); + + await AssertCommonValidatorsCalledAsync(sutProvider, request); + + await sutProvider.GetDependency().Received(1) + .MasterPasswordRotateUserAccountKeysAsync(Arg.Is(user), Arg.Is(d => + d.MasterPasswordUnlockData.Kdf.KdfType == request.UnlockMethodData.MasterPasswordUnlockData!.Kdf.KdfType + && d.MasterPasswordUnlockData.Kdf.Iterations == request.UnlockMethodData.MasterPasswordUnlockData.Kdf.Iterations + && d.MasterPasswordUnlockData.Kdf.Memory == request.UnlockMethodData.MasterPasswordUnlockData.Kdf.Memory + && d.MasterPasswordUnlockData.Kdf.Parallelism == request.UnlockMethodData.MasterPasswordUnlockData.Kdf.Parallelism + && d.MasterPasswordUnlockData.Salt == request.UnlockMethodData.MasterPasswordUnlockData.Salt + && d.MasterPasswordUnlockData.MasterKeyWrappedUserKey == request.UnlockMethodData.MasterPasswordUnlockData.MasterKeyWrappedUserKey + + && d.BaseData.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey == request.WrappedAccountCryptographicState.PublicKeyEncryptionKeyPair.WrappedPrivateKey + && d.BaseData.AccountKeys.PublicKeyEncryptionKeyPairData.PublicKey == request.WrappedAccountCryptographicState.PublicKeyEncryptionKeyPair.PublicKey + && d.BaseData.AccountKeys.PublicKeyEncryptionKeyPairData.SignedPublicKey == request.WrappedAccountCryptographicState.PublicKeyEncryptionKeyPair.SignedPublicKey + + && d.BaseData.AccountKeys.SignatureKeyPairData!.SignatureAlgorithm == Core.KeyManagement.Enums.SignatureAlgorithm.Ed25519 + && d.BaseData.AccountKeys.SignatureKeyPairData.WrappedSigningKey == request.WrappedAccountCryptographicState.SignatureKeyPair.WrappedSigningKey + && d.BaseData.AccountKeys.SignatureKeyPairData.VerifyingKey == request.WrappedAccountCryptographicState.SignatureKeyPair.VerifyingKey + )); + } + + private static async Task AssertCommonValidatorsCalledAsync(SutProvider sutProvider, RotateUserKeysRequestModel request) + { + await sutProvider.GetDependency, IEnumerable>>().Received(1) + .ValidateAsync(Arg.Any(), Arg.Is(request.UnlockData.EmergencyAccessUnlockData)); + await sutProvider.GetDependency, IReadOnlyList>>().Received(1) + .ValidateAsync(Arg.Any(), Arg.Is(request.UnlockData.OrganizationAccountRecoveryUnlockData)); + await sutProvider.GetDependency, IEnumerable>>().Received(1) + .ValidateAsync(Arg.Any(), Arg.Is(request.UnlockData.PasskeyUnlockData)); + + await sutProvider.GetDependency, IEnumerable>>().Received(1) + .ValidateAsync(Arg.Any(), Arg.Is(request.AccountData.Ciphers)); + await sutProvider.GetDependency, IEnumerable>>().Received(1) + .ValidateAsync(Arg.Any(), Arg.Is(request.AccountData.Folders)); + await sutProvider.GetDependency, IReadOnlyList>>().Received(1) + .ValidateAsync(Arg.Any(), Arg.Is(request.AccountData.Sends)); + } + + private static RotateUserKeysRequestModel SetupValidRotateUserKeysRequest(RotateUserKeysRequestModel request) + { + request.WrappedAccountCryptographicState.SignatureKeyPair = new SignatureKeyPairRequestModel + { + SignatureAlgorithm = "ed25519", + WrappedSigningKey = "wrappedSigningKey", + VerifyingKey = "verifyingKey" + }; + + request.UnlockMethodData = new UnlockMethodRequestModel() + { + UnlockMethod = UnlockMethod.MasterPassword, + MasterPasswordUnlockData = new MasterPasswordUnlockDataRequestModel() + { + Salt = "test", + MasterKeyWrappedUserKey = "test", + Kdf = new KdfRequestModel() + { + Iterations = 6000, + KdfType = KdfType.PBKDF2_SHA256, + } + }, + KeyConnectorKeyWrappedUserKey = null, + }; + return request; + } } diff --git a/test/Api.Test/KeyManagement/Models/Request/UnlockMethodRequestModelTests.cs b/test/Api.Test/KeyManagement/Models/Request/UnlockMethodRequestModelTests.cs new file mode 100644 index 000000000000..18ae5356140f --- /dev/null +++ b/test/Api.Test/KeyManagement/Models/Request/UnlockMethodRequestModelTests.cs @@ -0,0 +1,162 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.KeyManagement.Enums; +using Bit.Api.KeyManagement.Models.Requests; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Api.Request; +using Xunit; + +namespace Bit.Api.Test.KeyManagement.Models.Request; + +public class UnlockMethodRequestModelTests +{ + private const string _wrappedUserKey = + "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="; + + private const string _salt = "mockSalt"; + + [Fact] + public void Validate_MasterPassword_ValidData_PassesValidation() + { + var model = new UnlockMethodRequestModel + { + UnlockMethod = UnlockMethod.MasterPassword, + MasterPasswordUnlockData = BuildMasterPasswordUnlockDataRequestModel(), + KeyConnectorKeyWrappedUserKey = null + }; + + var result = Validate(model); + Assert.Empty(result); + } + + [Fact] + public void Validate_MasterPassword_MissingMasterPasswordUnlockData_FailsValidation() + { + var model = new UnlockMethodRequestModel + { + UnlockMethod = UnlockMethod.MasterPassword, + MasterPasswordUnlockData = null, + KeyConnectorKeyWrappedUserKey = null + }; + + var result = Validate(model); + Assert.Single(result); + Assert.NotNull(result.First().ErrorMessage); + } + + [Fact] + public void Validate_MasterPassword_KeyConnectorKeyPresent_FailsValidation() + { + var model = new UnlockMethodRequestModel + { + UnlockMethod = UnlockMethod.MasterPassword, + MasterPasswordUnlockData = BuildMasterPasswordUnlockDataRequestModel(), + KeyConnectorKeyWrappedUserKey = _wrappedUserKey + }; + + var result = Validate(model); + Assert.Single(result); + Assert.NotNull(result.First().ErrorMessage); + } + + [Fact] + public void Validate_Tde_NoExtraData_PassesValidation() + { + var model = new UnlockMethodRequestModel + { + UnlockMethod = UnlockMethod.Tde, + MasterPasswordUnlockData = null, + KeyConnectorKeyWrappedUserKey = null + }; + + var result = Validate(model); + Assert.Empty(result); + } + + [Fact] + public void Validate_Tde_MasterPasswordUnlockDataPresent_FailsValidation() + { + var model = new UnlockMethodRequestModel + { + UnlockMethod = UnlockMethod.Tde, + MasterPasswordUnlockData = BuildMasterPasswordUnlockDataRequestModel(), + KeyConnectorKeyWrappedUserKey = null + }; + + var result = Validate(model); + Assert.Single(result); + Assert.NotNull(result.First().ErrorMessage); + } + + [Fact] + public void Validate_Tde_KeyConnectorKeyPresent_FailsValidation() + { + var model = new UnlockMethodRequestModel + { + UnlockMethod = UnlockMethod.Tde, + MasterPasswordUnlockData = null, + KeyConnectorKeyWrappedUserKey = _wrappedUserKey + }; + + var result = Validate(model); + Assert.Single(result); + Assert.NotNull(result.First().ErrorMessage); + } + + [Fact] + public void Validate_KeyConnector_ValidData_PassesValidation() + { + var model = new UnlockMethodRequestModel + { + UnlockMethod = UnlockMethod.KeyConnector, + MasterPasswordUnlockData = null, + KeyConnectorKeyWrappedUserKey = _wrappedUserKey + }; + + var result = Validate(model); + Assert.Empty(result); + } + + [Fact] + public void Validate_KeyConnector_MissingKeyConnectorKey_FailsValidation() + { + var model = new UnlockMethodRequestModel + { + UnlockMethod = UnlockMethod.KeyConnector, + MasterPasswordUnlockData = null, + KeyConnectorKeyWrappedUserKey = null + }; + + var result = Validate(model); + Assert.Single(result); + Assert.NotNull(result.First().ErrorMessage); + } + + [Fact] + public void Validate_KeyConnector_MasterPasswordUnlockDataPresent_FailsValidation() + { + var model = new UnlockMethodRequestModel + { + UnlockMethod = UnlockMethod.KeyConnector, + MasterPasswordUnlockData = BuildMasterPasswordUnlockDataRequestModel(), + KeyConnectorKeyWrappedUserKey = _wrappedUserKey + }; + + var result = Validate(model); + Assert.Single(result); + Assert.NotNull(result.First().ErrorMessage); + } + + private static List Validate(UnlockMethodRequestModel model) + { + var results = new List(); + Validator.TryValidateObject(model, new ValidationContext(model), results, true); + return results; + } + + private static MasterPasswordUnlockDataRequestModel BuildMasterPasswordUnlockDataRequestModel() => new() + { + Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }, + MasterKeyWrappedUserKey = _wrappedUserKey, + Salt = _salt + }; +} diff --git a/test/Core.Test/KeyManagement/UserKey/RotateUserAccountKeysCommandTests.cs b/test/Core.Test/KeyManagement/UserKey/RotateUserAccountKeysCommandTests.cs index d06a54b32d6a..925d85727722 100644 --- a/test/Core.Test/KeyManagement/UserKey/RotateUserAccountKeysCommandTests.cs +++ b/test/Core.Test/KeyManagement/UserKey/RotateUserAccountKeysCommandTests.cs @@ -1,5 +1,6 @@ using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Enums; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.Repositories; @@ -7,6 +8,7 @@ using Bit.Core.KeyManagement.UserKey.Implementations; using Bit.Core.KeyManagement.UserKey.Models.Data; using Bit.Core.Platform.Push; +using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Tools.Entities; using Bit.Core.Tools.Repositories; @@ -591,6 +593,135 @@ await sutProvider.GetDependency().Received(1) .PushLogOutAsync(user.Id); } + [Theory] + [BitAutoData] + public async Task MasterPasswordRotateUserAccountKeysAsync_MissingUser_Throws( + SutProvider sutProvider, MasterPasswordRotateUserAccountKeysData model) => + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.MasterPasswordRotateUserAccountKeysAsync(null, model)); + + [Theory] + [BitAutoData(true, true)] + [BitAutoData(false, true)] + [BitAutoData(true, false)] + public async Task MasterPasswordRotateUserAccountKeysAsync_UserIsNotMasterPasswordUser_Throws(bool keyNull, + bool masterPasswordNull, + SutProvider sutProvider, User user, MasterPasswordRotateUserAccountKeysData model) + { + if (keyNull) + { + user.Key = null; + } + + if (masterPasswordNull) + { + user.MasterPassword = null; + } + + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.MasterPasswordRotateUserAccountKeysAsync(user, model)); + } + + [Theory] + [BitAutoData] + public async Task MasterPasswordRotateUserAccountKeysAsync_EmailChange_Throws( + SutProvider sutProvider, User user, MasterPasswordRotateUserAccountKeysData model) + { + model = SetupTestData(model); + SetupUserKdf(user, model); + user.Email += ".different-domain"; + + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.MasterPasswordRotateUserAccountKeysAsync(user, model)); + } + + [Theory] + [BitAutoData] + public async Task MasterPasswordRotateUserAccountKeysAsync_ChangedKdf_Throws( + SutProvider sutProvider, User user, MasterPasswordRotateUserAccountKeysData model) + { + model = SetupTestData(model); + SetupUserKdf(user, model); + user.Kdf = KdfType.PBKDF2_SHA256; + + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.MasterPasswordRotateUserAccountKeysAsync(user, model)); + } + + [Theory] + [BitAutoData] + public async Task MasterPasswordRotateUserAccountKeysAsync_V2User_Success( + SutProvider sutProvider, User user, MasterPasswordRotateUserAccountKeysData model) + { + model = SetupTestData(model); + SetupUserKdf(user, model); + var signatureRepository = sutProvider.GetDependency(); + SetV2ExistingUser(user, signatureRepository); + SetV2ModelUser(model.BaseData); + var originalSecurityStamp = user.SecurityStamp = Guid.NewGuid().ToString(); + + await sutProvider.Sut.MasterPasswordRotateUserAccountKeysAsync(user, model); + + Assert.Equal(model.MasterPasswordUnlockData.MasterKeyWrappedUserKey, user.Key); + await sutProvider.GetDependency().Received(1) + .UpdateUserKeyAndEncryptedDataV2Async(user, Arg.Any>()); + Assert.NotEqual(originalSecurityStamp, user.SecurityStamp); + await sutProvider.GetDependency().Received(1) + .PushLogOutAsync(user.Id); + } + + [Theory] + [BitAutoData] + public async Task MasterPasswordRotateUserAccountKeysAsync_V1User_WithNewV2UpgradeToken_PersistsToken( + SutProvider sutProvider, User user, MasterPasswordRotateUserAccountKeysData model) + { + model = SetupTestData(model); + SetupUserKdf(user, model); + var signatureRepository = sutProvider.GetDependency(); + SetV1ExistingUser(user, signatureRepository); + SetV1ModelUser(model.BaseData); + var originalSecurityStamp = user.SecurityStamp = Guid.NewGuid().ToString(); + model.BaseData.V2UpgradeToken = new V2UpgradeTokenData + { + WrappedUserKey1 = _mockEncryptedType7String, + WrappedUserKey2 = _mockEncryptedType2String + }; + + await sutProvider.Sut.MasterPasswordRotateUserAccountKeysAsync(user, model); + + Assert.NotNull(user.V2UpgradeToken); + Assert.Contains(_mockEncryptedType7String, user.V2UpgradeToken); + Assert.Contains(_mockEncryptedType2String, user.V2UpgradeToken); + Assert.Equal(originalSecurityStamp, user.SecurityStamp); + await sutProvider.GetDependency().Received(1) + .PushLogOutAsync(user.Id, false, PushNotificationLogOutReason.KeyRotation); + } + + [Theory] + [BitAutoData] + public async Task MasterPasswordRotateUserAccountKeysAsync_V2User_WithV2UpgradeToken_IgnoresTokenAndLogsOut( + SutProvider sutProvider, User user, MasterPasswordRotateUserAccountKeysData model) + { + model = SetupTestData(model); + SetupUserKdf(user, model); + var signatureRepository = sutProvider.GetDependency(); + SetV2ExistingUser(user, signatureRepository); + SetV2ModelUser(model.BaseData); + var originalSecurityStamp = user.SecurityStamp = Guid.NewGuid().ToString(); + model.BaseData.V2UpgradeToken = new V2UpgradeTokenData + { + WrappedUserKey1 = _mockEncryptedType7String, + WrappedUserKey2 = _mockEncryptedType2String + }; + + await sutProvider.Sut.MasterPasswordRotateUserAccountKeysAsync(user, model); + + Assert.Null(user.V2UpgradeToken); + Assert.NotEqual(originalSecurityStamp, user.SecurityStamp); + await sutProvider.GetDependency().Received(1) + .PushLogOutAsync(user.Id); + } + // Helper functions to set valid test parameters that match each other to the model and user. private static void SetTestKdfAndSaltForUserAndModel(User user, PasswordChangeAndRotateUserAccountKeysData model) { @@ -656,4 +787,32 @@ private static void SetV2ModelUser(BaseRotateUserAccountKeysData model) SecurityVersion = 2, }; } + + private static MasterPasswordRotateUserAccountKeysData SetupTestData(MasterPasswordRotateUserAccountKeysData model) + { + var testKdf = new KdfSettings { KdfType = KdfType.Argon2id, Iterations = 3, Memory = 64, Parallelism = 4 }; + model = new MasterPasswordRotateUserAccountKeysData + { + MasterPasswordUnlockData = new MasterPasswordUnlockData + { + Kdf = testKdf, + MasterKeyWrappedUserKey = _mockEncryptedType2String, + Salt = _mockSalt + }, + BaseData = model.BaseData + }; + return model; + } + + private static void SetupUserKdf(User user, MasterPasswordRotateUserAccountKeysData model) + { + user.Kdf = model.MasterPasswordUnlockData.Kdf.KdfType; + user.KdfIterations = model.MasterPasswordUnlockData.Kdf.Iterations; + user.KdfMemory = model.MasterPasswordUnlockData.Kdf.Memory; + user.KdfParallelism = model.MasterPasswordUnlockData.Kdf.Parallelism; + // For now email and salt are coupled. This will be changed later to read from user.Salt. + user.Email = model.MasterPasswordUnlockData.Salt; + user.Key = _mockEncryptedType2String; + user.MasterPassword = "mockMasterPasswordAuthenticationHash"; + } }