-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[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
- Loading branch information
1 parent
d88a103
commit 7637cbe
Showing
11 changed files
with
641 additions
and
0 deletions.
There are no files selected for viewing
50 changes: 50 additions & 0 deletions
50
src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
23 changes: 23 additions & 0 deletions
23
src/Api/KeyManagement/Models/Requests/KeyRegenerationRequestModel.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
13 changes: 13 additions & 0 deletions
13
src/Core/KeyManagement/Commands/Interfaces/IRegenerateUserAsymmetricKeysCommand.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
71 changes: 71 additions & 0 deletions
71
src/Core/KeyManagement/Commands/RegenerateUserAsymmetricKeysCommand.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
18
src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
164 changes: 164 additions & 0 deletions
164
test/Api.IntegrationTest/KeyManagement/Controllers/AccountsKeyManagementControllerTests.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
Oops, something went wrong.