Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
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
Expand Up @@ -13,6 +13,7 @@
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.KeyManagement.Models.Api.Request;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.KeyManagement.UserKey;
Expand Down Expand Up @@ -47,6 +48,7 @@ private readonly IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestMod
_webauthnKeyValidator;
private readonly IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> _deviceValidator;
private readonly IKeyConnectorConfirmationDetailsQuery _keyConnectorConfirmationDetailsQuery;
private readonly ISetKeyConnectorKeyCommand _setKeyConnectorKeyCommand;

public AccountsKeyManagementController(IUserService userService,
IFeatureService featureService,
Expand All @@ -62,8 +64,10 @@ public AccountsKeyManagementController(IUserService userService,
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;
Expand All @@ -79,6 +83,7 @@ public AccountsKeyManagementController(IUserService userService,
_webauthnKeyValidator = webAuthnKeyValidator;
_deviceValidator = deviceValidator;
_keyConnectorConfirmationDetailsQuery = keyConnectorConfirmationDetailsQuery;
_setKeyConnectorKeyCommand = setKeyConnectorKeyCommand;
}

[HttpPost("key-management/regenerate-keys")]
Expand Down Expand Up @@ -146,18 +151,28 @@ public async Task PostSetKeyConnectorKeyAsync([FromBody] SetKeyConnectorKeyReque
throw new UnauthorizedAccessException();
}

var result = await _userService.SetKeyConnectorKeyAsync(model.ToUser(user), model.Key, model.OrgIdentifier);
if (result.Succeeded)
if (model.IsV2Request())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing Error Handling for V2 Path

The V2 request path (lines 154-158) doesn't have any error handling or result checking, unlike the V1 path which checks result.Succeeded and adds model errors.

If SetKeyConnectorKeyForUserAsync completes without throwing an exception, the method returns successfully. However, if the command doesn't throw but also doesn't fully succeed (which shouldn't happen with current implementation, but could with future changes), there's no feedback to the client.

Recommendation for Consistency: While the current implementation is correct (the command throws exceptions on failure), consider adding explicit success tracking for consistency with other endpoints and to make the contract clearer:

if (model.IsV2Request())
{
    // V2 account registration
    await _setKeyConnectorKeyCommand.SetKeyConnectorKeyForUserAsync(user, model);
    return; // Explicitly return on success
}

The current code is functionally correct, but the explicit return makes it clearer that this is the success path.

{
return;
// V2 account registration
await _setKeyConnectorKeyCommand.SetKeyConnectorKeyForUserAsync(user, model);
}

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

foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}

throw new BadRequestException(ModelState);
}

throw new BadRequestException(ModelState);
}

[HttpPost("convert-to-key-connector")]
Expand Down
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typically we don't use API request models as input to commands which is why I think we moved this? We do this to enforce better separation of concerns for request models, data models, and entities. Sometimes we have to put API request models that need to be shared between multiple web projects like API and Identity in src/Core/KeyManagement/Models/Api.

For good separation of concerns in these kinds of cases we would make an data domain model in src/Core/KeyManagement/Models/Data.

In our controller we would have something like

// V2 account registration
await _setKeyConnectorKeyCommand.SetKeyConnectorKeyForUserAsync(user, model.ToKeyConnectorKeysData());

Looking at the command I would expect something like this for a data model.

public class KeyConnectorKeysData
{

    public required string KeyConnectorKeyWrappedUserKey { get; set; }

    public required UserAccountKeysData UserAccountKeysData { get; set; }

    public required string OrgIdentifier { get; set; }
}

This allows us to not expose things like deprecated JSON properties and request validation to our Core and commands. It's very DTO pattern like, but we use different names.

This file was deleted.

1 change: 1 addition & 0 deletions src/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ public static class FeatureFlagKeys
public const string DisableType0Decryption = "pm-25174-disable-type-0-decryption";
public const string ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component";
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";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
๏ปฟusing Bit.Core.Entities;
using Bit.Core.KeyManagement.Models.Api.Request;

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, SetKeyConnectorKeyRequestModel requestModel);
}
55 changes: 55 additions & 0 deletions src/Core/KeyManagement/Commands/SetKeyConnectorKeyCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
๏ปฟusing Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.KeyManagement.Models.Api.Request;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;

namespace Bit.Core.KeyManagement.Commands;

