From 7637cbe12ac67006db73573f3eb9a1010a7cb153 Mon Sep 17 00:00:00 2001
From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>
Date: Mon, 16 Dec 2024 12:01:09 -0600
Subject: [PATCH] [PM-13362] Add private key regeneration endpoint (#4929)

* Add new RegenerateUserAsymmetricKeysCommand

* add new command tests

* Add regen controller

* Add regen controller tests

* add feature flag

* Add push notification to sync new asymmetric keys to other devices
---
 .../AccountsKeyManagementController.cs        |  50 +++++
 .../Requests/KeyRegenerationRequestModel.cs   |  23 ++
 src/Core/Constants.cs                         |   1 +
 .../IRegenerateUserAsymmetricKeysCommand.cs   |  13 ++
 .../RegenerateUserAsymmetricKeysCommand.cs    |  71 +++++++
 ...eyManagementServiceCollectionExtensions.cs |  18 ++
 .../Utilities/ServiceCollectionExtensions.cs  |   2 +
 .../Helpers/LoginHelper.cs                    |   6 +
 .../AccountsKeyManagementControllerTests.cs   | 164 +++++++++++++++
 .../AccountsKeyManagementControllerTests.cs   |  96 +++++++++
 ...egenerateUserAsymmetricKeysCommandTests.cs | 197 ++++++++++++++++++
 11 files changed, 641 insertions(+)
 create mode 100644 src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs
 create mode 100644 src/Api/KeyManagement/Models/Requests/KeyRegenerationRequestModel.cs
 create mode 100644 src/Core/KeyManagement/Commands/Interfaces/IRegenerateUserAsymmetricKeysCommand.cs
 create mode 100644 src/Core/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommand.cs
 create mode 100644 src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs
 create mode 100644 test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs
 create mode 100644 test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs
 create mode 100644 test/Core.Test/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommandTests.cs

diff --git a/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs
new file mode 100644
index 0000000000..b8d5e30949
--- /dev/null
+++ b/src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs
@@ -0,0 +1,50 @@
+#nullable enable
+using Bit.Api.KeyManagement.Models.Requests;
+using Bit.Core;
+using Bit.Core.Exceptions;
+using Bit.Core.KeyManagement.Commands.Interfaces;
+using Bit.Core.Repositories;
+using Bit.Core.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Bit.Api.KeyManagement.Controllers;
+
+[Route("accounts/key-management")]
+[Authorize("Application")]
+public class AccountsKeyManagementController : Controller
+{
+    private readonly IEmergencyAccessRepository _emergencyAccessRepository;
+    private readonly IFeatureService _featureService;
+    private readonly IOrganizationUserRepository _organizationUserRepository;
+    private readonly IRegenerateUserAsymmetricKeysCommand _regenerateUserAsymmetricKeysCommand;
+    private readonly IUserService _userService;
+
+    public AccountsKeyManagementController(IUserService userService,
+        IFeatureService featureService,
+        IOrganizationUserRepository organizationUserRepository,
+        IEmergencyAccessRepository emergencyAccessRepository,
+        IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand)
+    {
+        _userService = userService;
+        _featureService = featureService;
+        _regenerateUserAsymmetricKeysCommand = regenerateUserAsymmetricKeysCommand;
+        _organizationUserRepository = organizationUserRepository;
+        _emergencyAccessRepository = emergencyAccessRepository;
+    }
+
+    [HttpPost("regenerate-keys")]
+    public async Task RegenerateKeysAsync([FromBody] KeyRegenerationRequestModel request)
+    {
+        if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration))
+        {
+            throw new NotFoundException();
+        }
+
+        var user = await _userService.GetUserByPrincipalAsync(User) ?? throw new UnauthorizedAccessException();
+        var usersOrganizationAccounts = await _organizationUserRepository.GetManyByUserAsync(user.Id);
+        var designatedEmergencyAccess = await _emergencyAccessRepository.GetManyDetailsByGranteeIdAsync(user.Id);
+        await _regenerateUserAsymmetricKeysCommand.RegenerateKeysAsync(request.ToUserAsymmetricKeys(user.Id),
+            usersOrganizationAccounts, designatedEmergencyAccess);
+    }
+}
diff --git a/src/Api/KeyManagement/Models/Requests/KeyRegenerationRequestModel.cs b/src/Api/KeyManagement/Models/Requests/KeyRegenerationRequestModel.cs
new file mode 100644
index 0000000000..495d13cccd
--- /dev/null
+++ b/src/Api/KeyManagement/Models/Requests/KeyRegenerationRequestModel.cs
@@ -0,0 +1,23 @@
+#nullable enable
+using Bit.Core.KeyManagement.Models.Data;
+using Bit.Core.Utilities;
+
+namespace Bit.Api.KeyManagement.Models.Requests;
+
+public class KeyRegenerationRequestModel
+{
+    public required string UserPublicKey { get; set; }
+
+    [EncryptedString]
+    public required string UserKeyEncryptedUserPrivateKey { get; set; }
+
+    public UserAsymmetricKeys ToUserAsymmetricKeys(Guid userId)
+    {
+        return new UserAsymmetricKeys
+        {
+            UserId = userId,
+            PublicKey = UserPublicKey,
+            UserKeyEncryptedPrivateKey = UserKeyEncryptedUserPrivateKey,
+        };
+    }
+}
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index df0abfb4b9..2c315b2578 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -160,6 +160,7 @@ public static class FeatureFlagKeys
     public const string PM12443RemovePagingLogic = "pm-12443-remove-paging-logic";
     public const string SelfHostLicenseRefactor = "pm-11516-self-host-license-refactor";
     public const string PromoteProviderServiceUserTool = "pm-15128-promote-provider-service-user-tool";
