diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index 4748e273523a..a857a6072120 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -160,7 +160,7 @@ public async Task PostSetKeyConnectorKeyAsync([FromBody] SetKeyConnectorKeyReque { // V1 account registration // TODO removed with https://bitwarden.atlassian.net/browse/PM-27328 - var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier); + var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key!, model.OrgIdentifier); if (result.Succeeded) { return; @@ -184,7 +184,30 @@ public async Task PostConvertToKeyConnectorAsync() throw new UnauthorizedAccessException(); } - var result = await _userService.ConvertToKeyConnectorAsync(user); + var result = await _userService.ConvertToKeyConnectorAsync(user, null); + if (result.Succeeded) + { + return; + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + throw new BadRequestException(ModelState); + } + + [HttpPost("key-connector/enroll")] + public async Task PostEnrollToKeyConnectorAsync([FromBody] KeyConnectorEnrollmentRequestModel model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var result = await _userService.ConvertToKeyConnectorAsync(user, model.KeyConnectorKeyWrappedUserKey); if (result.Succeeded) { return; diff --git a/src/Api/KeyManagement/Models/Requests/KeyConnectorEnrollmentRequestModel.cs b/src/Api/KeyManagement/Models/Requests/KeyConnectorEnrollmentRequestModel.cs new file mode 100644 index 000000000000..be8d883df982 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/KeyConnectorEnrollmentRequestModel.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Utilities; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class KeyConnectorEnrollmentRequestModel : IValidatableObject +{ + [EncryptedString] + public required string KeyConnectorKeyWrappedUserKey { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(KeyConnectorKeyWrappedUserKey)) + { + yield return new ValidationResult( + "KeyConnectorKeyWrappedUserKey must be supplied when request body is provided.", + [nameof(KeyConnectorKeyWrappedUserKey)]); + } + } +} diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 3957b504ba89..c021fa2668e2 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -32,7 +32,7 @@ Task ChangeEmailAsync(User user, string masterPassword, string n // TODO removed with https://bitwarden.atlassian.net/browse/PM-27328 [Obsolete("Use ISetKeyConnectorKeyCommand instead. This method will be removed in a future version.")] Task SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier); - Task ConvertToKeyConnectorAsync(User user); + Task ConvertToKeyConnectorAsync(User user, string keyConnectorKeyWrappedUserKey); Task AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key); Task UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint); Task RefreshSecurityStampAsync(User user, string masterPasswordHash); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index d471daaa4caa..58f5cbb218fd 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -522,7 +522,7 @@ public async Task SetKeyConnectorKeyAsync(User user, string key, return IdentityResult.Success; } - public async Task ConvertToKeyConnectorAsync(User user) + public async Task ConvertToKeyConnectorAsync(User user, string keyConnectorKeyWrappedUserKey = null) { var identityResult = CheckCanUseKeyConnector(user); if (identityResult != null) @@ -534,6 +534,11 @@ public async Task ConvertToKeyConnectorAsync(User user) user.MasterPassword = null; user.UsesKeyConnector = true; + if (!string.IsNullOrWhiteSpace(keyConnectorKeyWrappedUserKey)) + { + user.Key = keyConnectorKeyWrappedUserKey; + } + await _userRepository.ReplaceAsync(user); await _eventService.LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector); diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index f07189f960fc..4dd5df514fe8 100644 --- a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -386,6 +386,60 @@ public async Task PostConvertToKeyConnectorAsync_Success() Assert.Equal(DateTime.UtcNow, user.AccountRevisionDate, TimeSpan.FromMinutes(1)); } + [Fact] + public async Task PostEnrollToKeyConnectorAsync_NotLoggedIn_Unauthorized() + { + var request = new KeyConnectorEnrollmentRequestModel + { + KeyConnectorKeyWrappedUserKey = _mockEncryptedString + }; + + var response = await _client.PostAsJsonAsync("/accounts/key-connector/enroll", request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PostEnrollToKeyConnectorAsync_KeyConnectorKeyWrappedUserKeyMissing_BadRequest() + { + var (ssoUserEmail, _) = await SetupKeyConnectorTestAsync(OrganizationUserStatusType.Accepted); + + var request = new KeyConnectorEnrollmentRequestModel + { + KeyConnectorKeyWrappedUserKey = " " + }; + + var response = await _client.PostAsJsonAsync("/accounts/key-connector/enroll", request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + + var user = await _userRepository.GetByEmailAsync(ssoUserEmail); + Assert.NotNull(user); + Assert.False(user.UsesKeyConnector); + } + + [Fact] + public async Task PostEnrollToKeyConnectorAsync_Success() + { + var (ssoUserEmail, _) = await SetupKeyConnectorTestAsync(OrganizationUserStatusType.Accepted); + + var request = new KeyConnectorEnrollmentRequestModel + { + KeyConnectorKeyWrappedUserKey = _mockEncryptedString + }; + + var response = await _client.PostAsJsonAsync("/accounts/key-connector/enroll", request); + response.EnsureSuccessStatusCode(); + + var user = await _userRepository.GetByEmailAsync(ssoUserEmail); + Assert.NotNull(user); + Assert.Null(user.MasterPassword); + Assert.True(user.UsesKeyConnector); + Assert.Equal(request.KeyConnectorKeyWrappedUserKey, user.Key); + Assert.Equal(DateTime.UtcNow, user.RevisionDate, TimeSpan.FromMinutes(1)); + Assert.Equal(DateTime.UtcNow, user.AccountRevisionDate, TimeSpan.FromMinutes(1)); + } + [Theory] [BitAutoData] public async Task RotateV2UserAccountKeysAsync_Success(RotateUserAccountKeysAndDataRequestModel request) diff --git a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index 17639951e9e7..359b6dc53016 100644 --- a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -490,7 +490,7 @@ public async Task PostConvertToKeyConnectorAsync_UserNull_Throws( await Assert.ThrowsAsync(() => sutProvider.Sut.PostConvertToKeyConnectorAsync()); await sutProvider.GetDependency().ReceivedWithAnyArgs(0) - .ConvertToKeyConnectorAsync(Arg.Any()); + .ConvertToKeyConnectorAsync(Arg.Any(), Arg.Any()); } [Theory] @@ -502,7 +502,7 @@ public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorFails_Thro sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) .Returns(expectedUser); sutProvider.GetDependency() - .ConvertToKeyConnectorAsync(Arg.Any()) + .ConvertToKeyConnectorAsync(Arg.Any(), Arg.Any()) .Returns(IdentityResult.Failed(new IdentityError { Description = "convert to key connector error" })); var badRequestException = @@ -511,7 +511,7 @@ public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorFails_Thro Assert.Equal(1, badRequestException.ModelState!.ErrorCount); Assert.Equal("convert to key connector error", badRequestException.ModelState.Root.Errors[0].ErrorMessage); await sutProvider.GetDependency().Received(1) - .ConvertToKeyConnectorAsync(Arg.Is(expectedUser)); + .ConvertToKeyConnectorAsync(Arg.Is(expectedUser), Arg.Any()); } [Theory] @@ -523,13 +523,68 @@ public async Task PostConvertToKeyConnectorAsync_ConvertToKeyConnectorSucceeds_O sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) .Returns(expectedUser); sutProvider.GetDependency() - .ConvertToKeyConnectorAsync(Arg.Any()) + .ConvertToKeyConnectorAsync(Arg.Any(), Arg.Any()) .Returns(IdentityResult.Success); await sutProvider.Sut.PostConvertToKeyConnectorAsync(); await sutProvider.GetDependency().Received(1) - .ConvertToKeyConnectorAsync(Arg.Is(expectedUser)); + .ConvertToKeyConnectorAsync(Arg.Is(expectedUser), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PostEnrollToKeyConnectorAsync_UserNull_Throws( + SutProvider sutProvider, + KeyConnectorEnrollmentRequestModel data) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsNull(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.PostEnrollToKeyConnectorAsync(data)); + + await sutProvider.GetDependency().ReceivedWithAnyArgs(0) + .ConvertToKeyConnectorAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PostEnrollToKeyConnectorAsync_ConvertToKeyConnectorFails_ThrowsBadRequestWithErrorResponse( + SutProvider sutProvider, + User expectedUser, + KeyConnectorEnrollmentRequestModel data) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .ConvertToKeyConnectorAsync(Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Failed(new IdentityError { Description = "convert to key connector error" })); + + var badRequestException = + await Assert.ThrowsAsync(() => sutProvider.Sut.PostEnrollToKeyConnectorAsync(data)); + + Assert.Equal(1, badRequestException.ModelState!.ErrorCount); + Assert.Equal("convert to key connector error", badRequestException.ModelState.Root.Errors[0].ErrorMessage); + await sutProvider.GetDependency().Received(1) + .ConvertToKeyConnectorAsync(Arg.Is(expectedUser), Arg.Is(data.KeyConnectorKeyWrappedUserKey)); + } + + [Theory] + [BitAutoData] + public async Task PostEnrollToKeyConnectorAsync_ConvertToKeyConnectorSucceeds_OkResponse( + SutProvider sutProvider, + User expectedUser, + KeyConnectorEnrollmentRequestModel data) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .ConvertToKeyConnectorAsync(Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Success); + + await sutProvider.Sut.PostEnrollToKeyConnectorAsync(data); + + await sutProvider.GetDependency().Received(1) + .ConvertToKeyConnectorAsync(Arg.Is(expectedUser), Arg.Is(data.KeyConnectorKeyWrappedUserKey)); } [Theory] diff --git a/test/Api.Test/KeyManagement/Models/Request/KeyConnectorEnrollmentRequestModelTests.cs b/test/Api.Test/KeyManagement/Models/Request/KeyConnectorEnrollmentRequestModelTests.cs new file mode 100644 index 000000000000..9d7e47e3bd69 --- /dev/null +++ b/test/Api.Test/KeyManagement/Models/Request/KeyConnectorEnrollmentRequestModelTests.cs @@ -0,0 +1,58 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.KeyManagement.Models.Requests; +using Xunit; + +namespace Bit.Api.Test.KeyManagement.Models.Request; + +public class KeyConnectorEnrollmentRequestModelTests +{ + private const string _wrappedUserKey = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="; + + [Fact] + public void Validate_KeyConnectorKeyWrappedUserKeyNull_Invalid() + { + var model = new KeyConnectorEnrollmentRequestModel + { + KeyConnectorKeyWrappedUserKey = null! + }; + + var results = Validate(model); + + Assert.Contains(results, + r => r.ErrorMessage == "KeyConnectorKeyWrappedUserKey must be supplied when request body is provided."); + } + + [Fact] + public void Validate_KeyConnectorKeyWrappedUserKeyWhitespace_Invalid() + { + var model = new KeyConnectorEnrollmentRequestModel + { + KeyConnectorKeyWrappedUserKey = " " + }; + + var results = Validate(model); + + Assert.Contains(results, + r => r.ErrorMessage == "KeyConnectorKeyWrappedUserKey is not a valid encrypted string."); + } + + [Fact] + public void Validate_KeyConnectorKeyWrappedUserKeyValid_Success() + { + var model = new KeyConnectorEnrollmentRequestModel + { + KeyConnectorKeyWrappedUserKey = _wrappedUserKey + }; + + var results = Validate(model); + + Assert.Empty(results); + } + + private static List Validate(KeyConnectorEnrollmentRequestModel model) + { + var results = new List(); + Validator.TryValidateObject(model, new ValidationContext(model), results, true); + return results; + } +} diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 24c637459ca0..8c01f21d3893 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -15,6 +15,7 @@ using Bit.Core.Billing.Premium.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -585,6 +586,72 @@ public async Task RecoverTwoFactorAsync_IncorrectCode_ReturnsFalse( Assert.NotNull(user.TwoFactorProviders); } + [Theory] + [BitAutoData("wrapped-user-key")] + [BitAutoData("2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=")] + public async Task ConvertToKeyConnectorAsync_WrappedUserKeyProvided_SetsWrappedUserKey( + string wrappedUserKey, + SutProvider sutProvider, + User user) + { + // Arrange + user.UsesKeyConnector = false; + user.MasterPassword = "master-password"; + user.Key = "old-key"; + sutProvider.GetDependency().Organizations = []; + + // Act + var result = await sutProvider.Sut.ConvertToKeyConnectorAsync(user, wrappedUserKey); + + // Assert + Assert.True(result.Succeeded); + Assert.True(user.UsesKeyConnector); + Assert.Null(user.MasterPassword); + Assert.Equal(wrappedUserKey, user.Key); + Assert.Equal(user.RevisionDate, user.AccountRevisionDate); + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(Arg.Is(u => + u == user && + u.Key == wrappedUserKey && + u.MasterPassword == null && + u.UsesKeyConnector)); + await sutProvider.GetDependency().Received(1) + .LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector); + } + + [Theory, BitAutoData] + public async Task ConvertToKeyConnectorAsync_WrappedUserKeyNull_DoesNotOverwriteExistingKey( + SutProvider sutProvider, + User user) + { + // Arrange + const string existingUserKey = "existing-user-key"; + user.UsesKeyConnector = false; + user.MasterPassword = "master-password"; + user.Key = existingUserKey; + sutProvider.GetDependency().Organizations = []; + + // Act + var result = await sutProvider.Sut.ConvertToKeyConnectorAsync(user, null); + + // Assert + Assert.True(result.Succeeded); + Assert.True(user.UsesKeyConnector); + Assert.Null(user.MasterPassword); + Assert.Equal(existingUserKey, user.Key); + Assert.Equal(user.RevisionDate, user.AccountRevisionDate); + + await sutProvider.GetDependency().Received(1) + .ReplaceAsync(Arg.Is(u => + u == user && + u.Key == existingUserKey && + u.MasterPassword == null && + u.UsesKeyConnector)); + + await sutProvider.GetDependency().Received(1) + .LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector); + } + private static void SetupUserAndDevice(User user, bool shouldHavePassword) {