Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PM-13362] Add private key regeneration endpoint #4929

Merged
merged 28 commits into from
Dec 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a6f2118
Add stored procedure
Thomas-Avery Oct 15, 2024
4f49c0b
Add repository
Thomas-Avery Oct 15, 2024
2cb6045
Update SQL stored procedure
Thomas-Avery Oct 15, 2024
8a1c761
Bump revision dates
Thomas-Avery Oct 16, 2024
9278677
fix dapper call
Thomas-Avery Oct 16, 2024
5554adb
Merge branch 'main' into km/pm-13706/private-key-regen-db
Thomas-Avery Oct 16, 2024
88f2b97
Merge branch 'main' into km/pm-13706/private-key-regen-db
Thomas-Avery Oct 22, 2024
34bb84c
Add new RegenerateUserAsymmetricKeysCommand
Thomas-Avery Oct 22, 2024
5784903
add new command tests
Thomas-Avery Oct 22, 2024
3840b5c
Add regen controller
Thomas-Avery Oct 22, 2024
7d68d70
Add regen controller tests
Thomas-Avery Oct 22, 2024
9226389
Merge branch 'km/pm-13706/private-key-regen-db' into km/pm-13362/privโ€ฆ
Thomas-Avery Oct 22, 2024
bcd4e88
bump migration date
Thomas-Avery Oct 22, 2024
12da983
Merge branch 'km/pm-13706/private-key-regen-db' into km/pm-13362/privโ€ฆ
Thomas-Avery Oct 22, 2024
0b60bb9
add feature flag
Thomas-Avery Oct 23, 2024
292fa3e
Add more logging
Thomas-Avery Oct 24, 2024
4d6ae57
update request model
Thomas-Avery Oct 25, 2024
23d9e3f
update tests
Thomas-Avery Oct 25, 2024
4640ad8
Merge branch 'main' into km/pm-13706/private-key-regen-db
Thomas-Avery Nov 13, 2024
ff8e01e
bump migration date
Thomas-Avery Nov 13, 2024
3ffa555
Merge branch 'km/pm-13706/private-key-regen-db' into km/pm-13362/privโ€ฆ
Thomas-Avery Nov 13, 2024
6899e49
Add push notification to sync new asymmetric keys to other devices
Thomas-Avery Nov 14, 2024
58bf726
Merge branch 'main' into km/pm-13362/private-key-regen-endpoint
Thomas-Avery Nov 21, 2024
e38fb90
remove old migration
Thomas-Avery Nov 21, 2024
22f326d
Merge branch 'main' into km/pm-13362/private-key-regen-endpoint
Thomas-Avery Dec 11, 2024
fec5397
Merge branch 'main' into km/pm-13362/private-key-regen-endpoint
Thomas-Avery Dec 13, 2024
3f00218
Merge branch 'main' into km/pm-13362/private-key-regen-endpoint
Thomas-Avery Dec 16, 2024
2927a26
Merge branch 'main' into km/pm-13362/private-key-regen-endpoint
Thomas-Avery Dec 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}
}
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
Loading