+    public const string PrivateKeyRegeneration = "pm-12241-private-key-regeneration";
 
     public static List<string> GetAllKeys()
     {
diff --git a/src/Core/KeyManagement/Commands/Interfaces/IRegenerateUserAsymmetricKeysCommand.cs b/src/Core/KeyManagement/Commands/Interfaces/IRegenerateUserAsymmetricKeysCommand.cs
new file mode 100644
index 0000000000..d7ad7e3959
--- /dev/null
+++ b/src/Core/KeyManagement/Commands/Interfaces/IRegenerateUserAsymmetricKeysCommand.cs
@@ -0,0 +1,13 @@
+#nullable enable
+using Bit.Core.Auth.Models.Data;
+using Bit.Core.Entities;
+using Bit.Core.KeyManagement.Models.Data;
+
+namespace Bit.Core.KeyManagement.Commands.Interfaces;
+
+public interface IRegenerateUserAsymmetricKeysCommand
+{
+    Task RegenerateKeysAsync(UserAsymmetricKeys userAsymmetricKeys,
+        ICollection<OrganizationUser> usersOrganizationAccounts,
+        ICollection<EmergencyAccessDetails> designatedEmergencyAccess);
+}
diff --git a/src/Core/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommand.cs b/src/Core/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommand.cs
new file mode 100644
index 0000000000..a54223f685
--- /dev/null
+++ b/src/Core/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommand.cs
@@ -0,0 +1,71 @@
+#nullable enable
+using Bit.Core.Auth.Enums;
+using Bit.Core.Auth.Models.Data;
+using Bit.Core.Context;
+using Bit.Core.Entities;
+using Bit.Core.Enums;
+using Bit.Core.Exceptions;
+using Bit.Core.KeyManagement.Commands.Interfaces;
+using Bit.Core.KeyManagement.Models.Data;
+using Bit.Core.KeyManagement.Repositories;
+using Bit.Core.Services;
+using Microsoft.Extensions.Logging;
+
+namespace Bit.Core.KeyManagement.Commands;
+
+public class RegenerateUserAsymmetricKeysCommand : IRegenerateUserAsymmetricKeysCommand
+{
+    private readonly ICurrentContext _currentContext;
+    private readonly ILogger<RegenerateUserAsymmetricKeysCommand> _logger;
+    private readonly IUserAsymmetricKeysRepository _userAsymmetricKeysRepository;
+    private readonly IPushNotificationService _pushService;
+
+    public RegenerateUserAsymmetricKeysCommand(
+        ICurrentContext currentContext,
+        IUserAsymmetricKeysRepository userAsymmetricKeysRepository,
+        IPushNotificationService pushService,
+        ILogger<RegenerateUserAsymmetricKeysCommand> logger)
+    {
+        _currentContext = currentContext;
+        _logger = logger;
+        _userAsymmetricKeysRepository = userAsymmetricKeysRepository;
+        _pushService = pushService;
+    }
+
+    public async Task RegenerateKeysAsync(UserAsymmetricKeys userAsymmetricKeys,
+        ICollection<OrganizationUser> usersOrganizationAccounts,
+        ICollection<EmergencyAccessDetails> designatedEmergencyAccess)
+    {
+        var userId = _currentContext.UserId;
+        if (!userId.HasValue ||
+            userAsymmetricKeys.UserId != userId.Value ||
+            usersOrganizationAccounts.Any(ou => ou.UserId != userId) ||
+            designatedEmergencyAccess.Any(dea => dea.GranteeId != userId))
+        {
+            throw new NotFoundException();
+        }
+
+        var inOrganizations = usersOrganizationAccounts.Any(ou =>
+            ou.Status is OrganizationUserStatusType.Confirmed or OrganizationUserStatusType.Revoked);
+        var hasDesignatedEmergencyAccess = designatedEmergencyAccess.Any(x =>
+            x.Status is EmergencyAccessStatusType.Confirmed or EmergencyAccessStatusType.RecoveryApproved
+                or EmergencyAccessStatusType.RecoveryInitiated);
+
+        _logger.LogInformation(
+            "User asymmetric keys regeneration requested. UserId: {userId} OrganizationMembership: {inOrganizations} DesignatedEmergencyAccess: {hasDesignatedEmergencyAccess} DeviceType: {deviceType}",
+            userAsymmetricKeys.UserId, inOrganizations, hasDesignatedEmergencyAccess, _currentContext.DeviceType);
+
+        // For now, don't regenerate asymmetric keys for user's with organization membership and designated emergency access.
+        if (inOrganizations || hasDesignatedEmergencyAccess)
+        {
+            throw new BadRequestException("Key regeneration not supported for this user.");
+        }
+
+        await _userAsymmetricKeysRepository.RegenerateUserAsymmetricKeysAsync(userAsymmetricKeys);
+        _logger.LogInformation(
+            "User's asymmetric keys regenerated. UserId: {userId} OrganizationMembership: {inOrganizations} DesignatedEmergencyAccess: {hasDesignatedEmergencyAccess} DeviceType: {deviceType}",
+            userAsymmetricKeys.UserId, inOrganizations, hasDesignatedEmergencyAccess, _currentContext.DeviceType);
+
+        await _pushService.PushSyncSettingsAsync(userId.Value);
+    }
+}
diff --git a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..102630c7e6
--- /dev/null
+++ b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs
@@ -0,0 +1,18 @@
+using Bit.Core.KeyManagement.Commands;
+using Bit.Core.KeyManagement.Commands.Interfaces;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Bit.Core.KeyManagement;
+
+public static class KeyManagementServiceCollectionExtensions
+{
+    public static void AddKeyManagementServices(this IServiceCollection services)
+    {
+        services.AddKeyManagementCommands();
+    }
+
+    private static void AddKeyManagementCommands(this IServiceCollection services)
+    {
+        services.AddScoped<IRegenerateUserAsymmetricKeysCommand, RegenerateUserAsymmetricKeysCommand>();
+    }
+}
diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
index 7585739d82..c757f163e9 100644
--- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
+++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
@@ -26,6 +26,7 @@ using Bit.Core.Enums;
 using Bit.Core.HostedServices;
 using Bit.Core.Identity;
 using Bit.Core.IdentityServer;
+using Bit.Core.KeyManagement;
 using Bit.Core.NotificationHub;
 using Bit.Core.OrganizationFeatures;
 using Bit.Core.Repositories;
@@ -120,6 +121,7 @@ public static class ServiceCollectionExtensions
         services.AddScoped<IOrganizationDomainService, OrganizationDomainService>();
         services.AddVaultServices();
         services.AddReportingServices();
+        services.AddKeyManagementServices();
     }
 
     public static void AddTokenizers(this IServiceCollection services)
