1
0
mirror of https://github.com/bitwarden/server synced 2025-12-31 07:33:43 +00:00

[PM-27280] Support v2 encryption on key-connector signups (#6712)

* account v2 registration for key connector

* use new user repository functions

* test coverage

* integration test coverage

* documentation

* code review

* missing test coverage

* fix failing test

* failing test

* incorrect ticket number

* moved back request model to Api, created dedicated data class in Core

* sql stored procedure type mismatch, simplification

* key connector authorization handler
This commit is contained in:
Maciej Zieniuk
2025-12-18 19:43:03 +01:00
committed by GitHub
parent 2b742b0343
commit a92d7ac129
22 changed files with 1283 additions and 50 deletions

View File

@@ -47,6 +47,7 @@ public class AccountsKeyManagementController : Controller
_webauthnKeyValidator;
private readonly IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> _deviceValidator;
private readonly IKeyConnectorConfirmationDetailsQuery _keyConnectorConfirmationDetailsQuery;
private readonly ISetKeyConnectorKeyCommand _setKeyConnectorKeyCommand;
public AccountsKeyManagementController(IUserService userService,
IFeatureService featureService,
@@ -62,8 +63,10 @@ public class AccountsKeyManagementController : Controller
emergencyAccessValidator,
IRotationValidator<IEnumerable<ResetPasswordWithOrgIdRequestModel>, IReadOnlyList<OrganizationUser>>
organizationUserValidator,
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> webAuthnKeyValidator,
IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> deviceValidator)
IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
webAuthnKeyValidator,
IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> deviceValidator,
ISetKeyConnectorKeyCommand setKeyConnectorKeyCommand)
{
_userService = userService;
_featureService = featureService;
@@ -79,6 +82,7 @@ public class AccountsKeyManagementController : Controller
_webauthnKeyValidator = webAuthnKeyValidator;
_deviceValidator = deviceValidator;
_keyConnectorConfirmationDetailsQuery = keyConnectorConfirmationDetailsQuery;
_setKeyConnectorKeyCommand = setKeyConnectorKeyCommand;
}
[HttpPost("key-management/regenerate-keys")]
@@ -146,18 +150,28 @@ public class AccountsKeyManagementController : Controller
throw new UnauthorizedAccessException();
}
var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier);
if (result.Succeeded)
if (model.IsV2Request())
{
return;
// V2 account registration
await _setKeyConnectorKeyCommand.SetKeyConnectorKeyForUserAsync(user, model.ToKeyConnectorKeysData());
}
foreach (var error in result.Errors)
else
{
ModelState.AddModelError(string.Empty, error.Description);
}
// V1 account registration
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier);
if (result.Succeeded)
{
return;
}
throw new BadRequestException(ModelState);
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
throw new BadRequestException(ModelState);
}
}
[HttpPost("convert-to-key-connector")]

View File

