diff --git a/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs index cb66540a6bb4..6ceede492eaf 100644 --- a/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs @@ -1,6 +1,8 @@ using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Models.Api.Request; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Utilities; namespace Bit.Core.Auth.Models.Api.Request.Accounts; @@ -38,7 +40,12 @@ public class RegisterFinishRequestModel : IValidatableObject // in the MasterPasswordAuthenticationData. public string? UserSymmetricKey { get; set; } - public required KeysRequestModel UserAsymmetricKeys { get; set; } + // TODO Remove property below, deprecated due to new AccountKeys property + // https://bitwarden.atlassian.net/browse/PM-27326 + // Will throw error if both UserAsymmetricKeys and AccountKeys do not exist. + public KeysRequestModel? UserAsymmetricKeys { get; set; } + + public AccountKeysRequestModel? AccountKeys { get; set; } // PM-28143 - Remove line below (made optional during migration to MasterPasswordUnlockData) public KdfType? Kdf { get; set; } @@ -61,25 +68,84 @@ public class RegisterFinishRequestModel : IValidatableObject public Guid? ProviderUserId { get; set; } - public User ToUser() + public User ToUser(bool IsV2Encryption) { - var user = new User + // TODO remove IsV2Encryption bool and simplify logic below after a compatibility period - once V2 accounts are supported + // https://bitwarden.atlassian.net/browse/PM-27326 + if (!IsV2Encryption) + { + var user = new User + { + Email = Email, + MasterPasswordHint = MasterPasswordHint, + Kdf = (KdfType)(MasterPasswordUnlock?.Kdf.KdfType ?? Kdf)!, + KdfIterations = (int)(MasterPasswordUnlock?.Kdf.Iterations ?? KdfIterations)!, + // KdfMemory and KdfParallelism are optional (only used for Argon2id) + KdfMemory = MasterPasswordUnlock?.Kdf.Memory ?? KdfMemory, + KdfParallelism = MasterPasswordUnlock?.Kdf.Parallelism ?? KdfParallelism, + // PM-28827 To be added when MasterPasswordSalt is added to the user column + // MasterPasswordSalt = MasterPasswordUnlock?.Salt ?? Email.ToLower().Trim(), + Key = MasterPasswordUnlock?.MasterKeyWrappedUserKey ?? UserSymmetricKey + }; + + user = UserAsymmetricKeys?.ToUser(user) ?? throw new Exception("User's public and private account keys couldn't be found in either AccountKeys or UserAsymmetricKeys"); + + return user; + } + return new User { Email = Email, MasterPasswordHint = MasterPasswordHint, - Kdf = (KdfType)(MasterPasswordUnlock?.Kdf.KdfType ?? Kdf)!, - KdfIterations = (int)(MasterPasswordUnlock?.Kdf.Iterations ?? KdfIterations)!, - // KdfMemory and KdfParallelism are optional (only used for Argon2id) - KdfMemory = MasterPasswordUnlock?.Kdf.Memory ?? KdfMemory, - KdfParallelism = MasterPasswordUnlock?.Kdf.Parallelism ?? KdfParallelism, - // PM-28827 To be added when MasterPasswordSalt is added to the user column - // MasterPasswordSalt = MasterPasswordUnlock?.Salt ?? Email.ToLower().Trim(), - Key = MasterPasswordUnlock?.MasterKeyWrappedUserKey ?? UserSymmetricKey }; + } - UserAsymmetricKeys.ToUser(user); - - return user; + public RegisterFinishData ToData() + { + // TODO clean up flow once old fields are deprecated + // https://bitwarden.atlassian.net/browse/PM-27326 + return new RegisterFinishData + { + MasterPasswordUnlockData = MasterPasswordUnlock?.ToData() ?? + new MasterPasswordUnlockData + { + Kdf = new KdfSettings + { + KdfType = Kdf ?? throw new Exception("KdfType couldn't be found on either the MasterPasswordUnlockData or the Kdf property passed in."), + Iterations = KdfIterations ?? throw new Exception("KdfIterations couldn't be found on either the MasterPasswordUnlockData or the KdfIterations property passed in."), + // KdfMemory and KdfParallelism are optional (only used for Argon2id) + Memory = KdfMemory, + Parallelism = KdfParallelism, + }, + MasterKeyWrappedUserKey = UserSymmetricKey ?? throw new Exception("MasterKeyWrappedUserKey couldn't be found on either the MasterPasswordUnlockData or the UserSymmetricKey property passed in."), + // PM-28827 To be added when MasterPasswordSalt is added to the user column + Salt = Email.ToLowerInvariant().Trim(), + }, + UserAccountKeysData = AccountKeys?.ToAccountKeysData() ?? + new UserAccountKeysData + { + PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData + ( + UserAsymmetricKeys?.EncryptedPrivateKey ?? + throw new Exception("WrappedPrivateKey couldn't be found in either AccountKeys or UserAsymmetricKeys."), + UserAsymmetricKeys?.PublicKey ?? + throw new Exception("PublicKey couldn't be found in either AccountKeys or UserAsymmetricKeys") + ), + }, + MasterPasswordAuthenticationData = MasterPasswordAuthentication?.ToData() ?? + new MasterPasswordAuthenticationData + { + Kdf = new KdfSettings + { + KdfType = Kdf ?? throw new Exception("KdfType couldn't be found on either the MasterPasswordUnlockData or the Kdf property passed in."), + Iterations = KdfIterations ?? throw new Exception("KdfIterations couldn't be found on either the MasterPasswordUnlockData or the KdfIterations property passed in."), + // KdfMemory and KdfParallelism are optional (only used for Argon2id) + Memory = KdfMemory, + Parallelism = KdfParallelism, + }, + MasterPasswordAuthenticationHash = MasterPasswordHash ?? throw new BadRequestException("MasterPasswordHash couldn't be found on either the MasterPasswordAuthenticationData or the MasterPasswordHash property passed in."), + Salt = Email.ToLowerInvariant().Trim(), + } + }; } public RegisterFinishTokenType GetTokenType() @@ -188,6 +254,13 @@ public IEnumerable Validate(ValidationContext validationContex yield return new ValidationResult($"{nameof(MasterPasswordAuthentication)} not found on RequestModel", [nameof(MasterPasswordAuthentication)]); } + if (AccountKeys == null && UserAsymmetricKeys == null) + { + yield return new ValidationResult( + $"{nameof(AccountKeys.PublicKeyEncryptionKeyPair.PublicKey)} and {nameof(AccountKeys.PublicKeyEncryptionKeyPair.WrappedPrivateKey)} not found in RequestModel", + [nameof(AccountKeys.PublicKeyEncryptionKeyPair.PublicKey), nameof(AccountKeys.PublicKeyEncryptionKeyPair.WrappedPrivateKey)]); + } + // 3. Lastly, validate access token type and presence. Must be done last because of yield break. RegisterFinishTokenType tokenType; var tokenTypeResolved = true; diff --git a/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs index 97c2eabd3c8a..9f8ad616da1a 100644 --- a/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.UserFeatures.Registration; @@ -31,11 +32,11 @@ public interface IRegisterUserCommand /// If the organization has a 2FA required policy enabled, email verification will be enabled for the user. /// /// The to create - /// The hashed master password the user entered + /// Cryptographic data for finishing user registration /// The org invite token sent to the user via email /// The associated org user guid that was created at the time of invite /// - public Task RegisterUserViaOrganizationInviteToken(User user, string masterPasswordHash, string orgInviteToken, Guid? orgUserId); + public Task RegisterUserViaOrganizationInviteToken(User user, RegisterFinishData registerFinishData, string orgInviteToken, Guid? orgUserId); /// /// Creates a new user with a given master password hash, sends a welcome email, and raises the signup reference event. @@ -43,10 +44,10 @@ public interface IRegisterUserCommand /// An error will be thrown if the token is invalid or expired. /// /// The to create - /// The hashed master password the user entered + /// Cryptographic data for finishing user registration /// The email verification token sent to the user via email /// - public Task RegisterUserViaEmailVerificationToken(User user, string masterPasswordHash, string emailVerificationToken); + public Task RegisterUserViaEmailVerificationToken(User user, RegisterFinishData registerFinishData, string emailVerificationToken); /// /// Creates a new user with a given master password hash, sends a welcome email, and raises the signup reference event. @@ -54,10 +55,10 @@ public interface IRegisterUserCommand /// If the token is invalid or expired, an error will be thrown. /// /// The to create - /// The hashed master password the user entered + /// Cryptographic data for finishing user registration /// The org sponsored free family plan invite token sent to the user via email /// - public Task RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(User user, string masterPasswordHash, string orgSponsoredFreeFamilyPlanInviteToken); + public Task RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(User user, RegisterFinishData registerFinishData, string orgSponsoredFreeFamilyPlanInviteToken); /// /// Creates a new user with a given master password hash, sends a welcome email, and raises the signup reference event. @@ -65,11 +66,11 @@ public interface IRegisterUserCommand /// If the token is invalid or expired, an error will be thrown. /// /// The to create - /// The hashed master password the user entered + /// Cryptographic data for finishing user registration /// The emergency access invite token sent to the user via email /// The emergency access id (used to validate the token) /// - public Task RegisterUserViaAcceptEmergencyAccessInviteToken(User user, string masterPasswordHash, + public Task RegisterUserViaAcceptEmergencyAccessInviteToken(User user, RegisterFinishData registerFinishData, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId); /// @@ -78,10 +79,10 @@ public Task RegisterUserViaAcceptEmergencyAccessInviteToken(User /// If the token is invalid or expired, an error will be thrown. /// /// The to create - /// The hashed master password the user entered + /// Cryptographic data for finishing user registration /// The provider invite token sent to the user via email /// The provider user id which is used to validate the invite token /// - public Task RegisterUserViaProviderInviteToken(User user, string masterPasswordHash, string providerInviteToken, Guid providerUserId); + public Task RegisterUserViaProviderInviteToken(User user, RegisterFinishData registerFinishData, string providerInviteToken, Guid providerUserId); } diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index 823ec585ba0d..eb7323fd9199 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -8,6 +8,7 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Entities; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; @@ -107,7 +108,7 @@ public async Task RegisterSSOAutoProvisionedUserAsync(User user, return result; } - public async Task RegisterUserViaOrganizationInviteToken(User user, string masterPasswordHash, + public async Task RegisterUserViaOrganizationInviteToken(User user, RegisterFinishData registerFinishData, string orgInviteToken, Guid? orgUserId) { TryValidateOrgInviteToken(orgInviteToken, orgUserId, user); @@ -125,7 +126,7 @@ public async Task RegisterUserViaOrganizationInviteToken(User us user.EmailVerified = true; } - var result = await _userService.CreateUserAsync(user, masterPasswordHash); + var result = await _userService.CreateUserAsync(user, registerFinishData); var organization = await GetOrganizationUserOrganization(orgUserId ?? Guid.Empty, orgUser); if (result == IdentityResult.Success) { @@ -257,7 +258,7 @@ private async Task SendAppropriateWelcomeEmailAsync(User user, string initiation } } - public async Task RegisterUserViaEmailVerificationToken(User user, string masterPasswordHash, + public async Task RegisterUserViaEmailVerificationToken(User user, RegisterFinishData registerFinishData, string emailVerificationToken) { ValidateOpenRegistrationAllowed(); @@ -269,7 +270,7 @@ public async Task RegisterUserViaEmailVerificationToken(User use user.Name = tokenable.Name; user.ApiKey = CoreHelpers.SecureRandomString(30); // API key can't be null. - var result = await _userService.CreateUserAsync(user, masterPasswordHash); + var result = await _userService.CreateUserAsync(user, registerFinishData); if (result == IdentityResult.Success) { await SendWelcomeEmailAsync(user); @@ -278,7 +279,7 @@ public async Task RegisterUserViaEmailVerificationToken(User use return result; } - public async Task RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(User user, string masterPasswordHash, + public async Task RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(User user, RegisterFinishData registerFinishData, string orgSponsoredFreeFamilyPlanInviteToken) { ValidateOpenRegistrationAllowed(); @@ -288,7 +289,7 @@ public async Task RegisterUserViaOrganizationSponsoredFreeFamily user.EmailVerified = true; user.ApiKey = CoreHelpers.SecureRandomString(30); // API key can't be null. - var result = await _userService.CreateUserAsync(user, masterPasswordHash); + var result = await _userService.CreateUserAsync(user, registerFinishData); if (result == IdentityResult.Success) { await SendWelcomeEmailAsync(user); @@ -299,7 +300,7 @@ public async Task RegisterUserViaOrganizationSponsoredFreeFamily // TODO: in future, consider how we can consolidate base registration logic to reduce code duplication - public async Task RegisterUserViaAcceptEmergencyAccessInviteToken(User user, string masterPasswordHash, + public async Task RegisterUserViaAcceptEmergencyAccessInviteToken(User user, RegisterFinishData registerFinishData, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) { ValidateOpenRegistrationAllowed(); @@ -309,7 +310,7 @@ public async Task RegisterUserViaAcceptEmergencyAccessInviteToke user.EmailVerified = true; user.ApiKey = CoreHelpers.SecureRandomString(30); // API key can't be null. - var result = await _userService.CreateUserAsync(user, masterPasswordHash); + var result = await _userService.CreateUserAsync(user, registerFinishData); if (result == IdentityResult.Success) { await SendWelcomeEmailAsync(user); @@ -318,7 +319,7 @@ public async Task RegisterUserViaAcceptEmergencyAccessInviteToke return result; } - public async Task RegisterUserViaProviderInviteToken(User user, string masterPasswordHash, + public async Task RegisterUserViaProviderInviteToken(User user, RegisterFinishData registerFinishData, string providerInviteToken, Guid providerUserId) { ValidateOpenRegistrationAllowed(); @@ -328,7 +329,7 @@ public async Task RegisterUserViaProviderInviteToken(User user, user.EmailVerified = true; user.ApiKey = CoreHelpers.SecureRandomString(30); // API key can't be null. - var result = await _userService.CreateUserAsync(user, masterPasswordHash); + var result = await _userService.CreateUserAsync(user, registerFinishData); if (result == IdentityResult.Success) { await SendWelcomeEmailAsync(user); diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 08d7f6c29254..bb753e51c5b9 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -211,6 +211,7 @@ public static class FeatureFlagKeys 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"; + public const string EnableAccountEncryptionV2PasswordRegistration = "pm-27278-v2-password-registration"; /* Mobile Team */ public const string AndroidImportLoginsFlow = "import-logins-flow"; diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs index 6e53dfa744a4..ae95bf6ad4a5 100644 --- a/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs @@ -21,4 +21,21 @@ public void ValidateSaltUnchangedForUser(User user) throw new BadRequestException("Invalid master password salt."); } } + + public override bool Equals(object? obj) + { + if (obj is not MasterPasswordAuthenticationData other) + { + return false; + } + + return Kdf.Equals(other.Kdf) && + MasterPasswordAuthenticationHash == other.MasterPasswordAuthenticationHash && + Salt == other.Salt; + } + + public override int GetHashCode() + { + return HashCode.Combine(Kdf, MasterPasswordAuthenticationHash, Salt); + } } diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs index f8139cba99ff..3de075a7355c 100644 --- a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs @@ -21,4 +21,21 @@ public void ValidateSaltUnchangedForUser(User user) throw new BadRequestException("Invalid master password salt."); } } + + public override bool Equals(object? obj) + { + if (obj is not MasterPasswordUnlockData other) + { + return false; + } + + return Kdf.Equals(other.Kdf) && + MasterKeyWrappedUserKey == other.MasterKeyWrappedUserKey && + Salt == other.Salt; + } + + public override int GetHashCode() + { + return HashCode.Combine(Kdf, MasterKeyWrappedUserKey, Salt); + } } diff --git a/src/Core/KeyManagement/Models/Data/PublicKeyEncryptionKeyPairData.cs b/src/Core/KeyManagement/Models/Data/PublicKeyEncryptionKeyPairData.cs index fb8b09d39095..13f3c69f5417 100644 --- a/src/Core/KeyManagement/Models/Data/PublicKeyEncryptionKeyPairData.cs +++ b/src/Core/KeyManagement/Models/Data/PublicKeyEncryptionKeyPairData.cs @@ -17,4 +17,21 @@ public PublicKeyEncryptionKeyPairData(string wrappedPrivateKey, string publicKey PublicKey = publicKey ?? throw new ArgumentNullException(nameof(publicKey)); SignedPublicKey = signedPublicKey; } + + public override bool Equals(object? obj) + { + if (obj is not PublicKeyEncryptionKeyPairData other) + { + return false; + } + + return WrappedPrivateKey == other.WrappedPrivateKey && + SignedPublicKey == other.SignedPublicKey && + PublicKey == other.PublicKey; + } + + public override int GetHashCode() + { + return HashCode.Combine(WrappedPrivateKey, SignedPublicKey, PublicKey); + } } diff --git a/src/Core/KeyManagement/Models/Data/RegisterFinishData.cs b/src/Core/KeyManagement/Models/Data/RegisterFinishData.cs new file mode 100644 index 000000000000..35b4202aff49 --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/RegisterFinishData.cs @@ -0,0 +1,31 @@ +namespace Bit.Core.KeyManagement.Models.Data; + +public class RegisterFinishData +{ + public required MasterPasswordUnlockData MasterPasswordUnlockData { get; set; } + public required UserAccountKeysData UserAccountKeysData { get; set; } + public required MasterPasswordAuthenticationData MasterPasswordAuthenticationData { get; set; } + + public bool IsV2Encryption() + { + return UserAccountKeysData.IsV2Encryption(); + } + + public override bool Equals(object? obj) + { + if (obj is not RegisterFinishData other) + { + return false; + } + + return MasterPasswordUnlockData.Equals(other.MasterPasswordUnlockData) && + MasterPasswordAuthenticationData.Equals(other.MasterPasswordAuthenticationData) && + UserAccountKeysData.Equals(other.UserAccountKeysData) && + IsV2Encryption() == other.IsV2Encryption(); + } + + public override int GetHashCode() + { + return HashCode.Combine(MasterPasswordUnlockData, UserAccountKeysData, MasterPasswordAuthenticationData); + } +} diff --git a/src/Core/KeyManagement/Models/Data/SecurityStateData.cs b/src/Core/KeyManagement/Models/Data/SecurityStateData.cs index c9a4610387a1..a37663ba070c 100644 --- a/src/Core/KeyManagement/Models/Data/SecurityStateData.cs +++ b/src/Core/KeyManagement/Models/Data/SecurityStateData.cs @@ -7,4 +7,20 @@ public class SecurityStateData // The security version is included in the security state, but needs COSE parsing, // so this is a separate copy that can be used directly. public required int SecurityVersion { get; set; } + + public override bool Equals(object? obj) + { + if (obj is not SecurityStateData other) + { + return false; + } + + return SecurityState == other.SecurityState && + SecurityVersion == other.SecurityVersion; + } + + public override int GetHashCode() + { + return HashCode.Combine(SecurityState, SecurityVersion); + } } diff --git a/src/Core/KeyManagement/Models/Data/SignatureKeyPairData.cs b/src/Core/KeyManagement/Models/Data/SignatureKeyPairData.cs index 32ae3eef8fdd..66f977a46d71 100644 --- a/src/Core/KeyManagement/Models/Data/SignatureKeyPairData.cs +++ b/src/Core/KeyManagement/Models/Data/SignatureKeyPairData.cs @@ -18,4 +18,21 @@ public SignatureKeyPairData(SignatureAlgorithm signatureAlgorithm, string wrappe WrappedSigningKey = wrappedSigningKey ?? throw new ArgumentNullException(nameof(wrappedSigningKey)); VerifyingKey = verifyingKey ?? throw new ArgumentNullException(nameof(verifyingKey)); } + + public override bool Equals(object? obj) + { + if (obj is not SignatureKeyPairData other) + { + return false; + } + + return SignatureAlgorithm == other.SignatureAlgorithm && + WrappedSigningKey == other.WrappedSigningKey && + VerifyingKey == other.VerifyingKey; + } + + public override int GetHashCode() + { + return HashCode.Combine(SignatureAlgorithm, WrappedSigningKey, VerifyingKey); + } } diff --git a/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs b/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs index 3d552a10de07..0bd45916bc3f 100644 --- a/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs +++ b/src/Core/KeyManagement/Models/Data/UserAccountKeysData.cs @@ -31,4 +31,22 @@ public bool IsV2Encryption() throw new InvalidOperationException("Invalid account cryptographic state: V2 encryption fields must be either all present or all absent."); } } + + public override bool Equals(object? obj) + { + if (obj is not UserAccountKeysData other) + { + return false; + } + + return PublicKeyEncryptionKeyPairData.Equals(other.PublicKeyEncryptionKeyPairData) && + Equals(SignatureKeyPairData, other.SignatureKeyPairData) && + Equals(SecurityStateData, other.SecurityStateData) && + IsV2Encryption() == other.IsV2Encryption(); + } + + public override int GetHashCode() + { + return HashCode.Combine(PublicKeyEncryptionKeyPairData, SignatureKeyPairData, SecurityStateData); + } } diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index a5a8c4310b3e..71d5e7695c32 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -94,6 +94,8 @@ UpdateUserData SetMasterPassword(Guid userId, MasterPasswordUnlockData masterPas /// Actions to update user data. /// On success Task UpdateUserDataAsync(IEnumerable updateUserDataActions); + + UpdateUserData SetMasterPasswordUnlockUserData(Guid userId, MasterPasswordUnlockData masterPasswordUnlockData); } public delegate Task UpdateUserData(Microsoft.Data.SqlClient.SqlConnection? connection = null, diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 3957b504ba89..30ddcdd01ee2 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -7,6 +7,7 @@ using Bit.Core.Billing.Models.Business; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Models.Business; using Microsoft.AspNetCore.Identity; @@ -21,7 +22,7 @@ public interface IUserService Task GetAccountRevisionDateByIdAsync(Guid userId); Task SaveUserAsync(User user, bool push = false); Task CreateUserAsync(User user); - Task CreateUserAsync(User user, string masterPasswordHash); + Task CreateUserAsync(User user, RegisterFinishData registerFinishData); Task SendMasterPasswordHintAsync(string email); Task SendEmailVerificationAsync(User user); Task ConfirmEmailAsync(User user, string token); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 25bc577dccd2..b8c9cc821a36 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -23,6 +23,7 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; @@ -315,9 +316,22 @@ public async Task CreateUserAsync(User user) return await CreateAsync(user); } - public async Task CreateUserAsync(User user, string masterPasswordHash) + public async Task CreateUserAsync(User user, RegisterFinishData registerFinishData) { - return await CreateAsync(user, masterPasswordHash); + // TODO remove logic below after a compatibility period - once V2 accounts are fully supported + // https://bitwarden.atlassian.net/browse/PM-27326 + if (!registerFinishData.IsV2Encryption()) + { + return await CreateAsync(user, registerFinishData.MasterPasswordAuthenticationData.MasterPasswordAuthenticationHash); + } + + var result = await CreateAsync(user, registerFinishData.MasterPasswordAuthenticationData.MasterPasswordAuthenticationHash); + if (result.Succeeded) + { + var setRegisterFinishUserDataTask = _userRepository.SetMasterPasswordUnlockUserData(user.Id, registerFinishData.MasterPasswordUnlockData); + await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, registerFinishData.UserAccountKeysData, [setRegisterFinishUserDataTask]); + } + return result; } public async Task SendMasterPasswordHintAsync(string email) diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index e9807fb1fc4f..e2f748ca3792 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -141,28 +141,25 @@ public async Task PostRegisterVerificationEmailClicked([FromBody] [HttpPost("register/finish")] public async Task PostRegisterFinish([FromBody] RegisterFinishRequestModel model) { - User user = model.ToUser(); + var registerFinishData = model.ToData(); + var user = model.ToUser(registerFinishData.IsV2Encryption()); // Users will either have an emailed token or an email verification token - not both. IdentityResult? identityResult = null; - // PM-28143 - Just use the MasterPasswordAuthenticationData.MasterPasswordAuthenticationHash - string masterPasswordAuthenticationHash = model.MasterPasswordAuthentication?.MasterPasswordAuthenticationHash - ?? model.MasterPasswordHash!; - switch (model.GetTokenType()) { case RegisterFinishTokenType.EmailVerification: identityResult = await _registerUserCommand.RegisterUserViaEmailVerificationToken( user, - masterPasswordAuthenticationHash, + registerFinishData, model.EmailVerificationToken!); return ProcessRegistrationResult(identityResult, user); case RegisterFinishTokenType.OrganizationInvite: identityResult = await _registerUserCommand.RegisterUserViaOrganizationInviteToken( user, - masterPasswordAuthenticationHash, + registerFinishData, model.OrgInviteToken!, model.OrganizationUserId); return ProcessRegistrationResult(identityResult, user); @@ -170,14 +167,14 @@ public async Task PostRegisterFinish([FromBody] Reg case RegisterFinishTokenType.OrgSponsoredFreeFamilyPlan: identityResult = await _registerUserCommand.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken( user, - masterPasswordAuthenticationHash, + registerFinishData, model.OrgSponsoredFreeFamilyPlanToken!); return ProcessRegistrationResult(identityResult, user); case RegisterFinishTokenType.EmergencyAccessInvite: identityResult = await _registerUserCommand.RegisterUserViaAcceptEmergencyAccessInviteToken( user, - masterPasswordAuthenticationHash, + registerFinishData, model.AcceptEmergencyAccessInviteToken!, (Guid)model.AcceptEmergencyAccessId!); return ProcessRegistrationResult(identityResult, user); @@ -185,7 +182,7 @@ public async Task PostRegisterFinish([FromBody] Reg case RegisterFinishTokenType.ProviderInvite: identityResult = await _registerUserCommand.RegisterUserViaProviderInviteToken( user, - masterPasswordAuthenticationHash, + registerFinishData, model.ProviderInviteToken!, (Guid)model.ProviderUserId!); return ProcessRegistrationResult(identityResult, user); diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs index eaeaf5f1805a..a47e183719db 100644 --- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs @@ -516,6 +516,30 @@ public async Task UpdateUserDataAsync(IEnumerable updateUserData } } + public UpdateUserData SetMasterPasswordUnlockUserData(Guid userId, MasterPasswordUnlockData masterPasswordUnlockData) + { + return async (connection, transaction) => + { + var timestamp = DateTime.UtcNow; + + await connection!.ExecuteAsync( + "[dbo].[User_UpdateMasterPasswordUnlockUserData]", + new + { + Id = userId, + Kdf = masterPasswordUnlockData.Kdf.KdfType, + KdfIterations = masterPasswordUnlockData.Kdf.Iterations, + KdfMemory = masterPasswordUnlockData.Kdf.Memory, + KdfParallelism = masterPasswordUnlockData.Kdf.Parallelism, + Key = masterPasswordUnlockData.MasterKeyWrappedUserKey, + RevisionDate = timestamp, + AccountRevisionDate = timestamp, + }, + transaction: transaction, + commandType: CommandType.StoredProcedure); + }; + } + private async Task ProtectDataAndSaveAsync(User user, Func saveTask) { if (user == null) diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index c364eeb8bc4c..cb2d890045a1 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -581,6 +581,28 @@ public async Task UpdateUserDataAsync(IEnumerable updateUserData await transaction.CommitAsync(); } + public UpdateUserData SetMasterPasswordUnlockUserData(Guid userId, MasterPasswordUnlockData masterPasswordUnlockData) + { + return async (_, _) => + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + var userEntity = await dbContext.Users.FindAsync(userId) ?? throw new ArgumentException("User not found", nameof(userId)); + var timestamp = DateTime.UtcNow; + + userEntity.Kdf = masterPasswordUnlockData.Kdf.KdfType; + userEntity.KdfIterations = masterPasswordUnlockData.Kdf.Iterations; + userEntity.KdfMemory = masterPasswordUnlockData.Kdf.Memory; + userEntity.KdfParallelism = masterPasswordUnlockData.Kdf.Parallelism; + userEntity.Key = masterPasswordUnlockData.MasterKeyWrappedUserKey; + userEntity.RevisionDate = timestamp; + userEntity.AccountRevisionDate = timestamp; + + await dbContext.SaveChangesAsync(); + }; + } + private static void MigrateDefaultUserCollectionsToShared(DatabaseContext dbContext, IEnumerable userIds) { var defaultCollections = (from c in dbContext.Collections diff --git a/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateMasterPasswordUnlockUserData.sql b/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateMasterPasswordUnlockUserData.sql new file mode 100644 index 000000000000..21fdb70d86c7 --- /dev/null +++ b/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateMasterPasswordUnlockUserData.sql @@ -0,0 +1,26 @@ +CREATE PROCEDURE [dbo].[User_UpdateMasterPasswordUnlockUserData] + @Id UNIQUEIDENTIFIER, + @Kdf TINYINT, + @KdfIterations INT, + @KdfMemory INT, + @KdfParallelism INT, + @Key VARCHAR(MAX), + @RevisionDate DATETIME2(7), + @AccountRevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[User] + SET + [Key] = @Key, + [Kdf] = @Kdf, + [KdfIterations] = @KdfIterations, + [KdfMemory] = @KdfMemory, + [KdfParallelism] = @KdfParallelism, + [RevisionDate] = @RevisionDate, + [AccountRevisionDate] = @AccountRevisionDate + WHERE + [Id] = @Id +END diff --git a/test/Common/AutoFixture/SignatureKeyPairRequestModelFixtures.cs b/test/Common/AutoFixture/SignatureKeyPairRequestModelFixtures.cs new file mode 100644 index 000000000000..f3b66104b0b3 --- /dev/null +++ b/test/Common/AutoFixture/SignatureKeyPairRequestModelFixtures.cs @@ -0,0 +1,19 @@ +using AutoFixture; +using Bit.Core.KeyManagement.Models.Api.Request; +using Bit.Test.Common.AutoFixture.Attributes; + +namespace Bit.Test.Common.AutoFixture; + +internal class SignatureKeyPairRequestModelCustomization : ICustomization +{ + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(o => o.SignatureAlgorithm, "ed25519")); + } +} + +public class SignatureKeyPairRequestModelCustomizeAttribute : BitCustomizeAttribute +{ + public override ICustomization GetCustomization() => new SignatureKeyPairRequestModelCustomization(); +} diff --git a/test/Core.Test/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModelTests.cs b/test/Core.Test/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModelTests.cs index 3c099ce9626d..8654a94ae7db 100644 --- a/test/Core.Test/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModelTests.cs +++ b/test/Core.Test/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModelTests.cs @@ -1,6 +1,7 @@ using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Enums; using Bit.Core.KeyManagement.Models.Api.Request; +using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Xunit; @@ -150,15 +151,89 @@ public void GetTokenType_Returns_Invalid(string email, string masterPasswordHash [Theory] [BitAutoData] - public void ToUser_Returns_User(string email, string masterPasswordHash, string masterPasswordHint, - string userSymmetricKey, KeysRequestModel userAsymmetricKeys, KdfType kdf, int kdfIterations, - int? kdfMemory, int? kdfParallelism) + [SignatureKeyPairRequestModelCustomize] + public void ToData_Returns_ToData(string email, string masterPasswordHint, KdfRequestModel kdfRequest, string masterPasswordAuthenticationHash, AccountKeysRequestModel accountKeysRequest, string userSymmetricKey, KeysRequestModel userAsymmetricKeys, KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism) { // Arrange - var model = new RegisterFinishRequestModel + // V1 model and fields to be removed with + // https://bitwarden.atlassian.net/browse/PM-27326 + var legacyModel = new RegisterFinishRequestModel { Email = email, - MasterPasswordHash = masterPasswordHash, + MasterPasswordHash = masterPasswordAuthenticationHash, + MasterPasswordHint = masterPasswordHint, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys, + Kdf = kdf, + KdfIterations = kdfIterations, + KdfMemory = kdfMemory, + KdfParallelism = kdfParallelism + }; + var newModel = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHint = masterPasswordHint, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdfRequest, + MasterPasswordAuthenticationHash = masterPasswordAuthenticationHash, + Salt = email.ToLowerInvariant().Trim() + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdfRequest, + MasterKeyWrappedUserKey = userSymmetricKey, + Salt = email.ToLowerInvariant().Trim() + }, + AccountKeys = accountKeysRequest + }; + + // Act + var legacyData = legacyModel.ToData(); + var newData = newModel.ToData(); + + // Assert + Assert.False(legacyData.IsV2Encryption()); + Assert.Equal(legacyData.MasterPasswordUnlockData.Kdf.KdfType, kdf); + Assert.Equal(legacyData.MasterPasswordUnlockData.Kdf.Iterations, kdfIterations); + Assert.Equal(legacyData.MasterPasswordUnlockData.Kdf.Memory, kdfMemory); + Assert.Equal(legacyData.MasterPasswordUnlockData.Kdf.Parallelism, kdfParallelism); + Assert.Equal(legacyData.MasterPasswordUnlockData.MasterKeyWrappedUserKey, userSymmetricKey); + Assert.Equal(legacyData.MasterPasswordUnlockData.Salt, email.ToLowerInvariant().Trim()); + Assert.Equal(legacyData.UserAccountKeysData.PublicKeyEncryptionKeyPairData.PublicKey, userAsymmetricKeys.PublicKey); + Assert.Equal(legacyData.UserAccountKeysData.PublicKeyEncryptionKeyPairData.WrappedPrivateKey, userAsymmetricKeys.EncryptedPrivateKey); + Assert.Equal(legacyData.MasterPasswordAuthenticationData.Kdf.KdfType, kdf); + Assert.Equal(legacyData.MasterPasswordAuthenticationData.Kdf.Iterations, kdfIterations); + Assert.Equal(legacyData.MasterPasswordAuthenticationData.Kdf.Memory, kdfMemory); + Assert.Equal(legacyData.MasterPasswordAuthenticationData.Kdf.Parallelism, kdfParallelism); + Assert.Equal(legacyData.MasterPasswordAuthenticationData.MasterPasswordAuthenticationHash, masterPasswordAuthenticationHash); + Assert.Equal(legacyData.MasterPasswordAuthenticationData.Salt, email.ToLowerInvariant().Trim()); + + + Assert.True(newData.IsV2Encryption()); + Assert.Equal(newData.MasterPasswordUnlockData.Kdf, kdfRequest.ToData()); + Assert.Equal(newData.MasterPasswordUnlockData.MasterKeyWrappedUserKey, userSymmetricKey); + Assert.Equal(newData.MasterPasswordUnlockData.Salt, email.ToLowerInvariant()); + Assert.Equal(newData.MasterPasswordAuthenticationData.Kdf, kdfRequest.ToData()); + Assert.Equal(newData.MasterPasswordAuthenticationData.MasterPasswordAuthenticationHash, masterPasswordAuthenticationHash); + Assert.Equal(newData.MasterPasswordAuthenticationData.Salt, email.ToLowerInvariant().Trim()); + Assert.Equal(newData.UserAccountKeysData, accountKeysRequest.ToAccountKeysData()); + } + + [Theory] + [BitAutoData] + [SignatureKeyPairRequestModelCustomize] + public void ToUser_Returns_User(string email, string masterPasswordHint, AccountKeysRequestModel accountKeysRequest, + KdfRequestModel kdfRequest, string masterPasswordAuthenticationHash, string userSymmetricKey, + KeysRequestModel userAsymmetricKeys, KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism) + { + // Arrange + // V1 model and fields to be removed with + // https://bitwarden.atlassian.net/browse/PM-27326 + var legacyModel = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordAuthenticationHash, MasterPasswordHint = masterPasswordHint, UserSymmetricKey = userSymmetricKey, UserAsymmetricKeys = userAsymmetricKeys, @@ -167,20 +242,53 @@ public void ToUser_Returns_User(string email, string masterPasswordHash, string KdfMemory = kdfMemory, KdfParallelism = kdfParallelism }; + var newModel = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHint = masterPasswordHint, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdfRequest, + MasterPasswordAuthenticationHash = masterPasswordAuthenticationHash, + Salt = email.ToLowerInvariant().Trim() + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdfRequest, + MasterKeyWrappedUserKey = userSymmetricKey, + Salt = email.ToLowerInvariant().Trim() + }, + AccountKeys = accountKeysRequest + }; // Act - var result = model.ToUser(); + Assert.False(legacyModel.ToData().IsV2Encryption()); + var legacyResult = legacyModel.ToUser(false); + Assert.True(newModel.ToData().IsV2Encryption()); + var newResult = newModel.ToUser(true); // Assert - Assert.Equal(email, result.Email); - Assert.Equal(masterPasswordHint, result.MasterPasswordHint); - Assert.Equal(kdf, result.Kdf); - Assert.Equal(kdfIterations, result.KdfIterations); - Assert.Equal(kdfMemory, result.KdfMemory); - Assert.Equal(kdfParallelism, result.KdfParallelism); - Assert.Equal(userSymmetricKey, result.Key); - Assert.Equal(userAsymmetricKeys.PublicKey, result.PublicKey); - Assert.Equal(userAsymmetricKeys.EncryptedPrivateKey, result.PrivateKey); + Assert.Equal(email, legacyResult.Email); + Assert.Equal(masterPasswordHint, legacyResult.MasterPasswordHint); + Assert.Equal(kdf, legacyResult.Kdf); + Assert.Equal(kdfIterations, legacyResult.KdfIterations); + Assert.Equal(kdfMemory, legacyResult.KdfMemory); + Assert.Equal(kdfParallelism, legacyResult.KdfParallelism); + Assert.Equal(userSymmetricKey, legacyResult.Key); + Assert.Equal(userAsymmetricKeys.PublicKey, legacyResult.PublicKey); + Assert.Equal(userAsymmetricKeys.EncryptedPrivateKey, legacyResult.PrivateKey); + + // V2 expected fields + // all should be default/unset, with the exception of email and masterPasswordHint + Assert.Equal(email, newResult.Email); + Assert.Equal(masterPasswordHint, newResult.MasterPasswordHint); + Assert.Equal(KdfType.PBKDF2_SHA256, newResult.Kdf); + Assert.Equal(AuthConstants.PBKDF2_ITERATIONS.Default, newResult.KdfIterations); + Assert.Null(newResult.KdfMemory); + Assert.Null(newResult.KdfParallelism); + Assert.Null(newResult.Key); + Assert.Null(newResult.PublicKey); + Assert.Null(newResult.PrivateKey); } [Fact] diff --git a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs index 5631fd7f5442..77f307850593 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs @@ -10,6 +10,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; @@ -239,7 +240,7 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] public async Task RegisterUserViaOrganizationInviteToken_NoOrgInviteOrOrgUserIdOrReferenceData_Succeeds( - SutProvider sutProvider, User user, string masterPasswordHash) + SutProvider sutProvider, User user, RegisterFinishData registerFinishData) { // Arrange user.Email = $"test+{Guid.NewGuid()}@example.com"; @@ -250,18 +251,18 @@ public async Task RegisterUserViaOrganizationInviteToken_NoOrgInviteOrOrgUserIdO .Returns(false); sutProvider.GetDependency() - .CreateUserAsync(user, masterPasswordHash) + .CreateUserAsync(user, registerFinishData) .Returns(IdentityResult.Success); // Act - var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, null, null); + var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, registerFinishData, null, null); // Assert Assert.True(result.Succeeded); await sutProvider.GetDependency() .Received(1) - .CreateUserAsync(user, masterPasswordHash); + .CreateUserAsync(user, registerFinishData); } // Complex happy path test @@ -270,7 +271,7 @@ await sutProvider.GetDependency() [BitAutoData(true, "sampleInitiationPath")] [BitAutoData(true, "Secrets Manager trial")] public async Task RegisterUserViaOrganizationInviteToken_ComplexHappyPath_Succeeds(bool addUserReferenceData, string initiationPath, - SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId, + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId, [Policy(PolicyType.TwoFactorAuthentication, true)] PolicyStatus policy) { // Arrange @@ -308,13 +309,13 @@ public async Task RegisterUserViaOrganizationInviteToken_ComplexHappyPath_Succee .Returns(policy); sutProvider.GetDependency() - .CreateUserAsync(user, masterPasswordHash) + .CreateUserAsync(user, registerFinishData) .Returns(IdentityResult.Success); user.ReferenceData = addUserReferenceData ? $"{{\"initiationPath\":\"{initiationPath}\"}}" : null; // Act - var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUserId); + var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, registerFinishData, orgInviteToken, orgUserId); // Assert await sutProvider.GetDependency() @@ -346,7 +347,7 @@ await sutProvider.GetDependency() await sutProvider.GetDependency() .Received(1) - .CreateUserAsync(Arg.Is(u => u.EmailVerified == true && u.ApiKey != null), masterPasswordHash); + .CreateUserAsync(Arg.Is(u => u.EmailVerified == true && u.ApiKey != null), registerFinishData); if (addUserReferenceData) { @@ -380,7 +381,7 @@ await sutProvider.GetDependency() [BitAutoData("nullOrgInviteToken")] [BitAutoData("nullOrgUserId")] public async Task RegisterUserViaOrganizationInviteToken_MissingOrInvalidOrgInviteDataWithDisabledOpenRegistration_ThrowsBadRequestException(string scenario, - SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid? orgUserId) + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, OrganizationUser orgUser, string orgInviteToken, Guid? orgUserId) { // Arrange user.Email = $"test+{Guid.NewGuid()}@example.com"; @@ -415,7 +416,7 @@ public async Task RegisterUserViaOrganizationInviteToken_MissingOrInvalidOrgInvi } // Act & Assert - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUserId)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, registerFinishData, orgInviteToken, orgUserId)); Assert.Equal("Open registration has been disabled by the system administrator.", exception.Message); } @@ -424,7 +425,7 @@ public async Task RegisterUserViaOrganizationInviteToken_MissingOrInvalidOrgInvi [BitAutoData("nullOrgInviteToken")] [BitAutoData("nullOrgUserId")] public async Task RegisterUserViaOrganizationInviteToken_MissingOrInvalidOrgInviteDataWithEnabledOpenRegistration_ThrowsBadRequestException(string scenario, - SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid? orgUserId) + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, OrganizationUser orgUser, string orgInviteToken, Guid? orgUserId) { // Arrange user.Email = $"test+{Guid.NewGuid()}@example.com"; @@ -466,19 +467,19 @@ public async Task RegisterUserViaOrganizationInviteToken_MissingOrInvalidOrgInvi user.ReferenceData = null; sutProvider.GetDependency() - .CreateUserAsync(user, masterPasswordHash) + .CreateUserAsync(user, registerFinishData) .Returns(IdentityResult.Success); // Act var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUserId)); + sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, registerFinishData, orgInviteToken, orgUserId)); Assert.Equal(expectedErrorMessage, exception.Message); } [Theory] [BitAutoData] public async Task RegisterUserViaOrganizationInviteToken_BlockedDomainFromDifferentOrg_ThrowsBadRequestException( - SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId, + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId, [Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy) { // Arrange @@ -513,14 +514,14 @@ public async Task RegisterUserViaOrganizationInviteToken_BlockedDomainFromDiffer // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUserId)); + sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, registerFinishData, orgInviteToken, orgUserId)); Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message); } [Theory] [BitAutoData] public async Task RegisterUserViaOrganizationInviteToken_BlockedDomainFromSameOrg_Succeeds( - SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId, + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId, [Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy) { // Arrange @@ -551,7 +552,7 @@ public async Task RegisterUserViaOrganizationInviteToken_BlockedDomainFromSameOr .Returns(false); sutProvider.GetDependency() - .CreateUserAsync(user, masterPasswordHash) + .CreateUserAsync(user, registerFinishData) .Returns(IdentityResult.Success); sutProvider.GetDependency() @@ -559,7 +560,7 @@ public async Task RegisterUserViaOrganizationInviteToken_BlockedDomainFromSameOr .Returns(policy); // Act - var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUserId); + var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, registerFinishData, orgInviteToken, orgUserId); // Assert Assert.True(result.Succeeded); @@ -571,7 +572,7 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] public async Task RegisterUserViaOrganizationInviteToken_WithValidTokenButNullOrgUser_ThrowsBadRequestException( - SutProvider sutProvider, User user, string masterPasswordHash, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId) + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, OrganizationUser orgUser, string orgInviteToken, Guid orgUserId) { // Arrange user.Email = "user@example.com"; @@ -599,7 +600,7 @@ public async Task RegisterUserViaOrganizationInviteToken_WithValidTokenButNullOr // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUserId)); + sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, registerFinishData, orgInviteToken, orgUserId)); Assert.Equal("Invalid organization user invitation.", exception.Message); // Verify that GetByIdAsync was called @@ -610,7 +611,7 @@ await sutProvider.GetDependency() // Verify that user creation was never attempted await sutProvider.GetDependency() .DidNotReceive() - .CreateUserAsync(Arg.Any(), Arg.Any()); + .CreateUserAsync(Arg.Any(), Arg.Any()); } // ----------------------------------------------------------------------------------------------- @@ -619,7 +620,7 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] - public async Task RegisterUserViaEmailVerificationToken_Succeeds(SutProvider sutProvider, User user, string masterPasswordHash, string emailVerificationToken, bool receiveMarketingMaterials) + public async Task RegisterUserViaEmailVerificationToken_Succeeds(SutProvider sutProvider, User user, RegisterFinishData registerFinishData, string emailVerificationToken, bool receiveMarketingMaterials) { // Arrange user.Email = $"test+{Guid.NewGuid()}@example.com"; @@ -637,18 +638,18 @@ public async Task RegisterUserViaEmailVerificationToken_Succeeds(SutProvider() - .CreateUserAsync(user, masterPasswordHash) + .CreateUserAsync(user, registerFinishData) .Returns(IdentityResult.Success); // Act - var result = await sutProvider.Sut.RegisterUserViaEmailVerificationToken(user, masterPasswordHash, emailVerificationToken); + var result = await sutProvider.Sut.RegisterUserViaEmailVerificationToken(user, registerFinishData, emailVerificationToken); // Assert Assert.True(result.Succeeded); await sutProvider.GetDependency() .Received(1) - .CreateUserAsync(Arg.Is(u => u.Name == user.Name && u.EmailVerified == true && u.ApiKey != null), masterPasswordHash); + .CreateUserAsync(Arg.Is(u => u.Name == user.Name && u.EmailVerified == true && u.ApiKey != null), registerFinishData); await sutProvider.GetDependency() .Received(1) @@ -657,7 +658,7 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] - public async Task RegisterUserViaEmailVerificationToken_InvalidToken_ThrowsBadRequestException(SutProvider sutProvider, User user, string masterPasswordHash, string emailVerificationToken, bool receiveMarketingMaterials) + public async Task RegisterUserViaEmailVerificationToken_InvalidToken_ThrowsBadRequestException(SutProvider sutProvider, User user, RegisterFinishData registerFinishData, string emailVerificationToken, bool receiveMarketingMaterials) { // Arrange user.Email = $"test+{Guid.NewGuid()}@example.com"; @@ -675,14 +676,14 @@ public async Task RegisterUserViaEmailVerificationToken_InvalidToken_ThrowsBadRe }); // Act & Assert - var result = await Assert.ThrowsAsync(() => sutProvider.Sut.RegisterUserViaEmailVerificationToken(user, masterPasswordHash, emailVerificationToken)); + var result = await Assert.ThrowsAsync(() => sutProvider.Sut.RegisterUserViaEmailVerificationToken(user, registerFinishData, emailVerificationToken)); Assert.Equal("Invalid email verification token.", result.Message); } [Theory] [BitAutoData] - public async Task RegisterUserViaEmailVerificationToken_DisabledOpenRegistration_ThrowsBadRequestException(SutProvider sutProvider, User user, string masterPasswordHash, string emailVerificationToken) + public async Task RegisterUserViaEmailVerificationToken_DisabledOpenRegistration_ThrowsBadRequestException(SutProvider sutProvider, User user, RegisterFinishData registerFinishData, string emailVerificationToken) { // Arrange user.Email = $"test+{Guid.NewGuid()}@example.com"; @@ -695,7 +696,7 @@ public async Task RegisterUserViaEmailVerificationToken_DisabledOpenRegistration .DisableUserRegistration = true; // Act & Assert - var result = await Assert.ThrowsAsync(() => sutProvider.Sut.RegisterUserViaEmailVerificationToken(user, masterPasswordHash, emailVerificationToken)); + var result = await Assert.ThrowsAsync(() => sutProvider.Sut.RegisterUserViaEmailVerificationToken(user, registerFinishData, emailVerificationToken)); Assert.Equal("Open registration has been disabled by the system administrator.", result.Message); } @@ -706,7 +707,7 @@ public async Task RegisterUserViaEmailVerificationToken_DisabledOpenRegistration [Theory] [BitAutoData] - public async Task RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken_Succeeds(SutProvider sutProvider, User user, string masterPasswordHash, + public async Task RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken_Succeeds(SutProvider sutProvider, User user, RegisterFinishData registerFinishData, string orgSponsoredFreeFamilyPlanInviteToken) { // Arrange @@ -721,18 +722,18 @@ public async Task RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken_ .Returns((true, new OrganizationSponsorship())); sutProvider.GetDependency() - .CreateUserAsync(user, masterPasswordHash) + .CreateUserAsync(user, registerFinishData) .Returns(IdentityResult.Success); // Act - var result = await sutProvider.Sut.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, masterPasswordHash, orgSponsoredFreeFamilyPlanInviteToken); + var result = await sutProvider.Sut.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, registerFinishData, orgSponsoredFreeFamilyPlanInviteToken); // Assert Assert.True(result.Succeeded); await sutProvider.GetDependency() .Received(1) - .CreateUserAsync(Arg.Is(u => u.Name == user.Name && u.EmailVerified == true && u.ApiKey != null), masterPasswordHash); + .CreateUserAsync(Arg.Is(u => u.Name == user.Name && u.EmailVerified == true && u.ApiKey != null), registerFinishData); await sutProvider.GetDependency() .Received(1) @@ -742,7 +743,7 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] public async Task RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken_InvalidToken_ThrowsBadRequestException(SutProvider sutProvider, User user, - string masterPasswordHash, string orgSponsoredFreeFamilyPlanInviteToken) + RegisterFinishData registerFinishData, string orgSponsoredFreeFamilyPlanInviteToken) { // Arrange user.Email = $"test+{Guid.NewGuid()}@example.com"; @@ -757,7 +758,7 @@ public async Task RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken_ // Act & Assert var result = await Assert.ThrowsAsync(() => - sutProvider.Sut.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, masterPasswordHash, orgSponsoredFreeFamilyPlanInviteToken)); + sutProvider.Sut.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, registerFinishData, orgSponsoredFreeFamilyPlanInviteToken)); Assert.Equal("Invalid org sponsored free family plan token.", result.Message); } @@ -765,7 +766,7 @@ public async Task RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken_ [Theory] [BitAutoData] public async Task RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken_DisabledOpenRegistration_ThrowsBadRequestException(SutProvider sutProvider, User user, - string masterPasswordHash, string orgSponsoredFreeFamilyPlanInviteToken) + RegisterFinishData registerFinishData, string orgSponsoredFreeFamilyPlanInviteToken) { // Arrange user.Email = $"test+{Guid.NewGuid()}@example.com"; @@ -779,7 +780,7 @@ public async Task RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken_ // Act & Assert var result = await Assert.ThrowsAsync(() => - sutProvider.Sut.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, masterPasswordHash, orgSponsoredFreeFamilyPlanInviteToken)); + sutProvider.Sut.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, registerFinishData, orgSponsoredFreeFamilyPlanInviteToken)); Assert.Equal("Open registration has been disabled by the system administrator.", result.Message); } @@ -790,7 +791,7 @@ public async Task RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken_ [Theory] [BitAutoData] public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_Succeeds( - SutProvider sutProvider, User user, string masterPasswordHash, + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, EmergencyAccessEntity emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) { // Arrange @@ -811,18 +812,18 @@ public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_Succeeds( }); sutProvider.GetDependency() - .CreateUserAsync(user, masterPasswordHash) + .CreateUserAsync(user, registerFinishData) .Returns(IdentityResult.Success); // Act - var result = await sutProvider.Sut.RegisterUserViaAcceptEmergencyAccessInviteToken(user, masterPasswordHash, acceptEmergencyAccessInviteToken, acceptEmergencyAccessId); + var result = await sutProvider.Sut.RegisterUserViaAcceptEmergencyAccessInviteToken(user, registerFinishData, acceptEmergencyAccessInviteToken, acceptEmergencyAccessId); // Assert Assert.True(result.Succeeded); await sutProvider.GetDependency() .Received(1) - .CreateUserAsync(Arg.Is(u => u.Name == user.Name && u.EmailVerified == true && u.ApiKey != null), masterPasswordHash); + .CreateUserAsync(Arg.Is(u => u.Name == user.Name && u.EmailVerified == true && u.ApiKey != null), registerFinishData); await sutProvider.GetDependency() .Received(1) @@ -832,7 +833,7 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_InvalidToken_ThrowsBadRequestException(SutProvider sutProvider, User user, - string masterPasswordHash, EmergencyAccessEntity emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) + RegisterFinishData registerFinishData, EmergencyAccessEntity emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) { // Arrange user.Email = $"test+{Guid.NewGuid()}@example.com"; @@ -853,7 +854,7 @@ public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_InvalidToken_T // Act & Assert var result = await Assert.ThrowsAsync(() => - sutProvider.Sut.RegisterUserViaAcceptEmergencyAccessInviteToken(user, masterPasswordHash, acceptEmergencyAccessInviteToken, acceptEmergencyAccessId)); + sutProvider.Sut.RegisterUserViaAcceptEmergencyAccessInviteToken(user, registerFinishData, acceptEmergencyAccessInviteToken, acceptEmergencyAccessId)); Assert.Equal("Invalid accept emergency access invite token.", result.Message); } @@ -861,7 +862,7 @@ public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_InvalidToken_T [Theory] [BitAutoData] public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_DisabledOpenRegistration_ThrowsBadRequestException(SutProvider sutProvider, User user, - string masterPasswordHash, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) + RegisterFinishData registerFinishData, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) { // Arrange user.Email = $"test+{Guid.NewGuid()}@example.com"; @@ -875,7 +876,7 @@ public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_DisabledOpenRe // Act & Assert var result = await Assert.ThrowsAsync(() => - sutProvider.Sut.RegisterUserViaAcceptEmergencyAccessInviteToken(user, masterPasswordHash, acceptEmergencyAccessInviteToken, acceptEmergencyAccessId)); + sutProvider.Sut.RegisterUserViaAcceptEmergencyAccessInviteToken(user, registerFinishData, acceptEmergencyAccessInviteToken, acceptEmergencyAccessId)); Assert.Equal("Open registration has been disabled by the system administrator.", result.Message); } @@ -886,7 +887,7 @@ public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_DisabledOpenRe [Theory] [BitAutoData] public async Task RegisterUserViaProviderInviteToken_Succeeds(SutProvider sutProvider, - User user, string masterPasswordHash, Guid providerUserId) + User user, RegisterFinishData registerFinishData, Guid providerUserId) { // Arrange user.Email = $"test+{Guid.NewGuid()}@example.com"; @@ -918,7 +919,7 @@ public async Task RegisterUserViaProviderInviteToken_Succeeds(SutProvider() - .CreateUserAsync(user, masterPasswordHash) + .CreateUserAsync(user, registerFinishData) .Returns(IdentityResult.Success); // Using sutProvider in the parameters of the function means that the constructor has already run for the @@ -926,14 +927,14 @@ public async Task RegisterUserViaProviderInviteToken_Succeeds(SutProvider() .Received(1) - .CreateUserAsync(Arg.Is(u => u.Name == user.Name && u.EmailVerified == true && u.ApiKey != null), masterPasswordHash); + .CreateUserAsync(Arg.Is(u => u.Name == user.Name && u.EmailVerified == true && u.ApiKey != null), registerFinishData); await sutProvider.GetDependency() .Received(1) @@ -943,7 +944,7 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] public async Task RegisterUserViaProviderInviteToken_InvalidToken_ThrowsBadRequestException(SutProvider sutProvider, - User user, string masterPasswordHash, Guid providerUserId) + User user, RegisterFinishData registerFinishData, Guid providerUserId) { // Arrange user.Email = $"test+{Guid.NewGuid()}@example.com"; @@ -980,14 +981,14 @@ public async Task RegisterUserViaProviderInviteToken_InvalidToken_ThrowsBadReque // Act & Assert var result = await Assert.ThrowsAsync(() => - sutProvider.Sut.RegisterUserViaProviderInviteToken(user, masterPasswordHash, base64EncodedProviderInvToken, Guid.NewGuid())); + sutProvider.Sut.RegisterUserViaProviderInviteToken(user, registerFinishData, base64EncodedProviderInvToken, Guid.NewGuid())); Assert.Equal("Invalid provider invite token.", result.Message); } [Theory] [BitAutoData] public async Task RegisterUserViaProviderInviteToken_DisabledOpenRegistration_ThrowsBadRequestException(SutProvider sutProvider, - User user, string masterPasswordHash, Guid providerUserId) + User user, RegisterFinishData registerFinishData, Guid providerUserId) { // Arrange user.Email = $"test+{Guid.NewGuid()}@example.com"; @@ -1024,7 +1025,7 @@ public async Task RegisterUserViaProviderInviteToken_DisabledOpenRegistration_Th // Act & Assert var result = await Assert.ThrowsAsync(() => - sutProvider.Sut.RegisterUserViaProviderInviteToken(user, masterPasswordHash, base64EncodedProviderInvToken, providerUserId)); + sutProvider.Sut.RegisterUserViaProviderInviteToken(user, registerFinishData, base64EncodedProviderInvToken, providerUserId)); Assert.Equal("Open registration has been disabled by the system administrator.", result.Message); } @@ -1127,7 +1128,7 @@ await sutProvider.GetDependency() [Theory] [BitAutoData] public async Task RegisterUserViaEmailVerificationToken_BlockedDomain_ThrowsBadRequestException( - SutProvider sutProvider, User user, string masterPasswordHash, + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, string emailVerificationToken, bool receiveMarketingMaterials) { // Arrange @@ -1147,14 +1148,14 @@ public async Task RegisterUserViaEmailVerificationToken_BlockedDomain_ThrowsBadR // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.RegisterUserViaEmailVerificationToken(user, masterPasswordHash, emailVerificationToken)); + sutProvider.Sut.RegisterUserViaEmailVerificationToken(user, registerFinishData, emailVerificationToken)); Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message); } [Theory] [BitAutoData] public async Task RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken_BlockedDomain_ThrowsBadRequestException( - SutProvider sutProvider, User user, string masterPasswordHash, + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, string orgSponsoredFreeFamilyPlanInviteToken) { // Arrange @@ -1170,14 +1171,14 @@ public async Task RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken_ // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, masterPasswordHash, orgSponsoredFreeFamilyPlanInviteToken)); + sutProvider.Sut.RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(user, registerFinishData, orgSponsoredFreeFamilyPlanInviteToken)); Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message); } [Theory] [BitAutoData] public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_BlockedDomain_ThrowsBadRequestException( - SutProvider sutProvider, User user, string masterPasswordHash, + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, EmergencyAccessEntity emergencyAccess, string acceptEmergencyAccessInviteToken, Guid acceptEmergencyAccessId) { // Arrange @@ -1199,14 +1200,14 @@ public async Task RegisterUserViaAcceptEmergencyAccessInviteToken_BlockedDomain_ // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.RegisterUserViaAcceptEmergencyAccessInviteToken(user, masterPasswordHash, acceptEmergencyAccessInviteToken, acceptEmergencyAccessId)); + sutProvider.Sut.RegisterUserViaAcceptEmergencyAccessInviteToken(user, registerFinishData, acceptEmergencyAccessInviteToken, acceptEmergencyAccessId)); Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message); } [Theory] [BitAutoData] public async Task RegisterUserViaProviderInviteToken_BlockedDomain_ThrowsBadRequestException( - SutProvider sutProvider, User user, string masterPasswordHash, Guid providerUserId) + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, Guid providerUserId) { // Arrange user.Email = "user@blocked-domain.com"; @@ -1243,7 +1244,7 @@ public async Task RegisterUserViaProviderInviteToken_BlockedDomain_ThrowsBadRequ // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.RegisterUserViaProviderInviteToken(user, masterPasswordHash, base64EncodedProviderInvToken, providerUserId)); + sutProvider.Sut.RegisterUserViaProviderInviteToken(user, registerFinishData, base64EncodedProviderInvToken, providerUserId)); Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message); } @@ -1268,7 +1269,7 @@ public async Task RegisterUser_InvalidEmailFormat_ThrowsBadRequestException( [Theory] [BitAutoData] public async Task RegisterUserViaEmailVerificationToken_InvalidEmailFormat_ThrowsBadRequestException( - SutProvider sutProvider, User user, string masterPasswordHash, + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, string emailVerificationToken, bool receiveMarketingMaterials) { // Arrange @@ -1284,7 +1285,7 @@ public async Task RegisterUserViaEmailVerificationToken_InvalidEmailFormat_Throw // Act & Assert var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.RegisterUserViaEmailVerificationToken(user, masterPasswordHash, emailVerificationToken)); + sutProvider.Sut.RegisterUserViaEmailVerificationToken(user, registerFinishData, emailVerificationToken)); Assert.Equal("Invalid email address format.", exception.Message); } @@ -1294,7 +1295,7 @@ public async Task SendWelcomeEmail_OrganizationNull_SendsIndividualWelcomeEmail( User user, OrganizationUser orgUser, string orgInviteToken, - string masterPasswordHash, + RegisterFinishData registerFinishData, [Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy, SutProvider sutProvider) { @@ -1308,7 +1309,7 @@ public async Task SendWelcomeEmail_OrganizationNull_SendsIndividualWelcomeEmail( .Returns(false); sutProvider.GetDependency() - .CreateUserAsync(user, masterPasswordHash) + .CreateUserAsync(user, registerFinishData) .Returns(IdentityResult.Success); sutProvider.GetDependency() @@ -1338,7 +1339,7 @@ public async Task SendWelcomeEmail_OrganizationNull_SendsIndividualWelcomeEmail( }); // Act - var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUser.Id); + var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, registerFinishData, orgInviteToken, orgUser.Id); // Assert await sutProvider.GetDependency() @@ -1390,7 +1391,7 @@ public async Task GetOrganizationWelcomeEmailDetailsAsync_HappyPath_ReturnsOrgan Organization organization, User user, OrganizationUser orgUser, - string masterPasswordHash, + RegisterFinishData registerFinishData, string orgInviteToken, [Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy, SutProvider sutProvider) @@ -1406,7 +1407,7 @@ public async Task GetOrganizationWelcomeEmailDetailsAsync_HappyPath_ReturnsOrgan .Returns(false); sutProvider.GetDependency() - .CreateUserAsync(user, masterPasswordHash) + .CreateUserAsync(user, registerFinishData) .Returns(IdentityResult.Success); sutProvider.GetDependency() @@ -1436,7 +1437,7 @@ public async Task GetOrganizationWelcomeEmailDetailsAsync_HappyPath_ReturnsOrgan }); // Act - var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUser.Id); + var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, registerFinishData, orgInviteToken, orgUser.Id); // Assert Assert.True(result.Succeeded); diff --git a/test/Identity.Test/Controllers/AccountsControllerTests.cs b/test/Identity.Test/Controllers/AccountsControllerTests.cs index 86e461d1551c..7296e7ac388d 100644 --- a/test/Identity.Test/Controllers/AccountsControllerTests.cs +++ b/test/Identity.Test/Controllers/AccountsControllerTests.cs @@ -25,6 +25,7 @@ using NSubstitute; using NSubstitute.ReturnsExtensions; using Xunit; +using SignatureKeyPairRequestModelCustomizeAttribute = Bit.Test.Common.AutoFixture.SignatureKeyPairRequestModelCustomizeAttribute; namespace Bit.Identity.Test.Controllers; @@ -325,13 +326,13 @@ await _sendVerificationEmailForRegistrationCommand.Received(1) .Run(email, name, receiveMarketingEmails, null); // fromMarketing gets ignored and null gets passed } - [Theory, BitAutoData] + [Theory, BitAutoData, SignatureKeyPairRequestModelCustomize] public async Task PostRegisterFinish_WhenGivenOrgInvite_ShouldRegisterUser( string email, string masterPasswordHash, string orgInviteToken, Guid organizationUserId, string userSymmetricKey, - KeysRequestModel userAsymmetricKeys) + KeysRequestModel userAsymmetricKeys, AccountKeysRequestModel accountKeys) { // Arrange - var model = new RegisterFinishRequestModel + var legacyModel = new RegisterFinishRequestModel { Email = email, MasterPasswordHash = masterPasswordHash, @@ -343,34 +344,75 @@ public async Task PostRegisterFinish_WhenGivenOrgInvite_ShouldRegisterUser( UserAsymmetricKeys = userAsymmetricKeys }; - var user = model.ToUser(); + var kdfModel = new KdfRequestModel + { + KdfType = KdfType.Argon2id, + Iterations = AuthConstants.ARGON2_ITERATIONS.Default, + Memory = AuthConstants.ARGON2_MEMORY.Default, + Parallelism = AuthConstants.ARGON2_PARALLELISM.Default + }; - _registerUserCommand.RegisterUserViaOrganizationInviteToken(Arg.Any(), masterPasswordHash, orgInviteToken, organizationUserId) + var newModel = new RegisterFinishRequestModel + { + Email = email, + OrgInviteToken = orgInviteToken, + OrganizationUserId = organizationUserId, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + MasterPasswordAuthenticationHash = masterPasswordHash, + Kdf = kdfModel, + Salt = email.ToLowerInvariant().Trim(), + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdfModel, + MasterKeyWrappedUserKey = userSymmetricKey, + Salt = email.ToLowerInvariant().Trim(), + }, + AccountKeys = accountKeys + }; + + var legacyUser = legacyModel.ToUser(false); + var legacyData = legacyModel.ToData(); + + var newUser = newModel.ToUser(true); + var newData = newModel.ToData(); + + _registerUserCommand.RegisterUserViaOrganizationInviteToken(Arg.Any(), legacyData, orgInviteToken, organizationUserId) + .Returns(Task.FromResult(IdentityResult.Success)); + _registerUserCommand.RegisterUserViaOrganizationInviteToken(Arg.Any(), newData, orgInviteToken, organizationUserId) .Returns(Task.FromResult(IdentityResult.Success)); // Act - var result = await _sut.PostRegisterFinish(model); + var legacyResult = await _sut.PostRegisterFinish(legacyModel); + var newResult = await _sut.PostRegisterFinish(newModel); // Assert - Assert.NotNull(result); + Assert.NotNull(legacyResult); await _registerUserCommand.Received(1).RegisterUserViaOrganizationInviteToken(Arg.Is(u => - u.Email == user.Email && - u.MasterPasswordHint == user.MasterPasswordHint && - u.Kdf == user.Kdf && - u.KdfIterations == user.KdfIterations && - u.KdfMemory == user.KdfMemory && - u.KdfParallelism == user.KdfParallelism && - u.Key == user.Key - ), masterPasswordHash, orgInviteToken, organizationUserId); + u.Email == legacyUser.Email && + u.MasterPasswordHint == legacyUser.MasterPasswordHint && + u.Kdf == legacyUser.Kdf && + u.KdfIterations == legacyUser.KdfIterations && + u.KdfMemory == legacyUser.KdfMemory && + u.KdfParallelism == legacyUser.KdfParallelism && + u.Key == legacyUser.Key + ), legacyData, orgInviteToken, organizationUserId); + + Assert.NotNull(newResult); + await _registerUserCommand.Received(1).RegisterUserViaOrganizationInviteToken(Arg.Is(u => + u.Email == newUser.Email && + u.MasterPasswordHint == newUser.MasterPasswordHint + ), newData, orgInviteToken, organizationUserId); } - [Theory, BitAutoData] + [Theory, BitAutoData, SignatureKeyPairRequestModelCustomize] public async Task PostRegisterFinish_OrgInviteDuplicateUser_ThrowsBadRequestException( string email, string masterPasswordHash, string orgInviteToken, Guid organizationUserId, string userSymmetricKey, - KeysRequestModel userAsymmetricKeys) + KeysRequestModel userAsymmetricKeys, AccountKeysRequestModel accountKeys) { // Arrange - var model = new RegisterFinishRequestModel + var legacyModel = new RegisterFinishRequestModel { Email = email, MasterPasswordHash = masterPasswordHash, @@ -382,14 +424,46 @@ public async Task PostRegisterFinish_OrgInviteDuplicateUser_ThrowsBadRequestExce UserAsymmetricKeys = userAsymmetricKeys }; - var user = model.ToUser(); + var kdfModel = new KdfRequestModel + { + KdfType = KdfType.Argon2id, + Iterations = AuthConstants.ARGON2_ITERATIONS.Default, + Memory = AuthConstants.ARGON2_MEMORY.Default, + Parallelism = AuthConstants.ARGON2_ITERATIONS.Default + }; + + var newModel = new RegisterFinishRequestModel + { + Email = email, + OrgInviteToken = orgInviteToken, + OrganizationUserId = organizationUserId, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + MasterPasswordAuthenticationHash = masterPasswordHash, + Kdf = kdfModel, + Salt = email.ToLowerInvariant().Trim(), + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdfModel, + MasterKeyWrappedUserKey = userSymmetricKey, + Salt = email.ToLowerInvariant().Trim(), + }, + AccountKeys = accountKeys, + }; + + var legacyUser = legacyModel.ToUser(false); + var legacyData = legacyModel.ToData(); + + var newUser = newModel.ToUser(true); + var newData = newModel.ToData(); // Duplicates throw 2 errors, one for the email and one for the username var duplicateUserNameErrorCode = "DuplicateUserName"; - var duplicateUserNameErrorDesc = $"Username '{user.Email}' is already taken."; + var duplicateUserNameErrorDesc = $"Username '{email}' is already taken."; var duplicateUserEmailErrorCode = "DuplicateEmail"; - var duplicateUserEmailErrorDesc = $"Email '{user.Email}' is already taken."; + var duplicateUserEmailErrorDesc = $"Email '{email}' is already taken."; var failedIdentityResult = IdentityResult.Failed( new IdentityError { Code = duplicateUserNameErrorCode, Description = duplicateUserNameErrorDesc }, @@ -397,35 +471,49 @@ public async Task PostRegisterFinish_OrgInviteDuplicateUser_ThrowsBadRequestExce ); _registerUserCommand.RegisterUserViaOrganizationInviteToken(Arg.Is(u => - u.Email == user.Email && - u.MasterPasswordHint == user.MasterPasswordHint && - u.Kdf == user.Kdf && - u.KdfIterations == user.KdfIterations && - u.KdfMemory == user.KdfMemory && - u.KdfParallelism == user.KdfParallelism && - u.Key == user.Key - ), masterPasswordHash, orgInviteToken, organizationUserId) + u.Email == legacyUser.Email && + u.MasterPasswordHint == legacyUser.MasterPasswordHint && + u.Kdf == legacyUser.Kdf && + u.KdfIterations == legacyUser.KdfIterations && + u.KdfMemory == legacyUser.KdfMemory && + u.KdfParallelism == legacyUser.KdfParallelism && + u.Key == legacyUser.Key + ), legacyData, orgInviteToken, organizationUserId) + .Returns(Task.FromResult(failedIdentityResult)); + + _registerUserCommand.RegisterUserViaOrganizationInviteToken(Arg.Is(u => + u.Email == newUser.Email && + u.MasterPasswordHint == newUser.MasterPasswordHint + ), newData, orgInviteToken, organizationUserId) .Returns(Task.FromResult(failedIdentityResult)); // Act - var exception = await Assert.ThrowsAsync(() => _sut.PostRegisterFinish(model)); + var legacyException = await Assert.ThrowsAsync(() => _sut.PostRegisterFinish(legacyModel)); + var newException = await Assert.ThrowsAsync(() => _sut.PostRegisterFinish(newModel)); // We filter out the duplicate username error // so we should only see the duplicate email error - Assert.Equal(1, exception.ModelState.ErrorCount); - exception.ModelState.TryGetValue(string.Empty, out var modelStateEntry); - Assert.NotNull(modelStateEntry); - var modelError = modelStateEntry.Errors.First(); - Assert.Equal(duplicateUserEmailErrorDesc, modelError.ErrorMessage); + Assert.Equal(2, legacyException.ModelState.ErrorCount); + legacyException.ModelState.TryGetValue(string.Empty, out var legacyModelStateEntry); + Assert.NotNull(legacyModelStateEntry); + var legacyModelError = legacyModelStateEntry.Errors.First(); + Assert.Equal(duplicateUserEmailErrorDesc, legacyModelError.ErrorMessage); + + // TODO PM-27326 decrease back to 1 once legacy testing is removed + Assert.Equal(2, newException.ModelState.ErrorCount); + newException.ModelState.TryGetValue(string.Empty, out var newModelStateEntry); + Assert.NotNull(newModelStateEntry); + var newModelError = newModelStateEntry.Errors.First(); + Assert.Equal(duplicateUserEmailErrorDesc, newModelError.ErrorMessage); } - [Theory, BitAutoData] + [Theory, BitAutoData, SignatureKeyPairRequestModelCustomize] public async Task PostRegisterFinish_WhenGivenEmailVerificationToken_ShouldRegisterUser( string email, string masterPasswordHash, string emailVerificationToken, string userSymmetricKey, - KeysRequestModel userAsymmetricKeys) + KeysRequestModel userAsymmetricKeys, AccountKeysRequestModel accountKeys) { // Arrange - var model = new RegisterFinishRequestModel + var legacyModel = new RegisterFinishRequestModel { Email = email, MasterPasswordHash = masterPasswordHash, @@ -436,34 +524,74 @@ public async Task PostRegisterFinish_WhenGivenEmailVerificationToken_ShouldRegis UserAsymmetricKeys = userAsymmetricKeys }; - var user = model.ToUser(); + var kdfModel = new KdfRequestModel + { + KdfType = KdfType.Argon2id, + Iterations = AuthConstants.ARGON2_ITERATIONS.Default, + Memory = AuthConstants.ARGON2_MEMORY.Default, + Parallelism = AuthConstants.ARGON2_PARALLELISM.Default, + }; + + var newModel = new RegisterFinishRequestModel + { + Email = email, + EmailVerificationToken = emailVerificationToken, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + MasterPasswordAuthenticationHash = masterPasswordHash, + Kdf = kdfModel, + Salt = email.ToLowerInvariant().Trim(), + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdfModel, + MasterKeyWrappedUserKey = userSymmetricKey, + Salt = email.ToLowerInvariant().Trim(), + }, + AccountKeys = accountKeys, + }; - _registerUserCommand.RegisterUserViaEmailVerificationToken(Arg.Any(), masterPasswordHash, emailVerificationToken) + var legacyUser = legacyModel.ToUser(false); + var legacyData = legacyModel.ToData(); + + var newUser = newModel.ToUser(true); + var newData = newModel.ToData(); + + _registerUserCommand.RegisterUserViaEmailVerificationToken(Arg.Any(), legacyData, emailVerificationToken) + .Returns(Task.FromResult(IdentityResult.Success)); + _registerUserCommand.RegisterUserViaEmailVerificationToken(Arg.Any(), newData, emailVerificationToken) .Returns(Task.FromResult(IdentityResult.Success)); // Act - var result = await _sut.PostRegisterFinish(model); + var legacyResult = await _sut.PostRegisterFinish(legacyModel); + var newResult = await _sut.PostRegisterFinish(newModel); // Assert - Assert.NotNull(result); + Assert.NotNull(legacyResult); await _registerUserCommand.Received(1).RegisterUserViaEmailVerificationToken(Arg.Is(u => - u.Email == user.Email && - u.MasterPasswordHint == user.MasterPasswordHint && - u.Kdf == user.Kdf && - u.KdfIterations == user.KdfIterations && - u.KdfMemory == user.KdfMemory && - u.KdfParallelism == user.KdfParallelism && - u.Key == user.Key - ), masterPasswordHash, emailVerificationToken); + u.Email == legacyUser.Email && + u.MasterPasswordHint == legacyUser.MasterPasswordHint && + u.Kdf == legacyUser.Kdf && + u.KdfIterations == legacyUser.KdfIterations && + u.KdfMemory == legacyUser.KdfMemory && + u.KdfParallelism == legacyUser.KdfParallelism && + u.Key == legacyUser.Key + ), legacyData, emailVerificationToken); + + Assert.NotNull(newResult); + await _registerUserCommand.Received(1).RegisterUserViaEmailVerificationToken(Arg.Is(u => + u.Email == newUser.Email && + u.MasterPasswordHint == newUser.MasterPasswordHint + ), newData, emailVerificationToken); } - [Theory, BitAutoData] + [Theory, BitAutoData, SignatureKeyPairRequestModelCustomize] public async Task PostRegisterFinish_WhenGivenEmailVerificationTokenDuplicateUser_ThrowsBadRequestException( string email, string masterPasswordHash, string emailVerificationToken, string userSymmetricKey, - KeysRequestModel userAsymmetricKeys) + KeysRequestModel userAsymmetricKeys, AccountKeysRequestModel accountKeys) { // Arrange - var model = new RegisterFinishRequestModel + var legacyModel = new RegisterFinishRequestModel { Email = email, MasterPasswordHash = masterPasswordHash, @@ -474,14 +602,45 @@ public async Task PostRegisterFinish_WhenGivenEmailVerificationTokenDuplicateUse UserAsymmetricKeys = userAsymmetricKeys }; - var user = model.ToUser(); + var kdfModel = new KdfRequestModel + { + KdfType = KdfType.Argon2id, + Iterations = AuthConstants.ARGON2_ITERATIONS.Default, + Memory = AuthConstants.ARGON2_MEMORY.Default, + Parallelism = AuthConstants.ARGON2_PARALLELISM.Default + }; + + var newModel = new RegisterFinishRequestModel + { + Email = email, + EmailVerificationToken = emailVerificationToken, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + MasterPasswordAuthenticationHash = masterPasswordHash, + Kdf = kdfModel, + Salt = email.ToLowerInvariant().Trim(), + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdfModel, + MasterKeyWrappedUserKey = userSymmetricKey, + Salt = email.ToLowerInvariant().Trim(), + }, + AccountKeys = accountKeys, + }; + + var legacyUser = legacyModel.ToUser(false); + var legacyData = legacyModel.ToData(); + + var newUser = newModel.ToUser(true); + var newData = newModel.ToData(); // Duplicates throw 2 errors, one for the email and one for the username var duplicateUserNameErrorCode = "DuplicateUserName"; - var duplicateUserNameErrorDesc = $"Username '{user.Email}' is already taken."; + var duplicateUserNameErrorDesc = $"Username '{email}' is already taken."; var duplicateUserEmailErrorCode = "DuplicateEmail"; - var duplicateUserEmailErrorDesc = $"Email '{user.Email}' is already taken."; + var duplicateUserEmailErrorDesc = $"Email '{email}' is already taken."; var failedIdentityResult = IdentityResult.Failed( new IdentityError { Code = duplicateUserNameErrorCode, Description = duplicateUserNameErrorDesc }, @@ -489,26 +648,40 @@ public async Task PostRegisterFinish_WhenGivenEmailVerificationTokenDuplicateUse ); _registerUserCommand.RegisterUserViaEmailVerificationToken(Arg.Is(u => - u.Email == user.Email && - u.MasterPasswordHint == user.MasterPasswordHint && - u.Kdf == user.Kdf && - u.KdfIterations == user.KdfIterations && - u.KdfMemory == user.KdfMemory && - u.KdfParallelism == user.KdfParallelism && - u.Key == user.Key - ), masterPasswordHash, emailVerificationToken) + u.Email == legacyUser.Email && + u.MasterPasswordHint == legacyUser.MasterPasswordHint && + u.Kdf == legacyUser.Kdf && + u.KdfIterations == legacyUser.KdfIterations && + u.KdfMemory == legacyUser.KdfMemory && + u.KdfParallelism == legacyUser.KdfParallelism && + u.Key == legacyUser.Key + ), legacyData, emailVerificationToken) + .Returns(Task.FromResult(failedIdentityResult)); + + _registerUserCommand.RegisterUserViaEmailVerificationToken(Arg.Is(u => + u.Email == newUser.Email && + u.MasterPasswordHint == newUser.MasterPasswordHint + ), newData, emailVerificationToken) .Returns(Task.FromResult(failedIdentityResult)); // Act - var exception = await Assert.ThrowsAsync(() => _sut.PostRegisterFinish(model)); + var legacyException = await Assert.ThrowsAsync(() => _sut.PostRegisterFinish(legacyModel)); + var newException = await Assert.ThrowsAsync(() => _sut.PostRegisterFinish(newModel)); // We filter out the duplicate username error // so we should only see the duplicate email error - Assert.Equal(1, exception.ModelState.ErrorCount); - exception.ModelState.TryGetValue(string.Empty, out var modelStateEntry); - Assert.NotNull(modelStateEntry); - var modelError = modelStateEntry.Errors.First(); - Assert.Equal(duplicateUserEmailErrorDesc, modelError.ErrorMessage); + Assert.Equal(2, legacyException.ModelState.ErrorCount); + legacyException.ModelState.TryGetValue(string.Empty, out var legacyModelStateEntry); + Assert.NotNull(legacyModelStateEntry); + var legacyModelError = legacyModelStateEntry.Errors.First(); + Assert.Equal(duplicateUserEmailErrorDesc, legacyModelError.ErrorMessage); + + // TODO PM-27326 decrease back to 1 once legacy testing is removed + Assert.Equal(2, newException.ModelState.ErrorCount); + newException.ModelState.TryGetValue(string.Empty, out var newModelStateEntry); + Assert.NotNull(newModelStateEntry); + var newModelError = newModelStateEntry.Errors.First(); + Assert.Equal(duplicateUserEmailErrorDesc, newModelError.ErrorMessage); } @@ -594,14 +767,15 @@ public async Task PostRegisterVerificationEmailClicked_WhenTokenIsValidButExisti // PM-28143 - When removing the old properties, update this test to just test the new properties working // as expected. - [Theory, BitAutoData] + [Theory, BitAutoData, SignatureKeyPairRequestModelCustomize] public async Task PostRegisterFinish_EmailVerification_BothDataForms_ProduceEquivalentOutcomes( string email, string emailVerificationToken, string masterPasswordHash, string masterKeyWrappedUserKey, string publicKey, - string encryptedPrivateKey) + string encryptedPrivateKey, + AccountKeysRequestModel accountKeys) { // Arrange: new-form model (MasterPasswordAuthenticationData + MasterPasswordUnlockData) @@ -629,11 +803,7 @@ public async Task PostRegisterFinish_EmailVerification_BothDataForms_ProduceEqui MasterKeyWrappedUserKey = masterKeyWrappedUserKey, Salt = email }, - UserAsymmetricKeys = new KeysRequestModel - { - PublicKey = publicKey, - EncryptedPrivateKey = encryptedPrivateKey - } + AccountKeys = accountKeys, }; // Arrange: legacy-form model (MasterPasswordHash + legacy KDF + UserSymmetricKey) @@ -642,10 +812,8 @@ public async Task PostRegisterFinish_EmailVerification_BothDataForms_ProduceEqui Email = email, EmailVerificationToken = emailVerificationToken, MasterPasswordHash = masterPasswordHash, - Kdf = KdfType.Argon2id, - KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default, - KdfMemory = AuthConstants.ARGON2_MEMORY.Default, - KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, UserSymmetricKey = masterKeyWrappedUserKey, UserAsymmetricKeys = new KeysRequestModel { @@ -654,11 +822,18 @@ public async Task PostRegisterFinish_EmailVerification_BothDataForms_ProduceEqui } }; - var newUser = newModel.ToUser(); - var legacyUser = legacyModel.ToUser(); + var newUser = newModel.ToUser(true); + var newData = newModel.ToData(); + + var legacyUser = legacyModel.ToUser(false); + var legacyData = legacyModel.ToData(); _registerUserCommand - .RegisterUserViaEmailVerificationToken(Arg.Any(), masterPasswordHash, emailVerificationToken) + .RegisterUserViaEmailVerificationToken(Arg.Any(), legacyData, emailVerificationToken) + .Returns(Task.FromResult(IdentityResult.Success)); + + _registerUserCommand + .RegisterUserViaEmailVerificationToken(Arg.Any(), newData, emailVerificationToken) .Returns(Task.FromResult(IdentityResult.Success)); // Act: call with new form @@ -673,16 +848,9 @@ public async Task PostRegisterFinish_EmailVerification_BothDataForms_ProduceEqui // Assert: effective users are equivalent Assert.Equal(legacyUser.Email, newUser.Email); Assert.Equal(legacyUser.MasterPasswordHint, newUser.MasterPasswordHint); - Assert.Equal(legacyUser.Kdf, newUser.Kdf); - Assert.Equal(legacyUser.KdfIterations, newUser.KdfIterations); - Assert.Equal(legacyUser.KdfMemory, newUser.KdfMemory); - Assert.Equal(legacyUser.KdfParallelism, newUser.KdfParallelism); - Assert.Equal(legacyUser.Key, newUser.Key); - Assert.Equal(legacyUser.PublicKey, newUser.PublicKey); - Assert.Equal(legacyUser.PrivateKey, newUser.PrivateKey); // Assert: hash forwarded identically from both inputs - await _registerUserCommand.Received(2).RegisterUserViaEmailVerificationToken( + await _registerUserCommand.Received(1).RegisterUserViaEmailVerificationToken( Arg.Is(u => u.Email == newUser.Email && u.Kdf == newUser.Kdf && @@ -690,10 +858,10 @@ await _registerUserCommand.Received(2).RegisterUserViaEmailVerificationToken( u.KdfMemory == newUser.KdfMemory && u.KdfParallelism == newUser.KdfParallelism && u.Key == newUser.Key), - masterPasswordHash, + newData, emailVerificationToken); - await _registerUserCommand.Received(2).RegisterUserViaEmailVerificationToken( + await _registerUserCommand.Received(1).RegisterUserViaEmailVerificationToken( Arg.Is(u => u.Email == legacyUser.Email && u.Kdf == legacyUser.Kdf && @@ -701,13 +869,13 @@ await _registerUserCommand.Received(2).RegisterUserViaEmailVerificationToken( u.KdfMemory == legacyUser.KdfMemory && u.KdfParallelism == legacyUser.KdfParallelism && u.Key == legacyUser.Key), - masterPasswordHash, + legacyData, emailVerificationToken); } // PM-28143 - When removing the old properties, update this test to just test the new properties working // as expected. - [Theory, BitAutoData] + [Theory, BitAutoData, SignatureKeyPairRequestModelCustomize] public async Task PostRegisterFinish_OrgInvite_BothDataForms_ProduceEquivalentOutcomes( string email, string orgInviteToken, @@ -715,7 +883,8 @@ public async Task PostRegisterFinish_OrgInvite_BothDataForms_ProduceEquivalentOu string masterPasswordHash, string masterKeyWrappedUserKey, string publicKey, - string encryptedPrivateKey) + string encryptedPrivateKey, + AccountKeysRequestModel accountKeys) { var kdfData = new KdfRequestModel { @@ -743,11 +912,7 @@ public async Task PostRegisterFinish_OrgInvite_BothDataForms_ProduceEquivalentOu MasterKeyWrappedUserKey = masterKeyWrappedUserKey, Salt = email }, - UserAsymmetricKeys = new KeysRequestModel - { - PublicKey = publicKey, - EncryptedPrivateKey = encryptedPrivateKey - } + AccountKeys = accountKeys }; // Arrange: legacy-form model (MasterPasswordHash + legacy KDF + UserSymmetricKey) @@ -757,10 +922,8 @@ public async Task PostRegisterFinish_OrgInvite_BothDataForms_ProduceEquivalentOu OrgInviteToken = orgInviteToken, OrganizationUserId = organizationUserId, MasterPasswordHash = masterPasswordHash, - Kdf = kdfData.KdfType, - KdfIterations = kdfData.Iterations, - KdfMemory = kdfData.Memory, - KdfParallelism = kdfData.Parallelism, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, UserSymmetricKey = masterKeyWrappedUserKey, UserAsymmetricKeys = new KeysRequestModel { @@ -769,11 +932,18 @@ public async Task PostRegisterFinish_OrgInvite_BothDataForms_ProduceEquivalentOu } }; - var newUser = newModel.ToUser(); - var legacyUser = legacyModel.ToUser(); + var newUser = newModel.ToUser(true); + var newData = newModel.ToData(); + + var legacyUser = legacyModel.ToUser(false); + var legacyData = legacyModel.ToData(); _registerUserCommand - .RegisterUserViaOrganizationInviteToken(Arg.Any(), masterPasswordHash, orgInviteToken, organizationUserId) + .RegisterUserViaOrganizationInviteToken(Arg.Any(), newData, orgInviteToken, organizationUserId) + .Returns(Task.FromResult(IdentityResult.Success)); + + _registerUserCommand + .RegisterUserViaOrganizationInviteToken(Arg.Any(), legacyData, orgInviteToken, organizationUserId) .Returns(Task.FromResult(IdentityResult.Success)); // Act @@ -787,16 +957,9 @@ public async Task PostRegisterFinish_OrgInvite_BothDataForms_ProduceEquivalentOu // Assert: effective users are equivalent Assert.Equal(legacyUser.Email, newUser.Email); Assert.Equal(legacyUser.MasterPasswordHint, newUser.MasterPasswordHint); - Assert.Equal(legacyUser.Kdf, newUser.Kdf); - Assert.Equal(legacyUser.KdfIterations, newUser.KdfIterations); - Assert.Equal(legacyUser.KdfMemory, newUser.KdfMemory); - Assert.Equal(legacyUser.KdfParallelism, newUser.KdfParallelism); - Assert.Equal(legacyUser.Key, newUser.Key); - Assert.Equal(legacyUser.PublicKey, newUser.PublicKey); - Assert.Equal(legacyUser.PrivateKey, newUser.PrivateKey); // Assert: hash forwarded identically from both inputs - await _registerUserCommand.Received(2).RegisterUserViaOrganizationInviteToken( + await _registerUserCommand.Received(1).RegisterUserViaOrganizationInviteToken( Arg.Is(u => u.Email == newUser.Email && u.Kdf == newUser.Kdf && @@ -804,11 +967,11 @@ await _registerUserCommand.Received(2).RegisterUserViaOrganizationInviteToken( u.KdfMemory == newUser.KdfMemory && u.KdfParallelism == newUser.KdfParallelism && u.Key == newUser.Key), - masterPasswordHash, + newData, orgInviteToken, organizationUserId); - await _registerUserCommand.Received(2).RegisterUserViaOrganizationInviteToken( + await _registerUserCommand.Received(1).RegisterUserViaOrganizationInviteToken( Arg.Is(u => u.Email == legacyUser.Email && u.Kdf == legacyUser.Kdf && @@ -816,20 +979,19 @@ await _registerUserCommand.Received(2).RegisterUserViaOrganizationInviteToken( u.KdfMemory == legacyUser.KdfMemory && u.KdfParallelism == legacyUser.KdfParallelism && u.Key == legacyUser.Key), - masterPasswordHash, + legacyData, orgInviteToken, organizationUserId); } - [Theory, BitAutoData] + [Theory, BitAutoData, SignatureKeyPairRequestModelCustomize] public async Task PostRegisterFinish_NewForm_UsesUnlockDataForKdfAndKey_WhenRootFieldsNull( string email, string emailVerificationToken, string masterPasswordHash, string masterKeyWrappedUserKey, int iterations, - string publicKey, - string encryptedPrivateKey) + AccountKeysRequestModel accountKeys) { // Arrange: Provide only unlock-data KDF + key; leave root KDF fields null var unlockKdf = new KdfRequestModel @@ -858,15 +1020,13 @@ public async Task PostRegisterFinish_NewForm_UsesUnlockDataForKdfAndKey_WhenRoot // root KDF fields intentionally null Kdf = null, KdfIterations = null, - UserAsymmetricKeys = new KeysRequestModel - { - PublicKey = publicKey, - EncryptedPrivateKey = encryptedPrivateKey - } + AccountKeys = accountKeys }; + var data = model.ToData(); + _registerUserCommand - .RegisterUserViaEmailVerificationToken(Arg.Any(), masterPasswordHash, emailVerificationToken) + .RegisterUserViaEmailVerificationToken(Arg.Any(), data, emailVerificationToken) .Returns(Task.FromResult(IdentityResult.Success)); // Act @@ -874,12 +1034,8 @@ public async Task PostRegisterFinish_NewForm_UsesUnlockDataForKdfAndKey_WhenRoot // Assert: The user passed to command uses unlock-data values await _registerUserCommand.Received(1).RegisterUserViaEmailVerificationToken( - Arg.Is(u => - u.Email == email && - u.Kdf == unlockKdf.KdfType && - u.KdfIterations == unlockKdf.Iterations && - u.Key == masterKeyWrappedUserKey), - masterPasswordHash, + Arg.Is(u => u.Email == email), + data, emailVerificationToken); } @@ -909,8 +1065,10 @@ public async Task PostRegisterFinish_LegacyForm_UsesRootFields_WhenUnlockDataNul } }; + var data = model.ToData(); + _registerUserCommand - .RegisterUserViaEmailVerificationToken(Arg.Any(), masterPasswordHash, emailVerificationToken) + .RegisterUserViaEmailVerificationToken(Arg.Any(), data, emailVerificationToken) .Returns(Task.FromResult(IdentityResult.Success)); // Act @@ -923,7 +1081,7 @@ await _registerUserCommand.Received(1).RegisterUserViaEmailVerificationToken( u.Kdf == KdfType.PBKDF2_SHA256 && u.KdfIterations == AuthConstants.PBKDF2_ITERATIONS.Default && u.Key == legacyKey), - masterPasswordHash, + data, emailVerificationToken); } diff --git a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs index af60cadce464..7c0264053130 100644 --- a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs +++ b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs @@ -274,6 +274,53 @@ public async Task RegisterNewIdentityFactoryUserAsync( }; } + if (requestModel.AccountKeys != null) + { + var keys = requestModel.AccountKeys; + var encKeyPair = keys.PublicKeyEncryptionKeyPair; + var sigKeyPair = keys.SignatureKeyPair; + var securityState = keys.SecurityState; + + // enforce V2 encryption rules for AccountKeys structure + // all v2 parameters must be provided, or none + if (encKeyPair == null || sigKeyPair == null || securityState == null) + { + encKeyPair = null; + sigKeyPair = null; + securityState = null; + } + + if (encKeyPair != null) + { + encKeyPair = new PublicKeyEncryptionKeyPairRequestModel + { + WrappedPrivateKey = DefaultEncryptedString, + PublicKey = keys.PublicKeyEncryptionKeyPair.PublicKey, + SignedPublicKey = keys.PublicKeyEncryptionKeyPair.SignedPublicKey, + }; + } + + if (sigKeyPair != null) + { + sigKeyPair = new SignatureKeyPairRequestModel + { + SignatureAlgorithm = "ed25519", + WrappedSigningKey = DefaultEncryptedString, + VerifyingKey = keys.SignatureKeyPair.VerifyingKey, + }; + } + + // Force valid signature algorithm and encrypted strings to avoid model validation failure. + requestModel.AccountKeys = new AccountKeysRequestModel + { + UserKeyEncryptedAccountPrivateKey = DefaultEncryptedString, + AccountPublicKey = keys.AccountPublicKey, + PublicKeyEncryptionKeyPair = encKeyPair, + SignatureKeyPair = sigKeyPair, + SecurityState = securityState, + }; + } + var sendVerificationEmailReqModel = new RegisterSendVerificationEmailRequestModel { Email = requestModel.Email, diff --git a/util/Migrator/DbScripts/2026-03-04_00_User_UpdateMasterPasswordUnlockUserData.sql b/util/Migrator/DbScripts/2026-03-04_00_User_UpdateMasterPasswordUnlockUserData.sql new file mode 100644 index 000000000000..0daf50aeb148 --- /dev/null +++ b/util/Migrator/DbScripts/2026-03-04_00_User_UpdateMasterPasswordUnlockUserData.sql @@ -0,0 +1,27 @@ +CREATE OR ALTER PROCEDURE [dbo].[User_UpdateMasterPasswordUnlockUserData] + @Id UNIQUEIDENTIFIER, + @Kdf TINYINT, + @KdfIterations INT, + @KdfMemory INT, + @KdfParallelism INT, + @Key VARCHAR(MAX), + @RevisionDate DATETIME2(7), + @AccountRevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[User] + SET + [Key] = @Key, + [Kdf] = @Kdf, + [KdfIterations] = @KdfIterations, + [KdfMemory] = @KdfMemory, + [KdfParallelism] = @KdfParallelism, + [RevisionDate] = @RevisionDate, + [AccountRevisionDate] = @AccountRevisionDate + WHERE + [Id] = @Id +END +GO