1
0
mirror of https://github.com/bitwarden/server.git synced 2025-06-30 15:42:48 -05:00

[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
This commit is contained in:
Thomas Avery
2024-12-16 12:01:09 -06:00
committed by GitHub
parent d88a103fbc
commit 7637cbe12a
11 changed files with 641 additions and 0 deletions

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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));
}
}

View File

@ -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;
}
}