@@ -1,36 +1,112 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Models.Api.Request;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Utilities;
namespace Bit.Api.KeyManagement.Models.Requests;
public class SetKeyConnectorKeyRequestModel
public class SetKeyConnectorKeyRequestModel : IValidatableObject
{
[Required]
public string Key { get; set; }
[Required]
public KeysRequestModel Keys { get; set; }
[Required]
public KdfType Kdf { get; set; }
[Required]
public int KdfIterations { get; set; }
public int? KdfMemory { get; set; }
public int? KdfParallelism { get; set; }
[Required]
public string OrgIdentifier { get; set; }
// TODO will be removed with https://bitwarden.atlassian.net/browse/PM-27328
[Obsolete("Use KeyConnectorKeyWrappedUserKey instead")]
public string? Key { get; set; }
[Obsolete("Use AccountKeys instead")]
public KeysRequestModel? Keys { get; set; }
[Obsolete("Not used anymore")]
public KdfType? Kdf { get; set; }
[Obsolete("Not used anymore")]
public int? KdfIterations { get; set; }
[Obsolete("Not used anymore")]
public int? KdfMemory { get; set; }
[Obsolete("Not used anymore")]
public int? KdfParallelism { get; set; }
[EncryptedString]
public string? KeyConnectorKeyWrappedUserKey { get; set; }
public AccountKeysRequestModel? AccountKeys { get; set; }
[Required]
public required string OrgIdentifier { get; init; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (IsV2Request())
{
// V2 registration
yield break;
}
// V1 registration
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
if (string.IsNullOrEmpty(Key))
{
yield return new ValidationResult("Key must be supplied.");
}
if (Keys == null)
{
yield return new ValidationResult("Keys must be supplied.");
}
if (Kdf == null)
{
yield return new ValidationResult("Kdf must be supplied.");
}
if (KdfIterations == null)
{
yield return new ValidationResult("KdfIterations must be supplied.");
}
if (Kdf == KdfType.Argon2id)
{
if (KdfMemory == null)
{
yield return new ValidationResult("KdfMemory must be supplied when Kdf is Argon2id.");
}
if (KdfParallelism == null)
{
yield return new ValidationResult("KdfParallelism must be supplied when Kdf is Argon2id.");
}
}
}
public bool IsV2Request()
{
return !string.IsNullOrEmpty(KeyConnectorKeyWrappedUserKey) && AccountKeys != null;
}
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
public User ToUser(User existingUser)
{
existingUser.Kdf = Kdf;
existingUser.KdfIterations = KdfIterations;
existingUser.Kdf = Kdf!.Value;
existingUser.KdfIterations = KdfIterations!.Value;
existingUser.KdfMemory = KdfMemory;
existingUser.KdfParallelism = KdfParallelism;
existingUser.Key = Key;
Keys.ToUser(existingUser);
Keys!.ToUser(existingUser);
return existingUser;
}
public KeyConnectorKeysData ToKeyConnectorKeysData()
{
// TODO remove validation with https://bitwarden.atlassian.net/browse/PM-27328
if (string.IsNullOrEmpty(KeyConnectorKeyWrappedUserKey) || AccountKeys == null)
{
throw new BadRequestException("KeyConnectorKeyWrappedUserKey and AccountKeys must be supplied.");
}
return new KeyConnectorKeysData
{
KeyConnectorKeyWrappedUserKey = KeyConnectorKeyWrappedUserKey,
AccountKeys = AccountKeys,
OrgIdentifier = OrgIdentifier
};
}
}

View File

@@ -212,6 +212,7 @@ public static class FeatureFlagKeys
public const string ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component";
public const string V2RegistrationTDEJIT = "pm-27279-v2-registration-tde-jit";
public const string DataRecoveryTool = "pm-28813-data-recovery-tool";
public const string EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration";
/* Mobile Team */
public const string AndroidImportLoginsFlow = "import-logins-flow";

View File

@@ -0,0 +1,52 @@
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.KeyManagement.Authorization;
public class KeyConnectorAuthorizationHandler : AuthorizationHandler<KeyConnectorOperationsRequirement, User>
{
private readonly ICurrentContext _currentContext;
public KeyConnectorAuthorizationHandler(ICurrentContext currentContext)
{
_currentContext = currentContext;
}
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
KeyConnectorOperationsRequirement requirement,
User user)
{
var authorized = requirement switch
{
not null when requirement == KeyConnectorOperations.Use => CanUse(user),
_ => throw new ArgumentException("Unsupported operation requirement type provided.", nameof(requirement))
};
if (authorized)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
private bool CanUse(User user)
{
// User cannot use Key Connector if they already use it
if (user.UsesKeyConnector)
{
return false;
}
// User cannot use Key Connector if they are an owner or admin of any organization
if (_currentContext.Organizations.Any(u =>
u.Type is OrganizationUserType.Owner or OrganizationUserType.Admin))
{
return false;
}
return true;
}
}

View File

@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace Bit.Core.KeyManagement.Authorization;
public class KeyConnectorOperationsRequirement : OperationAuthorizationRequirement
{
public KeyConnectorOperationsRequirement(string name)
{
Name = name;
}
}
public static class KeyConnectorOperations
{
public static readonly KeyConnectorOperationsRequirement Use = new(nameof(Use));
}

View File

@@ -0,0 +1,13 @@
using Bit.Core.Entities;
using Bit.Core.KeyManagement.Models.Data;
namespace Bit.Core.KeyManagement.Commands.Interfaces;
/// <summary>
/// Creates the user key and account cryptographic state for a new user registering
/// with Key Connector SSO configuration.
/// </summary>
public interface ISetKeyConnectorKeyCommand
{
Task SetKeyConnectorKeyForUserAsync(User user, KeyConnectorKeysData keyConnectorKeysData);
}

