Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
@@ -1,6 +1,7 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Api.KeyManagement.Enums;
using Bit.Api.KeyManagement.Models.Requests;
using Bit.Api.KeyManagement.Models.Responses;
using Bit.Api.KeyManagement.Validators;
Expand Down Expand Up @@ -149,6 +150,34 @@
throw new BadRequestException(ModelState);
}

[HttpPost("key-management/rotate-user-keys")]
public async Task RotateUserKeysAsync([FromBody] RotateUserKeysRequestModel request)

Check failure on line 154 in src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

ModelState.IsValid should be checked in controller actions.

See more on https://sonarcloud.io/project/issues?id=bitwarden_server&issues=AZzpUJU_lN1Z5uvI_MSM&open=AZzpUJU_lN1Z5uvI_MSM&pullRequest=7216
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}

switch (request.UnlockMethodData.UnlockMethod)
{
case UnlockMethod.MasterPassword:
var dataModel = new MasterPasswordRotateUserAccountKeysData
{
MasterPasswordUnlockData = request.UnlockMethodData.MasterPasswordUnlockData!.ToData(),
BaseData = await ToBaseDataModelAsync(request, user),
};
await _rotateUserAccountKeysCommand.MasterPasswordRotateUserAccountKeysAsync(user, dataModel);
break;
case UnlockMethod.Tde:
throw new BadRequestException("TDE not implemented");
case UnlockMethod.KeyConnector:
throw new BadRequestException("Key connector not implemented");
default:
throw new ArgumentOutOfRangeException(nameof(request.UnlockMethodData.UnlockMethod), "Unrecognized unlock method");

Check warning on line 177 in src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Method RotateUserKeysAsync passes 'UnlockMethod' as the paramName argument to a ArgumentOutOfRangeException constructor. Replace this argument with one of the method's parameter names. Note that the provided parameter name should have the exact casing as declared on the method.

See more on https://sonarcloud.io/project/issues?id=bitwarden_server&issues=AZ0BrepHXYukZyFkexar&open=AZ0BrepHXYukZyFkexar&pullRequest=7216
}
}

[HttpPost("set-key-connector-key")]
public async Task PostSetKeyConnectorKeyAsync([FromBody] SetKeyConnectorKeyRequestModel model)
{
Expand Down Expand Up @@ -240,4 +269,24 @@
var details = await _keyConnectorConfirmationDetailsQuery.Run(orgSsoIdentifier, user.Id);
return new KeyConnectorConfirmationDetailsResponseModel(details);
}

private async Task<BaseRotateUserAccountKeysData> ToBaseDataModelAsync(RotateUserKeysRequestModel request, User user)
{
return new BaseRotateUserAccountKeysData
{
AccountKeys = request.WrappedAccountCryptographicState.ToAccountKeysData(),
EmergencyAccesses =
await _emergencyAccessValidator.ValidateAsync(user, request.UnlockData.EmergencyAccessUnlockData),
OrganizationUsers =
await _organizationUserValidator.ValidateAsync(user,
request.UnlockData.OrganizationAccountRecoveryUnlockData),
WebAuthnKeys = await _webauthnKeyValidator.ValidateAsync(user, request.UnlockData.PasskeyUnlockData),
DeviceKeys = await _deviceValidator.ValidateAsync(user, request.UnlockData.DeviceKeyUnlockData),
V2UpgradeToken = request.UnlockData.V2UpgradeToken?.ToData(),

Ciphers = await _cipherValidator.ValidateAsync(user, request.AccountData.Ciphers),
Folders = await _folderValidator.ValidateAsync(user, request.AccountData.Folders),
Sends = await _sendValidator.ValidateAsync(user, request.AccountData.Sends),
};
}
}
8 changes: 8 additions & 0 deletions src/Api/KeyManagement/Enums/UnlockMethod.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Bit.Api.KeyManagement.Enums;

