1
0
mirror of https://github.com/bitwarden/server synced 2026-02-21 03:43:44 +00:00

[PM-31052][PM-32469] Add V2UpgradeToken for key rotation without logout (#6995)

* User V2UpgradeToken for key rotation without logout

* reset old v2 upgrade token on manual key rotation

* sql migration fix

* missing table column

* missing view update

* tests for V2UpgradeToken clearing on manual key rotation

* V2 to V2 rotation causes logout. Updated wrapped key 1 to be a valid V2 encrypted string in tests.

* integration tests failures - increase assert recent for date time type from 2 to 5 seconds (usually for UpdatedAt assertions)

* repository test coverage

* migration script update

* new EF migration scripts

* broken EF migration scripts fixed

* refresh views due to User table alternation
This commit is contained in:
Maciej Zieniuk
2026-02-20 20:19:14 +01:00
committed by GitHub
parent a961626957
commit 6a7b8f5a89
35 changed files with 12395 additions and 242 deletions

View File

@@ -18,7 +18,9 @@ using Bit.Core.Enums;
using Bit.Core.KeyManagement.Entities;
using Bit.Core.KeyManagement.Enums;
using Bit.Core.KeyManagement.Models.Api.Request;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Repositories;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Vault.Enums;
@@ -33,7 +35,10 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
{
private static readonly string _mockEncryptedString =
"2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=";
private static readonly string _mockEncryptedType2String2 =
"2.06CDSJjTZaigYHUuswIq5A==|trxgZl2RCkYrrmCvGE9WNA==|w5p05eI5wsaYeSyWtsAPvBX63vj798kIMxBTfSB0BQg=";
private static readonly string _mockEncryptedType7String = "7.AOs41Hd8OQiCPXjyJKCiDA==";
private static readonly string _mockEncryptedType7String2 = "7.Mi1iaXR3YXJkZW4tZGF0YQo=";
private static readonly string _mockEncryptedType7WrappedSigningKey = "7.DRv74Kg1RSlFSam1MNFlGD==";
private readonly HttpClient _client;
@@ -46,6 +51,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
private readonly IPasswordHasher<User> _passwordHasher;
private readonly IOrganizationRepository _organizationRepository;
private readonly IUserSignatureKeyPairRepository _userSignatureKeyPairRepository;
private readonly IPushNotificationService _pushNotificationService;
private string _ownerEmail = null!;
public AccountsKeyManagementControllerTests(ApiApplicationFactory factory)
@@ -56,6 +62,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration, Arg.Any<bool>())
.Returns(true);
});
_factory.SubstituteService<IPushNotificationService>(_ => { });
_client = factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);
_userRepository = _factory.GetService<IUserRepository>();
@@ -65,6 +72,7 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
_passwordHasher = _factory.GetService<IPasswordHasher<User>>();
_organizationRepository = _factory.GetService<IOrganizationRepository>();
_userSignatureKeyPairRepository = _factory.GetService<IUserSignatureKeyPairRepository>();
_pushNotificationService = _factory.GetService<IPushNotificationService>();
}
public async Task InitializeAsync()
@@ -209,67 +217,10 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
[BitAutoData]
public async Task RotateUserAccountKeysAsync_Success(RotateUserAccountKeysAndDataRequestModel request)
{
await _loginHelper.LoginAsync(_ownerEmail);
var user = await _userRepository.GetByEmailAsync(_ownerEmail);
if (user == null)
{
throw new InvalidOperationException("User not found.");
}
var password = _passwordHasher.HashPassword(user, "newMasterPassword");
user.MasterPassword = password;
user.PublicKey = "publicKey";
user.PrivateKey = _mockEncryptedString;
await _userRepository.ReplaceAsync(user);
request.AccountUnlockData.MasterPasswordUnlockData.KdfType = user.Kdf;
request.AccountUnlockData.MasterPasswordUnlockData.KdfIterations = user.KdfIterations;
request.AccountUnlockData.MasterPasswordUnlockData.KdfMemory = user.KdfMemory;
request.AccountUnlockData.MasterPasswordUnlockData.KdfParallelism = user.KdfParallelism;
request.AccountUnlockData.MasterPasswordUnlockData.Email = user.Email;
request.AccountKeys.AccountPublicKey = "publicKey";
request.AccountKeys.UserKeyEncryptedAccountPrivateKey = _mockEncryptedString;
request.AccountKeys.PublicKeyEncryptionKeyPair = null;
request.AccountKeys.SignatureKeyPair = null;
request.OldMasterKeyAuthenticationHash = "newMasterPassword";
request.AccountData.Ciphers =
[
new CipherWithIdRequestModel
{
Id = Guid.NewGuid(),
Type = CipherType.Login,
Name = _mockEncryptedString,
Login = new CipherLoginModel
{
Username = _mockEncryptedString,
Password = _mockEncryptedString,
},
},
];
request.AccountData.Folders = [
new FolderWithIdRequestModel
{
Id = Guid.NewGuid(),
Name = _mockEncryptedString,
},
];
request.AccountData.Sends = [
new SendWithIdRequestModel
{
Id = Guid.NewGuid(),
Name = _mockEncryptedString,
Key = _mockEncryptedString,
Disabled = false,
DeletionDate = DateTime.UtcNow.AddDays(1),
},
];
request.AccountUnlockData.MasterPasswordUnlockData.MasterKeyEncryptedUserKey = _mockEncryptedString;
request.AccountUnlockData.PasskeyUnlockData = [];
request.AccountUnlockData.DeviceKeyUnlockData = [];
request.AccountUnlockData.EmergencyAccessUnlockData = [];
request.AccountUnlockData.OrganizationAccountRecoveryUnlockData = [];
var user = await SetupUserForKeyRotationAsync();
SetupRotateUserAccountUnlockData(request, user);
SetupRotateUserAccountData(request);
SetupRotateUserAccountKeys(request, isV2Crypto: false);
var response = await _client.PostAsJsonAsync("/accounts/key-management/rotate-user-account-keys", request);
var responseMessage = await response.Content.ReadAsStringAsync();
@@ -439,85 +390,10 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
[BitAutoData]
public async Task RotateV2UserAccountKeysAsync_Success(RotateUserAccountKeysAndDataRequestModel request)
{
await _loginHelper.LoginAsync(_ownerEmail);
var user = await _userRepository.GetByEmailAsync(_ownerEmail);
if (user == null)
{
throw new InvalidOperationException("User not found.");
}
var password = _passwordHasher.HashPassword(user, "newMasterPassword");
user.MasterPassword = password;
user.PublicKey = "publicKey";
user.PrivateKey = _mockEncryptedType7String;
await _userRepository.ReplaceAsync(user);
await _userSignatureKeyPairRepository.CreateAsync(new UserSignatureKeyPair
{
UserId = user.Id,
SignatureAlgorithm = SignatureAlgorithm.Ed25519,
SigningKey = _mockEncryptedType7String,
VerifyingKey = "verifyingKey",
});
request.AccountUnlockData.MasterPasswordUnlockData.KdfType = user.Kdf;
request.AccountUnlockData.MasterPasswordUnlockData.KdfIterations = user.KdfIterations;
request.AccountUnlockData.MasterPasswordUnlockData.KdfMemory = user.KdfMemory;
request.AccountUnlockData.MasterPasswordUnlockData.KdfParallelism = user.KdfParallelism;
request.AccountUnlockData.MasterPasswordUnlockData.Email = user.Email;
request.AccountKeys.AccountPublicKey = "publicKey";
request.AccountKeys.UserKeyEncryptedAccountPrivateKey = _mockEncryptedType7String;
request.AccountKeys.PublicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel
{
PublicKey = "publicKey",
WrappedPrivateKey = _mockEncryptedType7String,
SignedPublicKey = "signedPublicKey",
};
request.AccountKeys.SignatureKeyPair = new SignatureKeyPairRequestModel
{
SignatureAlgorithm = "ed25519",
WrappedSigningKey = _mockEncryptedType7String,
VerifyingKey = "verifyingKey",
};
request.OldMasterKeyAuthenticationHash = "newMasterPassword";
request.AccountData.Ciphers =
[
new CipherWithIdRequestModel
{
Id = Guid.NewGuid(),
Type = CipherType.Login,
Name = _mockEncryptedString,
Login = new CipherLoginModel
{
Username = _mockEncryptedString,
Password = _mockEncryptedString,
},
},
];
request.AccountData.Folders = [
new FolderWithIdRequestModel
{
Id = Guid.NewGuid(),
Name = _mockEncryptedString,
},
];
request.AccountData.Sends = [
new SendWithIdRequestModel
{
Id = Guid.NewGuid(),
Name = _mockEncryptedString,
Key = _mockEncryptedString,
Disabled = false,
DeletionDate = DateTime.UtcNow.AddDays(1),
},
];
request.AccountUnlockData.MasterPasswordUnlockData.MasterKeyEncryptedUserKey = _mockEncryptedString;
request.AccountUnlockData.PasskeyUnlockData = [];
request.AccountUnlockData.DeviceKeyUnlockData = [];
request.AccountUnlockData.EmergencyAccessUnlockData = [];
request.AccountUnlockData.OrganizationAccountRecoveryUnlockData = [];
var user = await SetupUserForKeyRotationAsync(_mockEncryptedType7String, createSignatureKeyPair: true);
SetupRotateUserAccountUnlockData(request, user);
SetupRotateUserAccountData(request);
SetupRotateUserAccountKeys(request, isV2Crypto: true);
var response = await _client.PostAsJsonAsync("/accounts/key-management/rotate-user-account-keys", request);
var responseMessage = await response.Content.ReadAsStringAsync();
@@ -530,89 +406,27 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfIterations, userNewState.KdfIterations);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfMemory, userNewState.KdfMemory);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfParallelism, userNewState.KdfParallelism);
// Assert V2-specific fields
Assert.Equal(request.AccountKeys.PublicKeyEncryptionKeyPair!.SignedPublicKey, userNewState.SignedPublicKey);
Assert.Equal(request.AccountKeys.SecurityState!.SecurityState, userNewState.SecurityState);
Assert.Equal(request.AccountKeys.SecurityState.SecurityVersion, userNewState.SecurityVersion);
var signatureKeyPair = await _userSignatureKeyPairRepository.GetByUserIdAsync(userNewState.Id);
Assert.NotNull(signatureKeyPair);
Assert.Equal(SignatureAlgorithm.Ed25519, signatureKeyPair.SignatureAlgorithm);
Assert.Equal(request.AccountKeys.SignatureKeyPair!.WrappedSigningKey, signatureKeyPair.WrappedSigningKey);
Assert.Equal(request.AccountKeys.SignatureKeyPair.VerifyingKey, signatureKeyPair.VerifyingKey);
}
[Theory]
[BitAutoData]
public async Task RotateUpgradeToV2UserAccountKeysAsync_Success(RotateUserAccountKeysAndDataRequestModel request)
{
await _loginHelper.LoginAsync(_ownerEmail);
var user = await _userRepository.GetByEmailAsync(_ownerEmail);
if (user == null)
{
throw new InvalidOperationException("User not found.");
}
var password = _passwordHasher.HashPassword(user, "newMasterPassword");
user.MasterPassword = password;
user.PublicKey = "publicKey";
user.PrivateKey = _mockEncryptedString;
await _userRepository.ReplaceAsync(user);
request.AccountUnlockData.MasterPasswordUnlockData.KdfType = user.Kdf;
request.AccountUnlockData.MasterPasswordUnlockData.KdfIterations = user.KdfIterations;
request.AccountUnlockData.MasterPasswordUnlockData.KdfMemory = user.KdfMemory;
request.AccountUnlockData.MasterPasswordUnlockData.KdfParallelism = user.KdfParallelism;
request.AccountUnlockData.MasterPasswordUnlockData.Email = user.Email;
request.AccountKeys.AccountPublicKey = "publicKey";
request.AccountKeys.UserKeyEncryptedAccountPrivateKey = _mockEncryptedType7String;
request.AccountKeys.PublicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel
{
PublicKey = "publicKey",
WrappedPrivateKey = _mockEncryptedType7String,
SignedPublicKey = "signedPublicKey",
};
request.AccountKeys.SignatureKeyPair = new SignatureKeyPairRequestModel
{
SignatureAlgorithm = "ed25519",
WrappedSigningKey = _mockEncryptedType7String,
VerifyingKey = "verifyingKey",
};
request.AccountKeys.SecurityState = new SecurityStateModel
{
SecurityVersion = 2,
SecurityState = "v2",
};
request.OldMasterKeyAuthenticationHash = "newMasterPassword";
request.AccountData.Ciphers =
[
new CipherWithIdRequestModel
{
Id = Guid.NewGuid(),
Type = CipherType.Login,
Name = _mockEncryptedString,
Login = new CipherLoginModel
{
Username = _mockEncryptedString,
Password = _mockEncryptedString,
},
},
];
request.AccountData.Folders = [
new FolderWithIdRequestModel
{
Id = Guid.NewGuid(),
Name = _mockEncryptedString,
},
];
request.AccountData.Sends = [
new SendWithIdRequestModel
{
Id = Guid.NewGuid(),
Name = _mockEncryptedString,
Key = _mockEncryptedString,
Disabled = false,
DeletionDate = DateTime.UtcNow.AddDays(1),
},
];
request.AccountUnlockData.MasterPasswordUnlockData.MasterKeyEncryptedUserKey = _mockEncryptedString;
request.AccountUnlockData.PasskeyUnlockData = [];
request.AccountUnlockData.DeviceKeyUnlockData = [];
request.AccountUnlockData.EmergencyAccessUnlockData = [];
request.AccountUnlockData.OrganizationAccountRecoveryUnlockData = [];
var user = await SetupUserForKeyRotationAsync();
SetupRotateUserAccountUnlockData(request, user);
SetupRotateUserAccountData(request);
SetupRotateUserAccountKeys(request, isV2Crypto: true);
var response = await _client.PostAsJsonAsync("/accounts/key-management/rotate-user-account-keys", request);
var responseMessage = await response.Content.ReadAsStringAsync();
@@ -625,6 +439,225 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfIterations, userNewState.KdfIterations);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfMemory, userNewState.KdfMemory);
Assert.Equal(request.AccountUnlockData.MasterPasswordUnlockData.KdfParallelism, userNewState.KdfParallelism);
// Assert V2 upgrade-specific fields
Assert.Equal(request.AccountKeys.PublicKeyEncryptionKeyPair!.SignedPublicKey, userNewState.SignedPublicKey);
Assert.Equal(request.AccountKeys.SecurityState!.SecurityState, userNewState.SecurityState);
Assert.Equal(request.AccountKeys.SecurityState.SecurityVersion, userNewState.SecurityVersion);
var signatureKeyPair = await _userSignatureKeyPairRepository.GetByUserIdAsync(userNewState.Id);
Assert.NotNull(signatureKeyPair);
Assert.Equal(SignatureAlgorithm.Ed25519, signatureKeyPair.SignatureAlgorithm);
Assert.Equal(request.AccountKeys.SignatureKeyPair!.WrappedSigningKey, signatureKeyPair.WrappedSigningKey);
Assert.Equal(request.AccountKeys.SignatureKeyPair.VerifyingKey, signatureKeyPair.VerifyingKey);
}
[Theory]
[BitAutoData]
public async Task RotateUserAccountKeys_V1Crypto_WithV2UpgradeToken_PersistsToken_AndDoesNotLogout(
RotateUserAccountKeysAndDataRequestModel request)
{
var user = await SetupUserForKeyRotationAsync();
SetupRotateUserAccountUnlockData(request, user);
SetupRotateUserAccountData(request);
SetupRotateUserAccountKeys(request, isV2Crypto: false);
request.AccountUnlockData.V2UpgradeToken = new V2UpgradeTokenRequestModel
{
WrappedUserKey1 = _mockEncryptedType7String,
WrappedUserKey2 = _mockEncryptedString
};
var response = await _client.PostAsJsonAsync("/accounts/key-management/rotate-user-account-keys", request);
response.EnsureSuccessStatusCode();
var userNewState = await _userRepository.GetByEmailAsync(_ownerEmail);
Assert.NotNull(userNewState);
Assert.NotNull(userNewState.V2UpgradeToken);
Assert.Contains($"\"WrappedUserKey1\":\"{_mockEncryptedType7String}\"", userNewState.V2UpgradeToken);
Assert.Contains($"\"WrappedUserKey2\":\"{_mockEncryptedString}\"", userNewState.V2UpgradeToken);
Assert.Equal(user.SecurityStamp, userNewState.SecurityStamp);
await _pushNotificationService.Received(1)
.PushLogOutAsync(userNewState.Id, false, PushNotificationLogOutReason.KeyRotation);
}
[Theory]
[BitAutoData]
public async Task RotateUserAccountKeys_V2Crypto_WithV2UpgradeToken_IgnoresToken_AndLogsOut(
RotateUserAccountKeysAndDataRequestModel request)
{
var user = await SetupUserForKeyRotationAsync(_mockEncryptedType7String, createSignatureKeyPair: true);
SetupRotateUserAccountUnlockData(request, user);
SetupRotateUserAccountData(request);
SetupRotateUserAccountKeys(request, isV2Crypto: true);
request.AccountUnlockData.V2UpgradeToken = new V2UpgradeTokenRequestModel
{
WrappedUserKey1 = _mockEncryptedType7String,
WrappedUserKey2 = _mockEncryptedString
};
var response = await _client.PostAsJsonAsync("/accounts/key-management/rotate-user-account-keys", request);
response.EnsureSuccessStatusCode();
var userNewState = await _userRepository.GetByEmailAsync(_ownerEmail);
Assert.NotNull(userNewState);
// Token must NOT be stored (V2 users don't need upgrade token)
Assert.Null(userNewState.V2UpgradeToken);
// Security stamp must change (logout occurred)
Assert.NotEqual(user.SecurityStamp, userNewState.SecurityStamp);
// Standard logout push sent without a reason (full logout, not KeyRotation)
await _pushNotificationService.Received(1)
.PushLogOutAsync(userNewState.Id, false, null);
}
[Theory]
[BitAutoData]
public async Task RotateUserAccountKeys_WithoutV2UpgradeToken_DoesNotSetToken_AndLogsOut(
RotateUserAccountKeysAndDataRequestModel request)
{
var user = await SetupUserForKeyRotationAsync();
SetupRotateUserAccountUnlockData(request, user);
SetupRotateUserAccountData(request);
SetupRotateUserAccountKeys(request, isV2Crypto: false);
var response = await _client.PostAsJsonAsync("/accounts/key-management/rotate-user-account-keys", request);
response.EnsureSuccessStatusCode();
var userNewState = await _userRepository.GetByEmailAsync(_ownerEmail);
Assert.NotNull(userNewState);
Assert.Null(userNewState.V2UpgradeToken);
Assert.NotEqual(user.SecurityStamp, userNewState.SecurityStamp);
await _pushNotificationService.Received(1).PushLogOutAsync(userNewState.Id, false, null);
}
[Theory]
[BitAutoData]
public async Task RotateUserAccountKeys_WithExistingV2UpgradeToken_WithoutNewToken_ClearsStaleToken_AndLogsOut(
RotateUserAccountKeysAndDataRequestModel request)
{
// Arrange
var user = await SetupUserForKeyRotationAsync();
// Add existing stale token to user BEFORE rotation
var staleToken = new V2UpgradeTokenData
{
WrappedUserKey1 = _mockEncryptedType7String,
WrappedUserKey2 = _mockEncryptedString
};
user.V2UpgradeToken = staleToken.ToJson();
await _userRepository.ReplaceAsync(user);
// Setup request WITHOUT V2UpgradeToken
SetupRotateUserAccountUnlockData(request, user);
SetupRotateUserAccountData(request);
SetupRotateUserAccountKeys(request, isV2Crypto: false);
request.AccountUnlockData.V2UpgradeToken = null; // Explicit: No new token
// Act
var response = await _client.PostAsJsonAsync("/accounts/key-management/rotate-user-account-keys", request);
response.EnsureSuccessStatusCode();
// Assert
var userNewState = await _userRepository.GetByEmailAsync(_ownerEmail);
Assert.NotNull(userNewState);
// Critical: Verify stale token is cleared
Assert.Null(userNewState.V2UpgradeToken);
// Verify logout behavior (SecurityStamp should be different)
Assert.NotEqual(user.SecurityStamp, userNewState.SecurityStamp);
await _pushNotificationService.Received(1).PushLogOutAsync(userNewState.Id, false, null);
}
[Theory]
[BitAutoData]
public async Task RotateUserAccountKeys_WithExistingV2UpgradeToken_WithNewToken_ReplacesToken_AndDoesNotLogout(
RotateUserAccountKeysAndDataRequestModel request)
{
// Arrange
var user = await SetupUserForKeyRotationAsync();
// Add existing old token to user BEFORE rotation
var oldToken = new V2UpgradeTokenData
{
WrappedUserKey1 = _mockEncryptedType7String2,
WrappedUserKey2 = _mockEncryptedType2String2
};
user.V2UpgradeToken = oldToken.ToJson();
await _userRepository.ReplaceAsync(user);
// Setup request WITH new V2UpgradeToken
SetupRotateUserAccountUnlockData(request, user);
SetupRotateUserAccountData(request);
SetupRotateUserAccountKeys(request, isV2Crypto: false);
request.AccountUnlockData.V2UpgradeToken = new V2UpgradeTokenRequestModel
{
WrappedUserKey1 = _mockEncryptedType7String,
WrappedUserKey2 = _mockEncryptedString
};
// Act
var response = await _client.PostAsJsonAsync("/accounts/key-management/rotate-user-account-keys", request);
response.EnsureSuccessStatusCode();
// Assert
var userNewState = await _userRepository.GetByEmailAsync(_ownerEmail);
Assert.NotNull(userNewState);
Assert.NotNull(userNewState.V2UpgradeToken);
// Verify new token is present
Assert.Contains($"\"WrappedUserKey1\":\"{_mockEncryptedType7String}\"", userNewState.V2UpgradeToken);
Assert.Contains($"\"WrappedUserKey2\":\"{_mockEncryptedString}\"", userNewState.V2UpgradeToken);
// Verify old token is NOT present
Assert.DoesNotContain(oldToken.WrappedUserKey1, userNewState.V2UpgradeToken);
Assert.DoesNotContain(oldToken.WrappedUserKey2, userNewState.V2UpgradeToken);
// Verify NO logout (SecurityStamp should be the same for key rotation with token)
Assert.Equal(user.SecurityStamp, userNewState.SecurityStamp);
await _pushNotificationService.Received(1)
.PushLogOutAsync(userNewState.Id, false, PushNotificationLogOutReason.KeyRotation);
}
[Theory]
[BitAutoData]
public async Task RotateUserAccountKeys_V2Crypto_WithExistingV2UpgradeToken_WithoutNewToken_ClearsStaleToken_AndLogsOut(
RotateUserAccountKeysAndDataRequestModel request)
{
// Arrange
var user = await SetupUserForKeyRotationAsync(_mockEncryptedType7String, createSignatureKeyPair: true);
// Add existing stale token to V2 crypto user BEFORE rotation
var staleToken = new V2UpgradeTokenData
{
WrappedUserKey1 = _mockEncryptedType7String,
WrappedUserKey2 = _mockEncryptedString
};
user.V2UpgradeToken = staleToken.ToJson();
await _userRepository.ReplaceAsync(user);
// Setup request WITHOUT V2UpgradeToken
SetupRotateUserAccountUnlockData(request, user);
SetupRotateUserAccountData(request);
SetupRotateUserAccountKeys(request, isV2Crypto: true);
request.AccountUnlockData.V2UpgradeToken = null; // Explicit: No new token
// Act
var response = await _client.PostAsJsonAsync("/accounts/key-management/rotate-user-account-keys", request);
response.EnsureSuccessStatusCode();
// Assert
var userNewState = await _userRepository.GetByEmailAsync(_ownerEmail);
Assert.NotNull(userNewState);
// Critical: Verify stale token is cleared for V2 crypto users
Assert.Null(userNewState.V2UpgradeToken);
// Verify logout behavior (SecurityStamp should be different)
Assert.NotEqual(user.SecurityStamp, userNewState.SecurityStamp);
await _pushNotificationService.Received(1).PushLogOutAsync(userNewState.Id, false, null);
}
[Fact]
@@ -663,4 +696,145 @@ public class AccountsKeyManagementControllerTests : IClassFixture<ApiApplication
return (ssoUserEmail, organization);
}
private async Task<User> SetupUserForKeyRotationAsync(
string? privateKey = null,
bool createSignatureKeyPair = false)
{
await _loginHelper.LoginAsync(_ownerEmail);
var user = await _userRepository.GetByEmailAsync(_ownerEmail);
if (user == null)
{
throw new InvalidOperationException("User not found.");
}
var password = _passwordHasher.HashPassword(user, "newMasterPassword");
user.MasterPassword = password;
user.PublicKey = "publicKey";
user.PrivateKey = privateKey ?? _mockEncryptedString;
// If creating signature key pair, user should already have V2 signed state
if (createSignatureKeyPair)
{
user.SignedPublicKey = "signedPublicKey";
user.SecurityState = "v2";
user.SecurityVersion = 2;
}
await _userRepository.ReplaceAsync(user);
if (createSignatureKeyPair)
{
await _userSignatureKeyPairRepository.CreateAsync(new UserSignatureKeyPair
{
UserId = user.Id,
SignatureAlgorithm = SignatureAlgorithm.Ed25519,
SigningKey = _mockEncryptedType7String,
VerifyingKey = "verifyingKey",
});
}
return user;
}
private void SetupRotateUserAccountUnlockData(
RotateUserAccountKeysAndDataRequestModel request,
User user)
{
// KDF settings
request.AccountUnlockData.MasterPasswordUnlockData.KdfType = user.Kdf;
request.AccountUnlockData.MasterPasswordUnlockData.KdfIterations = user.KdfIterations;
request.AccountUnlockData.MasterPasswordUnlockData.KdfMemory = user.KdfMemory;
request.AccountUnlockData.MasterPasswordUnlockData.KdfParallelism = user.KdfParallelism;
request.AccountUnlockData.MasterPasswordUnlockData.Email = user.Email;
request.AccountUnlockData.MasterPasswordUnlockData.MasterKeyEncryptedUserKey = _mockEncryptedString;
// Unlock data arrays
request.AccountUnlockData.PasskeyUnlockData = [];
request.AccountUnlockData.DeviceKeyUnlockData = [];
request.AccountUnlockData.EmergencyAccessUnlockData = [];
request.AccountUnlockData.OrganizationAccountRecoveryUnlockData = [];
// Authentication hash
request.OldMasterKeyAuthenticationHash = "newMasterPassword";
}
private void SetupRotateUserAccountData(RotateUserAccountKeysAndDataRequestModel request)
{
request.AccountData.Ciphers =
[
new CipherWithIdRequestModel
{
Id = Guid.NewGuid(),
Type = CipherType.Login,
Name = _mockEncryptedString,
Login = new CipherLoginModel
{
Username = _mockEncryptedString,
Password = _mockEncryptedString,
},
},
];
request.AccountData.Folders =
[
new FolderWithIdRequestModel
{
Id = Guid.NewGuid(),
Name = _mockEncryptedString,
},
];
request.AccountData.Sends =
[
new SendWithIdRequestModel
{
Id = Guid.NewGuid(),
Name = _mockEncryptedString,
Key = _mockEncryptedString,
Disabled = false,
DeletionDate = DateTime.UtcNow.AddDays(1),
},
];
}
private void SetupRotateUserAccountKeys(
RotateUserAccountKeysAndDataRequestModel request,
bool isV2Crypto)
{
request.AccountKeys.AccountPublicKey = "publicKey";
if (isV2Crypto)
{
// V2 crypto: Type 7 encryption with V2 keys and SecurityState
request.AccountKeys.UserKeyEncryptedAccountPrivateKey = _mockEncryptedType7String;
request.AccountKeys.PublicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel
{
PublicKey = "publicKey",
WrappedPrivateKey = _mockEncryptedType7String,
SignedPublicKey = "signedPublicKey",
};
request.AccountKeys.SignatureKeyPair = new SignatureKeyPairRequestModel
{
SignatureAlgorithm = "ed25519",
WrappedSigningKey = _mockEncryptedType7String,
VerifyingKey = "verifyingKey",
};
request.AccountKeys.SecurityState = new SecurityStateModel
{
SecurityVersion = 2,
SecurityState = "v2",
};
}
else
{
// V1 crypto: Type 2 encryption, no V2 keys
request.AccountKeys.UserKeyEncryptedAccountPrivateKey = _mockEncryptedString;
request.AccountKeys.PublicKeyEncryptionKeyPair = null;
request.AccountKeys.SignatureKeyPair = null;
request.AccountKeys.SecurityState = null;
}
request.AccountUnlockData.V2UpgradeToken = null;
}
}