View File

@@ -0,0 +1,60 @@
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Authorization;
using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Core.KeyManagement.Commands;
public class SetKeyConnectorKeyCommand : ISetKeyConnectorKeyCommand
{
private readonly IAuthorizationService _authorizationService;
private readonly ICurrentContext _currentContext;
private readonly IEventService _eventService;
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
private readonly IUserService _userService;
private readonly IUserRepository _userRepository;
public SetKeyConnectorKeyCommand(
IAuthorizationService authorizationService,
ICurrentContext currentContext,
IEventService eventService,
IAcceptOrgUserCommand acceptOrgUserCommand,
IUserService userService,
IUserRepository userRepository)
{
_authorizationService = authorizationService;
_currentContext = currentContext;
_eventService = eventService;
_acceptOrgUserCommand = acceptOrgUserCommand;
_userService = userService;
_userRepository = userRepository;
}
public async Task SetKeyConnectorKeyForUserAsync(User user, KeyConnectorKeysData keyConnectorKeysData)
{
var authorizationResult = await _authorizationService.AuthorizeAsync(_currentContext.HttpContext.User, user,
KeyConnectorOperations.Use);
if (!authorizationResult.Succeeded)
{
throw new BadRequestException("Cannot use Key Connector");
}
var setKeyConnectorUserKeyTask =
_userRepository.SetKeyConnectorUserKey(user.Id, keyConnectorKeysData.KeyConnectorKeyWrappedUserKey);
await _userRepository.SetV2AccountCryptographicStateAsync(user.Id,
keyConnectorKeysData.AccountKeys.ToAccountKeysData(), [setKeyConnectorUserKeyTask]);
await _eventService.LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector);
await _acceptOrgUserCommand.AcceptOrgUserByOrgSsoIdAsync(keyConnectorKeysData.OrgIdentifier, user,
_userService);
}
}

View File

@@ -1,9 +1,11 @@
using Bit.Core.KeyManagement.Commands;
using Bit.Core.KeyManagement.Authorization;
using Bit.Core.KeyManagement.Commands;
using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.KeyManagement.Kdf;
using Bit.Core.KeyManagement.Kdf.Implementations;
using Bit.Core.KeyManagement.Queries;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Core.KeyManagement;
@@ -12,15 +14,22 @@ public static class KeyManagementServiceCollectionExtensions
{
public static void AddKeyManagementServices(this IServiceCollection services)
{
services.AddKeyManagementAuthorizationHandlers();
services.AddKeyManagementCommands();
services.AddKeyManagementQueries();
services.AddSendPasswordServices();
}
private static void AddKeyManagementAuthorizationHandlers(this IServiceCollection services)
{
services.AddScoped<IAuthorizationHandler, KeyConnectorAuthorizationHandler>();
}
private static void AddKeyManagementCommands(this IServiceCollection services)
{
services.AddScoped<IRegenerateUserAsymmetricKeysCommand, RegenerateUserAsymmetricKeysCommand>();
services.AddScoped<IChangeKdfCommand, ChangeKdfCommand>();
services.AddScoped<ISetKeyConnectorKeyCommand, SetKeyConnectorKeyCommand>();
}
private static void AddKeyManagementQueries(this IServiceCollection services)

View File

@@ -0,0 +1,12 @@
using Bit.Core.KeyManagement.Models.Api.Request;
namespace Bit.Core.KeyManagement.Models.Data;
public class KeyConnectorKeysData
{
public required string KeyConnectorKeyWrappedUserKey { get; set; }
public required AccountKeysRequestModel AccountKeys { get; set; }
public required string OrgIdentifier { get; init; }
}

View File

@@ -72,6 +72,8 @@ public interface IUserRepository : IRepository<User, Guid>
UserAccountKeysData accountKeysData,
IEnumerable<UpdateUserData>? updateUserDataActions = null);
Task DeleteManyAsync(IEnumerable<User> users);
UpdateUserData SetKeyConnectorUserKey(Guid userId, string keyConnectorWrappedUserKey);
}
public delegate Task UpdateUserData(Microsoft.Data.SqlClient.SqlConnection? connection = null,

View File

@@ -33,6 +33,8 @@ public interface IUserService
Task<IdentityResult> ChangeEmailAsync(User user, string masterPassword, string newEmail, string newMasterPassword,
string token, string key);
Task<IdentityResult> ChangePasswordAsync(User user, string masterPassword, string newMasterPassword, string passwordHint, string key);
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
[Obsolete("Use ISetKeyConnectorKeyCommand instead. This method will be removed in a future version.")]
Task<IdentityResult> SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier);
Task<IdentityResult> ConvertToKeyConnectorAsync(User user);
Task<IdentityResult> AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key);