diff --git a/test/Api.IntegrationTest/Helpers/LoginHelper.cs b/test/Api.IntegrationTest/Helpers/LoginHelper.cs
index d6ce911bd0..1f5eb725d9 100644
--- a/test/Api.IntegrationTest/Helpers/LoginHelper.cs
+++ b/test/Api.IntegrationTest/Helpers/LoginHelper.cs
@@ -16,6 +16,12 @@ public class LoginHelper
         _client = client;
     }
 
+    public async Task LoginAsync(string email)
+    {
+        var tokens = await _factory.LoginAsync(email);
+        _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Token);
+    }
+
     public async Task LoginWithOrganizationApiKeyAsync(Guid organizationId)
     {
         var (clientId, apiKey) = await GetOrganizationApiKey(_factory, organizationId);
diff --git a/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs
new file mode 100644
index 0000000000..ec7ca37460
--- /dev/null
+++ b/test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs
@@ -0,0 +1,164 @@
+using System.Net;
+using Bit.Api.IntegrationTest.Factories;
+using Bit.Api.IntegrationTest.Helpers;
+using Bit.Api.KeyManagement.Models.Requests;
+using Bit.Core.Auth.Entities;
+using Bit.Core.Auth.Enums;
+using Bit.Core.Billing.Enums;
+using Bit.Core.Enums;
+using Bit.Core.Repositories;
+using Bit.Test.Common.AutoFixture.Attributes;
+using Xunit;
+
+namespace Bit.Api.IntegrationTest.KeyManagement.Controllers;
+
+public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
+{
+    private static readonly string _mockEncryptedString =
+        "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=";
+
+    private readonly HttpClient _client;
+    private readonly IEmergencyAccessRepository _emergencyAccessRepository;
+    private readonly IOrganizationUserRepository _organizationUserRepository;
+    private readonly ApiApplicationFactory _factory;
+    private readonly LoginHelper _loginHelper;
+    private readonly IUserRepository _userRepository;
+    private string _ownerEmail = null!;
+
+    public AccountsKeyManagementControllerTests(ApiApplicationFactory factory)
+    {
+        _factory = factory;
+        _factory.UpdateConfiguration("globalSettings:launchDarkly:flagValues:pm-12241-private-key-regeneration",
+            "true");
+        _client = factory.CreateClient();
+        _loginHelper = new LoginHelper(_factory, _client);
+        _userRepository = _factory.GetService<IUserRepository>();
+        _emergencyAccessRepository = _factory.GetService<IEmergencyAccessRepository>();
+        _organizationUserRepository = _factory.GetService<IOrganizationUserRepository>();
+    }
+
+    public async Task InitializeAsync()
+    {
+        _ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
+        await _factory.LoginWithNewAccount(_ownerEmail);
+    }
+
+    public Task DisposeAsync()
+    {
+        _client.Dispose();
+        return Task.CompletedTask;
+    }
+
+    [Theory]
+    [BitAutoData]
+    public async Task RegenerateKeysAsync_FeatureFlagTurnedOff_NotFound(KeyRegenerationRequestModel request)
+    {
+        // Localize factory to inject a false value for the feature flag.
+        var localFactory = new ApiApplicationFactory();
+        localFactory.UpdateConfiguration("globalSettings:launchDarkly:flagValues:pm-12241-private-key-regeneration",
+            "false");
+        var localClient = localFactory.CreateClient();
+        var localEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
+        var localLoginHelper = new LoginHelper(localFactory, localClient);
+        await localFactory.LoginWithNewAccount(localEmail);
+        await localLoginHelper.LoginAsync(localEmail);
+
+        request.UserKeyEncryptedUserPrivateKey = _mockEncryptedString;
+
+        var response = await localClient.PostAsJsonAsync("/accounts/key-management/regenerate-keys", request);
+
+        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+    }
+
+    [Theory]
+    [BitAutoData]
+    public async Task RegenerateKeysAsync_NotLoggedIn_Unauthorized(KeyRegenerationRequestModel request)
+    {
+        request.UserKeyEncryptedUserPrivateKey = _mockEncryptedString;
+
+        var response = await _client.PostAsJsonAsync("/accounts/key-management/regenerate-keys", request);
+
+        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+    }
+
+    [Theory]
+    [BitAutoData(OrganizationUserStatusType.Confirmed, EmergencyAccessStatusType.Confirmed)]
+    [BitAutoData(OrganizationUserStatusType.Confirmed, EmergencyAccessStatusType.RecoveryApproved)]
+    [BitAutoData(OrganizationUserStatusType.Confirmed, EmergencyAccessStatusType.RecoveryInitiated)]
+    [BitAutoData(OrganizationUserStatusType.Revoked, EmergencyAccessStatusType.Confirmed)]
+    [BitAutoData(OrganizationUserStatusType.Revoked, EmergencyAccessStatusType.RecoveryApproved)]
+    [BitAutoData(OrganizationUserStatusType.Revoked, EmergencyAccessStatusType.RecoveryInitiated)]
+    [BitAutoData(OrganizationUserStatusType.Confirmed, null)]
+    [BitAutoData(OrganizationUserStatusType.Revoked, null)]
+    [BitAutoData(OrganizationUserStatusType.Invited, EmergencyAccessStatusType.Confirmed)]
+    [BitAutoData(OrganizationUserStatusType.Invited, EmergencyAccessStatusType.RecoveryApproved)]
+    [BitAutoData(OrganizationUserStatusType.Invited, EmergencyAccessStatusType.RecoveryInitiated)]
+    public async Task RegenerateKeysAsync_UserInOrgOrHasDesignatedEmergencyAccess_ThrowsBadRequest(
+        OrganizationUserStatusType organizationUserStatus,
+        EmergencyAccessStatusType? emergencyAccessStatus,
+        KeyRegenerationRequestModel request)
+    {
+        if (organizationUserStatus is OrganizationUserStatusType.Confirmed or OrganizationUserStatusType.Revoked)
+        {
+            await CreateOrganizationUserAsync(organizationUserStatus);
+        }
+
+        if (emergencyAccessStatus != null)
+        {
+            await CreateDesignatedEmergencyAccessAsync(emergencyAccessStatus.Value);
+        }
+
+        await _loginHelper.LoginAsync(_ownerEmail);
+        request.UserKeyEncryptedUserPrivateKey = _mockEncryptedString;
+
+        var response = await _client.PostAsJsonAsync("/accounts/key-management/regenerate-keys", request);
+
+        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+    }
+
+    [Theory]
+    [BitAutoData]
+    public async Task RegenerateKeysAsync_Success(KeyRegenerationRequestModel request)
+    {
+        await _loginHelper.LoginAsync(_ownerEmail);
+        request.UserKeyEncryptedUserPrivateKey = _mockEncryptedString;
+
+        var response = await _client.PostAsJsonAsync("/accounts/key-management/regenerate-keys", request);
+        response.EnsureSuccessStatusCode();
+
+        var user = await _userRepository.GetByEmailAsync(_ownerEmail);
+        Assert.NotNull(user);
+        Assert.Equal(request.UserPublicKey, user.PublicKey);
+        Assert.Equal(request.UserKeyEncryptedUserPrivateKey, user.PrivateKey);
+    }
+
+    private async Task CreateOrganizationUserAsync(OrganizationUserStatusType organizationUserStatus)
+    {
+        var (_, organizationUser) = await OrganizationTestHelpers.SignUpAsync(_factory,
+            PlanType.EnterpriseAnnually, _ownerEmail, passwordManagerSeats: 10,
+            paymentMethod: PaymentMethodType.Card);
+        organizationUser.Status = organizationUserStatus;
+        await _organizationUserRepository.ReplaceAsync(organizationUser);
+    }
+
+    private async Task CreateDesignatedEmergencyAccessAsync(EmergencyAccessStatusType emergencyAccessStatus)
+    {
+        var tempEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com";
+        await _factory.LoginWithNewAccount(tempEmail);
+
+        var tempUser = await _userRepository.GetByEmailAsync(tempEmail);
+        var user = await _userRepository.GetByEmailAsync(_ownerEmail);
+        var emergencyAccess = new EmergencyAccess
+        {
+            GrantorId = tempUser!.Id,
+            GranteeId = user!.Id,
+            KeyEncrypted = _mockEncryptedString,
+            Status = emergencyAccessStatus,
+            Type = EmergencyAccessType.View,
+            WaitTimeDays = 10,
+            CreationDate = DateTime.UtcNow,
+            RevisionDate = DateTime.UtcNow
+        };
+        await _emergencyAccessRepository.CreateAsync(emergencyAccess);
+    }
+}
diff --git a/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs
new file mode 100644
index 0000000000..2615697ad3
--- /dev/null
+++ b/test/Api.Test/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs
@@ -0,0 +1,96 @@
+#nullable enable
+using System.Security.Claims;
+using Bit.Api.KeyManagement.Controllers;
+using Bit.Api.KeyManagement.Models.Requests;
+using Bit.Core;
+using Bit.Core.Auth.Models.Data;
+using Bit.Core.Entities;
+using Bit.Core.Exceptions;
+using Bit.Core.KeyManagement.Commands.Interfaces;
+using Bit.Core.KeyManagement.Models.Data;
+using Bit.Core.Repositories;
+using Bit.Core.Services;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using NSubstitute;
+using NSubstitute.ReturnsExtensions;
+using Xunit;
+
+namespace Bit.Api.Test.KeyManagement.Controllers;
+
+[ControllerCustomize(typeof(AccountsKeyManagementController))]
+[SutProviderCustomize]
+[JsonDocumentCustomize]
+public class AccountsKeyManagementControllerTests
+{
+    [Theory]
+    [BitAutoData]
+    public async Task RegenerateKeysAsync_FeatureFlagOff_Throws(
+        SutProvider<AccountsKeyManagementController> sutProvider,
+        KeyRegenerationRequestModel data)
+    {
+        sutProvider.GetDependency<IFeatureService>().IsEnabled(Arg.Is(FeatureFlagKeys.PrivateKeyRegeneration))
+            .Returns(false);
+        sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).ReturnsNull();
+
+        await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.RegenerateKeysAsync(data));
+
+        await sutProvider.GetDependency<IOrganizationUserRepository>().ReceivedWithAnyArgs(0)
+            .GetManyByUserAsync(Arg.Any<Guid>());
+        await sutProvider.GetDependency<IEmergencyAccessRepository>().ReceivedWithAnyArgs(0)
+            .GetManyDetailsByGranteeIdAsync(Arg.Any<Guid>());
+        await sutProvider.GetDependency<IRegenerateUserAsymmetricKeysCommand>().ReceivedWithAnyArgs(0)
+            .RegenerateKeysAsync(Arg.Any<UserAsymmetricKeys>(),
+                Arg.Any<ICollection<OrganizationUser>>(),
+                Arg.Any<ICollection<EmergencyAccessDetails>>());
+    }
+
+    [Theory]
+    [BitAutoData]
+    public async Task RegenerateKeysAsync_UserNull_Throws(SutProvider<AccountsKeyManagementController> sutProvider,
+        KeyRegenerationRequestModel data)
+    {
+        sutProvider.GetDependency<IFeatureService>().IsEnabled(Arg.Is(FeatureFlagKeys.PrivateKeyRegeneration))
+            .Returns(true);
+        sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).ReturnsNull();
+
+        await Assert.ThrowsAsync<UnauthorizedAccessException>(() => sutProvider.Sut.RegenerateKeysAsync(data));
+
+        await sutProvider.GetDependency<IOrganizationUserRepository>().ReceivedWithAnyArgs(0)
+            .GetManyByUserAsync(Arg.Any<Guid>());
+        await sutProvider.GetDependency<IEmergencyAccessRepository>().ReceivedWithAnyArgs(0)
+            .GetManyDetailsByGranteeIdAsync(Arg.Any<Guid>());
+        await sutProvider.GetDependency<IRegenerateUserAsymmetricKeysCommand>().ReceivedWithAnyArgs(0)
+            .RegenerateKeysAsync(Arg.Any<UserAsymmetricKeys>(),
+                Arg.Any<ICollection<OrganizationUser>>(),
+                Arg.Any<ICollection<EmergencyAccessDetails>>());
+    }
+
+    [Theory]
+    [BitAutoData]
+    public async Task RegenerateKeysAsync_Success(SutProvider<AccountsKeyManagementController> sutProvider,
+        KeyRegenerationRequestModel data, User user, ICollection<OrganizationUser> orgUsers,
+        ICollection<EmergencyAccessDetails> accessDetails)
+    {
+        sutProvider.GetDependency<IFeatureService>().IsEnabled(Arg.Is(FeatureFlagKeys.PrivateKeyRegeneration))
+            .Returns(true);
+        sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
+        sutProvider.GetDependency<IOrganizationUserRepository>().GetManyByUserAsync(Arg.Is(user.Id)).Returns(orgUsers);
+        sutProvider.GetDependency<IEmergencyAccessRepository>().GetManyDetailsByGranteeIdAsync(Arg.Is(user.Id))
+            .Returns(accessDetails);
+
+        await sutProvider.Sut.RegenerateKeysAsync(data);
+
+        await sutProvider.GetDependency<IOrganizationUserRepository>().Received(1)
+            .GetManyByUserAsync(Arg.Is(user.Id));
+        await sutProvider.GetDependency<IEmergencyAccessRepository>().Received(1)
+            .GetManyDetailsByGranteeIdAsync(Arg.Is(user.Id));
+        await sutProvider.GetDependency<IRegenerateUserAsymmetricKeysCommand>().Received(1)
+            .RegenerateKeysAsync(
+                Arg.Is<UserAsymmetricKeys>(u =>
+                    u.UserId == user.Id && u.PublicKey == data.UserPublicKey &&
+                    u.UserKeyEncryptedPrivateKey == data.UserKeyEncryptedUserPrivateKey),
+                Arg.Is(orgUsers),
+                Arg.Is(accessDetails));
+    }
+}
diff --git a/test/Core.Test/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommandTests.cs b/test/Core.Test/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommandTests.cs
new file mode 100644
index 0000000000..3388956156
--- /dev/null
+++ b/test/Core.Test/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommandTests.cs
@@ -0,0 +1,197 @@
+#nullable enable
+using Bit.Core.Auth.Enums;
+using Bit.Core.Auth.Models.Data;
+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.KeyManagement.Repositories;
+using Bit.Core.Services;
+using Bit.Test.Common.AutoFixture;
+using Bit.Test.Common.AutoFixture.Attributes;
+using NSubstitute;
+using NSubstitute.ReturnsExtensions;
+using Xunit;
+
+namespace Bit.Core.Test.KeyManagement.Commands;
+
+[SutProviderCustomize]
+public class RegenerateUserAsymmetricKeysCommandTests
+{
+    [Theory]
+    [BitAutoData]
+    public async Task RegenerateKeysAsync_NoCurrentContext_NotFoundException(
+        SutProvider<RegenerateUserAsymmetricKeysCommand> sutProvider,
+        UserAsymmetricKeys userAsymmetricKeys)
+    {
+        sutProvider.GetDependency<ICurrentContext>().UserId.ReturnsNullForAnyArgs();
+        var usersOrganizationAccounts = new List<OrganizationUser>();
+        var designatedEmergencyAccess = new List<EmergencyAccessDetails>();
+
+        await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.RegenerateKeysAsync(userAsymmetricKeys,
+            usersOrganizationAccounts, designatedEmergencyAccess));
+    }
+
+    [Theory]
+    [BitAutoData]
+    public async Task RegenerateKeysAsync_UserHasNoSharedAccess_Success(
+        SutProvider<RegenerateUserAsymmetricKeysCommand> sutProvider,
+        UserAsymmetricKeys userAsymmetricKeys)
+    {
+        sutProvider.GetDependency<ICurrentContext>().UserId.ReturnsForAnyArgs(userAsymmetricKeys.UserId);
+        var usersOrganizationAccounts = new List<OrganizationUser>();
+        var designatedEmergencyAccess = new List<EmergencyAccessDetails>();
+
+        await sutProvider.Sut.RegenerateKeysAsync(userAsymmetricKeys,
+            usersOrganizationAccounts, designatedEmergencyAccess);
+
+        await sutProvider.GetDependency<IUserAsymmetricKeysRepository>()
+            .Received(1)
+            .RegenerateUserAsymmetricKeysAsync(Arg.Is(userAsymmetricKeys));
+        await sutProvider.GetDependency<IPushNotificationService>()
+            .Received(1)
+            .PushSyncSettingsAsync(Arg.Is(userAsymmetricKeys.UserId));
+    }
+
+    [Theory]
+    [BitAutoData(false, false, true)]
+    [BitAutoData(false, true, false)]
+    [BitAutoData(false, true, true)]
+    [BitAutoData(true, false, false)]
+    [BitAutoData(true, false, true)]
+    [BitAutoData(true, true, false)]
+    [BitAutoData(true, true, true)]
+    public async Task RegenerateKeysAsync_UserIdMisMatch_NotFoundException(
+        bool userAsymmetricKeysMismatch,
+        bool orgMismatch,
+        bool emergencyAccessMismatch,
+        SutProvider<RegenerateUserAsymmetricKeysCommand> sutProvider,
+        UserAsymmetricKeys userAsymmetricKeys,
+        ICollection<OrganizationUser> usersOrganizationAccounts,
+        ICollection<EmergencyAccessDetails> designatedEmergencyAccess)
+    {
+        sutProvider.GetDependency<ICurrentContext>().UserId
+            .ReturnsForAnyArgs(userAsymmetricKeysMismatch ? new Guid() : userAsymmetricKeys.UserId);
+
+        if (!orgMismatch)
+        {
+            usersOrganizationAccounts =
+                SetupOrganizationUserAccounts(userAsymmetricKeys.UserId, usersOrganizationAccounts);
+        }
+
+        if (!emergencyAccessMismatch)
+        {
+            designatedEmergencyAccess = SetupEmergencyAccess(userAsymmetricKeys.UserId, designatedEmergencyAccess);
+        }
+
+        await Assert.ThrowsAsync<NotFoundException>(() => sutProvider.Sut.RegenerateKeysAsync(userAsymmetricKeys,
+            usersOrganizationAccounts, designatedEmergencyAccess));
+
+        await sutProvider.GetDependency<IUserAsymmetricKeysRepository>()
+            .ReceivedWithAnyArgs(0)
+            .RegenerateUserAsymmetricKeysAsync(Arg.Any<UserAsymmetricKeys>());
+        await sutProvider.GetDependency<IPushNotificationService>()
+            .ReceivedWithAnyArgs(0)
+            .PushSyncSettingsAsync(Arg.Any<Guid>());
+    }
+
+    [Theory]
+    [BitAutoData(OrganizationUserStatusType.Confirmed)]
+    [BitAutoData(OrganizationUserStatusType.Revoked)]
+    public async Task RegenerateKeysAsync_UserInOrganizations_BadRequestException(
+        OrganizationUserStatusType organizationUserStatus,
+        SutProvider<RegenerateUserAsymmetricKeysCommand> sutProvider,
+        UserAsymmetricKeys userAsymmetricKeys,
+        ICollection<OrganizationUser> usersOrganizationAccounts)
+    {
+        sutProvider.GetDependency<ICurrentContext>().UserId.ReturnsForAnyArgs(userAsymmetricKeys.UserId);
+        usersOrganizationAccounts = CreateInOrganizationAccounts(userAsymmetricKeys.UserId, organizationUserStatus,
+            usersOrganizationAccounts);
+        var designatedEmergencyAccess = new List<EmergencyAccessDetails>();
+
+        await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.RegenerateKeysAsync(userAsymmetricKeys,
+            usersOrganizationAccounts, designatedEmergencyAccess));
+
+        await sutProvider.GetDependency<IUserAsymmetricKeysRepository>()
+            .ReceivedWithAnyArgs(0)
+            .RegenerateUserAsymmetricKeysAsync(Arg.Any<UserAsymmetricKeys>());
+        await sutProvider.GetDependency<IPushNotificationService>()
+            .ReceivedWithAnyArgs(0)
+            .PushSyncSettingsAsync(Arg.Any<Guid>());
+    }
+
+    [Theory]
+    [BitAutoData(EmergencyAccessStatusType.Confirmed)]
+    [BitAutoData(EmergencyAccessStatusType.RecoveryApproved)]
+    [BitAutoData(EmergencyAccessStatusType.RecoveryInitiated)]
+    public async Task RegenerateKeysAsync_UserHasDesignatedEmergencyAccess_BadRequestException(
+        EmergencyAccessStatusType statusType,
+        SutProvider<RegenerateUserAsymmetricKeysCommand> sutProvider,
+        UserAsymmetricKeys userAsymmetricKeys,
+        ICollection<EmergencyAccessDetails> designatedEmergencyAccess)
+    {
+        sutProvider.GetDependency<ICurrentContext>().UserId.ReturnsForAnyArgs(userAsymmetricKeys.UserId);
+        designatedEmergencyAccess =
+            CreateDesignatedEmergencyAccess(userAsymmetricKeys.UserId, statusType, designatedEmergencyAccess);
+        var usersOrganizationAccounts = new List<OrganizationUser>();
+
+
+        await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.RegenerateKeysAsync(userAsymmetricKeys,
+            usersOrganizationAccounts, designatedEmergencyAccess));
+
+        await sutProvider.GetDependency<IUserAsymmetricKeysRepository>()
+            .ReceivedWithAnyArgs(0)
+            .RegenerateUserAsymmetricKeysAsync(Arg.Any<UserAsymmetricKeys>());
+        await sutProvider.GetDependency<IPushNotificationService>()
+            .ReceivedWithAnyArgs(0)
+            .PushSyncSettingsAsync(Arg.Any<Guid>());
+    }
+
+    private static ICollection<OrganizationUser> CreateInOrganizationAccounts(Guid userId,
+        OrganizationUserStatusType organizationUserStatus, ICollection<OrganizationUser> organizationUserAccounts)
+    {
+        foreach (var organizationUserAccount in organizationUserAccounts)
+        {
+            organizationUserAccount.UserId = userId;
+            organizationUserAccount.Status = organizationUserStatus;
+        }
+
+        return organizationUserAccounts;
+    }
+
+    private static ICollection<EmergencyAccessDetails> CreateDesignatedEmergencyAccess(Guid userId,
+        EmergencyAccessStatusType status, ICollection<EmergencyAccessDetails> designatedEmergencyAccess)
+    {
+        foreach (var designated in designatedEmergencyAccess)
+        {
+            designated.GranteeId = userId;
+            designated.Status = status;
+        }
+
+        return designatedEmergencyAccess;
+    }
+
+    private static ICollection<OrganizationUser> SetupOrganizationUserAccounts(Guid userId,
+        ICollection<OrganizationUser> organizationUserAccounts)
+    {
+        foreach (var organizationUserAccount in organizationUserAccounts)
+        {
+            organizationUserAccount.UserId = userId;
+        }
+
+        return organizationUserAccounts;
+    }
+
+    private static ICollection<EmergencyAccessDetails> SetupEmergencyAccess(Guid userId,
+        ICollection<EmergencyAccessDetails> emergencyAccessDetails)
+    {
+        foreach (var emergencyAccessDetail in emergencyAccessDetails)
+        {
+            emergencyAccessDetail.GranteeId = userId;
+        }
+
+        return emergencyAccessDetails;
+    }
+}