From 23aaddb2b6e43acc971251ece4d5c7885137ad24 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 4 Mar 2026 15:06:05 +0100 Subject: [PATCH 1/2] Add key-connector enrollment --- .../AccountsKeyManagementController.cs | 32 +++++++- .../KeyConnectorEnrollmentRequestModel.cs | 9 +++ src/Core/Services/IUserService.cs | 2 +- .../Services/Implementations/UserService.cs | 7 +- .../AccountsKeyManagementControllerTests.cs | 54 +++++++++++++ .../AccountsKeyManagementControllerTests.cs | 77 +++++++++++++++++++ 6 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 src/Api/KeyManagement/Models/Requests/KeyConnectorEnrollmentRequestModel.cs diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index a124616e3019..6276bcf00bf5 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -159,7 +159,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; @@ -183,7 +183,35 @@ 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(); + } + + if (string.IsNullOrWhiteSpace(model.KeyConnectorKeyWrappedUserKey)) + { + throw new BadRequestException("KeyConnectorKeyWrappedUserKey must be supplied when request body is provided."); + } + + 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..be178c53f419 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/KeyConnectorEnrollmentRequestModel.cs @@ -0,0 +1,9 @@ +using Bit.Core.Utilities; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class KeyConnectorEnrollmentRequestModel +{ + [EncryptedString] + public required string KeyConnectorKeyWrappedUserKey { get; set; } +} diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index a531883db114..f093a116ca8f 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -36,7 +36,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 5f87ee85d2c8..a38eaf718d8d 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -672,7 +672,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) @@ -684,6 +684,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 eddffb6b364f..0ef9b05b1cbd 100644 --- a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -435,6 +435,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 c843d24bc320..42f670cdb08d 100644 --- a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -471,6 +471,83 @@ await sutProvider.GetDependency().Received(1) .ConvertToKeyConnectorAsync(Arg.Is(expectedUser)); } + [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_KeyConnectorKeyWrappedUserKeyMissing_ThrowsBadRequest( + SutProvider sutProvider) + { + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(new User()); + + var request = new KeyConnectorEnrollmentRequestModel + { + KeyConnectorKeyWrappedUserKey = " " + }; + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.PostEnrollToKeyConnectorAsync(request)); + + Assert.Equal("KeyConnectorKeyWrappedUserKey must be supplied when request body is provided.", + exception.Message); + 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] [BitAutoData] public async Task GetKeyConnectorConfirmationDetailsAsync_NoUser_Throws( From 5055b8c2b7dc5aaffcfebe02fcfd2f43e00687dc Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 4 Mar 2026 15:27:05 +0100 Subject: [PATCH 2/2] Fix tests --- .../AccountsKeyManagementControllerTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index 42f670cdb08d..64e1c4b8b41e 100644 --- a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -429,7 +429,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] @@ -441,7 +441,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 = @@ -450,7 +450,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] @@ -462,13 +462,13 @@ 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]