public class SetKeyConnectorKeyCommand : ISetKeyConnectorKeyCommand
{
private readonly ICanUseKeyConnectorQuery _canUseKeyConnectorQuery;
private readonly IEventService _eventService;
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
private readonly IUserService _userService;
private readonly IUserRepository _userRepository;

public SetKeyConnectorKeyCommand(
ICanUseKeyConnectorQuery canUseKeyConnectorQuery,
IEventService eventService,
IAcceptOrgUserCommand acceptOrgUserCommand,
IUserService userService,
IUserRepository userRepository)
{
_canUseKeyConnectorQuery = canUseKeyConnectorQuery;
_eventService = eventService;
_acceptOrgUserCommand = acceptOrgUserCommand;
_userService = userService;
_userRepository = userRepository;
}

public async Task SetKeyConnectorKeyForUserAsync(User user, SetKeyConnectorKeyRequestModel requestModel)
{
// TODO remove validation with https://bitwarden.atlassian.net/browse/PM-27280
if (string.IsNullOrEmpty(requestModel.KeyConnectorKeyWrappedUserKey) || requestModel.AccountKeys == null)
{
throw new BadRequestException("KeyConnectorKeyWrappedUserKey and AccountKeys must be provided");
}

_canUseKeyConnectorQuery.VerifyCanUseKeyConnector(user);

var setKeyConnectorUserKeyTask =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential Race Condition: Multiple Simultaneous Calls

If a client makes multiple simultaneous calls to this endpoint (which could happen due to network issues or client bugs), there's no protection against race conditions. The VerifyCanUseKeyConnector check at line 43 happens outside the transaction, so two concurrent requests could both pass the check.

While SetV2AccountCryptographicStateAsync uses a transaction, the validation user.UsesKeyConnector in CanUseKeyConnectorQuery (line 20) happens before the transaction starts. This means:

  1. Request A checks user.UsesKeyConnector โ†’ false โœ“
  2. Request B checks user.UsesKeyConnector โ†’ false โœ“
  3. Both proceed and one overwrites the other

Recommendation: Consider adding optimistic concurrency control or checking UsesKeyConnector within the transaction. Alternatively, document that this is expected behavior (last write wins) if that's acceptable.

_userRepository.SetKeyConnectorUserKey(user.Id, requestModel.KeyConnectorKeyWrappedUserKey);

await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, requestModel.AccountKeys.ToAccountKeysData(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security Concern: Null Reference Exception Risk

The code calls requestModel.AccountKeys.ToAccountKeysData() without null checking, even though validation only checks if AccountKeys is not null at line 38. However, there's a potential race condition or programming error risk here.

More importantly, the ToAccountKeysData() method can return different types of data structures depending on whether certain optional fields are present (see AccountKeysRequestModel.cs lines 19-48). For V2 encryption with Key Connector, we should be explicitly validating that ALL required V2 fields are present:

  • PublicKeyEncryptionKeyPair
  • SignatureKeyPair
  • SecurityState

Without this validation, if a client sends only partial V2 data, the call to SetV2AccountCryptographicStateAsync at line 297 of UserRepository.cs will throw an ArgumentException saying "Provided account keys data is not valid V2 encryption data", but this happens after we've already called SetKeyConnectorUserKey.

Recommendation: Add explicit validation that all V2 encryption fields are present before making any repository calls:

if (requestModel.AccountKeys.PublicKeyEncryptionKeyPair == null ||
    requestModel.AccountKeys.SignatureKeyPair == null ||
    requestModel.AccountKeys.SecurityState == null)
{
    throw new BadRequestException("Complete V2 encryption data must be provided for Key Connector registration");
}

[setKeyConnectorUserKeyTask]);

await _eventService.LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Event Logging Timing: Log Before Full Success

The event User_MigratedKeyToKeyConnector is logged at line 51, but AcceptOrgUserByOrgSsoIdAsync is called at line 53. If the organization user acceptance fails (line 53), the event will have already been logged, creating an inconsistent audit trail.

Risk: The audit log will show the user migrated to Key Connector, but they won't actually be accepted into the organization. This could cause confusion during security audits.

Recommendation: Move the event logging to after all operations complete successfully:

await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, requestModel.AccountKeys.ToAccountKeysData(),
    [setKeyConnectorUserKeyTask]);

await _acceptOrgUserCommand.AcceptOrgUserByOrgSsoIdAsync(requestModel.OrgIdentifier, user, _userService);

// Log event only after all operations succeed
await _eventService.LogUserEventAsync(user.Id, EventType.User_MigratedKeyToKeyConnector);


await _acceptOrgUserCommand.AcceptOrgUserByOrgSsoIdAsync(requestModel.OrgIdentifier, user, _userService);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ 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)
{
services.AddScoped<IUserAccountKeysQuery, UserAccountKeysQuery>();
services.AddScoped<IKeyConnectorConfirmationDetailsQuery, KeyConnectorConfirmationDetailsQuery>();
services.AddScoped<ICanUseKeyConnectorQuery, CanUseKeyConnectorQuery>();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
๏ปฟusing System.ComponentModel.DataAnnotations;
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;

namespace Bit.Core.KeyManagement.Models.Api.Request;

public class SetKeyConnectorKeyRequestModel : IValidatableObject
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would recommend moving this back to API as discussed above.

{
// 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing Validation for V2 Requests

The Validate method returns early for V2 requests (line 35-38) without performing any validation. This means V2 requests bypass all validation logic, relying solely on:

  1. The [EncryptedString] attribute on KeyConnectorKeyWrappedUserKey (line 26)
  2. The [Required] attribute on OrgIdentifier (line 30)

However, this doesn't validate that AccountKeys is not null or that it contains all required V2 encryption fields (PublicKeyEncryptionKeyPair, SignatureKeyPair, SecurityState).

Security Risk: A client could send a V2 request with:

  • Valid KeyConnectorKeyWrappedUserKey
  • Valid OrgIdentifier
  • AccountKeys = new AccountKeysRequestModel() with only minimal fields

This would pass validation here but fail later in the repository layer, potentially after partial database updates.

Recommendation: Add validation for V2 requests:

if (IsV2Request())
{
    // V2 registration - validate all required fields are present
    if (AccountKeys!.PublicKeyEncryptionKeyPair == null)
    {
        yield return new ValidationResult("PublicKeyEncryptionKeyPair is required for V2 encryption.");
    }
    if (AccountKeys!.SignatureKeyPair == null)
    {
        yield return new ValidationResult("SignatureKeyPair is required for V2 encryption.");
    }
    if (AccountKeys!.SecurityState == null)
    {
        yield return new ValidationResult("SecurityState is required for V2 encryption.");
    }
    yield break;
}

{
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!.Value;
existingUser.KdfIterations = KdfIterations!.Value;
existingUser.KdfMemory = KdfMemory;
existingUser.KdfParallelism = KdfParallelism;
existingUser.Key = Key;
Keys!.ToUser(existingUser);
return existingUser;
}
}
31 changes: 31 additions & 0 deletions src/Core/KeyManagement/Queries/CanUseKeyConnectorQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
๏ปฟusing Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Queries.Interfaces;

namespace Bit.Core.KeyManagement.Queries;

public class CanUseKeyConnectorQuery : ICanUseKeyConnectorQuery
{
private readonly ICurrentContext _currentContext;

public CanUseKeyConnectorQuery(ICurrentContext currentContext)
{
_currentContext = currentContext;
}

public void VerifyCanUseKeyConnector(User user)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ’ญ Perhaps this would be better as an AuthorizationHandler on the controller. It is odd for a query not to return any data. Happy to help if you would like to see what that would look like.

{
if (user.UsesKeyConnector)
{
throw new BadRequestException("Already uses Key Connector.");
}

if (_currentContext.Organizations.Any(u =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: Missing Verification of Organization SSO Configuration

The validation checks if the user is an admin/owner of ANY organization, but it doesn't verify:

  1. That the organization identified by OrgIdentifier (passed in the request) exists
  2. That the organization has Key Connector enabled/configured
  3. That the user is actually invited to or a member of that specific organization

This means a user could potentially call this endpoint with any OrgIdentifier, and as long as they're not an admin/owner of other orgs and don't already use Key Connector, the validation passes.

Recommendation: Add validation that:

  • The organization exists and has Key Connector configured
  • The user has a valid invitation or membership to that specific organization

This validation might belong in the command itself rather than this query, but it should exist somewhere before the key is set.

u.Type is OrganizationUserType.Owner or OrganizationUserType.Admin))
{
throw new BadRequestException("Cannot use Key Connector when admin or owner of an organization.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
๏ปฟusing Bit.Core.Entities;

namespace Bit.Core.KeyManagement.Queries.Interfaces;

/// <summary>
/// Query to verify if the user can use the key connector
/// </summary>
public interface ICanUseKeyConnectorQuery
{
/// <summary>
/// Throws an exception if the user cannot use the key connector
/// </summary>
/// <param name="user">User to validate</param>
void VerifyCanUseKeyConnector(User user);
}
2 changes: 2 additions & 0 deletions src/Core/Repositories/IUserRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ Task SetV2AccountCryptographicStateAsync(
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,
Expand Down
2 changes: 2 additions & 0 deletions src/Core/Services/IUserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/Core/Services/Implementations/UserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@
return null;
}

_currentContext.User = await _userRepository.GetByIdAsync(userIdGuid);

Check warning on line 177 in src/Core/Services/Implementations/UserService.cs

View workflow job for this annotation

GitHub Actions / Sonar / Quality scan

'_currentContext' is null on at least one execution path. (https://rules.sonarsource.com/csharp/RSPEC-2259)
return _currentContext.User;
}

Expand All @@ -185,7 +185,7 @@
return _currentContext.User;
}

_currentContext.User = await _userRepository.GetByIdAsync(userId);

Check warning on line 188 in src/Core/Services/Implementations/UserService.cs

View workflow job for this annotation

GitHub Actions / Sonar / Quality scan

'_currentContext' is null on at least one execution path. (https://rules.sonarsource.com/csharp/RSPEC-2259)
return _currentContext.User;
}

Expand Down Expand Up @@ -617,6 +617,7 @@
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);
Expand Down Expand Up @@ -914,7 +915,7 @@
}
catch when (!_globalSettings.SelfHosted)
{
await paymentService.CancelAndRecoverChargesAsync(user);

Check warning on line 918 in src/Core/Services/Implementations/UserService.cs

View workflow job for this annotation

GitHub Actions / Sonar / Quality scan

'paymentService' is null on at least one execution path. (https://rules.sonarsource.com/csharp/RSPEC-2259)
throw;
}

Expand Down
Loading
Loading