Skip to content

Commit

Permalink
[PM-13362] Add private key regeneration endpoint (#4929)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Thomas-Avery authored Dec 16, 2024
1 parent d88a103 commit 7637cbe
Show file tree
Hide file tree
Showing 11 changed files with 641 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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,
};
}
}
1 change: 1 addition & 0 deletions src/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
18 changes: 18 additions & 0 deletions src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -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>();
}
}
2 changes: 2 additions & 0 deletions src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
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;
Expand Down Expand Up @@ -120,6 +121,7 @@ public static void AddBaseServices(this IServiceCollection services, IGlobalSett
services.AddScoped<IOrganizationDomainService, OrganizationDomainService>();
services.AddVaultServices();
services.AddReportingServices();
services.AddKeyManagementServices();
}

public static void AddTokenizers(this IServiceCollection services)
Expand Down
6 changes: 6 additions & 0 deletions test/Api.IntegrationTest/Helpers/LoginHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ public LoginHelper(ApiApplicationFactory factory, HttpClient client)
_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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading

0 comments on commit 7637cbe

Please sign in to comment.