View File

@@ -621,6 +621,7 @@ public class UserService : UserManager<User>, IUserService
return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch());
}
// TODO removed with https://bitwarden.atlassian.net/browse/PM-27328
public async Task<IdentityResult> SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier)
{
var identityResult = CheckCanUseKeyConnector(user);

View File

@@ -3,6 +3,7 @@ using System.Text.Json;
using Bit.Core;
using Bit.Core.Billing.Premium.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Models.Data;
@@ -401,6 +402,32 @@ public class UserRepository : Repository<User, Guid>, IUserRepository
return result.SingleOrDefault();
}
public UpdateUserData SetKeyConnectorUserKey(Guid userId, string keyConnectorWrappedUserKey)
{
return async (connection, transaction) =>
{
var timestamp = DateTime.UtcNow;
await connection!.ExecuteAsync(
"[dbo].[User_UpdateKeyConnectorUserKey]",
new
{
Id = userId,
Key = keyConnectorWrappedUserKey,
// Key Connector does not use KDF, so we set some defaults
Kdf = KdfType.Argon2id,
KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default,
KdfMemory = AuthConstants.ARGON2_MEMORY.Default,
KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default,
UsesKeyConnector = true,
RevisionDate = timestamp,
AccountRevisionDate = timestamp
},
transaction: transaction,
commandType: CommandType.StoredProcedure);
};
}
private async Task ProtectDataAndSaveAsync(User user, Func<Task> saveTask)
{
if (user == null)

View File

@@ -1,5 +1,7 @@
using AutoMapper;
using Bit.Core;
using Bit.Core.Billing.Premium.Models;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Models.Data;
@@ -479,6 +481,35 @@ public class UserRepository : Repository<Core.Entities.User, User, Guid>, IUserR
}
}
public UpdateUserData SetKeyConnectorUserKey(Guid userId, string keyConnectorWrappedUserKey)
{
return async (_, _) =>
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var userEntity = await dbContext.Users.FindAsync(userId);
if (userEntity == null)
{
throw new ArgumentException("User not found", nameof(userId));
}
var timestamp = DateTime.UtcNow;
userEntity.Key = keyConnectorWrappedUserKey;
// Key Connector does not use KDF, so we set some defaults
userEntity.Kdf = KdfType.Argon2id;
userEntity.KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default;
userEntity.KdfMemory = AuthConstants.ARGON2_MEMORY.Default;
userEntity.KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default;
userEntity.UsesKeyConnector = true;
userEntity.RevisionDate = timestamp;
userEntity.AccountRevisionDate = timestamp;
await dbContext.SaveChangesAsync();
};
}
private static void MigrateDefaultUserCollectionsToShared(DatabaseContext dbContext, IEnumerable<Guid> userIds)
{
var defaultCollections = (from c in dbContext.Collections

View File

@@ -0,0 +1,28 @@
CREATE PROCEDURE [dbo].[User_UpdateKeyConnectorUserKey]
@Id UNIQUEIDENTIFIER,
@Key VARCHAR(MAX),
@Kdf TINYINT,
@KdfIterations INT,
@KdfMemory INT,
@KdfParallelism INT,
@UsesKeyConnector BIT,
@RevisionDate DATETIME2(7),
@AccountRevisionDate DATETIME2(7)
AS
BEGIN
SET NOCOUNT ON
UPDATE
[dbo].[User]
SET
[Key] = @Key,
[Kdf] = @Kdf,
[KdfIterations] = @KdfIterations,
[KdfMemory] = @KdfMemory,
[KdfParallelism] = @KdfParallelism,
[UsesKeyConnector] = @UsesKeyConnector,
[RevisionDate] = @RevisionDate,
[AccountRevisionDate] = @AccountRevisionDate
WHERE
[Id] = @Id
END