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:
@@ -121,6 +121,7 @@ public class AccountsKeyManagementController : Controller
|
||||
OrganizationUsers = await _organizationUserValidator.ValidateAsync(user, model.AccountUnlockData.OrganizationAccountRecoveryUnlockData),
|
||||
WebAuthnKeys = await _webauthnKeyValidator.ValidateAsync(user, model.AccountUnlockData.PasskeyUnlockData),
|
||||
DeviceKeys = await _deviceValidator.ValidateAsync(user, model.AccountUnlockData.DeviceKeyUnlockData),
|
||||
V2UpgradeToken = model.AccountUnlockData.V2UpgradeToken?.ToData(),
|
||||
|
||||
Ciphers = await _cipherValidator.ValidateAsync(user, model.AccountData.Ciphers),
|
||||
Folders = await _folderValidator.ValidateAsync(user, model.AccountData.Folders),
|
||||
|
||||
@@ -14,4 +14,5 @@ public class UnlockDataRequestModel
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Bit.Core.KeyManagement.Models.Data;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Api.KeyManagement.Models.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Request model for V2 upgrade token submitted during key rotation.
|
||||
/// Contains wrapped user keys allowing clients to unlock after V1→V2 upgrade.
|
||||
/// </summary>
|
||||
public class V2UpgradeTokenRequestModel
|
||||
{
|
||||
/// <summary>
|
||||
/// User Key V2 Wrapped User Key V1.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[EncryptedString]
|
||||
public required string WrappedUserKey1 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User Key V1 Wrapped User Key V2.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[EncryptedString]
|
||||
public required string WrappedUserKey2 { get; init; }
|
||||
|
||||
public V2UpgradeTokenData ToData()
|
||||
{
|
||||
return new V2UpgradeTokenData
|
||||
{
|
||||
WrappedUserKey1 = WrappedUserKey1,
|
||||
WrappedUserKey2 = WrappedUserKey2
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,14 @@ public class SyncResponseModel() : ResponseModel("sync")
|
||||
Salt = user.Email.ToLowerInvariant()
|
||||
}
|
||||
: null,
|
||||
WebAuthnPrfOptions = webAuthnPrfOptions.Length > 0 ? webAuthnPrfOptions : null
|
||||
WebAuthnPrfOptions = webAuthnPrfOptions.Length > 0 ? webAuthnPrfOptions : null,
|
||||
V2UpgradeToken = V2UpgradeTokenData.FromJson(user.V2UpgradeToken) is { } data
|
||||
? new V2UpgradeTokenResponseModel
|
||||
{
|
||||
WrappedUserKey1 = data.WrappedUserKey1,
|
||||
WrappedUserKey2 = data.WrappedUserKey2
|
||||
}
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -210,6 +210,7 @@ public static class FeatureFlagKeys
|
||||
public const string EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration";
|
||||
public const string SdkKeyRotation = "pm-30144-sdk-key-rotation";
|
||||
public const string UnlockViaSdk = "unlock-via-sdk";
|
||||
public const string NoLogoutOnKeyUpgradeRotation = "pm-31050-no-logout-key-upgrade-rotation";
|
||||
public const string EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration";
|
||||
|
||||
/* Mobile Team */
|
||||
|
||||
@@ -105,6 +105,11 @@ public class User : ITableObject<Guid>, IStorableSubscriber, IRevisable, ITwoFac
|
||||
public DateTime? LastKeyRotationDate { get; set; }
|
||||
public DateTime? LastEmailChangeDate { get; set; }
|
||||
public bool VerifyDevices { get; set; } = true;
|
||||
/// <summary>
|
||||
/// V2 upgrade token stored as JSON containing two wrapped user keys.
|
||||
/// Allows clients to unlock vault after V1 to V2 key rotation without logout.
|
||||
/// </summary>
|
||||
public string? V2UpgradeToken { get; set; }
|
||||
// PM-28827 Uncomment below line.
|
||||
// public string? MasterPasswordSalt { get; set; }
|
||||
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
|
||||
public enum PushNotificationLogOutReason : byte
|
||||
{
|
||||
KdfChange = 0
|
||||
KdfChange = 0,
|
||||
KeyRotation = 1
|
||||
}
|
||||
|
||||
@@ -15,4 +15,10 @@ public class UserDecryptionResponseModel
|
||||
/// </summary>
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public WebAuthnPrfDecryptionOption[]? WebAuthnPrfOptions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// V2 upgrade token returned when available, allowing unlock after V1→V2 upgrade.
|
||||
/// </summary>
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public V2UpgradeTokenResponseModel? V2UpgradeToken { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Bit.Core.KeyManagement.Models.Api.Response;
|
||||
|
||||
public class V2UpgradeTokenResponseModel
|
||||
{
|
||||
public required string WrappedUserKey1 { get; set; }
|
||||
public required string WrappedUserKey2 { get; set; }
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public class RotateUserAccountKeysData
|
||||
public required IReadOnlyList<OrganizationUser> OrganizationUsers { get; set; }
|
||||
public required IEnumerable<WebAuthnLoginRotateKeyData> WebAuthnKeys { get; set; }
|
||||
public required IEnumerable<Device> DeviceKeys { get; set; }
|
||||
public V2UpgradeTokenData? V2UpgradeToken { get; set; }
|
||||
|
||||
// User vault data encrypted by the userkey
|
||||
public required IEnumerable<Cipher> Ciphers { get; set; }
|
||||
|
||||
31
src/Core/KeyManagement/Models/Data/V2UpgradeTokenData.cs
Normal file
31
src/Core/KeyManagement/Models/Data/V2UpgradeTokenData.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Bit.Core.KeyManagement.Models.Data;
|
||||
|
||||
public class V2UpgradeTokenData
|
||||
{
|
||||
public required string WrappedUserKey1 { get; init; }
|
||||
public required string WrappedUserKey2 { get; init; }
|
||||
|
||||
public string ToJson()
|
||||
{
|
||||
return JsonSerializer.Serialize(this);
|
||||
}
|
||||
|
||||
public static V2UpgradeTokenData? FromJson(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<V2UpgradeTokenData>(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,19 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
||||
var now = DateTime.UtcNow;
|
||||
user.RevisionDate = user.AccountRevisionDate = now;
|
||||
user.LastKeyRotationDate = now;
|
||||
user.SecurityStamp = Guid.NewGuid().ToString();
|
||||
|
||||
// V2UpgradeToken is only valid for V1 users transitioning to V2.
|
||||
// For V2 users the token is semantically invalid — discard it and perform a full logout.
|
||||
var shouldPersistV2UpgradeToken = model.V2UpgradeToken != null && !IsV2EncryptionUserAsync(user);
|
||||
if (shouldPersistV2UpgradeToken)
|
||||
{
|
||||
user.V2UpgradeToken = model.V2UpgradeToken!.ToJson();
|
||||
}
|
||||
else
|
||||
{
|
||||
user.V2UpgradeToken = null;
|
||||
user.SecurityStamp = Guid.NewGuid().ToString();
|
||||
}
|
||||
|
||||
List<UpdateEncryptedDataForKeyRotation> saveEncryptedDataActions = [];
|
||||
|
||||
@@ -99,7 +111,17 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
|
||||
UpdateUserData(model, user, saveEncryptedDataActions);
|
||||
|
||||
await _userRepository.UpdateUserKeyAndEncryptedDataV2Async(user, saveEncryptedDataActions);
|
||||
await _pushService.PushLogOutAsync(user.Id);
|
||||
|
||||
if (shouldPersistV2UpgradeToken)
|
||||
{
|
||||
await _pushService.PushLogOutAsync(user.Id,
|
||||
reason: PushNotificationLogOutReason.KeyRotation);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _pushService.PushLogOutAsync(user.Id);
|
||||
}
|
||||
|
||||
return IdentityResult.Success;
|
||||
}
|
||||
|
||||
|
||||
@@ -255,6 +255,12 @@ public class UserRepository : Repository<Core.Entities.User, User, Guid>, IUserR
|
||||
userEntity.AccountRevisionDate = user.AccountRevisionDate;
|
||||
userEntity.RevisionDate = user.RevisionDate;
|
||||
|
||||
userEntity.SignedPublicKey = user.SignedPublicKey;
|
||||
userEntity.SecurityState = user.SecurityState;
|
||||
userEntity.SecurityVersion = user.SecurityVersion;
|
||||
|
||||
userEntity.V2UpgradeToken = user.V2UpgradeToken;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
// Update re-encrypted data
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
@VerifyDevices BIT = 1,
|
||||
@SecurityState VARCHAR(MAX) = NULL,
|
||||
@SecurityVersion INT = NULL,
|
||||
@SignedPublicKey VARCHAR(MAX) = NULL
|
||||
@SignedPublicKey VARCHAR(MAX) = NULL,
|
||||
@V2UpgradeToken VARCHAR(MAX) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
@@ -97,7 +98,8 @@ BEGIN
|
||||
[SecurityState],
|
||||
[SecurityVersion],
|
||||
[SignedPublicKey],
|
||||
[MaxStorageGbIncreased]
|
||||
[MaxStorageGbIncreased],
|
||||
[V2UpgradeToken]
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@@ -147,6 +149,7 @@ BEGIN
|
||||
@SecurityState,
|
||||
@SecurityVersion,
|
||||
@SignedPublicKey,
|
||||
@MaxStorageGb
|
||||
@MaxStorageGb,
|
||||
@V2UpgradeToken
|
||||
)
|
||||
END
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
@VerifyDevices BIT = 1,
|
||||
@SecurityState VARCHAR(MAX) = NULL,
|
||||
@SecurityVersion INT = NULL,
|
||||
@SignedPublicKey VARCHAR(MAX) = NULL
|
||||
@SignedPublicKey VARCHAR(MAX) = NULL,
|
||||
@V2UpgradeToken VARCHAR(MAX) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
@@ -97,7 +98,8 @@ BEGIN
|
||||
[SecurityState] = @SecurityState,
|
||||
[SecurityVersion] = @SecurityVersion,
|
||||
[SignedPublicKey] = @SignedPublicKey,
|
||||
[MaxStorageGbIncreased] = @MaxStorageGb
|
||||
[MaxStorageGbIncreased] = @MaxStorageGb,
|
||||
[V2UpgradeToken] = @V2UpgradeToken
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
||||
|
||||
@@ -42,10 +42,11 @@
|
||||
[LastKeyRotationDate] DATETIME2 (7) NULL,
|
||||
[LastEmailChangeDate] DATETIME2 (7) NULL,
|
||||
[VerifyDevices] BIT DEFAULT ((1)) NOT NULL,
|
||||
[SecurityState] VARCHAR (MAX) NULL,
|
||||
[SecurityState] VARCHAR (MAX) NULL,
|
||||
[SecurityVersion] INT NULL,
|
||||
[SignedPublicKey] VARCHAR (MAX) NULL,
|
||||
[SignedPublicKey] VARCHAR (MAX) NULL,
|
||||
[MaxStorageGbIncreased] SMALLINT NULL,
|
||||
[V2UpgradeToken] VARCHAR(MAX) NULL,
|
||||
CONSTRAINT [PK_User] PRIMARY KEY CLUSTERED ([Id] ASC)
|
||||
);
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ SELECT
|
||||
[VerifyDevices],
|
||||
[SecurityState],
|
||||
[SecurityVersion],
|
||||
[SignedPublicKey]
|
||||
[SignedPublicKey],
|
||||
[V2UpgradeToken]
|
||||
FROM
|
||||
[dbo].[User]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
170
test/Api.Test/Vault/Models/Response/SyncResponseModelTests.cs
Normal file
170
test/Api.Test/Vault/Models/Response/SyncResponseModelTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
333
util/Migrator/DbScripts/2026-02-19_00_User_V2UpgradeToken.sql
Normal file
333
util/Migrator/DbScripts/2026-02-19_00_User_V2UpgradeToken.sql
Normal file
@@ -0,0 +1,333 @@
|
||||
IF COL_LENGTH('[dbo].[User]', 'V2UpgradeToken') IS NULL
|
||||
BEGIN
|
||||
ALTER TABLE
|
||||
[dbo].[User]
|
||||
ADD
|
||||
[V2UpgradeToken] VARCHAR(MAX) NULL
|
||||
END
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE [dbo].[User_Create]
|
||||
@Id UNIQUEIDENTIFIER OUTPUT,
|
||||
@Name NVARCHAR(50),
|
||||
@Email NVARCHAR(256),
|
||||
@EmailVerified BIT,
|
||||
@MasterPassword NVARCHAR(300),
|
||||
@MasterPasswordHint NVARCHAR(50),
|
||||
@Culture NVARCHAR(10),
|
||||
@SecurityStamp NVARCHAR(50),
|
||||
@TwoFactorProviders NVARCHAR(MAX),
|
||||
@TwoFactorRecoveryCode NVARCHAR(32),
|
||||
@EquivalentDomains NVARCHAR(MAX),
|
||||
@ExcludedGlobalEquivalentDomains NVARCHAR(MAX),
|
||||
@AccountRevisionDate DATETIME2(7),
|
||||
@Key NVARCHAR(MAX),
|
||||
@PublicKey NVARCHAR(MAX),
|
||||
@PrivateKey NVARCHAR(MAX),
|
||||
@Premium BIT,
|
||||
@PremiumExpirationDate DATETIME2(7),
|
||||
@RenewalReminderDate DATETIME2(7),
|
||||
@Storage BIGINT,
|
||||
@MaxStorageGb SMALLINT,
|
||||
@Gateway TINYINT,
|
||||
@GatewayCustomerId VARCHAR(50),
|
||||
@GatewaySubscriptionId VARCHAR(50),
|
||||
@ReferenceData VARCHAR(MAX),
|
||||
@LicenseKey VARCHAR(100),
|
||||
@Kdf TINYINT,
|
||||
@KdfIterations INT,
|
||||
@KdfMemory INT = NULL,
|
||||
@KdfParallelism INT = NULL,
|
||||
@CreationDate DATETIME2(7),
|
||||
@RevisionDate DATETIME2(7),
|
||||
@ApiKey VARCHAR(30),
|
||||
@ForcePasswordReset BIT = 0,
|
||||
@UsesKeyConnector BIT = 0,
|
||||
@FailedLoginCount INT = 0,
|
||||
@LastFailedLoginDate DATETIME2(7),
|
||||
@AvatarColor VARCHAR(7) = NULL,
|
||||
@LastPasswordChangeDate DATETIME2(7) = NULL,
|
||||
@LastKdfChangeDate DATETIME2(7) = NULL,
|
||||
@LastKeyRotationDate DATETIME2(7) = NULL,
|
||||
@LastEmailChangeDate DATETIME2(7) = NULL,
|
||||
@VerifyDevices BIT = 1,
|
||||
@SecurityState VARCHAR(MAX) = NULL,
|
||||
@SecurityVersion INT = NULL,
|
||||
@SignedPublicKey VARCHAR(MAX) = NULL,
|
||||
@V2UpgradeToken VARCHAR(MAX) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
INSERT INTO [dbo].[User]
|
||||
(
|
||||
[Id],
|
||||
[Name],
|
||||
[Email],
|
||||
[EmailVerified],
|
||||
[MasterPassword],
|
||||
[MasterPasswordHint],
|
||||
[Culture],
|
||||
[SecurityStamp],
|
||||
[TwoFactorProviders],
|
||||
[TwoFactorRecoveryCode],
|
||||
[EquivalentDomains],
|
||||
[ExcludedGlobalEquivalentDomains],
|
||||
[AccountRevisionDate],
|
||||
[Key],
|
||||
[PublicKey],
|
||||
[PrivateKey],
|
||||
[Premium],
|
||||
[PremiumExpirationDate],
|
||||
[RenewalReminderDate],
|
||||
[Storage],
|
||||
[MaxStorageGb],
|
||||
[Gateway],
|
||||
[GatewayCustomerId],
|
||||
[GatewaySubscriptionId],
|
||||
[ReferenceData],
|
||||
[LicenseKey],
|
||||
[Kdf],
|
||||
[KdfIterations],
|
||||
[CreationDate],
|
||||
[RevisionDate],
|
||||
[ApiKey],
|
||||
[ForcePasswordReset],
|
||||
[UsesKeyConnector],
|
||||
[FailedLoginCount],
|
||||
[LastFailedLoginDate],
|
||||
[AvatarColor],
|
||||
[KdfMemory],
|
||||
[KdfParallelism],
|
||||
[LastPasswordChangeDate],
|
||||
[LastKdfChangeDate],
|
||||
[LastKeyRotationDate],
|
||||
[LastEmailChangeDate],
|
||||
[VerifyDevices],
|
||||
[SecurityState],
|
||||
[SecurityVersion],
|
||||
[SignedPublicKey],
|
||||
[MaxStorageGbIncreased],
|
||||
[V2UpgradeToken]
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
@Id,
|
||||
@Name,
|
||||
@Email,
|
||||
@EmailVerified,
|
||||
@MasterPassword,
|
||||
@MasterPasswordHint,
|
||||
@Culture,
|
||||
@SecurityStamp,
|
||||
@TwoFactorProviders,
|
||||
@TwoFactorRecoveryCode,
|
||||
@EquivalentDomains,
|
||||
@ExcludedGlobalEquivalentDomains,
|
||||
@AccountRevisionDate,
|
||||
@Key,
|
||||
@PublicKey,
|
||||
@PrivateKey,
|
||||
@Premium,
|
||||
@PremiumExpirationDate,
|
||||
@RenewalReminderDate,
|
||||
@Storage,
|
||||
@MaxStorageGb,
|
||||
@Gateway,
|
||||
@GatewayCustomerId,
|
||||
@GatewaySubscriptionId,
|
||||
@ReferenceData,
|
||||
@LicenseKey,
|
||||
@Kdf,
|
||||
@KdfIterations,
|
||||
@CreationDate,
|
||||
@RevisionDate,
|
||||
@ApiKey,
|
||||
@ForcePasswordReset,
|
||||
@UsesKeyConnector,
|
||||
@FailedLoginCount,
|
||||
@LastFailedLoginDate,
|
||||
@AvatarColor,
|
||||
@KdfMemory,
|
||||
@KdfParallelism,
|
||||
@LastPasswordChangeDate,
|
||||
@LastKdfChangeDate,
|
||||
@LastKeyRotationDate,
|
||||
@LastEmailChangeDate,
|
||||
@VerifyDevices,
|
||||
@SecurityState,
|
||||
@SecurityVersion,
|
||||
@SignedPublicKey,
|
||||
@MaxStorageGb,
|
||||
@V2UpgradeToken
|
||||
)
|
||||
END
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE [dbo].[User_Update]
|
||||
@Id UNIQUEIDENTIFIER,
|
||||
@Name NVARCHAR(50),
|
||||
@Email NVARCHAR(256),
|
||||
@EmailVerified BIT,
|
||||
@MasterPassword NVARCHAR(300),
|
||||
@MasterPasswordHint NVARCHAR(50),
|
||||
@Culture NVARCHAR(10),
|
||||
@SecurityStamp NVARCHAR(50),
|
||||
@TwoFactorProviders NVARCHAR(MAX),
|
||||
@TwoFactorRecoveryCode NVARCHAR(32),
|
||||
@EquivalentDomains NVARCHAR(MAX),
|
||||
@ExcludedGlobalEquivalentDomains NVARCHAR(MAX),
|
||||
@AccountRevisionDate DATETIME2(7),
|
||||
@Key NVARCHAR(MAX),
|
||||
@PublicKey NVARCHAR(MAX),
|
||||
@PrivateKey NVARCHAR(MAX),
|
||||
@Premium BIT,
|
||||
@PremiumExpirationDate DATETIME2(7),
|
||||
@RenewalReminderDate DATETIME2(7),
|
||||
@Storage BIGINT,
|
||||
@MaxStorageGb SMALLINT,
|
||||
@Gateway TINYINT,
|
||||
@GatewayCustomerId VARCHAR(50),
|
||||
@GatewaySubscriptionId VARCHAR(50),
|
||||
@ReferenceData VARCHAR(MAX),
|
||||
@LicenseKey VARCHAR(100),
|
||||
@Kdf TINYINT,
|
||||
@KdfIterations INT,
|
||||
@KdfMemory INT = NULL,
|
||||
@KdfParallelism INT = NULL,
|
||||
@CreationDate DATETIME2(7),
|
||||
@RevisionDate DATETIME2(7),
|
||||
@ApiKey VARCHAR(30),
|
||||
@ForcePasswordReset BIT = 0,
|
||||
@UsesKeyConnector BIT = 0,
|
||||
@FailedLoginCount INT,
|
||||
@LastFailedLoginDate DATETIME2(7),
|
||||
@AvatarColor VARCHAR(7),
|
||||
@LastPasswordChangeDate DATETIME2(7) = NULL,
|
||||
@LastKdfChangeDate DATETIME2(7) = NULL,
|
||||
@LastKeyRotationDate DATETIME2(7) = NULL,
|
||||
@LastEmailChangeDate DATETIME2(7) = NULL,
|
||||
@VerifyDevices BIT = 1,
|
||||
@SecurityState VARCHAR(MAX) = NULL,
|
||||
@SecurityVersion INT = NULL,
|
||||
@SignedPublicKey VARCHAR(MAX) = NULL,
|
||||
@V2UpgradeToken VARCHAR(MAX) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON
|
||||
|
||||
UPDATE
|
||||
[dbo].[User]
|
||||
SET
|
||||
[Name] = @Name,
|
||||
[Email] = @Email,
|
||||
[EmailVerified] = @EmailVerified,
|
||||
[MasterPassword] = @MasterPassword,
|
||||
[MasterPasswordHint] = @MasterPasswordHint,
|
||||
[Culture] = @Culture,
|
||||
[SecurityStamp] = @SecurityStamp,
|
||||
[TwoFactorProviders] = @TwoFactorProviders,
|
||||
[TwoFactorRecoveryCode] = @TwoFactorRecoveryCode,
|
||||
[EquivalentDomains] = @EquivalentDomains,
|
||||
[ExcludedGlobalEquivalentDomains] = @ExcludedGlobalEquivalentDomains,
|
||||
[AccountRevisionDate] = @AccountRevisionDate,
|
||||
[Key] = @Key,
|
||||
[PublicKey] = @PublicKey,
|
||||
[PrivateKey] = @PrivateKey,
|
||||
[Premium] = @Premium,
|
||||
[PremiumExpirationDate] = @PremiumExpirationDate,
|
||||
[RenewalReminderDate] = @RenewalReminderDate,
|
||||
[Storage] = @Storage,
|
||||
[MaxStorageGb] = @MaxStorageGb,
|
||||
[Gateway] = @Gateway,
|
||||
[GatewayCustomerId] = @GatewayCustomerId,
|
||||
[GatewaySubscriptionId] = @GatewaySubscriptionId,
|
||||
[ReferenceData] = @ReferenceData,
|
||||
[LicenseKey] = @LicenseKey,
|
||||
[Kdf] = @Kdf,
|
||||
[KdfIterations] = @KdfIterations,
|
||||
[KdfMemory] = @KdfMemory,
|
||||
[KdfParallelism] = @KdfParallelism,
|
||||
[CreationDate] = @CreationDate,
|
||||
[RevisionDate] = @RevisionDate,
|
||||
[ApiKey] = @ApiKey,
|
||||
[ForcePasswordReset] = @ForcePasswordReset,
|
||||
[UsesKeyConnector] = @UsesKeyConnector,
|
||||
[FailedLoginCount] = @FailedLoginCount,
|
||||
[LastFailedLoginDate] = @LastFailedLoginDate,
|
||||
[AvatarColor] = @AvatarColor,
|
||||
[LastPasswordChangeDate] = @LastPasswordChangeDate,
|
||||
[LastKdfChangeDate] = @LastKdfChangeDate,
|
||||
[LastKeyRotationDate] = @LastKeyRotationDate,
|
||||
[LastEmailChangeDate] = @LastEmailChangeDate,
|
||||
[VerifyDevices] = @VerifyDevices,
|
||||
[SecurityState] = @SecurityState,
|
||||
[SecurityVersion] = @SecurityVersion,
|
||||
[SignedPublicKey] = @SignedPublicKey,
|
||||
[MaxStorageGbIncreased] = @MaxStorageGb,
|
||||
[V2UpgradeToken] = @V2UpgradeToken
|
||||
WHERE
|
||||
[Id] = @Id
|
||||
END
|
||||
GO
|
||||
|
||||
CREATE OR ALTER VIEW [dbo].[UserView]
|
||||
AS
|
||||
SELECT
|
||||
[Id],
|
||||
[Name],
|
||||
[Email],
|
||||
[EmailVerified],
|
||||
[MasterPassword],
|
||||
[MasterPasswordHint],
|
||||
[Culture],
|
||||
[SecurityStamp],
|
||||
[TwoFactorProviders],
|
||||
[TwoFactorRecoveryCode],
|
||||
[EquivalentDomains],
|
||||
[ExcludedGlobalEquivalentDomains],
|
||||
[AccountRevisionDate],
|
||||
[Key],
|
||||
[PublicKey],
|
||||
[PrivateKey],
|
||||
[Premium],
|
||||
[PremiumExpirationDate],
|
||||
[RenewalReminderDate],
|
||||
[Storage],
|
||||
COALESCE([MaxStorageGbIncreased], [MaxStorageGb]) AS [MaxStorageGb],
|
||||
[Gateway],
|
||||
[GatewayCustomerId],
|
||||
[GatewaySubscriptionId],
|
||||
[ReferenceData],
|
||||
[LicenseKey],
|
||||
[ApiKey],
|
||||
[Kdf],
|
||||
[KdfIterations],
|
||||
[KdfMemory],
|
||||
[KdfParallelism],
|
||||
[CreationDate],
|
||||
[RevisionDate],
|
||||
[ForcePasswordReset],
|
||||
[UsesKeyConnector],
|
||||
[FailedLoginCount],
|
||||
[LastFailedLoginDate],
|
||||
[AvatarColor],
|
||||
[LastPasswordChangeDate],
|
||||
[LastKdfChangeDate],
|
||||
[LastKeyRotationDate],
|
||||
[LastEmailChangeDate],
|
||||
[VerifyDevices],
|
||||
[SecurityState],
|
||||
[SecurityVersion],
|
||||
[SignedPublicKey],
|
||||
[V2UpgradeToken]
|
||||
FROM
|
||||
[dbo].[User]
|
||||
GO
|
||||
|
||||
EXECUTE sp_refreshview N'[dbo].[EmergencyAccessDetailsView]';
|
||||
EXECUTE sp_refreshview N'[dbo].[OrganizationUserUserDetailsView]';
|
||||
EXECUTE sp_refreshview N'[dbo].[ProviderUserUserDetailsView]';
|
||||
EXECUTE sp_refreshview N'[dbo].[UserEmailDomainView]';
|
||||
EXECUTE sp_refreshview N'[dbo].[UserPremiumAccessView]';
|
||||
GO
|
||||
3582
util/MySqlMigrations/Migrations/20260219164247_V2UpgradeToken.Designer.cs
generated
Normal file
3582
util/MySqlMigrations/Migrations/20260219164247_V2UpgradeToken.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bit.MySqlMigrations.Migrations;
|
||||
|
||||
/// <inheritdoc />
|
||||
public partial class V2UpgradeToken : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "V2UpgradeToken",
|
||||
table: "User",
|
||||
type: "longtext",
|
||||
nullable: true)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "V2UpgradeToken",
|
||||
table: "User");
|
||||
}
|
||||
}
|
||||
@@ -2052,6 +2052,9 @@ namespace Bit.MySqlMigrations.Migrations
|
||||
b.Property<bool>("UsesKeyConnector")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<string>("V2UpgradeToken")
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<bool>("VerifyDevices")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
|
||||
3588
util/PostgresMigrations/Migrations/20260219164255_V2UpgradeToken.Designer.cs
generated
Normal file
3588
util/PostgresMigrations/Migrations/20260219164255_V2UpgradeToken.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bit.PostgresMigrations.Migrations;
|
||||
|
||||
/// <inheritdoc />
|
||||
public partial class V2UpgradeToken : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "V2UpgradeToken",
|
||||
table: "User",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "V2UpgradeToken",
|
||||
table: "User");
|
||||
}
|
||||
}
|
||||
@@ -2058,6 +2058,9 @@ namespace Bit.PostgresMigrations.Migrations
|
||||
b.Property<bool>("UsesKeyConnector")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("V2UpgradeToken")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("VerifyDevices")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
|
||||
3571
util/SqliteMigrations/Migrations/20260219164301_V2UpgradeToken.Designer.cs
generated
Normal file
3571
util/SqliteMigrations/Migrations/20260219164301_V2UpgradeToken.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bit.SqliteMigrations.Migrations;
|
||||
|
||||
/// <inheritdoc />
|
||||
public partial class V2UpgradeToken : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "V2UpgradeToken",
|
||||
table: "User",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "V2UpgradeToken",
|
||||
table: "User");
|
||||
}
|
||||
}
|
||||
@@ -2041,6 +2041,9 @@ namespace Bit.SqliteMigrations.Migrations
|
||||
b.Property<bool>("UsesKeyConnector")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("V2UpgradeToken")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("VerifyDevices")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user