diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs index b944cdd052..a124616e30 100644 --- a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs +++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs @@ -47,6 +47,7 @@ public class AccountsKeyManagementController : Controller _webauthnKeyValidator; private readonly IRotationValidator, IEnumerable> _deviceValidator; private readonly IKeyConnectorConfirmationDetailsQuery _keyConnectorConfirmationDetailsQuery; + private readonly ISetKeyConnectorKeyCommand _setKeyConnectorKeyCommand; public AccountsKeyManagementController(IUserService userService, IFeatureService featureService, @@ -62,8 +63,10 @@ public class AccountsKeyManagementController : Controller emergencyAccessValidator, IRotationValidator, IReadOnlyList> organizationUserValidator, - IRotationValidator, IEnumerable> webAuthnKeyValidator, - IRotationValidator, IEnumerable> deviceValidator) + IRotationValidator, IEnumerable> + webAuthnKeyValidator, + IRotationValidator, IEnumerable> deviceValidator, + ISetKeyConnectorKeyCommand setKeyConnectorKeyCommand) { _userService = userService; _featureService = featureService; @@ -79,6 +82,7 @@ public class AccountsKeyManagementController : Controller _webauthnKeyValidator = webAuthnKeyValidator; _deviceValidator = deviceValidator; _keyConnectorConfirmationDetailsQuery = keyConnectorConfirmationDetailsQuery; + _setKeyConnectorKeyCommand = setKeyConnectorKeyCommand; } [HttpPost("key-management/regenerate-keys")] @@ -146,18 +150,28 @@ public class AccountsKeyManagementController : Controller throw new UnauthorizedAccessException(); } - var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier); - if (result.Succeeded) + if (model.IsV2Request()) { - return; + // V2 account registration + await _setKeyConnectorKeyCommand.SetKeyConnectorKeyForUserAsync(user, model.ToKeyConnectorKeysData()); } - - foreach (var error in result.Errors) + else { - ModelState.AddModelError(string.Empty, error.Description); - } + // 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); + if (result.Succeeded) + { + return; + } - throw new BadRequestException(ModelState); + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + throw new BadRequestException(ModelState); + } } [HttpPost("convert-to-key-connector")] diff --git a/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs b/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs index 9f52a97383..6cd13fdf83 100644 --- a/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/SetKeyConnectorKeyRequestModel.cs @@ -1,36 +1,112 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Api.Request; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Utilities; namespace Bit.Api.KeyManagement.Models.Requests; -public class SetKeyConnectorKeyRequestModel +public class SetKeyConnectorKeyRequestModel : IValidatableObject { - [Required] - public string Key { get; set; } - [Required] - public KeysRequestModel Keys { get; set; } - [Required] - public KdfType Kdf { get; set; } - [Required] - public int KdfIterations { get; set; } - public int? KdfMemory { get; set; } - public int? KdfParallelism { get; set; } - [Required] - public string OrgIdentifier { get; set; } + // TODO will be removed with https://bitwarden.atlassian.net/browse/PM-27328 + [Obsolete("Use KeyConnectorKeyWrappedUserKey instead")] + public string? Key { get; set; } + [Obsolete("Use AccountKeys instead")] + public KeysRequestModel? Keys { get; set; } + [Obsolete("Not used anymore")] + public KdfType? Kdf { get; set; } + [Obsolete("Not used anymore")] + public int? KdfIterations { get; set; } + [Obsolete("Not used anymore")] + public int? KdfMemory { get; set; } + [Obsolete("Not used anymore")] + public int? KdfParallelism { get; set; } + + [EncryptedString] + public string? KeyConnectorKeyWrappedUserKey { get; set; } + public AccountKeysRequestModel? AccountKeys { get; set; } + + [Required] + public required string OrgIdentifier { get; init; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (IsV2Request()) + { + // V2 registration + yield break; + } + + // V1 registration + // TODO removed with https://bitwarden.atlassian.net/browse/PM-27328 + if (string.IsNullOrEmpty(Key)) + { + yield return new ValidationResult("Key must be supplied."); + } + + if (Keys == null) + { + yield return new ValidationResult("Keys must be supplied."); + } + + if (Kdf == null) + { + yield return new ValidationResult("Kdf must be supplied."); + } + + if (KdfIterations == null) + { + yield return new ValidationResult("KdfIterations must be supplied."); + } + + if (Kdf == KdfType.Argon2id) + { + if (KdfMemory == null) + { + yield return new ValidationResult("KdfMemory must be supplied when Kdf is Argon2id."); + } + + if (KdfParallelism == null) + { + yield return new ValidationResult("KdfParallelism must be supplied when Kdf is Argon2id."); + } + } + } + + public bool IsV2Request() + { + return !string.IsNullOrEmpty(KeyConnectorKeyWrappedUserKey) && AccountKeys != null; + } + + // TODO removed with https://bitwarden.atlassian.net/browse/PM-27328 public User ToUser(User existingUser) { - existingUser.Kdf = Kdf; - existingUser.KdfIterations = KdfIterations; + existingUser.Kdf = Kdf!.Value; + existingUser.KdfIterations = KdfIterations!.Value; existingUser.KdfMemory = KdfMemory; existingUser.KdfParallelism = KdfParallelism; existingUser.Key = Key; - Keys.ToUser(existingUser); + Keys!.ToUser(existingUser); return existingUser; } + + public KeyConnectorKeysData ToKeyConnectorKeysData() + { + // TODO remove validation with https://bitwarden.atlassian.net/browse/PM-27328 + if (string.IsNullOrEmpty(KeyConnectorKeyWrappedUserKey) || AccountKeys == null) + { + throw new BadRequestException("KeyConnectorKeyWrappedUserKey and AccountKeys must be supplied."); + } + + return new KeyConnectorKeysData + { + KeyConnectorKeyWrappedUserKey = KeyConnectorKeyWrappedUserKey, + AccountKeys = AccountKeys, + OrgIdentifier = OrgIdentifier + }; + } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 12c144c142..94f599a2a6 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -212,6 +212,7 @@ public static class FeatureFlagKeys public const string ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component"; public const string V2RegistrationTDEJIT = "pm-27279-v2-registration-tde-jit"; public const string DataRecoveryTool = "pm-28813-data-recovery-tool"; + public const string EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration"; /* Mobile Team */ public const string AndroidImportLoginsFlow = "import-logins-flow"; diff --git a/src/Core/KeyManagement/Authorization/KeyConnectorAuthorizationHandler.cs b/src/Core/KeyManagement/Authorization/KeyConnectorAuthorizationHandler.cs new file mode 100644 index 0000000000..7937390a8c --- /dev/null +++ b/src/Core/KeyManagement/Authorization/KeyConnectorAuthorizationHandler.cs @@ -0,0 +1,52 @@ +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Core.KeyManagement.Authorization; + +public class KeyConnectorAuthorizationHandler : AuthorizationHandler +{ + private readonly ICurrentContext _currentContext; + + public KeyConnectorAuthorizationHandler(ICurrentContext currentContext) + { + _currentContext = currentContext; + } + + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, + KeyConnectorOperationsRequirement requirement, + User user) + { + var authorized = requirement switch + { + not null when requirement == KeyConnectorOperations.Use => CanUse(user), + _ => throw new ArgumentException("Unsupported operation requirement type provided.", nameof(requirement)) + }; + + if (authorized) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } + + private bool CanUse(User user) + { + // User cannot use Key Connector if they already use it + if (user.UsesKeyConnector) + { + return false; + } + + // User cannot use Key Connector if they are an owner or admin of any organization + if (_currentContext.Organizations.Any(u => + u.Type is OrganizationUserType.Owner or OrganizationUserType.Admin)) + { + return false; + } + + return true; + } +} diff --git a/src/Core/KeyManagement/Authorization/KeyConnectorOperations.cs b/src/Core/KeyManagement/Authorization/KeyConnectorOperations.cs new file mode 100644 index 0000000000..a8d09a6ac7 --- /dev/null +++ b/src/Core/KeyManagement/Authorization/KeyConnectorOperations.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization.Infrastructure; + +namespace Bit.Core.KeyManagement.Authorization; + +public class KeyConnectorOperationsRequirement : OperationAuthorizationRequirement +{ + public KeyConnectorOperationsRequirement(string name) + { + Name = name; + } +} + +public static class KeyConnectorOperations +{ + public static readonly KeyConnectorOperationsRequirement Use = new(nameof(Use)); +} diff --git a/src/Core/KeyManagement/Commands/Interfaces/ISetKeyConnectorKeyCommand.cs b/src/Core/KeyManagement/Commands/Interfaces/ISetKeyConnectorKeyCommand.cs new file mode 100644 index 0000000000..65f6cddeb5 --- /dev/null +++ b/src/Core/KeyManagement/Commands/Interfaces/ISetKeyConnectorKeyCommand.cs @@ -0,0 +1,13 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.KeyManagement.Commands.Interfaces; + +/// +/// Creates the user key and account cryptographic state for a new user registering +/// with Key Connector SSO configuration. +/// +public interface ISetKeyConnectorKeyCommand +{ + Task SetKeyConnectorKeyForUserAsync(User user, KeyConnectorKeysData keyConnectorKeysData); +} diff --git a/src/Core/KeyManagement/Commands/SetKeyConnectorKeyCommand.cs b/src/Core/KeyManagement/Commands/SetKeyConnectorKeyCommand.cs new file mode 100644 index 0000000000..a96042de30 --- /dev/null +++ b/src/Core/KeyManagement/Commands/SetKeyConnectorKeyCommand.cs @@ -0,0 +1,60 @@ +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Authorization; +using Bit.Core.KeyManagement.Commands.Interfaces; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Core.KeyManagement.Commands; + +public class SetKeyConnectorKeyCommand : ISetKeyConnectorKeyCommand +{ + private readonly IAuthorizationService _authorizationService; + private readonly ICurrentContext _currentContext; + private readonly IEventService _eventService; + private readonly IAcceptOrgUserCommand _acceptOrgUserCommand; + private readonly IUserService _userService; + private readonly IUserRepository _userRepository; + + public SetKeyConnectorKeyCommand( + IAuthorizationService authorizationService, + ICurrentContext currentContext, + IEventService eventService, + IAcceptOrgUserCommand acceptOrgUserCommand, + IUserService userService, + IUserRepository userRepository) + { + _authorizationService = authorizationService; + _currentContext = currentContext; + _eventService = eventService; + _acceptOrgUserCommand = acceptOrgUserCommand; + _userService = userService; + _userRepository = userRepository; + } + + public async Task SetKeyConnectorKeyForUserAsync(User user, KeyConnectorKeysData keyConnectorKeysData) + { + var authorizationResult = await _authorizationService.AuthorizeAsync(_currentContext.HttpContext.User, user, + KeyConnectorOperations.Use); + if (!authorizationResult.Succeeded) + { + throw new BadRequestException("Cannot use Key Connector"); + } + + var setKeyConnectorUserKeyTask = + _userRepository.SetKeyConnectorUserKey(user.Id, keyConnectorKeysData.KeyConnectorKeyWrappedUserKey); + + await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, + keyConnectorKeysData.AccountKeys.ToAccountKeysData(), [setKeyConnectorUserKeyTask]); + + await _eventService.LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector); + + await _acceptOrgUserCommand.AcceptOrgUserByOrgSsoIdAsync(keyConnectorKeysData.OrgIdentifier, user, + _userService); + } +} diff --git a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs index abaf9406ba..96f990c299 100644 --- a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs +++ b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs @@ -1,9 +1,11 @@ -using Bit.Core.KeyManagement.Commands; +using Bit.Core.KeyManagement.Authorization; +using Bit.Core.KeyManagement.Commands; using Bit.Core.KeyManagement.Commands.Interfaces; using Bit.Core.KeyManagement.Kdf; using Bit.Core.KeyManagement.Kdf.Implementations; using Bit.Core.KeyManagement.Queries; using Bit.Core.KeyManagement.Queries.Interfaces; +using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.KeyManagement; @@ -12,15 +14,22 @@ public static class KeyManagementServiceCollectionExtensions { public static void AddKeyManagementServices(this IServiceCollection services) { + services.AddKeyManagementAuthorizationHandlers(); services.AddKeyManagementCommands(); services.AddKeyManagementQueries(); services.AddSendPasswordServices(); } + private static void AddKeyManagementAuthorizationHandlers(this IServiceCollection services) + { + services.AddScoped(); + } + private static void AddKeyManagementCommands(this IServiceCollection services) { services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void AddKeyManagementQueries(this IServiceCollection services) diff --git a/src/Core/KeyManagement/Models/Data/KeyConnectorKeysData.cs b/src/Core/KeyManagement/Models/Data/KeyConnectorKeysData.cs new file mode 100644 index 0000000000..5675c6bc96 --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/KeyConnectorKeysData.cs @@ -0,0 +1,12 @@ +using Bit.Core.KeyManagement.Models.Api.Request; + +namespace Bit.Core.KeyManagement.Models.Data; + +public class KeyConnectorKeysData +{ + public required string KeyConnectorKeyWrappedUserKey { get; set; } + + public required AccountKeysRequestModel AccountKeys { get; set; } + + public required string OrgIdentifier { get; init; } +} diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 47ddb86f8e..93316d78bd 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -72,6 +72,8 @@ public interface IUserRepository : IRepository UserAccountKeysData accountKeysData, IEnumerable? updateUserDataActions = null); Task DeleteManyAsync(IEnumerable users); + + UpdateUserData SetKeyConnectorUserKey(Guid userId, string keyConnectorWrappedUserKey); } public delegate Task UpdateUserData(Microsoft.Data.SqlClient.SqlConnection? connection = null, diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index fade63de51..a531883db1 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -33,6 +33,8 @@ public interface IUserService Task ChangeEmailAsync(User user, string masterPassword, string newEmail, string newMasterPassword, string token, string key); Task ChangePasswordAsync(User user, string masterPassword, string newMasterPassword, string passwordHint, string key); + // 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 AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 8db66211b1..4e65e88767 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -621,6 +621,7 @@ public class UserService : UserManager, IUserService return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); } + // TODO removed with https://bitwarden.atlassian.net/browse/PM-27328 public async Task SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier) { var identityResult = CheckCanUseKeyConnector(user); diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs index 224351f034..571319e4c7 100644 --- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Bit.Core; using Bit.Core.Billing.Premium.Models; using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; @@ -401,6 +402,32 @@ public class UserRepository : Repository, IUserRepository return result.SingleOrDefault(); } + public UpdateUserData SetKeyConnectorUserKey(Guid userId, string keyConnectorWrappedUserKey) + { + return async (connection, transaction) => + { + var timestamp = DateTime.UtcNow; + + await connection!.ExecuteAsync( + "[dbo].[User_UpdateKeyConnectorUserKey]", + new + { + Id = userId, + Key = keyConnectorWrappedUserKey, + // Key Connector does not use KDF, so we set some defaults + Kdf = KdfType.Argon2id, + KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default, + KdfMemory = AuthConstants.ARGON2_MEMORY.Default, + KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default, + UsesKeyConnector = true, + RevisionDate = timestamp, + AccountRevisionDate = timestamp + }, + transaction: transaction, + commandType: CommandType.StoredProcedure); + }; + } + private async Task ProtectDataAndSaveAsync(User user, Func saveTask) { if (user == null) diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index 9bf093e506..56d64094d0 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -1,5 +1,7 @@ using AutoMapper; +using Bit.Core; using Bit.Core.Billing.Premium.Models; +using Bit.Core.Enums; using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Models.Data; @@ -479,6 +481,35 @@ public class UserRepository : Repository, IUserR } } + public UpdateUserData SetKeyConnectorUserKey(Guid userId, string keyConnectorWrappedUserKey) + { + return async (_, _) => + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + var userEntity = await dbContext.Users.FindAsync(userId); + if (userEntity == null) + { + throw new ArgumentException("User not found", nameof(userId)); + } + + var timestamp = DateTime.UtcNow; + + userEntity.Key = keyConnectorWrappedUserKey; + // Key Connector does not use KDF, so we set some defaults + userEntity.Kdf = KdfType.Argon2id; + userEntity.KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default; + userEntity.KdfMemory = AuthConstants.ARGON2_MEMORY.Default; + userEntity.KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default; + userEntity.UsesKeyConnector = true; + userEntity.RevisionDate = timestamp; + userEntity.AccountRevisionDate = timestamp; + + await dbContext.SaveChangesAsync(); + }; + } + private static void MigrateDefaultUserCollectionsToShared(DatabaseContext dbContext, IEnumerable userIds) { var defaultCollections = (from c in dbContext.Collections diff --git a/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateKeyConnectorUserKey.sql b/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateKeyConnectorUserKey.sql new file mode 100644 index 0000000000..7ab20a42af --- /dev/null +++ b/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateKeyConnectorUserKey.sql @@ -0,0 +1,28 @@ +CREATE PROCEDURE [dbo].[User_UpdateKeyConnectorUserKey] + @Id UNIQUEIDENTIFIER, + @Key VARCHAR(MAX), + @Kdf TINYINT, + @KdfIterations INT, + @KdfMemory INT, + @KdfParallelism INT, + @UsesKeyConnector BIT, + @RevisionDate DATETIME2(7), + @AccountRevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[User] + SET + [Key] = @Key, + [Kdf] = @Kdf, + [KdfIterations] = @KdfIterations, + [KdfMemory] = @KdfMemory, + [KdfParallelism] = @KdfParallelism, + [UsesKeyConnector] = @UsesKeyConnector, + [RevisionDate] = @RevisionDate, + [AccountRevisionDate] = @AccountRevisionDate + WHERE + [Id] = @Id +END diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs index 1c456df106..eddffb6b36 100644 --- a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs +++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs @@ -7,6 +7,7 @@ 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; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; @@ -19,9 +20,11 @@ using Bit.Core.KeyManagement.Enums; using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.KeyManagement.Repositories; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Vault.Enums; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Identity; +using NSubstitute; using Xunit; namespace Bit.Api.IntegrationTest.KeyManagement.Controllers; @@ -31,6 +34,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture(featureService => + { + featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration, Arg.Any()) + .Returns(true); + }); _client = factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); _userRepository = _factory.GetService(); @@ -78,8 +85,11 @@ public class AccountsKeyManagementControllerTests : IClassFixture(featureService => + { + featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration, Arg.Any()) + .Returns(false); + }); var localClient = localFactory.CreateClient(); var localEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; var localLoginHelper = new LoginHelper(localFactory, localClient); @@ -285,21 +295,21 @@ public class AccountsKeyManagementControllerTests : IClassFixture sutProvider, SetKeyConnectorKeyRequestModel data) { + data.KeyConnectorKeyWrappedUserKey = null; + data.AccountKeys = null; + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsNull(); await Assert.ThrowsAsync(() => sutProvider.Sut.PostSetKeyConnectorKeyAsync(data)); @@ -252,10 +255,13 @@ public class AccountsKeyManagementControllerTests [Theory] [BitAutoData] - public async Task PostSetKeyConnectorKeyAsync_SetKeyConnectorKeyFails_ThrowsBadRequestWithErrorResponse( + public async Task PostSetKeyConnectorKeyAsync_V1_SetKeyConnectorKeyFails_ThrowsBadRequestWithErrorResponse( SutProvider sutProvider, SetKeyConnectorKeyRequestModel data, User expectedUser) { + data.KeyConnectorKeyWrappedUserKey = null; + data.AccountKeys = null; + expectedUser.PublicKey = null; expectedUser.PrivateKey = null; sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) @@ -278,17 +284,20 @@ public class AccountsKeyManagementControllerTests Assert.Equal(data.KdfIterations, user.KdfIterations); Assert.Equal(data.KdfMemory, user.KdfMemory); Assert.Equal(data.KdfParallelism, user.KdfParallelism); - Assert.Equal(data.Keys.PublicKey, user.PublicKey); - Assert.Equal(data.Keys.EncryptedPrivateKey, user.PrivateKey); + Assert.Equal(data.Keys!.PublicKey, user.PublicKey); + Assert.Equal(data.Keys!.EncryptedPrivateKey, user.PrivateKey); }), Arg.Is(data.Key), Arg.Is(data.OrgIdentifier)); } [Theory] [BitAutoData] - public async Task PostSetKeyConnectorKeyAsync_SetKeyConnectorKeySucceeds_OkResponse( + public async Task PostSetKeyConnectorKeyAsync_V1_SetKeyConnectorKeySucceeds_OkResponse( SutProvider sutProvider, SetKeyConnectorKeyRequestModel data, User expectedUser) { + data.KeyConnectorKeyWrappedUserKey = null; + data.AccountKeys = null; + expectedUser.PublicKey = null; expectedUser.PrivateKey = null; sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) @@ -308,11 +317,108 @@ public class AccountsKeyManagementControllerTests Assert.Equal(data.KdfIterations, user.KdfIterations); Assert.Equal(data.KdfMemory, user.KdfMemory); Assert.Equal(data.KdfParallelism, user.KdfParallelism); - Assert.Equal(data.Keys.PublicKey, user.PublicKey); - Assert.Equal(data.Keys.EncryptedPrivateKey, user.PrivateKey); + Assert.Equal(data.Keys!.PublicKey, user.PublicKey); + Assert.Equal(data.Keys!.EncryptedPrivateKey, user.PrivateKey); }), Arg.Is(data.Key), Arg.Is(data.OrgIdentifier)); } + [Theory] + [BitAutoData] + public async Task PostSetKeyConnectorKeyAsync_V2_UserNull_Throws( + SutProvider sutProvider) + { + var request = new SetKeyConnectorKeyRequestModel + { + KeyConnectorKeyWrappedUserKey = "wrapped-user-key", + AccountKeys = new AccountKeysRequestModel + { + AccountPublicKey = "public-key", + UserKeyEncryptedAccountPrivateKey = "encrypted-private-key" + }, + OrgIdentifier = "test-org" + }; + + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()).ReturnsNull(); + + await Assert.ThrowsAsync(() => sutProvider.Sut.PostSetKeyConnectorKeyAsync(request)); + + await sutProvider.GetDependency().DidNotReceive() + .SetKeyConnectorKeyForUserAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PostSetKeyConnectorKeyAsync_V2_Success( + SutProvider sutProvider, + User expectedUser) + { + var request = new SetKeyConnectorKeyRequestModel + { + KeyConnectorKeyWrappedUserKey = "wrapped-user-key", + AccountKeys = new AccountKeysRequestModel + { + AccountPublicKey = "public-key", + UserKeyEncryptedAccountPrivateKey = "encrypted-private-key" + }, + OrgIdentifier = "test-org" + }; + + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + + await sutProvider.Sut.PostSetKeyConnectorKeyAsync(request); + + await sutProvider.GetDependency().Received(1) + .SetKeyConnectorKeyForUserAsync(Arg.Is(expectedUser), + Arg.Do(data => + { + Assert.Equal(request.KeyConnectorKeyWrappedUserKey, data.KeyConnectorKeyWrappedUserKey); + Assert.Equal(request.AccountKeys.AccountPublicKey, data.AccountKeys.AccountPublicKey); + Assert.Equal(request.AccountKeys.UserKeyEncryptedAccountPrivateKey, + data.AccountKeys.UserKeyEncryptedAccountPrivateKey); + Assert.Equal(request.OrgIdentifier, data.OrgIdentifier); + })); + } + + [Theory] + [BitAutoData] + public async Task PostSetKeyConnectorKeyAsync_V2_CommandThrows_PropagatesException( + SutProvider sutProvider, + User expectedUser) + { + var request = new SetKeyConnectorKeyRequestModel + { + KeyConnectorKeyWrappedUserKey = "wrapped-user-key", + AccountKeys = new AccountKeysRequestModel + { + AccountPublicKey = "public-key", + UserKeyEncryptedAccountPrivateKey = "encrypted-private-key" + }, + OrgIdentifier = "test-org" + }; + + sutProvider.GetDependency().GetUserByPrincipalAsync(Arg.Any()) + .Returns(expectedUser); + sutProvider.GetDependency() + .When(x => x.SetKeyConnectorKeyForUserAsync(Arg.Any(), Arg.Any())) + .Do(_ => throw new BadRequestException("Command failed")); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.PostSetKeyConnectorKeyAsync(request)); + + Assert.Equal("Command failed", exception.Message); + await sutProvider.GetDependency().Received(1) + .SetKeyConnectorKeyForUserAsync(Arg.Is(expectedUser), + Arg.Do(data => + { + Assert.Equal(request.KeyConnectorKeyWrappedUserKey, data.KeyConnectorKeyWrappedUserKey); + Assert.Equal(request.AccountKeys.AccountPublicKey, data.AccountKeys.AccountPublicKey); + Assert.Equal(request.AccountKeys.UserKeyEncryptedAccountPrivateKey, + data.AccountKeys.UserKeyEncryptedAccountPrivateKey); + Assert.Equal(request.OrgIdentifier, data.OrgIdentifier); + })); + } + [Theory] [BitAutoData] public async Task PostConvertToKeyConnectorAsync_UserNull_Throws( diff --git a/test/Api.Test/KeyManagement/Models/Request/SetKeyConnectorKeyRequestModelTests.cs b/test/Api.Test/KeyManagement/Models/Request/SetKeyConnectorKeyRequestModelTests.cs new file mode 100644 index 0000000000..95ee743d02 --- /dev/null +++ b/test/Api.Test/KeyManagement/Models/Request/SetKeyConnectorKeyRequestModelTests.cs @@ -0,0 +1,333 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.KeyManagement.Models.Requests; +using Bit.Core; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Api.Request; +using Xunit; + +namespace Bit.Api.Test.KeyManagement.Models.Request; + +public class SetKeyConnectorKeyRequestModelTests +{ + private const string _wrappedUserKey = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="; + private const string _publicKey = "public-key"; + private const string _privateKey = "private-key"; + private const string _userKey = "user-key"; + private const string _orgIdentifier = "org-identifier"; + + [Fact] + public void Validate_V2Registration_Valid() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + KeyConnectorKeyWrappedUserKey = _wrappedUserKey, + AccountKeys = new AccountKeysRequestModel + { + AccountPublicKey = _publicKey, + UserKeyEncryptedAccountPrivateKey = _privateKey + }, + OrgIdentifier = _orgIdentifier + }; + + // Act + var results = Validate(model); + + // Assert + Assert.Empty(results); + } + + [Fact] + public void Validate_V2Registration_WrappedUserKeyNotEncryptedString_Invalid() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + KeyConnectorKeyWrappedUserKey = "not-encrypted-string", + AccountKeys = new AccountKeysRequestModel + { + AccountPublicKey = _publicKey, + UserKeyEncryptedAccountPrivateKey = _privateKey + }, + OrgIdentifier = _orgIdentifier + }; + + // Act + var results = Validate(model); + + // Assert + Assert.Single(results); + Assert.Contains(results, + r => r.ErrorMessage == "KeyConnectorKeyWrappedUserKey is not a valid encrypted string."); + } + + [Fact] + public void Validate_V1Registration_Valid() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + Key = _userKey, + Keys = new KeysRequestModel + { + PublicKey = _publicKey, + EncryptedPrivateKey = _privateKey + }, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + OrgIdentifier = _orgIdentifier + }; + + // Act + var results = Validate(model); + + // Assert + Assert.Empty(results); + } + + [Fact] + public void Validate_V1Registration_MissingKey_Invalid() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + Key = null, + Keys = new KeysRequestModel + { + PublicKey = _publicKey, + EncryptedPrivateKey = _privateKey + }, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + OrgIdentifier = _orgIdentifier + }; + + // Act + var results = Validate(model); + + // Assert + Assert.Single(results); + Assert.Contains(results, r => r.ErrorMessage == "Key must be supplied."); + } + + [Fact] + public void Validate_V1Registration_MissingKeys_Invalid() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + Key = _userKey, + Keys = null, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + OrgIdentifier = _orgIdentifier + }; + + // Act + var results = Validate(model); + + // Assert + Assert.Single(results); + Assert.Contains(results, r => r.ErrorMessage == "Keys must be supplied."); + } + + [Fact] + public void Validate_V1Registration_MissingKdf_Invalid() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + Key = _userKey, + Keys = new KeysRequestModel + { + PublicKey = _publicKey, + EncryptedPrivateKey = _privateKey + }, + Kdf = null, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + OrgIdentifier = _orgIdentifier + }; + + // Act + var results = Validate(model); + + // Assert + Assert.Single(results); + Assert.Contains(results, r => r.ErrorMessage == "Kdf must be supplied."); + } + + [Fact] + public void Validate_V1Registration_MissingKdfIterations_Invalid() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + Key = _userKey, + Keys = new KeysRequestModel + { + PublicKey = _publicKey, + EncryptedPrivateKey = _privateKey + }, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = null, + OrgIdentifier = _orgIdentifier + }; + + // Act + var results = Validate(model); + + // Assert + Assert.Single(results); + Assert.Contains(results, r => r.ErrorMessage == "KdfIterations must be supplied."); + } + + [Fact] + public void Validate_V1Registration_Argon2id_MissingKdfMemory_Invalid() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + Key = _userKey, + Keys = new KeysRequestModel + { + PublicKey = _publicKey, + EncryptedPrivateKey = _privateKey + }, + Kdf = KdfType.Argon2id, + KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default, + KdfMemory = null, + KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default, + OrgIdentifier = _orgIdentifier + }; + + // Act + var results = Validate(model); + + // Assert + Assert.Single(results); + Assert.Contains(results, r => r.ErrorMessage == "KdfMemory must be supplied when Kdf is Argon2id."); + } + + [Fact] + public void Validate_V1Registration_Argon2id_MissingKdfParallelism_Invalid() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + Key = _userKey, + Keys = new KeysRequestModel + { + PublicKey = _publicKey, + EncryptedPrivateKey = _privateKey + }, + Kdf = KdfType.Argon2id, + KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default, + KdfMemory = AuthConstants.ARGON2_MEMORY.Default, + KdfParallelism = null, + OrgIdentifier = _orgIdentifier + }; + + // Act + var results = Validate(model); + + // Assert + Assert.Single(results); + Assert.Contains(results, r => r.ErrorMessage == "KdfParallelism must be supplied when Kdf is Argon2id."); + } + + [Fact] + public void ToKeyConnectorKeysData_EmptyKeyConnectorKeyWrappedUserKey_ThrowsException() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + KeyConnectorKeyWrappedUserKey = "", + AccountKeys = new AccountKeysRequestModel + { + AccountPublicKey = _publicKey, + UserKeyEncryptedAccountPrivateKey = _privateKey + }, + OrgIdentifier = _orgIdentifier + }; + + // Act + var exception = Assert.Throws(() => model.ToKeyConnectorKeysData()); + + // Assert + Assert.Equal("KeyConnectorKeyWrappedUserKey and AccountKeys must be supplied.", exception.Message); + } + + [Fact] + public void ToKeyConnectorKeysData_NullKeyConnectorKeyWrappedUserKey_ThrowsException() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + KeyConnectorKeyWrappedUserKey = null, + AccountKeys = new AccountKeysRequestModel + { + AccountPublicKey = _publicKey, + UserKeyEncryptedAccountPrivateKey = _privateKey + }, + OrgIdentifier = _orgIdentifier + }; + + // Act + var exception = Assert.Throws(() => model.ToKeyConnectorKeysData()); + + // Assert + Assert.Equal("KeyConnectorKeyWrappedUserKey and AccountKeys must be supplied.", exception.Message); + } + + [Fact] + public void ToKeyConnectorKeysData_NullAccountKeys_ThrowsException() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + KeyConnectorKeyWrappedUserKey = _wrappedUserKey, + AccountKeys = null, + OrgIdentifier = _orgIdentifier + }; + + // Act + var exception = Assert.Throws(() => model.ToKeyConnectorKeysData()); + + // Assert + Assert.Equal("KeyConnectorKeyWrappedUserKey and AccountKeys must be supplied.", exception.Message); + } + + [Fact] + public void ToKeyConnectorKeysData_Valid_Success() + { + // Arrange + var model = new SetKeyConnectorKeyRequestModel + { + KeyConnectorKeyWrappedUserKey = _wrappedUserKey, + AccountKeys = new AccountKeysRequestModel + { + AccountPublicKey = _publicKey, + UserKeyEncryptedAccountPrivateKey = _privateKey + }, + OrgIdentifier = _orgIdentifier + }; + + // Act + var data = model.ToKeyConnectorKeysData(); + + // Assert + Assert.Equal(_wrappedUserKey, data.KeyConnectorKeyWrappedUserKey); + Assert.Equal(_publicKey, data.AccountKeys.AccountPublicKey); + Assert.Equal(_privateKey, data.AccountKeys.UserKeyEncryptedAccountPrivateKey); + Assert.Equal(_orgIdentifier, data.OrgIdentifier); + } + + private static List Validate(SetKeyConnectorKeyRequestModel model) + { + var results = new List(); + Validator.TryValidateObject(model, new ValidationContext(model), results, true); + return results; + } +} diff --git a/test/Core.Test/KeyManagement/Authorization/KeyConnectorAuthorizationHandlerTests.cs b/test/Core.Test/KeyManagement/Authorization/KeyConnectorAuthorizationHandlerTests.cs new file mode 100644 index 0000000000..fb774a78ac --- /dev/null +++ b/test/Core.Test/KeyManagement/Authorization/KeyConnectorAuthorizationHandlerTests.cs @@ -0,0 +1,151 @@ +using System.Security.Claims; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Authorization; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Authorization; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.Authorization; + +[SutProviderCustomize] +public class KeyConnectorAuthorizationHandlerTests +{ + [Theory, BitAutoData] + public async Task HandleRequirementAsync_UserCanUseKeyConnector_Success( + User user, + ClaimsPrincipal claimsPrincipal, + SutProvider sutProvider) + { + // Arrange + user.UsesKeyConnector = false; + sutProvider.GetDependency().Organizations + .Returns(new List()); + + var requirement = KeyConnectorOperations.Use; + var context = new AuthorizationHandlerContext([requirement], claimsPrincipal, user); + + // Act + await sutProvider.Sut.HandleAsync(context); + + // Assert + Assert.True(context.HasSucceeded); + } + + [Theory, BitAutoData] + public async Task HandleRequirementAsync_UserAlreadyUsesKeyConnector_Fails( + User user, + ClaimsPrincipal claimsPrincipal, + SutProvider sutProvider) + { + // Arrange + user.UsesKeyConnector = true; + sutProvider.GetDependency().Organizations + .Returns(new List()); + + var requirement = KeyConnectorOperations.Use; + var context = new AuthorizationHandlerContext([requirement], claimsPrincipal, user); + + // Act + await sutProvider.Sut.HandleAsync(context); + + // Assert + Assert.False(context.HasSucceeded); + } + + [Theory, BitAutoData] + public async Task HandleRequirementAsync_UserIsOwner_Fails( + User user, + Guid organizationId, + ClaimsPrincipal claimsPrincipal, + SutProvider sutProvider) + { + // Arrange + user.UsesKeyConnector = false; + var organizations = new List + { + new() { Id = organizationId, Type = OrganizationUserType.Owner } + }; + sutProvider.GetDependency().Organizations.Returns(organizations); + + var requirement = KeyConnectorOperations.Use; + var context = new AuthorizationHandlerContext([requirement], claimsPrincipal, user); + + // Act + await sutProvider.Sut.HandleAsync(context); + + // Assert + Assert.False(context.HasSucceeded); + } + + [Theory, BitAutoData] + public async Task HandleRequirementAsync_UserIsAdmin_Fails( + User user, + Guid organizationId, + ClaimsPrincipal claimsPrincipal, + SutProvider sutProvider) + { + // Arrange + user.UsesKeyConnector = false; + var organizations = new List + { + new() { Id = organizationId, Type = OrganizationUserType.Admin } + }; + sutProvider.GetDependency().Organizations.Returns(organizations); + + var requirement = KeyConnectorOperations.Use; + var context = new AuthorizationHandlerContext([requirement], claimsPrincipal, user); + + // Act + await sutProvider.Sut.HandleAsync(context); + + // Assert + Assert.False(context.HasSucceeded); + } + + [Theory, BitAutoData] + public async Task HandleRequirementAsync_UserIsRegularMember_Success( + User user, + Guid organizationId, + ClaimsPrincipal claimsPrincipal, + SutProvider sutProvider) + { + // Arrange + user.UsesKeyConnector = false; + var organizations = new List + { + new() { Id = organizationId, Type = OrganizationUserType.User } + }; + sutProvider.GetDependency().Organizations.Returns(organizations); + + var requirement = KeyConnectorOperations.Use; + var context = new AuthorizationHandlerContext([requirement], claimsPrincipal, user); + + // Act + await sutProvider.Sut.HandleAsync(context); + + // Assert + Assert.True(context.HasSucceeded); + } + + [Theory, BitAutoData] + public async Task HandleRequirementAsync_UnsupportedRequirement_ThrowsArgumentException( + User user, + ClaimsPrincipal claimsPrincipal, + SutProvider sutProvider) + { + // Arrange + user.UsesKeyConnector = false; + sutProvider.GetDependency().Organizations + .Returns(new List()); + + var unsupportedRequirement = new KeyConnectorOperationsRequirement("UnsupportedOperation"); + var context = new AuthorizationHandlerContext([unsupportedRequirement], claimsPrincipal, user); + + // Act & Assert + await Assert.ThrowsAsync(() => sutProvider.Sut.HandleAsync(context)); + } +} diff --git a/test/Core.Test/KeyManagement/Commands/SetKeyConnectorKeyCommandTests.cs b/test/Core.Test/KeyManagement/Commands/SetKeyConnectorKeyCommandTests.cs new file mode 100644 index 0000000000..74f76f368b --- /dev/null +++ b/test/Core.Test/KeyManagement/Commands/SetKeyConnectorKeyCommandTests.cs @@ -0,0 +1,125 @@ +using System.Security.Claims; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Commands; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.Commands; + +[SutProviderCustomize] +public class SetKeyConnectorKeyCommandTests +{ + + [Theory, BitAutoData] + public async Task SetKeyConnectorKeyForUserAsync_Success_SetsAccountKeys( + User user, + KeyConnectorKeysData data, + SutProvider sutProvider) + { + // Set up valid V2 encryption data + if (data.AccountKeys!.SignatureKeyPair != null) + { + data.AccountKeys.SignatureKeyPair.SignatureAlgorithm = "ed25519"; + } + + var expectedAccountKeysData = data.AccountKeys.ToAccountKeysData(); + + // Arrange + user.UsesKeyConnector = false; + var currentContext = sutProvider.GetDependency(); + var httpContext = Substitute.For(); + httpContext.User.Returns(new ClaimsPrincipal()); + currentContext.HttpContext.Returns(httpContext); + + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), user, Arg.Any>()) + .Returns(AuthorizationResult.Success()); + + var userRepository = sutProvider.GetDependency(); + var mockUpdateUserData = Substitute.For(); + userRepository.SetKeyConnectorUserKey(user.Id, data.KeyConnectorKeyWrappedUserKey!) + .Returns(mockUpdateUserData); + + // Act + await sutProvider.Sut.SetKeyConnectorKeyForUserAsync(user, data); + + // Assert + + userRepository + .Received(1) + .SetKeyConnectorUserKey(user.Id, data.KeyConnectorKeyWrappedUserKey); + + await userRepository + .Received(1) + .SetV2AccountCryptographicStateAsync( + user.Id, + Arg.Is(data => + data.PublicKeyEncryptionKeyPairData.PublicKey == expectedAccountKeysData.PublicKeyEncryptionKeyPairData.PublicKey && + data.PublicKeyEncryptionKeyPairData.WrappedPrivateKey == expectedAccountKeysData.PublicKeyEncryptionKeyPairData.WrappedPrivateKey && + data.PublicKeyEncryptionKeyPairData.SignedPublicKey == expectedAccountKeysData.PublicKeyEncryptionKeyPairData.SignedPublicKey && + data.SignatureKeyPairData!.SignatureAlgorithm == expectedAccountKeysData.SignatureKeyPairData!.SignatureAlgorithm && + data.SignatureKeyPairData.WrappedSigningKey == expectedAccountKeysData.SignatureKeyPairData.WrappedSigningKey && + data.SignatureKeyPairData.VerifyingKey == expectedAccountKeysData.SignatureKeyPairData.VerifyingKey && + data.SecurityStateData!.SecurityState == expectedAccountKeysData.SecurityStateData!.SecurityState && + data.SecurityStateData.SecurityVersion == expectedAccountKeysData.SecurityStateData.SecurityVersion), + Arg.Is>(actions => + actions.Count() == 1 && actions.First() == mockUpdateUserData)); + + await sutProvider.GetDependency() + .Received(1) + .LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector); + + await sutProvider.GetDependency() + .Received(1) + .AcceptOrgUserByOrgSsoIdAsync(data.OrgIdentifier, user, sutProvider.GetDependency()); + } + + [Theory, BitAutoData] + public async Task SetKeyConnectorKeyForUserAsync_UserCantUseKeyConnector_ThrowsException( + User user, + KeyConnectorKeysData data, + SutProvider sutProvider) + { + // Arrange + user.UsesKeyConnector = true; + var currentContext = sutProvider.GetDependency(); + var httpContext = Substitute.For(); + httpContext.User.Returns(new ClaimsPrincipal()); + currentContext.HttpContext.Returns(httpContext); + + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), user, Arg.Any>()) + .Returns(AuthorizationResult.Failed()); + + // Act & Assert + await Assert.ThrowsAsync( + () => sutProvider.Sut.SetKeyConnectorKeyForUserAsync(user, data)); + + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SetKeyConnectorUserKey(Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SetV2AccountCryptographicStateAsync(Arg.Any(), Arg.Any(), Arg.Any>()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .LogUserEventAsync(Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .AcceptOrgUserByOrgSsoIdAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs similarity index 91% rename from test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs rename to test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs index bbbd6d5cdb..98b2613ecb 100644 --- a/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Repositories/UserRepositoryTests.cs @@ -1,9 +1,11 @@ -using Bit.Core.AdminConsole.Repositories; +using Bit.Core; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Infrastructure.IntegrationTest.AdminConsole; +using Microsoft.Data.SqlClient; using Xunit; namespace Bit.Infrastructure.IntegrationTest.Repositories; @@ -500,4 +502,54 @@ public class UserRepositoryTests // Assert Assert.Empty(results); } + + [Theory, DatabaseData] + public async Task SetKeyConnectorUserKey_UpdatesUserKey(IUserRepository userRepository, Database database) + { + var user = await userRepository.CreateTestUserAsync(); + + const string keyConnectorWrappedKey = "key-connector-wrapped-user-key"; + + var setKeyConnectorUserKeyDelegate = userRepository.SetKeyConnectorUserKey(user.Id, keyConnectorWrappedKey); + + await RunUpdateUserDataAsync(setKeyConnectorUserKeyDelegate, database); + + var updatedUser = await userRepository.GetByIdAsync(user.Id); + + Assert.NotNull(updatedUser); + Assert.Equal(keyConnectorWrappedKey, updatedUser.Key); + Assert.True(updatedUser.UsesKeyConnector); + Assert.Equal(KdfType.Argon2id, updatedUser.Kdf); + Assert.Equal(AuthConstants.ARGON2_ITERATIONS.Default, updatedUser.KdfIterations); + Assert.Equal(AuthConstants.ARGON2_MEMORY.Default, updatedUser.KdfMemory); + Assert.Equal(AuthConstants.ARGON2_PARALLELISM.Default, updatedUser.KdfParallelism); + Assert.Equal(DateTime.UtcNow, updatedUser.RevisionDate, TimeSpan.FromMinutes(1)); + Assert.Equal(DateTime.UtcNow, updatedUser.AccountRevisionDate, TimeSpan.FromMinutes(1)); + } + + private static async Task RunUpdateUserDataAsync(UpdateUserData task, Database database) + { + if (database.Type == SupportedDatabaseProviders.SqlServer && !database.UseEf) + { + await using var connection = new SqlConnection(database.ConnectionString); + connection.Open(); + + await using var transaction = connection.BeginTransaction(); + try + { + await task(connection, transaction); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + else + { + await task(); + } + } } diff --git a/util/Migrator/DbScripts/2025-12-17_00_User_UpdateKeyConnectorUserKey.sql b/util/Migrator/DbScripts/2025-12-17_00_User_UpdateKeyConnectorUserKey.sql new file mode 100644 index 0000000000..545bf830f6 --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-17_00_User_UpdateKeyConnectorUserKey.sql @@ -0,0 +1,29 @@ +CREATE OR ALTER PROCEDURE [dbo].[User_UpdateKeyConnectorUserKey] + @Id UNIQUEIDENTIFIER, + @Key VARCHAR(MAX), + @Kdf TINYINT, + @KdfIterations INT, + @KdfMemory INT, + @KdfParallelism INT, + @UsesKeyConnector BIT, + @RevisionDate DATETIME2(7), + @AccountRevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + +UPDATE + [dbo].[User] +SET + [Key] = @Key, + [Kdf] = @Kdf, + [KdfIterations] = @KdfIterations, + [KdfMemory] = @KdfMemory, + [KdfParallelism] = @KdfParallelism, + [UsesKeyConnector] = @UsesKeyConnector, + [RevisionDate] = @RevisionDate, + [AccountRevisionDate] = @AccountRevisionDate +WHERE + [Id] = @Id +END +GO