View File

@@ -36,6 +36,10 @@ namespace Bit.Api.Test.KeyManagement.Controllers;
[JsonDocumentCustomize]
public class AccountsKeyManagementControllerTests
{
private static readonly string _mockEncryptedType2String =
"2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=";
private static readonly string _mockEncryptedType7String = "7.AOs41Hd8OQiCPXjyJKCiDA==";
[Theory]
[BitAutoData]
public async Task RegenerateKeysAsync_FeatureFlagOff_Throws(
@@ -109,7 +113,8 @@ public class AccountsKeyManagementControllerTests
[Theory]
[BitAutoData]
public async Task RotateUserAccountKeysSuccess(SutProvider<AccountsKeyManagementController> sutProvider,
public async Task RotateUserAccountKeys_UserCryptoV1_Success(
SutProvider<AccountsKeyManagementController> sutProvider,
RotateUserAccountKeysAndDataRequestModel data, User user)
{
data.AccountKeys.SignatureKeyPair = null;
@@ -236,6 +241,62 @@ public class AccountsKeyManagementControllerTests
}
}
[Theory]
[BitAutoData]
public async Task RotateUserAccountKeys_WithV2UpgradeToken_PassesTokenToCommand(
SutProvider<AccountsKeyManagementController> sutProvider,
RotateUserAccountKeysAndDataRequestModel data,
User user)
{
// Arrange
data.AccountKeys.SignatureKeyPair = null;
data.AccountUnlockData.V2UpgradeToken = new V2UpgradeTokenRequestModel
{
WrappedUserKey1 = _mockEncryptedType7String,
WrappedUserKey2 = _mockEncryptedType2String
};
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
sutProvider.GetDependency<IRotateUserAccountKeysCommand>()
.RotateUserAccountKeysAsync(Arg.Any<User>(), Arg.Any<RotateUserAccountKeysData>())
.Returns(IdentityResult.Success);
// Act
await sutProvider.Sut.RotateUserAccountKeysAsync(data);
// Assert
await sutProvider.GetDependency<IRotateUserAccountKeysCommand>().Received(1)
.RotateUserAccountKeysAsync(Arg.Is(user), Arg.Is<RotateUserAccountKeysData>(d =>
d.V2UpgradeToken != null &&
d.V2UpgradeToken.WrappedUserKey1 == _mockEncryptedType7String &&
d.V2UpgradeToken.WrappedUserKey2 == _mockEncryptedType2String));
}
[Theory]
[BitAutoData]
public async Task RotateUserAccountKeys_WithoutV2UpgradeToken_PassesNullToCommand(
SutProvider<AccountsKeyManagementController> sutProvider,
RotateUserAccountKeysAndDataRequestModel data,
User user)
{
// Arrange
data.AccountKeys.SignatureKeyPair = null;
data.AccountUnlockData.V2UpgradeToken = null;
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
sutProvider.GetDependency<IRotateUserAccountKeysCommand>()
.RotateUserAccountKeysAsync(Arg.Any<User>(), Arg.Any<RotateUserAccountKeysData>())
.Returns(IdentityResult.Success);
// Act
await sutProvider.Sut.RotateUserAccountKeysAsync(data);
// Assert
await sutProvider.GetDependency<IRotateUserAccountKeysCommand>().Received(1)
.RotateUserAccountKeysAsync(Arg.Is(user), Arg.Is<RotateUserAccountKeysData>(d =>
d.V2UpgradeToken == null));
}
[Theory]
[BitAutoData]
public async Task PostSetKeyConnectorKeyAsync_V1_UserNull_Throws(

View File

@@ -0,0 +1,125 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.KeyManagement.Models.Requests;
using Xunit;
namespace Bit.Api.Test.KeyManagement.Models.Request;
public class V2UpgradeTokenRequestModelTests
{
private const string _validWrappedKey1 = "7.AOs41Hd8OQiCPXjyJKCiDA==";
private const string _validWrappedKey2 = "2.BPt52Ie9PQjDQYkzKLDjEB==|P7PIiu3V3iKHCTOHojnKnh==|jE44t9C79D9KiZZiTb5W2uBskwMs9fFbHrPW8CSp6Kl=";
[Fact]
public void Validate_WithValidEncStrings_ReturnsNoErrors()
{
// Arrange
var model = new V2UpgradeTokenRequestModel
{
WrappedUserKey1 = _validWrappedKey1,
WrappedUserKey2 = _validWrappedKey2
};
// Act
var results = Validate(model);
// Assert
Assert.Empty(results);
}
[Fact]
public void Validate_WithMissingWrappedUserKey1_ReturnsValidationError()
{
// Arrange
var model = new V2UpgradeTokenRequestModel
{
WrappedUserKey1 = null!,
WrappedUserKey2 = _validWrappedKey2
};
// Act
var results = Validate(model);
// Assert
Assert.Single(results);
Assert.Contains(results, r => r.MemberNames.Contains("WrappedUserKey1"));
}
[Fact]
public void Validate_WithMissingWrappedUserKey2_ReturnsValidationError()
{
// Arrange
var model = new V2UpgradeTokenRequestModel
{
WrappedUserKey1 = _validWrappedKey1,
WrappedUserKey2 = null!
};
// Act
var results = Validate(model);
// Assert
Assert.Single(results);
Assert.Contains(results, r => r.MemberNames.Contains("WrappedUserKey2"));
}
[Fact]
public void Validate_WithInvalidEncStringFormatKey1_ReturnsValidationError()
{
// Arrange
var model = new V2UpgradeTokenRequestModel
{
WrappedUserKey1 = "not-an-encrypted-string",
WrappedUserKey2 = _validWrappedKey2
};
// Act
var results = Validate(model);
// Assert
Assert.Single(results);
Assert.Contains(results, r => r.ErrorMessage == "WrappedUserKey1 is not a valid encrypted string.");
}
[Fact]
public void Validate_WithInvalidEncStringFormatKey2_ReturnsValidationError()
{
// Arrange
var model = new V2UpgradeTokenRequestModel
{
WrappedUserKey1 = _validWrappedKey1,
WrappedUserKey2 = "not-an-encrypted-string"
};
// Act
var results = Validate(model);
// Assert
Assert.Single(results);
Assert.Contains(results, r => r.ErrorMessage == "WrappedUserKey2 is not a valid encrypted string.");
}
[Fact]
public void ToData_WithValidModel_MapsPropertiesCorrectly()
{
// Arrange
var model = new V2UpgradeTokenRequestModel
{
WrappedUserKey1 = _validWrappedKey1,
WrappedUserKey2 = _validWrappedKey2
};
// Act
var data = model.ToData();
// Assert
Assert.Equal(_validWrappedKey1, data.WrappedUserKey1);
Assert.Equal(_validWrappedKey2, data.WrappedUserKey2);
}
private static List<ValidationResult> Validate(V2UpgradeTokenRequestModel model)
{
var results = new List<ValidationResult>();
Validator.TryValidateObject(model, new ValidationContext(model), results, true);
return results;
}
}

View File

@@ -0,0 +1,170 @@
using Bit.Api.Vault.Models.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Auth.Entities;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities;
using Bit.Core.Vault.Models.Data;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Api.Test.Vault.Models.Response;
public class SyncResponseModelTests
{
private const string _mockEncryptedKey1 = "2.key1==|data1==|hmac1==";
private const string _mockEncryptedKey2 = "2.key2==|data2==|hmac2==";
private const string _mockEncryptedKey3 = "2.key3==|data3==|hmac3==";
private static SyncResponseModel CreateSyncResponseModel(
User user,
IEnumerable<WebAuthnCredential>? webAuthnCredentials = null)
{
return new SyncResponseModel(
new GlobalSettings(),
user,
new UserAccountKeysData
{
PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData("private", "public", null)
},
false,
false,
new Dictionary<Guid, OrganizationAbility>(),
new List<Guid>(),
new List<OrganizationUserOrganizationDetails>(),
new List<ProviderUserProviderDetails>(),
new List<ProviderUserOrganizationDetails>(),
new List<Folder>(),
new List<CollectionDetails>(),
new List<CipherDetails>(),
new Dictionary<Guid, IGrouping<Guid, CollectionCipher>>(),
true, // excludeDomains: true to avoid JSON deserialization issues in tests
new List<Policy>(),
new List<Send>(),
webAuthnCredentials ?? new List<WebAuthnCredential>());
}
[Theory]
[BitAutoData]
public void Constructor_UserWithMasterPassword_SetsMasterPasswordUnlock(User user)
{
// Arrange
user.MasterPassword = "hashed-password";
user.Key = _mockEncryptedKey1;
user.Kdf = KdfType.Argon2id;
user.KdfIterations = 3;
user.KdfMemory = 64;
user.KdfParallelism = 4;
// Act
var result = CreateSyncResponseModel(user);
// Assert
Assert.NotNull(result.UserDecryption);
Assert.NotNull(result.UserDecryption.MasterPasswordUnlock);
Assert.Equal(_mockEncryptedKey1, result.UserDecryption.MasterPasswordUnlock.MasterKeyEncryptedUserKey);
Assert.Equal(user.Email.ToLowerInvariant(), result.UserDecryption.MasterPasswordUnlock.Salt);
Assert.NotNull(result.UserDecryption.MasterPasswordUnlock.Kdf);
Assert.Equal(KdfType.Argon2id, result.UserDecryption.MasterPasswordUnlock.Kdf.KdfType);
Assert.Equal(3, result.UserDecryption.MasterPasswordUnlock.Kdf.Iterations);
Assert.Equal(64, result.UserDecryption.MasterPasswordUnlock.Kdf.Memory);
Assert.Equal(4, result.UserDecryption.MasterPasswordUnlock.Kdf.Parallelism);
}
[Theory]
[BitAutoData]
public void Constructor_UserWithoutMasterPassword_MasterPasswordUnlockIsNull(User user)
{
// Arrange
user.MasterPassword = null;
// Act
var result = CreateSyncResponseModel(user);
// Assert
Assert.NotNull(result.UserDecryption);
Assert.Null(result.UserDecryption.MasterPasswordUnlock);
}
[Theory]
[BitAutoData]
public void Constructor_WithEnabledWebAuthnPrfCredentials_SetsWebAuthnPrfOptions(
User user,
WebAuthnCredential credential)
{
// Arrange
credential.SupportsPrf = true;
credential.EncryptedPrivateKey = _mockEncryptedKey1;
credential.EncryptedUserKey = _mockEncryptedKey2;
credential.EncryptedPublicKey = _mockEncryptedKey3;
// Act
var result = CreateSyncResponseModel(user, new List<WebAuthnCredential> { credential });
// Assert
Assert.NotNull(result.UserDecryption);
Assert.NotNull(result.UserDecryption.WebAuthnPrfOptions);
Assert.Single(result.UserDecryption.WebAuthnPrfOptions);
var option = result.UserDecryption.WebAuthnPrfOptions[0];
Assert.Equal(_mockEncryptedKey1, option.EncryptedPrivateKey);
Assert.Equal(_mockEncryptedKey2, option.EncryptedUserKey);
Assert.Equal(credential.CredentialId, option.CredentialId);
Assert.Empty(option.Transports);
}
[Theory]
[BitAutoData]
public void Constructor_WithoutEnabledWebAuthnPrfCredentials_WebAuthnPrfOptionsIsNull(User user)
{
// Act
var result = CreateSyncResponseModel(user);
// Assert
Assert.NotNull(result.UserDecryption);
Assert.Null(result.UserDecryption.WebAuthnPrfOptions);
}
[Theory]
[BitAutoData]
public void Constructor_UserWithV2UpgradeToken_SetsV2UpgradeToken(User user)
{
// Arrange
var tokenData = new V2UpgradeTokenData
{
WrappedUserKey1 = _mockEncryptedKey1,
WrappedUserKey2 = _mockEncryptedKey2
};
user.V2UpgradeToken = tokenData.ToJson();
// Act
var result = CreateSyncResponseModel(user);
// Assert
Assert.NotNull(result.UserDecryption);
Assert.NotNull(result.UserDecryption.V2UpgradeToken);
Assert.Equal(_mockEncryptedKey1, result.UserDecryption.V2UpgradeToken.WrappedUserKey1);
Assert.Equal(_mockEncryptedKey2, result.UserDecryption.V2UpgradeToken.WrappedUserKey2);
}
[Theory]
[BitAutoData]
public void Constructor_UserWithoutV2UpgradeToken_V2UpgradeTokenIsNull(User user)
{
// Arrange
user.V2UpgradeToken = null;
// Act
var result = CreateSyncResponseModel(user);
// Assert
Assert.NotNull(result.UserDecryption);
Assert.Null(result.UserDecryption.V2UpgradeToken);
}
}

View File

@@ -228,7 +228,7 @@ public static class AssertHelper
return await JsonSerializer.DeserializeAsync<T>(context.Response.Body);
}
public static TimeSpan AssertRecent(DateTime dateTime, int skewSeconds = 2)
public static TimeSpan AssertRecent(DateTime dateTime, int skewSeconds = 5)
=> AssertRecent(dateTime, TimeSpan.FromSeconds(skewSeconds));
public static TimeSpan AssertRecent(DateTime dateTime, TimeSpan skew)

View File

@@ -0,0 +1,57 @@
using Bit.Core.KeyManagement.Models.Data;
using Xunit;
namespace Bit.Core.Test.KeyManagement.Models.Data;
public class V2UpgradeTokenDataTests
{
private static readonly string _mockEncryptedType2String =
"2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=";
private static readonly string _mockEncryptedType7String = "7.AOs41Hd8OQiCPXjyJKCiDA==";
[Fact]
public void ToJson_SerializesCorrectly()
{
var data = new V2UpgradeTokenData
{
WrappedUserKey1 = _mockEncryptedType7String,
WrappedUserKey2 = _mockEncryptedType2String
};
var json = data.ToJson();
var expected = $"{{\"WrappedUserKey1\":\"{_mockEncryptedType7String}\",\"WrappedUserKey2\":\"{_mockEncryptedType2String}\"}}";
Assert.Equal(expected, json);
}
[Fact]
public void FromJson_ValidJson_DeserializesCorrectly()
{
var json = $"{{\"WrappedUserKey1\":\"{_mockEncryptedType7String}\",\"WrappedUserKey2\":\"{_mockEncryptedType2String}\"}}";
var result = V2UpgradeTokenData.FromJson(json);
Assert.NotNull(result);
Assert.Equal(_mockEncryptedType7String, result.WrappedUserKey1);
Assert.Equal(_mockEncryptedType2String, result.WrappedUserKey2);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void FromJson_NullOrEmptyInput_ReturnsNull(string? input)
{
var result = V2UpgradeTokenData.FromJson(input);
Assert.Null(result);
}
[Fact]
public void FromJson_InvalidJson_ReturnsNull()
{
var result = V2UpgradeTokenData.FromJson("{\"invalid\": json}");
Assert.Null(result);
}
}

View File

@@ -3,6 +3,7 @@ using Bit.Core.KeyManagement.Enums;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Repositories;
using Bit.Core.KeyManagement.UserKey.Implementations;
using Bit.Core.Platform.Push;
using Bit.Core.Services;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Repositories;
@@ -20,6 +21,13 @@ namespace Bit.Core.Test.KeyManagement.UserKey;
[SutProviderCustomize]
public class RotateUserAccountKeysCommandTests
{
private static readonly string _mockEncryptedType2String =
"2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=";
private static readonly string _mockEncryptedType2String2 =
"2.06CDSJjTZaigYHUuswIq5A==|trxgZl2RCkYrrmCvGE9WNA==|w5p05eI5wsaYeSyWtsAPvBX63vj798kIMxBTfSB0BQg=";
private static readonly string _mockEncryptedType7String = "7.AOs41Hd8OQiCPXjyJKCiDA==";
private static readonly string _mockEncryptedType7String2 = "7.Mi1iaXR3YXJkZW4tZGF0YQo=";
[Theory, BitAutoData]
public async Task RotateUserAccountKeysAsync_WrongOldMasterPassword_Rejects(SutProvider<RotateUserAccountKeysCommand> sutProvider, User user,
RotateUserAccountKeysData model)
@@ -144,7 +152,7 @@ public class RotateUserAccountKeysCommandTests
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV2ExistingUser(user, signatureRepository);
SetV2ModelUser(model);
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = "2.xxx";
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = _mockEncryptedType2String;
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
@@ -157,7 +165,7 @@ public class RotateUserAccountKeysCommandTests
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV1ModelUser(model);
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = "7.xxx";
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = _mockEncryptedType7String;
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
@@ -228,7 +236,7 @@ public class RotateUserAccountKeysCommandTests
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV2ExistingUser(user, signatureRepository);
SetV2ModelUser(model);
model.AccountKeys.SignatureKeyPairData.WrappedSigningKey = "2.xxx";
model.AccountKeys.SignatureKeyPairData.WrappedSigningKey = _mockEncryptedType2String;
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
@@ -256,7 +264,7 @@ public class RotateUserAccountKeysCommandTests
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV2ModelUser(model);
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = "2.abc";
model.AccountKeys.PublicKeyEncryptionKeyPairData.WrappedPrivateKey = _mockEncryptedType2String;
var saveEncryptedDataActions = new List<Core.KeyManagement.UserKey.UpdateEncryptedDataForKeyRotation>();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await sutProvider.Sut.UpdateAccountKeysAsync(model, user, saveEncryptedDataActions));
@@ -389,7 +397,199 @@ public class RotateUserAccountKeysCommandTests
}
}
// Helper functions to set valid test parameters that match each other to the model and user.
[Theory, BitAutoData]
public async Task RotateUserAccountKeysAsync_WithV2UpgradeToken_NoLogout(
SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
// Arrange
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV1ModelUser(model);
var originalSecurityStamp = user.SecurityStamp = Guid.NewGuid().ToString();
model.V2UpgradeToken = new V2UpgradeTokenData
{
WrappedUserKey1 = _mockEncryptedType7String,
WrappedUserKey2 = _mockEncryptedType2String
};
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
.Returns(true);
// Act
await sutProvider.Sut.RotateUserAccountKeysAsync(user, model);
// Assert - Security stamp is not updated
Assert.Equal(originalSecurityStamp, user.SecurityStamp);
// Assert - Token is stored on user
Assert.NotNull(user.V2UpgradeToken);
Assert.Contains(_mockEncryptedType7String, user.V2UpgradeToken);
Assert.Contains(_mockEncryptedType2String, user.V2UpgradeToken);
// Assert - Push notification sent with KeyRotation reason
await sutProvider.GetDependency<IPushNotificationService>().Received(1)
.PushLogOutAsync(user.Id, false, Enums.PushNotificationLogOutReason.KeyRotation);
}
[Theory, BitAutoData]
public async Task RotateUserAccountKeysAsync_WithoutV2UpgradeToken_Logout(
SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
// Arrange
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV1ModelUser(model);
var originalSecurityStamp = user.SecurityStamp = Guid.NewGuid().ToString();
user.V2UpgradeToken = null;
model.V2UpgradeToken = null;
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
.Returns(true);
// Act
await sutProvider.Sut.RotateUserAccountKeysAsync(user, model);
// Assert - Security stamp is updated
Assert.NotEqual(originalSecurityStamp, user.SecurityStamp);
// Assert - Token is not stored on user
Assert.Null(user.V2UpgradeToken);
// Assert - Push notification sent without reason
await sutProvider.GetDependency<IPushNotificationService>().Received(1)
.PushLogOutAsync(user.Id);
}
[Theory, BitAutoData]
public async Task RotateUserAccountKeysAsync_WithExistingToken_WithoutNewToken_ClearsStaleToken(
SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
// Arrange
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV1ModelUser(model);
var originalSecurityStamp = user.SecurityStamp = Guid.NewGuid().ToString();
// User has existing stale token from previous rotation
var staleToken = new V2UpgradeTokenData
{
WrappedUserKey1 = _mockEncryptedType7String,
WrappedUserKey2 = _mockEncryptedType2String
};
user.V2UpgradeToken = staleToken.ToJson();
// Model does NOT provide new token
model.V2UpgradeToken = null;
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
.Returns(true);
// Act
await sutProvider.Sut.RotateUserAccountKeysAsync(user, model);
// Assert - Stale token explicitly cleared
Assert.Null(user.V2UpgradeToken);
// Assert - Security stamp is updated (logout behavior)
Assert.NotEqual(originalSecurityStamp, user.SecurityStamp);
// Assert - Push notification sent without reason (standard logout)
await sutProvider.GetDependency<IPushNotificationService>().Received(1)
.PushLogOutAsync(user.Id);
}
[Theory, BitAutoData]
public async Task RotateUserAccountKeysAsync_WithExistingToken_WithNewToken_UpdatesToken(
SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
// Arrange
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV1ExistingUser(user, signatureRepository);
SetV1ModelUser(model);
var originalSecurityStamp = user.SecurityStamp = Guid.NewGuid().ToString();
// User has existing token from previous rotation
var oldToken = new V2UpgradeTokenData
{
WrappedUserKey1 = _mockEncryptedType7String,
WrappedUserKey2 = _mockEncryptedType2String
};
user.V2UpgradeToken = oldToken.ToJson();
// Model provides NEW token
model.V2UpgradeToken = new V2UpgradeTokenData
{
WrappedUserKey1 = _mockEncryptedType7String2,
WrappedUserKey2 = _mockEncryptedType2String2
};
sutProvider.GetDependency<IUserService>().CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
.Returns(true);
// Act
await sutProvider.Sut.RotateUserAccountKeysAsync(user, model);
// Assert - Security stamp is not updated (no logout)
Assert.Equal(originalSecurityStamp, user.SecurityStamp);
// Assert - Token contains new wrapped keys
Assert.NotNull(user.V2UpgradeToken);
Assert.Contains(_mockEncryptedType7String2, user.V2UpgradeToken);
Assert.Contains(_mockEncryptedType2String2, user.V2UpgradeToken);
// Assert - Token does NOT contain old wrapped keys
Assert.DoesNotContain(oldToken.WrappedUserKey1, user.V2UpgradeToken);
Assert.DoesNotContain(oldToken.WrappedUserKey2, user.V2UpgradeToken);
// Assert - Push notification sent with KeyRotation reason (no logout)
await sutProvider.GetDependency<IPushNotificationService>().Received(1)
.PushLogOutAsync(user.Id, false, Enums.PushNotificationLogOutReason.KeyRotation);
}
[Theory, BitAutoData]
public async Task RotateUserAccountKeysAsync_V2User_WithV2UpgradeToken_IgnoresTokenAndLogsOut(
SutProvider<RotateUserAccountKeysCommand> sutProvider, User user, RotateUserAccountKeysData model)
{
// Arrange
SetTestKdfAndSaltForUserAndModel(user, model);
var signatureRepository = sutProvider.GetDependency<IUserSignatureKeyPairRepository>();
SetV2ExistingUser(user, signatureRepository);
SetV2ModelUser(model);
var originalSecurityStamp = user.SecurityStamp = Guid.NewGuid().ToString();
model.V2UpgradeToken = new V2UpgradeTokenData
{
WrappedUserKey1 = _mockEncryptedType7String,
WrappedUserKey2 = _mockEncryptedType2String
};
sutProvider.GetDependency<IUserService>()
.CheckPasswordAsync(user, model.OldMasterKeyAuthenticationHash)
.Returns(true);
// Act
await sutProvider.Sut.RotateUserAccountKeysAsync(user, model);
// Assert - Token is NOT stored (V2 users don't need upgrade token)
Assert.Null(user.V2UpgradeToken);
// Assert - Security stamp IS updated (full logout)
Assert.NotEqual(originalSecurityStamp, user.SecurityStamp);
// Assert - Standard logout push, not KeyRotation reason
await sutProvider.GetDependency<IPushNotificationService>().Received(1)
.PushLogOutAsync(user.Id);
}
// Helper functions to set valid test parameters that match each other to the model and user.
private static void SetTestKdfAndSaltForUserAndModel(User user, RotateUserAccountKeysData model)
{
user.Kdf = Enums.KdfType.Argon2id;
@@ -406,7 +606,7 @@ public class RotateUserAccountKeysCommandTests
private static void SetV1ExistingUser(User user, IUserSignatureKeyPairRepository userSignatureKeyPairRepository)
{
user.PrivateKey = "2.abc";
user.PrivateKey = _mockEncryptedType2String;
user.PublicKey = "public";
user.SignedPublicKey = null;
userSignatureKeyPairRepository.GetByUserIdAsync(user.Id).ReturnsNull();
@@ -414,23 +614,23 @@ public class RotateUserAccountKeysCommandTests
private static void SetV2ExistingUser(User user, IUserSignatureKeyPairRepository userSignatureKeyPairRepository)
{
user.PrivateKey = "7.abc";
user.PrivateKey = _mockEncryptedType7String;
user.PublicKey = "public";
user.SignedPublicKey = "signed-public";
userSignatureKeyPairRepository.GetByUserIdAsync(user.Id).Returns(new SignatureKeyPairData(SignatureAlgorithm.Ed25519, "7.abc", "verifying-key"));
userSignatureKeyPairRepository.GetByUserIdAsync(user.Id).Returns(new SignatureKeyPairData(SignatureAlgorithm.Ed25519, _mockEncryptedType7String, "verifying-key"));
}
private static void SetV1ModelUser(RotateUserAccountKeysData model)
{
model.AccountKeys.PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData("2.abc", "public", null);
model.AccountKeys.PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(_mockEncryptedType2String, "public", null);
model.AccountKeys.SignatureKeyPairData = null;
model.AccountKeys.SecurityStateData = null;
}
private static void SetV2ModelUser(RotateUserAccountKeysData model)
{
model.AccountKeys.PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData("7.abc", "public", "signed-public");
model.AccountKeys.SignatureKeyPairData = new SignatureKeyPairData(SignatureAlgorithm.Ed25519, "7.abc", "verifying-key");
model.AccountKeys.PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData(_mockEncryptedType7String, "public", "signed-public");
model.AccountKeys.SignatureKeyPairData = new SignatureKeyPairData(SignatureAlgorithm.Ed25519, _mockEncryptedType7String, "verifying-key");
model.AccountKeys.SecurityStateData = new SecurityStateData
{
SecurityState = "abc",

View File

@@ -2,6 +2,7 @@
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Infrastructure.IntegrationTest.AdminConsole;
@@ -527,6 +528,75 @@ public class UserRepositoryTests
Assert.Equal(DateTime.UtcNow, updatedUser.AccountRevisionDate, TimeSpan.FromMinutes(1));
}
[Theory, DatabaseData]
public async Task UpdateUserKeyAndEncryptedDataV2Async_UpdatesAllUserFields(IUserRepository userRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
var newSecurityStamp = Guid.NewGuid().ToString();
user.Key = "new-user-key";
user.PrivateKey = "new-private-key";
user.SecurityStamp = newSecurityStamp;
user.Kdf = KdfType.Argon2id;
user.KdfIterations = 3;
user.KdfMemory = 64;
user.KdfParallelism = 4;
user.Email = $"updated+{Guid.NewGuid()}@example.com";
user.MasterPassword = "new-master-password-hash";
user.MasterPasswordHint = "new-hint";
user.LastKeyRotationDate = DateTime.UtcNow;
user.RevisionDate = DateTime.UtcNow;
user.AccountRevisionDate = DateTime.UtcNow;
user.SignedPublicKey = "new-signed-public-key";
user.SecurityState = "new-security-state";
user.SecurityVersion = 2;
user.V2UpgradeToken = null;
// Act
await userRepository.UpdateUserKeyAndEncryptedDataV2Async(user, []);
// Assert
var updatedUser = await userRepository.GetByIdAsync(user.Id);
Assert.NotNull(updatedUser);
Assert.Equal("new-user-key", updatedUser.Key);
Assert.Equal("new-private-key", updatedUser.PrivateKey);
Assert.Equal(newSecurityStamp, updatedUser.SecurityStamp);
Assert.Equal(KdfType.Argon2id, updatedUser.Kdf);
Assert.Equal(3, updatedUser.KdfIterations);
Assert.Equal(64, updatedUser.KdfMemory);
Assert.Equal(4, updatedUser.KdfParallelism);
Assert.Equal(user.Email, updatedUser.Email);
Assert.Equal("new-master-password-hash", updatedUser.MasterPassword);
Assert.Equal("new-hint", updatedUser.MasterPasswordHint);
Assert.Equal("new-signed-public-key", updatedUser.SignedPublicKey);
Assert.Equal("new-security-state", updatedUser.SecurityState);
Assert.Equal(2, updatedUser.SecurityVersion);
Assert.Null(updatedUser.V2UpgradeToken);
Assert.Equal(DateTime.UtcNow, updatedUser.RevisionDate, TimeSpan.FromMinutes(1));
}
[Theory, DatabaseData]
public async Task UpdateUserKeyAndEncryptedDataV2Async_InvokesUpdateDataActions(IUserRepository userRepository)
{
// Arrange
var user = await userRepository.CreateTestUserAsync();
user.RevisionDate = DateTime.UtcNow;
var actionWasInvoked = false;
UpdateEncryptedDataForKeyRotation action = (_, _) =>
{
actionWasInvoked = true;
return Task.CompletedTask;
};
// Act
await userRepository.UpdateUserKeyAndEncryptedDataV2Async(user, [action]);
// Assert
Assert.True(actionWasInvoked);
}
private static async Task RunUpdateUserDataAsync(UpdateUserData task, Database database)
{
if (database.Type == SupportedDatabaseProviders.SqlServer && !database.UseEf)