public enum UnlockMethod
{
Tde,
MasterPassword,
KeyConnector,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Core.Auth.Models.Api.Request;

namespace Bit.Api.KeyManagement.Models.Requests;

public class CommonUnlockDataRequestModel
{
public required IEnumerable<EmergencyAccessWithIdRequestModel> EmergencyAccessUnlockData { get; set; }
public required IEnumerable<ResetPasswordWithOrgIdRequestModel> OrganizationAccountRecoveryUnlockData { get; set; }
public required IEnumerable<WebAuthnLoginRotateKeyRequestModel> PasskeyUnlockData { get; set; }
public required IEnumerable<OtherDeviceKeysUpdateRequestModel> DeviceKeyUnlockData { get; set; }
public V2UpgradeTokenRequestModel? V2UpgradeToken { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Bit.Api.KeyManagement.Models.Requests;

public class RotateUserKeysRequestModel
{
public required WrappedAccountCryptographicStateRequestModel WrappedAccountCryptographicState { get; set; }
public required CommonUnlockDataRequestModel UnlockData { get; set; }
public required AccountDataRequestModel AccountData { get; set; }
public required UnlockMethodRequestModel UnlockMethodData { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.KeyManagement.Enums;
using Bit.Core.KeyManagement.Models.Api.Request;
using Bit.Core.Utilities;

namespace Bit.Api.KeyManagement.Models.Requests;

public class UnlockMethodRequestModel : IValidatableObject
{
[Required]
public required UnlockMethod UnlockMethod { get; init; }

// Master password user
public MasterPasswordUnlockDataRequestModel? MasterPasswordUnlockData { get; init; }

// Key Connector user.
[EncryptedString]
public string? KeyConnectorKeyWrappedUserKey { get; init; }

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
switch (UnlockMethod)
{
case UnlockMethod.MasterPassword:
if (MasterPasswordUnlockData == null || KeyConnectorKeyWrappedUserKey != null)
{
yield return new ValidationResult("Invalid MasterPassword unlock method request, MasterPasswordUnlockData must be provided and KeyConnectorKeyWrappedUserKey must be null");
}
break;
case UnlockMethod.Tde:
if (MasterPasswordUnlockData != null || KeyConnectorKeyWrappedUserKey != null)
{
yield return new ValidationResult("Invalid Tde unlock method request, MasterPasswordUnlockData must be null and KeyConnectorKeyWrappedUserKey must be null");
}
break;
case UnlockMethod.KeyConnector:
if (KeyConnectorKeyWrappedUserKey == null || MasterPasswordUnlockData != null)
{
yield return new ValidationResult("Invalid KeyConnector unlock method request, KeyConnectorKeyWrappedUserKey must be provided and MasterPasswordUnlockData must be null");
}
break;
default:
yield return new ValidationResult("Unrecognized unlock method");
break;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Bit.Core.KeyManagement.Models.Api.Request;
using Bit.Core.KeyManagement.Models.Data;

namespace Bit.Api.KeyManagement.Models.Requests;

// This request model is meant to be used when the user will be submitting a v2 encryption WrappedAccountCryptographicState payload.
public class WrappedAccountCryptographicStateRequestModel
Copy link
Contributor

Choose a reason for hiding this comment

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

❓ We already have AccountKeysRequestModel. Might be better to combine this with https://bitwarden.atlassian.net/browse/PM-22384 ?
If this is outside of scope, could we comment on the AccountKeysRequestModel that it's deprecated and will be superseded by WrappedAccountCryptographicStateRequestModel ?

Copy link
Contributor Author

@Thomas-Avery Thomas-Avery Mar 18, 2026

Choose a reason for hiding this comment

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

The problem is AccountsKeyRequestModel has been augmented to handle both v1 and v2 encryption payloads (plus has some leftover properties) . @quexten had requested to make a new model that only accepts v2 encryption payloads.

I don't mind adding a comment on AccountKeysRequestModel but we will have to make it clear we can only fully move over to WrappedAccountCryptographicStateRequestModel after we stop v1 encryption rotation support.

I'm not following on the ticket linked (PM-22384). That one is already done and where we added v2 encryption support to AccountsKeyRequestModel?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added a note on the new request model bc6bfe5.

I also created a ticket for tracking the tech debt for user key rotation https://bitwarden.atlassian.net/browse/PM-33860. I don't think now is the correct time to mark AccountKeysRequestModel as obsolete. It is used in many places that will still need to support v1 encryption payloads.

Copy link
Contributor

Choose a reason for hiding this comment

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

I provided wrong link, i meant https://bitwarden.atlassian.net/browse/PM-23751

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Lets leave that out of scope for this PR. Since this is a new endpoint I was planning on doing the QA after merging and introducing usage on the client. For removing those properties I would want to do a regression test.

{
public required PublicKeyEncryptionKeyPairRequestModel PublicKeyEncryptionKeyPair { get; set; }
public required SignatureKeyPairRequestModel SignatureKeyPair { get; set; }
public required SecurityStateModel SecurityState { get; set; }

public UserAccountKeysData ToAccountKeysData()
{
return new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = PublicKeyEncryptionKeyPair.ToPublicKeyEncryptionKeyPairData(),
SignatureKeyPairData = SignatureKeyPair.ToSignatureKeyPairData(),
SecurityStateData = SecurityState.ToSecurityState()
};
}
}
11 changes: 11 additions & 0 deletions src/Core/KeyManagement/UserKey/IRotateUserAccountKeysCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#nullable disable

using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.UserKey.Models.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.SqlClient;
Expand All @@ -21,6 +22,16 @@ public interface IRotateUserAccountKeysCommand
/// <exception cref="ArgumentNullException">User must be provided.</exception>
/// <exception cref="InvalidOperationException">User KDF settings and email must match the model provided settings.</exception>
Task<IdentityResult> PasswordChangeAndRotateUserAccountKeysAsync(User user, PasswordChangeAndRotateUserAccountKeysData model);

/// <summary>
/// For a master password user, rotates the user key and updates all encrypted data without changing the master password.
/// </summary>
/// <param name="model">Rotation data. All encrypted data must be included or the request will be rejected.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="user"/> is null.</exception>
/// <exception cref="BadRequestException">Thrown when <paramref name="user"/> is not a master password user.</exception>
/// <exception cref="BadRequestException">Thrown when <paramref name="user"/> salt does not match <paramref name="model"/> MasterPasswordUnlockData.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="user"/> KDF settings do not match <paramref name="model"/> MasterPasswordUnlockData.</exception>
Task MasterPasswordRotateUserAccountKeysAsync(User user, MasterPasswordRotateUserAccountKeysData model);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,23 @@ public async Task<IdentityResult> PasswordChangeAndRotateUserAccountKeysAsync(Us
return IdentityResult.Success;
}

/// <inheritdoc />
public async Task MasterPasswordRotateUserAccountKeysAsync(User user, MasterPasswordRotateUserAccountKeysData model)
{
ArgumentNullException.ThrowIfNull(user);

model.ValidateForUser(user);

List<UpdateEncryptedDataForKeyRotation> saveEncryptedDataActions = [];
var shouldPersistV2UpgradeToken =
await BaseRotateUserAccountKeysAsync(model.BaseData, user, saveEncryptedDataActions);
user.Key = model.MasterPasswordUnlockData.MasterKeyWrappedUserKey;

await _userRepository.UpdateUserKeyAndEncryptedDataV2Async(user, saveEncryptedDataActions);

await HandlePushNotificationAsync(shouldPersistV2UpgradeToken, user);
}

private async Task RotateV2AccountKeysAsync(BaseRotateUserAccountKeysData model, User user, List<UpdateEncryptedDataForKeyRotation> saveEncryptedDataActions)
{
ValidateV2Encryption(model);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Models.Data;

namespace Bit.Core.KeyManagement.UserKey.Models.Data;

public class MasterPasswordRotateUserAccountKeysData
{
public required MasterPasswordUnlockData MasterPasswordUnlockData { get; init; }
public required BaseRotateUserAccountKeysData BaseData { get; init; }

public void ValidateForUser(User user)
{
var isMasterPasswordUser = user is { Key: not null, MasterPassword: not null };
if (!isMasterPasswordUser)
{
throw new BadRequestException("User is in an invalid state for master password key rotation.");
}

MasterPasswordUnlockData.ValidateSaltUnchangedForUser(user);
MasterPasswordUnlockData.Kdf.ValidateUnchangedForUser(user);
}
}
Loading
Loading