From 23aaddb2b6e43acc971251ece4d5c7885137ad24 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 4 Mar 2026 15:06:05 +0100 Subject: [PATCH 01/10] 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 02/10] 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] From 6107731c114eb57de82f4555b19c1e1c50efd07d Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 10 Mar 2026 10:51:06 +0100 Subject: [PATCH 03/10] Update src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> --- .../KeyManagement/Controllers/AccountsKeyManagementController.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index 8794937d4451..89333fa0fc20 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -198,6 +198,7 @@ public async Task PostConvertToKeyConnectorAsync() throw new BadRequestException(ModelState); } + [SelfHosted(SelfHostedOnly = true)] [HttpPost("key-connector/enroll")] public async Task PostEnrollToKeyConnectorAsync([FromBody] KeyConnectorEnrollmentRequestModel model) { From 728543e5df6338c4ce6e32ba24bf9d61d270af95 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 10 Mar 2026 10:59:37 +0100 Subject: [PATCH 04/10] Move validation to request model --- .../AccountsKeyManagementController.cs | 5 -- .../KeyConnectorEnrollmentRequestModel.cs | 15 ++++- .../AccountsKeyManagementControllerTests.cs | 22 ------- ...KeyConnectorEnrollmentRequestModelTests.cs | 58 +++++++++++++++++++ 4 files changed, 71 insertions(+), 29 deletions(-) create mode 100644 test/Api.Test/KeyManagement/Models/Request/KeyConnectorEnrollmentRequestModelTests.cs diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index 6276bcf00bf5..f190cbfbed67 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -206,11 +206,6 @@ public async Task PostEnrollToKeyConnectorAsync([FromBody] KeyConnectorEnrollmen 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) { diff --git a/src/Api/KeyManagement/Models/Requests/KeyConnectorEnrollmentRequestModel.cs b/src/Api/KeyManagement/Models/Requests/KeyConnectorEnrollmentRequestModel.cs index be178c53f419..be8d883df982 100644 --- a/src/Api/KeyManagement/Models/Requests/KeyConnectorEnrollmentRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/KeyConnectorEnrollmentRequestModel.cs @@ -1,9 +1,20 @@ -using Bit.Core.Utilities; +using System.ComponentModel.DataAnnotations; +using Bit.Core.Utilities; namespace Bit.Api.KeyManagement.Models.Requests; -public class KeyConnectorEnrollmentRequestModel +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/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index 64e1c4b8b41e..d79f1fc47de2 100644 --- a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -485,28 +485,6 @@ 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( 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..6011e779755f --- /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 must be supplied when request body is provided."); + } + + [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; + } +} From 65c86f402366c9c41a9a0770a9835b7a951b67c7 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 10 Mar 2026 11:08:52 +0100 Subject: [PATCH 05/10] Add tests --- test/Core.Test/Services/UserServiceTests.cs | 67 +++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs index 073379820ea7..12425b868cc8 100644 --- a/test/Core.Test/Services/UserServiceTests.cs +++ b/test/Core.Test/Services/UserServiceTests.cs @@ -13,6 +13,7 @@ using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Services; +using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -586,6 +587,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) { From b864fa6da8749416597a06330bc7a0e282ccc511 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 10 Mar 2026 11:19:58 +0100 Subject: [PATCH 06/10] Fix build --- .../KeyManagement/Controllers/AccountsKeyManagementController.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index 034070ad0e9c..25c2607ac2f9 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -19,6 +19,7 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Tools.Entities; +using Bit.Core.Utilities; using Bit.Core.Vault.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; From f4dd1e3429cf37e624aab6a8caef1ecb4fb3066d Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 10 Mar 2026 12:11:01 +0100 Subject: [PATCH 07/10] Attempt to fix build --- .../Controllers/AccountsKeyManagementControllerTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index 4dd5df514fe8..6810fa90a790 100644 --- a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -57,6 +57,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture(featureService => { featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration, Arg.Any()) From 8400a6edd554660bb5a18a7705aca509cd24eb4b Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 10 Mar 2026 12:31:51 +0100 Subject: [PATCH 08/10] Attempt to fix remaining tests --- .../Controllers/AccountsKeyManagementControllerTests.cs | 1 - .../Models/Request/KeyConnectorEnrollmentRequestModelTests.cs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index 6810fa90a790..4dd5df514fe8 100644 --- a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -57,7 +57,6 @@ public class AccountsKeyManagementControllerTests : IClassFixture(featureService => { featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration, Arg.Any()) diff --git a/test/Api.Test/KeyManagement/Models/Request/KeyConnectorEnrollmentRequestModelTests.cs b/test/Api.Test/KeyManagement/Models/Request/KeyConnectorEnrollmentRequestModelTests.cs index 6011e779755f..9d7e47e3bd69 100644 --- a/test/Api.Test/KeyManagement/Models/Request/KeyConnectorEnrollmentRequestModelTests.cs +++ b/test/Api.Test/KeyManagement/Models/Request/KeyConnectorEnrollmentRequestModelTests.cs @@ -33,7 +33,7 @@ public void Validate_KeyConnectorKeyWrappedUserKeyWhitespace_Invalid() var results = Validate(model); Assert.Contains(results, - r => r.ErrorMessage == "KeyConnectorKeyWrappedUserKey must be supplied when request body is provided."); + r => r.ErrorMessage == "KeyConnectorKeyWrappedUserKey is not a valid encrypted string."); } [Fact] From 69d659398846a7e187157ec16605004f303e1504 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 10 Mar 2026 13:10:26 +0100 Subject: [PATCH 09/10] Fix tests --- .../KeyManagement/Controllers/AccountsKeyManagementController.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index 25c2607ac2f9..039737f387c9 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -199,7 +199,6 @@ public async Task PostConvertToKeyConnectorAsync() throw new BadRequestException(ModelState); } - [SelfHosted(SelfHostedOnly = true)] [HttpPost("key-connector/enroll")] public async Task PostEnrollToKeyConnectorAsync([FromBody] KeyConnectorEnrollmentRequestModel model) { From c08c8dc9998a7d7006994aae9254d0cacbb24153 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 10 Mar 2026 13:16:54 +0100 Subject: [PATCH 10/10] Format --- .../KeyManagement/Controllers/AccountsKeyManagementController.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index 039737f387c9..a857a6072120 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -19,7 +19,6 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Tools.Entities; -using Bit.Core.Utilities; using Bit.Core.Vault.Entities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc;