From 2e92a53f1129679c80d889b886f55914a0234555 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Fri, 9 Jan 2026 09:17:45 +0100 Subject: [PATCH] [PM-27281] Support v2 account encryption on JIT master password signups (#6777) * V2 prep, rename existing SSO JIT MP command to V1 * set initial master password for account registraton V2 * later removel docs * TDE MP onboarding split * revert separate TDE onboarding controller api * Server side hash of the user master password hash * use `ValidationResult` instead for validation errors * unit test coverage * integration test coverage * update sql migration script date * revert validate password change * better requests validation * explicit error message when org sso identifier invalid * more unit test coverage * renamed onboarding to set, hash naming clarifications * update db sql script, formatting * use raw json as request instead of request models for integration test * v1 integration test coverage * change of name --- .../Auth/Controllers/AccountsController.cs | 67 +- .../SetInitialPasswordRequestModel.cs | 160 ++++ .../Accounts/SetPasswordRequestModel.cs | 40 - ...rPasswordAuthenticationDataRequestModel.cs | 5 +- .../MasterPasswordUnlockDataRequestModel.cs | 8 +- .../Data/SetInitialMasterPasswordDataModel.cs | 23 + .../ISetInitialMasterPasswordCommand.cs | 24 +- .../ISetInitialMasterPasswordCommandV1.cs | 21 + .../Interfaces/ITdeSetPasswordCommand.cs | 26 + .../SetInitialMasterPasswordCommand.cs | 89 +-- .../SetInitialMasterPasswordCommandV1.cs | 103 +++ .../TdeSetPasswordCommand.cs | 70 ++ .../UserServiceCollectionExtensions.cs | 2 + src/Core/Constants.cs | 1 + src/Core/Repositories/IUserRepository.cs | 18 + .../Repositories/UserRepository.cs | 49 ++ .../Repositories/UserRepository.cs | 45 ++ .../User_UpdateMasterPassword.sql | 30 + .../Controllers/AccountsControllerTest.cs | 548 ++++++++++++++ .../Controllers/AccountsControllerTests.cs | 218 +++++- .../SetInitialPasswordRequestModelTests.cs | 682 ++++++++++++++++++ .../SetInitialMasterPasswordCommandTests.cs | 244 ++++--- .../SetInitialMasterPasswordCommandV1Tests.cs | 194 +++++ .../TdeSetPasswordCommandTests.cs | 223 ++++++ ...026-01-06_00_User_UpdateMasterPassword.sql | 31 + 25 files changed, 2642 insertions(+), 279 deletions(-) create mode 100644 src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs delete mode 100644 src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs create mode 100644 src/Core/Auth/Models/Data/SetInitialMasterPasswordDataModel.cs create mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordCommandV1.cs create mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ITdeSetPasswordCommand.cs create mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1.cs create mode 100644 src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs create mode 100644 src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateMasterPassword.sql create mode 100644 test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs create mode 100644 test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1Tests.cs create mode 100644 test/Core.Test/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommandTests.cs create mode 100644 util/Migrator/DbScripts/2026-01-06_00_User_UpdateMasterPassword.sql diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 839d00f7a1..1223e2f05f 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -38,7 +38,9 @@ public class AccountsController : Controller private readonly IProviderUserRepository _providerUserRepository; private readonly IUserService _userService; private readonly IPolicyService _policyService; + private readonly ISetInitialMasterPasswordCommandV1 _setInitialMasterPasswordCommandV1; private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; + private readonly ITdeSetPasswordCommand _tdeSetPasswordCommand; private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IFeatureService _featureService; @@ -54,6 +56,8 @@ public class AccountsController : Controller IUserService userService, IPolicyService policyService, ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand, + ISetInitialMasterPasswordCommandV1 setInitialMasterPasswordCommandV1, + ITdeSetPasswordCommand tdeSetPasswordCommand, ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IFeatureService featureService, @@ -69,6 +73,8 @@ public class AccountsController : Controller _userService = userService; _policyService = policyService; _setInitialMasterPasswordCommand = setInitialMasterPasswordCommand; + _setInitialMasterPasswordCommandV1 = setInitialMasterPasswordCommandV1; + _tdeSetPasswordCommand = tdeSetPasswordCommand; _tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _featureService = featureService; @@ -208,7 +214,7 @@ public class AccountsController : Controller } [HttpPost("set-password")] - public async Task PostSetPasswordAsync([FromBody] SetPasswordRequestModel model) + public async Task PostSetPasswordAsync([FromBody] SetInitialPasswordRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); if (user == null) @@ -216,33 +222,48 @@ public class AccountsController : Controller throw new UnauthorizedAccessException(); } - try + if (model.IsV2Request()) { - user = model.ToUser(user); + if (model.IsTdeSetPasswordRequest()) + { + await _tdeSetPasswordCommand.SetMasterPasswordAsync(user, model.ToData()); + } + else + { + await _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(user, model.ToData()); + } } - catch (Exception e) + else { - ModelState.AddModelError(string.Empty, e.Message); + // TODO removed with https://bitwarden.atlassian.net/browse/PM-27327 + try + { + user = model.ToUser(user); + } + catch (Exception e) + { + ModelState.AddModelError(string.Empty, e.Message); + throw new BadRequestException(ModelState); + } + + var result = await _setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync( + user, + model.MasterPasswordHash, + model.Key, + model.OrgIdentifier); + + if (result.Succeeded) + { + return; + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + throw new BadRequestException(ModelState); } - - var result = await _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync( - user, - model.MasterPasswordHash, - model.Key, - model.OrgIdentifier); - - if (result.Succeeded) - { - return; - } - - foreach (var error in result.Errors) - { - ModelState.AddModelError(string.Empty, error.Description); - } - - throw new BadRequestException(ModelState); } [HttpPost("verify-password")] diff --git a/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs new file mode 100644 index 0000000000..55ffdca94b --- /dev/null +++ b/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs @@ -0,0 +1,160 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.KeyManagement.Models.Requests; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Api.Request; +using Bit.Core.Utilities; + +namespace Bit.Api.Auth.Models.Request.Accounts; + +public class SetInitialPasswordRequestModel : IValidatableObject +{ + // TODO will be removed with https://bitwarden.atlassian.net/browse/PM-27327 + [Obsolete("Use MasterPasswordAuthentication instead")] + [StringLength(300)] + public string? MasterPasswordHash { get; set; } + + [Obsolete("Use MasterPasswordUnlock instead")] + public string? Key { get; set; } + + [Obsolete("Use AccountKeys instead")] + public KeysRequestModel? Keys { get; set; } + + [Obsolete("Use MasterPasswordAuthentication instead")] + public KdfType? Kdf { get; set; } + + [Obsolete("Use MasterPasswordAuthentication instead")] + public int? KdfIterations { get; set; } + + [Obsolete("Use MasterPasswordAuthentication instead")] + public int? KdfMemory { get; set; } + + [Obsolete("Use MasterPasswordAuthentication instead")] + public int? KdfParallelism { get; set; } + + public MasterPasswordAuthenticationDataRequestModel? MasterPasswordAuthentication { get; set; } + public MasterPasswordUnlockDataRequestModel? MasterPasswordUnlock { get; set; } + public AccountKeysRequestModel? AccountKeys { get; set; } + + [StringLength(50)] + public string? MasterPasswordHint { get; set; } + + [Required] + public required string OrgIdentifier { get; set; } + + // TODO removed with https://bitwarden.atlassian.net/browse/PM-27327 + public User ToUser(User existingUser) + { + existingUser.MasterPasswordHint = MasterPasswordHint; + existingUser.Kdf = Kdf!.Value; + existingUser.KdfIterations = KdfIterations!.Value; + existingUser.KdfMemory = KdfMemory; + existingUser.KdfParallelism = KdfParallelism; + existingUser.Key = Key; + Keys?.ToUser(existingUser); + return existingUser; + } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (IsV2Request()) + { + // V2 registration + + // Validate Kdf + var authenticationKdf = MasterPasswordAuthentication!.Kdf.ToData(); + var unlockKdf = MasterPasswordUnlock!.Kdf.ToData(); + + // Currently, KDF settings are not saved separately for authentication and unlock and must therefore be equal + if (!authenticationKdf.Equals(unlockKdf)) + { + yield return new ValidationResult("KDF settings must be equal for authentication and unlock.", + [$"{nameof(MasterPasswordAuthentication)}.{nameof(MasterPasswordAuthenticationDataRequestModel.Kdf)}", + $"{nameof(MasterPasswordUnlock)}.{nameof(MasterPasswordUnlockDataRequestModel.Kdf)}"]); + } + + var authenticationValidationErrors = KdfSettingsValidator.Validate(authenticationKdf).ToList(); + if (authenticationValidationErrors.Count != 0) + { + yield return authenticationValidationErrors.First(); + } + + var unlockValidationErrors = KdfSettingsValidator.Validate(unlockKdf).ToList(); + if (unlockValidationErrors.Count != 0) + { + yield return unlockValidationErrors.First(); + } + + yield break; + } + + // V1 registration + // TODO removed with https://bitwarden.atlassian.net/browse/PM-27327 + if (string.IsNullOrEmpty(MasterPasswordHash)) + { + yield return new ValidationResult("MasterPasswordHash must be supplied."); + } + + if (string.IsNullOrEmpty(Key)) + { + yield return new ValidationResult("Key must be supplied."); + } + + if (Kdf == null) + { + yield return new ValidationResult("Kdf must be supplied."); + yield break; + } + + if (KdfIterations == null) + { + yield return new ValidationResult("KdfIterations must be supplied."); + yield break; + } + + if (Kdf == KdfType.Argon2id) + { + if (KdfMemory == null) + { + yield return new ValidationResult("KdfMemory must be supplied when Kdf is Argon2id."); + } + + if (KdfParallelism == null) + { + yield return new ValidationResult("KdfParallelism must be supplied when Kdf is Argon2id."); + } + } + + var validationErrors = KdfSettingsValidator + .Validate(Kdf!.Value, KdfIterations!.Value, KdfMemory, KdfParallelism).ToList(); + if (validationErrors.Count != 0) + { + yield return validationErrors.First(); + } + } + + public bool IsV2Request() + { + // AccountKeys can be null for TDE users, so we don't check that here + return MasterPasswordAuthentication != null && MasterPasswordUnlock != null; + } + + public bool IsTdeSetPasswordRequest() + { + return AccountKeys == null; + } + + public SetInitialMasterPasswordDataModel ToData() + { + return new SetInitialMasterPasswordDataModel + { + MasterPasswordAuthentication = MasterPasswordAuthentication!.ToData(), + MasterPasswordUnlock = MasterPasswordUnlock!.ToData(), + OrgSsoIdentifier = OrgIdentifier, + AccountKeys = AccountKeys?.ToAccountKeysData(), + MasterPasswordHint = MasterPasswordHint + }; + } +} diff --git a/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs deleted file mode 100644 index 0d809c6c11..0000000000 --- a/src/Api/Auth/Models/Request/Accounts/SetPasswordRequestModel.cs +++ /dev/null @@ -1,40 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; -using Bit.Core.Auth.Models.Api.Request.Accounts; -using Bit.Core.Entities; -using Bit.Core.Enums; - -namespace Bit.Api.Auth.Models.Request.Accounts; - -public class SetPasswordRequestModel -{ - [Required] - [StringLength(300)] - public string MasterPasswordHash { get; set; } - [Required] - public string Key { get; set; } - [StringLength(50)] - public string MasterPasswordHint { get; set; } - public KeysRequestModel Keys { get; set; } - [Required] - public KdfType Kdf { get; set; } - [Required] - public int KdfIterations { get; set; } - public int? KdfMemory { get; set; } - public int? KdfParallelism { get; set; } - public string OrgIdentifier { get; set; } - - public User ToUser(User existingUser) - { - existingUser.MasterPasswordHint = MasterPasswordHint; - existingUser.Kdf = Kdf; - existingUser.KdfIterations = KdfIterations; - existingUser.KdfMemory = KdfMemory; - existingUser.KdfParallelism = KdfParallelism; - existingUser.Key = Key; - Keys?.ToUser(existingUser); - return existingUser; - } -} diff --git a/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs index d65dc8fcb7..4f70a1135f 100644 --- a/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs @@ -6,8 +6,11 @@ namespace Bit.Api.KeyManagement.Models.Requests; public class MasterPasswordAuthenticationDataRequestModel { public required KdfRequestModel Kdf { get; init; } + [Required] public required string MasterPasswordAuthenticationHash { get; init; } - [StringLength(256)] public required string Salt { get; init; } + [Required] + [StringLength(256)] + public required string Salt { get; init; } public MasterPasswordAuthenticationData ToData() { diff --git a/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs index ce7a2b343f..e1d7863cae 100644 --- a/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs @@ -7,8 +7,12 @@ namespace Bit.Api.KeyManagement.Models.Requests; public class MasterPasswordUnlockDataRequestModel { public required KdfRequestModel Kdf { get; init; } - [EncryptedString] public required string MasterKeyWrappedUserKey { get; init; } - [StringLength(256)] public required string Salt { get; init; } + [Required] + [EncryptedString] + public required string MasterKeyWrappedUserKey { get; init; } + [Required] + [StringLength(256)] + public required string Salt { get; init; } public MasterPasswordUnlockData ToData() { diff --git a/src/Core/Auth/Models/Data/SetInitialMasterPasswordDataModel.cs b/src/Core/Auth/Models/Data/SetInitialMasterPasswordDataModel.cs new file mode 100644 index 0000000000..82bcb3da5e --- /dev/null +++ b/src/Core/Auth/Models/Data/SetInitialMasterPasswordDataModel.cs @@ -0,0 +1,23 @@ +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.Auth.Models.Data; + +/// +/// Data model for setting an initial master password for a user. +/// +public class SetInitialMasterPasswordDataModel +{ + public required MasterPasswordAuthenticationData MasterPasswordAuthentication { get; set; } + public required MasterPasswordUnlockData MasterPasswordUnlock { get; set; } + + /// + /// Organization SSO identifier. + /// + public required string OrgSsoIdentifier { get; set; } + + /// + /// User account keys. Required for Master Password decryption user. + /// + public required UserAccountKeysData? AccountKeys { get; set; } + public string? MasterPasswordHint { get; set; } +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordCommand.cs index 31dd19d5bf..d695f2586c 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordCommand.cs @@ -1,19 +1,25 @@ -using Bit.Core.Entities; -using Microsoft.AspNetCore.Identity; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Entities; +using Bit.Core.Exceptions; namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; /// /// Manages the setting of the initial master password for a in an organization. -/// This class is primarily invoked in two scenarios: -/// 1) In organizations configured with Single Sign-On (SSO) and master password decryption: +/// In organizations configured with Single Sign-On (SSO) and master password decryption: /// just in time (JIT) provisioned users logging in via SSO are required to set a master password. -/// 2) In organizations configured with SSO and trusted devices decryption: -/// Users who are upgraded to have admin account recovery permissions must set a master password -/// to ensure their ability to reset other users' accounts. /// public interface ISetInitialMasterPasswordCommand { - public Task SetInitialMasterPasswordAsync(User user, string masterPassword, string key, - string orgSsoIdentifier); + /// + /// Sets the initial master password and account keys for the specified user. + /// + /// User to set the master password for + /// Initial master password setup data + /// A task that completes when the operation succeeds + /// + /// Thrown if the user's master password is already set, the organization is not found, + /// the user is not a member of the organization, or the account keys are missing. + /// + public Task SetInitialMasterPasswordAsync(User user, SetInitialMasterPasswordDataModel masterPasswordDataModel); } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordCommandV1.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordCommandV1.cs new file mode 100644 index 0000000000..7f4818a535 --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISetInitialMasterPasswordCommandV1.cs @@ -0,0 +1,21 @@ +using Bit.Core.Entities; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; + +/// +/// Manages the setting of the initial master password for a in an organization. +/// This class is primarily invoked in two scenarios: +/// 1) In organizations configured with Single Sign-On (SSO) and master password decryption: +/// just in time (JIT) provisioned users logging in via SSO are required to set a master password. +/// 2) In organizations configured with SSO and trusted devices decryption: +/// Users who are upgraded to have admin account recovery permissions must set a master password +/// to ensure their ability to reset other users' accounts. +/// +// TODO removed with https://bitwarden.atlassian.net/browse/PM-27327 +[Obsolete("Use ISetInitialMasterPasswordCommand instead")] +public interface ISetInitialMasterPasswordCommandV1 +{ + public Task SetInitialMasterPasswordAsync(User user, string masterPassword, string key, + string orgSsoIdentifier); +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ITdeSetPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ITdeSetPasswordCommand.cs new file mode 100644 index 0000000000..4815aea8a0 --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ITdeSetPasswordCommand.cs @@ -0,0 +1,26 @@ +using Bit.Core.Auth.Models.Data; +using Bit.Core.Entities; +using Bit.Core.Exceptions; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; + +/// +/// Manages the setting of the master password for a TDE in an organization. +/// In organizations configured with SSO and trusted devices decryption: +/// Users who are upgraded to have admin account recovery permissions must set a master password +/// to ensure their ability to reset other users' accounts. +/// +public interface ITdeSetPasswordCommand +{ + /// + /// Sets the master password for the specified TDE user. + /// + /// User to set the master password for + /// Master password setup data + /// A task that completes when the operation succeeds + /// + /// Thrown if the user's master password is already set, the organization is not found, + /// the user is not a member of the organization, or the user is a TDE user without account keys set. + /// + Task SetMasterPasswordAsync(User user, SetInitialMasterPasswordDataModel masterPasswordDataModel); +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommand.cs index 32966f5710..b91ac61f7f 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommand.cs @@ -1,4 +1,5 @@ -using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -6,98 +7,74 @@ using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Logging; namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; public class SetInitialMasterPasswordCommand : ISetInitialMasterPasswordCommand { - private readonly ILogger _logger; - private readonly IdentityErrorDescriber _identityErrorDescriber; private readonly IUserService _userService; private readonly IUserRepository _userRepository; - private readonly IEventService _eventService; private readonly IAcceptOrgUserCommand _acceptOrgUserCommand; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationRepository _organizationRepository; + private readonly IPasswordHasher _passwordHasher; + private readonly IEventService _eventService; - - public SetInitialMasterPasswordCommand( - ILogger logger, - IdentityErrorDescriber identityErrorDescriber, - IUserService userService, - IUserRepository userRepository, - IEventService eventService, - IAcceptOrgUserCommand acceptOrgUserCommand, - IOrganizationUserRepository organizationUserRepository, - IOrganizationRepository organizationRepository) + public SetInitialMasterPasswordCommand(IUserService userService, IUserRepository userRepository, + IAcceptOrgUserCommand acceptOrgUserCommand, IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, IPasswordHasher passwordHasher, + IEventService eventService) { - _logger = logger; - _identityErrorDescriber = identityErrorDescriber; _userService = userService; _userRepository = userRepository; - _eventService = eventService; _acceptOrgUserCommand = acceptOrgUserCommand; _organizationUserRepository = organizationUserRepository; _organizationRepository = organizationRepository; + _passwordHasher = passwordHasher; + _eventService = eventService; } - public async Task SetInitialMasterPasswordAsync(User user, string masterPassword, string key, - string orgSsoIdentifier) + public async Task SetInitialMasterPasswordAsync(User user, + SetInitialMasterPasswordDataModel masterPasswordDataModel) { - if (user == null) + if (user.Key != null) { - throw new ArgumentNullException(nameof(user)); + throw new BadRequestException("User already has a master password set."); } - if (!string.IsNullOrWhiteSpace(user.MasterPassword)) + if (masterPasswordDataModel.AccountKeys == null) { - _logger.LogWarning("Change password failed for user {userId} - already has password.", user.Id); - return IdentityResult.Failed(_identityErrorDescriber.UserAlreadyHasPassword()); + throw new BadRequestException("Account keys are required."); } - var result = await _userService.UpdatePasswordHash(user, masterPassword, validatePassword: true, refreshStamp: false); - if (!result.Succeeded) - { - return result; - } - - user.RevisionDate = user.AccountRevisionDate = DateTime.UtcNow; - user.Key = key; - - await _userRepository.ReplaceAsync(user); - await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword); - - - if (string.IsNullOrWhiteSpace(orgSsoIdentifier)) - { - throw new BadRequestException("Organization SSO Identifier required."); - } - - var org = await _organizationRepository.GetByIdentifierAsync(orgSsoIdentifier); + // Prevent a de-synced salt value from creating an un-decryptable unlock method + masterPasswordDataModel.MasterPasswordAuthentication.ValidateSaltUnchangedForUser(user); + masterPasswordDataModel.MasterPasswordUnlock.ValidateSaltUnchangedForUser(user); + var org = await _organizationRepository.GetByIdentifierAsync(masterPasswordDataModel.OrgSsoIdentifier); if (org == null) { - throw new BadRequestException("Organization invalid."); + throw new BadRequestException("Organization SSO identifier is invalid."); } var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, user.Id); - if (orgUser == null) { throw new BadRequestException("User not found within organization."); } - // TDE users who go from a user without admin acct recovery permission to having it will be - // required to set a MP for the first time and we don't want to re-execute the accept logic - // as they are already confirmed. - // TLDR: only accept post SSO user if they are invited - if (orgUser.Status == OrganizationUserStatusType.Invited) - { - await _acceptOrgUserCommand.AcceptOrgUserAsync(orgUser, user, _userService); - } + // Hash the provided user master password authentication hash on the server side + var serverSideHashedMasterPasswordAuthenticationHash = _passwordHasher.HashPassword(user, + masterPasswordDataModel.MasterPasswordAuthentication.MasterPasswordAuthenticationHash); - return IdentityResult.Success; + var setMasterPasswordTask = _userRepository.SetMasterPassword(user.Id, + masterPasswordDataModel.MasterPasswordUnlock, serverSideHashedMasterPasswordAuthenticationHash, + masterPasswordDataModel.MasterPasswordHint); + await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, masterPasswordDataModel.AccountKeys, + [setMasterPasswordTask]); + + await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword); + + await _acceptOrgUserCommand.AcceptOrgUserAsync(orgUser, user, _userService); } - } diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1.cs new file mode 100644 index 0000000000..df5f0d02f7 --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1.cs @@ -0,0 +1,103 @@ +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; + +public class SetInitialMasterPasswordCommandV1 : ISetInitialMasterPasswordCommandV1 +{ + private readonly ILogger _logger; + private readonly IdentityErrorDescriber _identityErrorDescriber; + private readonly IUserService _userService; + private readonly IUserRepository _userRepository; + private readonly IEventService _eventService; + private readonly IAcceptOrgUserCommand _acceptOrgUserCommand; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationRepository _organizationRepository; + + + public SetInitialMasterPasswordCommandV1( + ILogger logger, + IdentityErrorDescriber identityErrorDescriber, + IUserService userService, + IUserRepository userRepository, + IEventService eventService, + IAcceptOrgUserCommand acceptOrgUserCommand, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository) + { + _logger = logger; + _identityErrorDescriber = identityErrorDescriber; + _userService = userService; + _userRepository = userRepository; + _eventService = eventService; + _acceptOrgUserCommand = acceptOrgUserCommand; + _organizationUserRepository = organizationUserRepository; + _organizationRepository = organizationRepository; + } + + public async Task SetInitialMasterPasswordAsync(User user, string masterPassword, string key, + string orgSsoIdentifier) + { + if (user == null) + { + throw new ArgumentNullException(nameof(user)); + } + + if (!string.IsNullOrWhiteSpace(user.MasterPassword)) + { + _logger.LogWarning("Change password failed for user {userId} - already has password.", user.Id); + return IdentityResult.Failed(_identityErrorDescriber.UserAlreadyHasPassword()); + } + + var result = await _userService.UpdatePasswordHash(user, masterPassword, validatePassword: true, refreshStamp: false); + if (!result.Succeeded) + { + return result; + } + + user.RevisionDate = user.AccountRevisionDate = DateTime.UtcNow; + user.Key = key; + + await _userRepository.ReplaceAsync(user); + await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword); + + + if (string.IsNullOrWhiteSpace(orgSsoIdentifier)) + { + throw new BadRequestException("Organization SSO Identifier required."); + } + + var org = await _organizationRepository.GetByIdentifierAsync(orgSsoIdentifier); + + if (org == null) + { + throw new BadRequestException("Organization invalid."); + } + + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, user.Id); + + if (orgUser == null) + { + throw new BadRequestException("User not found within organization."); + } + + // TDE users who go from a user without admin acct recovery permission to having it will be + // required to set a MP for the first time and we don't want to re-execute the accept logic + // as they are already confirmed. + // TLDR: only accept post SSO user if they are invited + if (orgUser.Status == OrganizationUserStatusType.Invited) + { + await _acceptOrgUserCommand.AcceptOrgUserAsync(orgUser, user, _userService); + } + + return IdentityResult.Success; + } + +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs new file mode 100644 index 0000000000..afd28e95d9 --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs @@ -0,0 +1,70 @@ +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; + +public class TdeSetPasswordCommand : ITdeSetPasswordCommand +{ + private readonly IUserRepository _userRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IPasswordHasher _passwordHasher; + private readonly IEventService _eventService; + + public TdeSetPasswordCommand(IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository, + IPasswordHasher passwordHasher, IEventService eventService) + { + _userRepository = userRepository; + _organizationUserRepository = organizationUserRepository; + _organizationRepository = organizationRepository; + _passwordHasher = passwordHasher; + _eventService = eventService; + } + + public async Task SetMasterPasswordAsync(User user, SetInitialMasterPasswordDataModel masterPasswordDataModel) + { + if (user.Key != null) + { + throw new BadRequestException("User already has a master password set."); + } + + if (user.PublicKey == null || user.PrivateKey == null) + { + throw new BadRequestException("TDE user account keys must be set before setting initial master password."); + } + + // Prevent a de-synced salt value from creating an un-decryptable unlock method + masterPasswordDataModel.MasterPasswordAuthentication.ValidateSaltUnchangedForUser(user); + masterPasswordDataModel.MasterPasswordUnlock.ValidateSaltUnchangedForUser(user); + + var org = await _organizationRepository.GetByIdentifierAsync(masterPasswordDataModel.OrgSsoIdentifier); + if (org == null) + { + throw new BadRequestException("Organization SSO identifier is invalid."); + } + + var orgUser = await _organizationUserRepository.GetByOrganizationAsync(org.Id, user.Id); + if (orgUser == null) + { + throw new BadRequestException("User not found within organization."); + } + + // Hash the provided user master password authentication hash on the server side + var serverSideHashedMasterPasswordAuthenticationHash = _passwordHasher.HashPassword(user, + masterPasswordDataModel.MasterPasswordAuthentication.MasterPasswordAuthenticationHash); + + var setMasterPasswordTask = _userRepository.SetMasterPassword(user.Id, + masterPasswordDataModel.MasterPasswordUnlock, serverSideHashedMasterPasswordAuthenticationHash, + masterPasswordDataModel.MasterPasswordHint); + await _userRepository.UpdateUserDataAsync([setMasterPasswordTask]); + + await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword); + } +} diff --git a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs index 7c50f7f17b..6249d1cb1c 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -44,6 +44,8 @@ public static class UserServiceCollectionExtensions private static void AddUserPasswordCommands(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } private static void AddTdeOffboardingPasswordCommands(this IServiceCollection services) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 9b2186d901..373107bb66 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -204,6 +204,7 @@ public static class FeatureFlagKeys public const string V2RegistrationTDEJIT = "pm-27279-v2-registration-tde-jit"; public const string DataRecoveryTool = "pm-28813-data-recovery-tool"; public const string EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration"; + public const string EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration"; /* Mobile Team */ public const string AndroidImportLoginsFlow = "import-logins-flow"; diff --git a/src/Core/Repositories/IUserRepository.cs b/src/Core/Repositories/IUserRepository.cs index 93316d78bd..c5f78d76fc 100644 --- a/src/Core/Repositories/IUserRepository.cs +++ b/src/Core/Repositories/IUserRepository.cs @@ -74,6 +74,24 @@ public interface IUserRepository : IRepository Task DeleteManyAsync(IEnumerable users); UpdateUserData SetKeyConnectorUserKey(Guid userId, string keyConnectorWrappedUserKey); + + /// + /// Sets the master password and KDF for a user. + /// + /// The user identifier. + /// Data for unlocking with the master password. + /// Server side hash of the user master authentication password hash + /// Optional hint for the master password. + /// A task to complete the operation. + UpdateUserData SetMasterPassword(Guid userId, MasterPasswordUnlockData masterPasswordUnlockData, + string serverSideHashedMasterPasswordAuthenticationHash, string? masterPasswordHint); + + /// + /// Updates multiple user data properties in a single transaction. + /// + /// Actions to update user data. + /// On success + Task UpdateUserDataAsync(IEnumerable updateUserDataActions); } public delegate Task UpdateUserData(Microsoft.Data.SqlClient.SqlConnection? connection = null, diff --git a/src/Infrastructure.Dapper/Repositories/UserRepository.cs b/src/Infrastructure.Dapper/Repositories/UserRepository.cs index 571319e4c7..920145f2f2 100644 --- a/src/Infrastructure.Dapper/Repositories/UserRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/UserRepository.cs @@ -428,6 +428,55 @@ public class UserRepository : Repository, IUserRepository }; } + public UpdateUserData SetMasterPassword(Guid userId, MasterPasswordUnlockData masterPasswordUnlockData, + string serverSideHashedMasterPasswordAuthenticationHash, string? masterPasswordHint) + { + return async (connection, transaction) => + { + var timestamp = DateTime.UtcNow; + + await connection!.ExecuteAsync( + "[dbo].[User_UpdateMasterPassword]", + new + { + Id = userId, + MasterPassword = serverSideHashedMasterPasswordAuthenticationHash, + MasterPasswordHint = masterPasswordHint, + Key = masterPasswordUnlockData.MasterKeyWrappedUserKey, + Kdf = masterPasswordUnlockData.Kdf.KdfType, + KdfIterations = masterPasswordUnlockData.Kdf.Iterations, + KdfMemory = masterPasswordUnlockData.Kdf.Memory, + KdfParallelism = masterPasswordUnlockData.Kdf.Parallelism, + RevisionDate = timestamp, + AccountRevisionDate = timestamp + }, + transaction: transaction, + commandType: CommandType.StoredProcedure); + }; + } + + public async Task UpdateUserDataAsync(IEnumerable updateUserDataActions) + { + await using var connection = new SqlConnection(ConnectionString); + await connection.OpenAsync(); + + await using var transaction = connection.BeginTransaction(); + try + { + foreach (var action in updateUserDataActions) + { + await action(connection, transaction); + } + + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } + } + 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 56d64094d0..24c88d592c 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -510,6 +510,51 @@ public class UserRepository : Repository, IUserR }; } + public UpdateUserData SetMasterPassword(Guid userId, MasterPasswordUnlockData masterPasswordUnlockData, + string serverSideHashedMasterPasswordAuthenticationHash, string? masterPasswordHint) + { + return async (_, _) => + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + var userEntity = await dbContext.Users.FindAsync(userId); + if (userEntity == null) + { + throw new ArgumentException("User not found", nameof(userId)); + } + + var timestamp = DateTime.UtcNow; + + userEntity.MasterPassword = serverSideHashedMasterPasswordAuthenticationHash; + userEntity.MasterPasswordHint = masterPasswordHint; + userEntity.Key = masterPasswordUnlockData.MasterKeyWrappedUserKey; + userEntity.Kdf = masterPasswordUnlockData.Kdf.KdfType; + userEntity.KdfIterations = masterPasswordUnlockData.Kdf.Iterations; + userEntity.KdfMemory = masterPasswordUnlockData.Kdf.Memory; + userEntity.KdfParallelism = masterPasswordUnlockData.Kdf.Parallelism; + userEntity.RevisionDate = timestamp; + userEntity.AccountRevisionDate = timestamp; + + await dbContext.SaveChangesAsync(); + }; + } + + public async Task UpdateUserDataAsync(IEnumerable updateUserDataActions) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + await using var transaction = await dbContext.Database.BeginTransactionAsync(); + + foreach (var action in updateUserDataActions) + { + await action(); + } + + await transaction.CommitAsync(); + } + 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_UpdateMasterPassword.sql b/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateMasterPassword.sql new file mode 100644 index 0000000000..42b3cbcb84 --- /dev/null +++ b/src/Sql/dbo/KeyManagement/Stored Procedures/User_UpdateMasterPassword.sql @@ -0,0 +1,30 @@ +CREATE PROCEDURE [dbo].[User_UpdateMasterPassword] + @Id UNIQUEIDENTIFIER, + @MasterPassword NVARCHAR(300), + @MasterPasswordHint NVARCHAR(50) = NULL, + @Key VARCHAR(MAX), + @Kdf TINYINT, + @KdfIterations INT, + @KdfMemory INT = NULL, + @KdfParallelism INT = NULL, + @RevisionDate DATETIME2(7), + @AccountRevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[User] + SET + [MasterPassword] = @MasterPassword, + [MasterPasswordHint] = @MasterPasswordHint, + [Key] = @Key, + [Kdf] = @Kdf, + [KdfIterations] = @KdfIterations, + [KdfMemory] = @KdfMemory, + [KdfParallelism] = @KdfParallelism, + [RevisionDate] = @RevisionDate, + [AccountRevisionDate] = @AccountRevisionDate + WHERE + [Id] = @Id +END diff --git a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs index 09ec5b010f..d055418f3a 100644 --- a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs @@ -1,19 +1,28 @@ using System.Net; +using System.Text.Json; using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.KeyManagement.Models.Requests; using Bit.Api.Models.Response; using Bit.Core; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.KeyManagement.Repositories; +using Bit.Core.Models.Data; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Identity; using NSubstitute; using Xunit; +using static Bit.Core.KeyManagement.Enums.SignatureAlgorithm; namespace Bit.Api.IntegrationTest.Controllers; @@ -21,6 +30,8 @@ public class AccountsControllerTest : IClassFixture, IAsy { private static readonly string _masterKeyWrappedUserKey = "2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk="; + private static readonly string _mockEncryptedType7String = "7.AOs41Hd8OQiCPXjyJKCiDA=="; + private static readonly string _mockEncryptedType7WrappedSigningKey = "7.DRv74Kg1RSlFSam1MNFlGD=="; private static readonly string _masterPasswordHash = "master_password_hash"; private static readonly string _newMasterPasswordHash = "new_master_password_hash"; @@ -35,6 +46,11 @@ public class AccountsControllerTest : IClassFixture, IAsy private readonly IPushNotificationService _pushNotificationService; private readonly IFeatureService _featureService; private readonly IPasswordHasher _passwordHasher; + private readonly IOrganizationRepository _organizationRepository; + private readonly ISsoConfigRepository _ssoConfigRepository; + private readonly IUserSignatureKeyPairRepository _userSignatureKeyPairRepository; + private readonly IEventRepository _eventRepository; + private readonly IOrganizationUserRepository _organizationUserRepository; private string _ownerEmail = null!; @@ -49,6 +65,11 @@ public class AccountsControllerTest : IClassFixture, IAsy _pushNotificationService = _factory.GetService(); _featureService = _factory.GetService(); _passwordHasher = _factory.GetService>(); + _organizationRepository = _factory.GetService(); + _ssoConfigRepository = _factory.GetService(); + _userSignatureKeyPairRepository = _factory.GetService(); + _eventRepository = _factory.GetService(); + _organizationUserRepository = _factory.GetService(); } public async Task InitializeAsync() @@ -435,4 +456,531 @@ public class AccountsControllerTest : IClassFixture, IAsy message.Content = JsonContent.Create(requestModel); return await _client.SendAsync(message); } + + [Theory] + [BitAutoData] + public async Task PostSetPasswordAsync_V1_MasterPasswordDecryption_Success(string organizationSsoIdentifier) + { + // Arrange - Create organization and user + var ownerEmail = $"owner-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(ownerEmail); + + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, + ownerEmail: ownerEmail, + name: "Test Org V1"); + organization.UseSso = true; + organization.Identifier = organizationSsoIdentifier; + await _organizationRepository.ReplaceAsync(organization); + + await _ssoConfigRepository.CreateAsync(new SsoConfig + { + OrganizationId = organization.Id, + Enabled = true, + Data = JsonSerializer.Serialize(new SsoConfigurationData + { + MemberDecryptionType = MemberDecryptionType.MasterPassword, + }, JsonHelpers.CamelCase), + }); + + // Create user with password initially, so we can login + var userEmail = $"user-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(userEmail); + + // Add user to organization + var user = await _userRepository.GetByEmailAsync(userEmail); + Assert.NotNull(user); + await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, userEmail, + OrganizationUserType.User, userStatusType: OrganizationUserStatusType.Invited); + + // Login as the user + await _loginHelper.LoginAsync(userEmail); + + // Remove the master password and keys to simulate newly registered SSO user + user.MasterPassword = null; + user.Key = null; + user.PrivateKey = null; + user.PublicKey = null; + await _userRepository.ReplaceAsync(user); + + // V1 (Obsolete) request format - to be removed with PM-27327 + var request = new + { + masterPasswordHash = _newMasterPasswordHash, + key = _masterKeyWrappedUserKey, + keys = new + { + publicKey = "v1-publicKey", + encryptedPrivateKey = "v1-encryptedPrivateKey" + }, + kdf = 0, // PBKDF2_SHA256 + kdfIterations = 600000, + kdfMemory = (int?)null, + kdfParallelism = (int?)null, + masterPasswordHint = "v1-integration-test-hint", + orgIdentifier = organization.Identifier + }; + + var jsonRequest = JsonSerializer.Serialize(request, JsonHelpers.CamelCase); + + // Act + using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password"); + message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json"); + var response = await _client.SendAsync(message); + + // Assert + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Assert.Fail($"Expected success but got {response.StatusCode}. Error: {errorContent}"); + } + + // Verify user in database + var updatedUser = await _userRepository.GetByEmailAsync(userEmail); + Assert.NotNull(updatedUser); + Assert.Equal("v1-integration-test-hint", updatedUser.MasterPasswordHint); + + // Verify the master password is hashed and stored + Assert.NotNull(updatedUser.MasterPassword); + var verificationResult = _passwordHasher.VerifyHashedPassword(updatedUser, updatedUser.MasterPassword, _newMasterPasswordHash); + Assert.Equal(PasswordVerificationResult.Success, verificationResult); + + // Verify KDF settings + Assert.Equal(KdfType.PBKDF2_SHA256, updatedUser.Kdf); + Assert.Equal(600_000, updatedUser.KdfIterations); + Assert.Null(updatedUser.KdfMemory); + Assert.Null(updatedUser.KdfParallelism); + + // Verify timestamps are updated + Assert.Equal(DateTime.UtcNow, updatedUser.RevisionDate, TimeSpan.FromMinutes(1)); + Assert.Equal(DateTime.UtcNow, updatedUser.AccountRevisionDate, TimeSpan.FromMinutes(1)); + + // Verify keys are set (V1 uses Keys property) + Assert.Equal(_masterKeyWrappedUserKey, updatedUser.Key); + Assert.Equal("v1-publicKey", updatedUser.PublicKey); + Assert.Equal("v1-encryptedPrivateKey", updatedUser.PrivateKey); + + // Verify User_ChangedPassword event was logged + var events = await _eventRepository.GetManyByUserAsync(updatedUser.Id, DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow.AddMinutes(1), new PageOptions { PageSize = 100 }); + Assert.NotNull(events); + Assert.Contains(events.Data, e => e.Type == EventType.User_ChangedPassword && e.UserId == updatedUser.Id); + + // Verify user was accepted into the organization + var orgUsers = await _organizationUserRepository.GetManyByUserAsync(updatedUser.Id); + var orgUser = orgUsers.FirstOrDefault(ou => ou.OrganizationId == organization.Id); + Assert.NotNull(orgUser); + Assert.Equal(OrganizationUserStatusType.Accepted, orgUser.Status); + } + + [Theory] + [BitAutoData] + public async Task PostSetPasswordAsync_V2_MasterPasswordDecryption_Success(string organizationSsoIdentifier) + { + // Arrange - Create organization and user + var ownerEmail = $"owner-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(ownerEmail); + + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, + ownerEmail: ownerEmail, + name: "Test Org"); + organization.UseSso = true; + organization.Identifier = organizationSsoIdentifier; + await _organizationRepository.ReplaceAsync(organization); + + await _ssoConfigRepository.CreateAsync(new SsoConfig + { + OrganizationId = organization.Id, + Enabled = true, + Data = JsonSerializer.Serialize(new SsoConfigurationData + { + MemberDecryptionType = MemberDecryptionType.MasterPassword, + }, JsonHelpers.CamelCase), + }); + + // Create user with password initially, so we can login + var userEmail = $"user-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(userEmail); + + // Add user to organization + var user = await _userRepository.GetByEmailAsync(userEmail); + Assert.NotNull(user); + await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, userEmail, + OrganizationUserType.User, userStatusType: OrganizationUserStatusType.Invited); + + // Login as the user + await _loginHelper.LoginAsync(userEmail); + + // Remove the master password and keys to simulate newly registered SSO user + user.MasterPassword = null; + user.Key = null; + user.PrivateKey = null; + user.PublicKey = null; + user.SignedPublicKey = null; + await _userRepository.ReplaceAsync(user); + + var jsonRequest = CreateV2SetPasswordRequestJson( + userEmail, + organization.Identifier, + "integration-test-hint", + includeAccountKeys: true); + + // Act + using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password"); + message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json"); + var response = await _client.SendAsync(message); + + // Assert + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Assert.Fail($"Expected success but got {response.StatusCode}. Error: {errorContent}"); + } + + // Verify user in database + var updatedUser = await _userRepository.GetByEmailAsync(userEmail); + Assert.NotNull(updatedUser); + Assert.Equal("integration-test-hint", updatedUser.MasterPasswordHint); + + // Verify the master password is hashed and stored + Assert.NotNull(updatedUser.MasterPassword); + var verificationResult = _passwordHasher.VerifyHashedPassword(updatedUser, updatedUser.MasterPassword, _newMasterPasswordHash); + Assert.Equal(PasswordVerificationResult.Success, verificationResult); + + // Verify KDF settings + Assert.Equal(KdfType.PBKDF2_SHA256, updatedUser.Kdf); + Assert.Equal(600_000, updatedUser.KdfIterations); + Assert.Null(updatedUser.KdfMemory); + Assert.Null(updatedUser.KdfParallelism); + + // Verify timestamps are updated + Assert.Equal(DateTime.UtcNow, updatedUser.RevisionDate, TimeSpan.FromMinutes(1)); + Assert.Equal(DateTime.UtcNow, updatedUser.AccountRevisionDate, TimeSpan.FromMinutes(1)); + + // Verify keys are set + Assert.Equal(_masterKeyWrappedUserKey, updatedUser.Key); + Assert.Equal("publicKey", updatedUser.PublicKey); + Assert.Equal(_mockEncryptedType7String, updatedUser.PrivateKey); + Assert.Equal("signedPublicKey", updatedUser.SignedPublicKey); + + // Verify security state + Assert.Equal(2, updatedUser.SecurityVersion); + Assert.Equal("v2", updatedUser.SecurityState); + + // Verify signature key pair data + var signatureKeyPair = await _userSignatureKeyPairRepository.GetByUserIdAsync(updatedUser.Id); + Assert.NotNull(signatureKeyPair); + Assert.Equal(Ed25519, signatureKeyPair.SignatureAlgorithm); + Assert.Equal(_mockEncryptedType7WrappedSigningKey, signatureKeyPair.WrappedSigningKey); + Assert.Equal("verifyingKey", signatureKeyPair.VerifyingKey); + + // Verify User_ChangedPassword event was logged + var events = await _eventRepository.GetManyByUserAsync(updatedUser.Id, DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow.AddMinutes(1), new PageOptions { PageSize = 100 }); + Assert.NotNull(events); + Assert.Contains(events.Data, e => e.Type == EventType.User_ChangedPassword && e.UserId == updatedUser.Id); + + // Verify user was accepted into the organization + var orgUsers = await _organizationUserRepository.GetManyByUserAsync(updatedUser.Id); + var orgUser = orgUsers.FirstOrDefault(ou => ou.OrganizationId == organization.Id); + Assert.NotNull(orgUser); + Assert.Equal(OrganizationUserStatusType.Accepted, orgUser.Status); + } + + [Theory] + [BitAutoData] + public async Task PostSetPasswordAsync_V2_TDEDecryption_Success(string organizationSsoIdentifier) + { + // Arrange - Create organization with TDE + var ownerEmail = $"owner-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(ownerEmail); + + var (organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, + ownerEmail: ownerEmail, + name: "Test Org TDE"); + organization.UseSso = true; + organization.Identifier = organizationSsoIdentifier; + await _organizationRepository.ReplaceAsync(organization); + + // Configure SSO for TDE (TrustedDeviceEncryption) + await _ssoConfigRepository.CreateAsync(new SsoConfig + { + OrganizationId = organization.Id, + Enabled = true, + Data = JsonSerializer.Serialize(new SsoConfigurationData + { + MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption, + }, JsonHelpers.CamelCase), + }); + + // Create user with password initially, so we can login + var userEmail = $"user-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(userEmail); + + var user = await _userRepository.GetByEmailAsync(userEmail); + Assert.NotNull(user); + + // Add user to organization and confirm them (TDE users are confirmed, not invited) + await OrganizationTestHelpers.CreateUserAsync(_factory, organization.Id, userEmail, + OrganizationUserType.User, userStatusType: OrganizationUserStatusType.Confirmed); + + // Login as the user + await _loginHelper.LoginAsync(userEmail); + + // Set up TDE user with V2 account keys but no master password + // TDE users already have their account keys from device provisioning + user.MasterPassword = null; + user.Key = null; + user.PublicKey = "tde-publicKey"; + user.PrivateKey = _mockEncryptedType7String; + user.SignedPublicKey = "tde-signedPublicKey"; + user.SecurityVersion = 2; + user.SecurityState = "v2-tde"; + await _userRepository.ReplaceAsync(user); + + // Create signature key pair for TDE user + var signatureKeyPairData = new Core.KeyManagement.Models.Data.SignatureKeyPairData( + Ed25519, + _mockEncryptedType7WrappedSigningKey, + "tde-verifyingKey"); + var setSignatureKeyPair = await _userSignatureKeyPairRepository.GetByUserIdAsync(user.Id); + if (setSignatureKeyPair == null) + { + var newKeyPair = new Core.KeyManagement.Entities.UserSignatureKeyPair + { + UserId = user.Id, + SignatureAlgorithm = signatureKeyPairData.SignatureAlgorithm, + SigningKey = signatureKeyPairData.WrappedSigningKey, + VerifyingKey = signatureKeyPairData.VerifyingKey, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow + }; + newKeyPair.SetNewId(); + await _userSignatureKeyPairRepository.CreateAsync(newKeyPair); + } + + var jsonRequest = CreateV2SetPasswordRequestJson( + userEmail, + organization.Identifier, + "tde-test-hint", + includeAccountKeys: false); + + // Act + using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password"); + message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json"); + var response = await _client.SendAsync(message); + + // Assert + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Assert.Fail($"Expected success but got {response.StatusCode}. Error: {errorContent}"); + } + + // Verify user in database + var updatedUser = await _userRepository.GetByEmailAsync(userEmail); + Assert.NotNull(updatedUser); + Assert.Equal("tde-test-hint", updatedUser.MasterPasswordHint); + + // Verify the master password is hashed and stored + Assert.NotNull(updatedUser.MasterPassword); + var verificationResult = _passwordHasher.VerifyHashedPassword(updatedUser, updatedUser.MasterPassword, _newMasterPasswordHash); + Assert.Equal(PasswordVerificationResult.Success, verificationResult); + + // Verify KDF settings + Assert.Equal(KdfType.PBKDF2_SHA256, updatedUser.Kdf); + Assert.Equal(600_000, updatedUser.KdfIterations); + Assert.Null(updatedUser.KdfMemory); + Assert.Null(updatedUser.KdfParallelism); + + // Verify timestamps are updated + Assert.Equal(DateTime.UtcNow, updatedUser.RevisionDate, TimeSpan.FromMinutes(1)); + Assert.Equal(DateTime.UtcNow, updatedUser.AccountRevisionDate, TimeSpan.FromMinutes(1)); + + // Verify key is set + Assert.Equal(_masterKeyWrappedUserKey, updatedUser.Key); + + // Verify AccountKeys are preserved (TDE users already had V2 keys) + Assert.Equal("tde-publicKey", updatedUser.PublicKey); + Assert.Equal(_mockEncryptedType7String, updatedUser.PrivateKey); + Assert.Equal("tde-signedPublicKey", updatedUser.SignedPublicKey); + Assert.Equal(2, updatedUser.SecurityVersion); + Assert.Equal("v2-tde", updatedUser.SecurityState); + + // Verify signature key pair is preserved (TDE users already had signature keys) + var signatureKeyPair = await _userSignatureKeyPairRepository.GetByUserIdAsync(updatedUser.Id); + Assert.NotNull(signatureKeyPair); + Assert.Equal(Ed25519, signatureKeyPair.SignatureAlgorithm); + Assert.Equal(_mockEncryptedType7WrappedSigningKey, signatureKeyPair.WrappedSigningKey); + Assert.Equal("tde-verifyingKey", signatureKeyPair.VerifyingKey); + + // Verify User_ChangedPassword event was logged + var events = await _eventRepository.GetManyByUserAsync(updatedUser.Id, DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow.AddMinutes(1), new PageOptions { PageSize = 100 }); + Assert.NotNull(events); + Assert.Contains(events.Data, e => e.Type == EventType.User_ChangedPassword && e.UserId == updatedUser.Id); + + // Verify user remains confirmed in the organization + var orgUsers = await _organizationUserRepository.GetManyByUserAsync(updatedUser.Id); + var orgUser = orgUsers.FirstOrDefault(ou => ou.OrganizationId == organization.Id); + Assert.NotNull(orgUser); + Assert.Equal(OrganizationUserStatusType.Confirmed, orgUser.Status); + } + + [Fact] + public async Task PostSetPasswordAsync_V2_Unauthorized_ReturnsUnauthorized() + { + // Arrange - Don't login + var jsonRequest = CreateV2SetPasswordRequestJson( + "test@bitwarden.com", + "test-org-identifier", + "test-hint", + includeAccountKeys: true); + + // Act + using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password"); + message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json"); + var response = await _client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PostSetPasswordAsync_V2_MismatchedKdfSettings_ReturnsBadRequest() + { + // Arrange + var email = $"kdf-mismatch-test-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(email); + await _loginHelper.LoginAsync(email); + + // Test mismatched KDF settings (600000 vs 650000 iterations) + var request = new + { + masterPasswordAuthentication = new + { + kdf = new + { + kdfType = 0, + iterations = 600000 + }, + masterPasswordAuthenticationHash = _newMasterPasswordHash, + salt = email + }, + masterPasswordUnlock = new + { + kdf = new + { + kdfType = 0, + iterations = 650000 // Different from authentication KDF + }, + masterKeyWrappedUserKey = _masterKeyWrappedUserKey, + salt = email + }, + accountKeys = new + { + userKeyEncryptedAccountPrivateKey = "7.AOs41Hd8OQiCPXjyJKCiDA==", + accountPublicKey = "public-key" + }, + orgIdentifier = "test-org-identifier" + }; + + var jsonRequest = JsonSerializer.Serialize(request, JsonHelpers.CamelCase); + + // Act + using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password"); + message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json"); + var response = await _client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Theory] + [InlineData(KdfType.PBKDF2_SHA256, 1, null, null)] + [InlineData(KdfType.Argon2id, 4, null, 5)] + [InlineData(KdfType.Argon2id, 4, 65, null)] + public async Task PostSetPasswordAsync_V2_InvalidKdfSettings_ReturnsBadRequest( + KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism) + { + // Arrange + var email = $"invalid-kdf-test-{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(email); + await _loginHelper.LoginAsync(email); + + var jsonRequest = CreateV2SetPasswordRequestJson( + email, + "test-org-identifier", + "test-hint", + includeAccountKeys: true, + kdfType: kdf, + kdfIterations: kdfIterations, + kdfMemory: kdfMemory, + kdfParallelism: kdfParallelism); + + // Act + using var message = new HttpRequestMessage(HttpMethod.Post, "/accounts/set-password"); + message.Content = new StringContent(jsonRequest, System.Text.Encoding.UTF8, "application/json"); + var response = await _client.SendAsync(message); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + private static string CreateV2SetPasswordRequestJson( + string userEmail, + string orgIdentifier, + string hint, + bool includeAccountKeys = true, + KdfType? kdfType = null, + int? kdfIterations = null, + int? kdfMemory = null, + int? kdfParallelism = null) + { + var kdf = new + { + kdfType = (int)(kdfType ?? KdfType.PBKDF2_SHA256), + iterations = kdfIterations ?? 600000, + memory = kdfMemory, + parallelism = kdfParallelism + }; + + var request = new + { + masterPasswordAuthentication = new + { + kdf, + masterPasswordAuthenticationHash = _newMasterPasswordHash, + salt = userEmail + }, + masterPasswordUnlock = new + { + kdf, + masterKeyWrappedUserKey = _masterKeyWrappedUserKey, + salt = userEmail + }, + accountKeys = includeAccountKeys ? new + { + accountPublicKey = "publicKey", + userKeyEncryptedAccountPrivateKey = _mockEncryptedType7String, + publicKeyEncryptionKeyPair = new + { + publicKey = "publicKey", + wrappedPrivateKey = _mockEncryptedType7String, + signedPublicKey = "signedPublicKey" + }, + signatureKeyPair = new + { + signatureAlgorithm = "ed25519", + wrappedSigningKey = _mockEncryptedType7WrappedSigningKey, + verifyingKey = "verifyingKey" + }, + securityState = new + { + securityVersion = 2, + securityState = "v2" + } + } : null, + masterPasswordHint = hint, + orgIdentifier + }; + + return JsonSerializer.Serialize(request, JsonHelpers.CamelCase); + } } diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index 300a4d823d..6cddd341d5 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -1,14 +1,17 @@ using System.Security.Claims; using Bit.Api.Auth.Controllers; using Bit.Api.Auth.Models.Request.Accounts; +using Bit.Api.KeyManagement.Models.Requests; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; +using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Kdf; using Bit.Core.KeyManagement.Models.Api.Request; @@ -33,7 +36,9 @@ public class AccountsControllerTests : IDisposable private readonly IProviderUserRepository _providerUserRepository; private readonly IPolicyService _policyService; private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand; + private readonly ISetInitialMasterPasswordCommandV1 _setInitialMasterPasswordCommandV1; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + private readonly ITdeSetPasswordCommand _tdeSetPasswordCommand; private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; private readonly IFeatureService _featureService; private readonly IUserAccountKeysQuery _userAccountKeysQuery; @@ -49,7 +54,9 @@ public class AccountsControllerTests : IDisposable _providerUserRepository = Substitute.For(); _policyService = Substitute.For(); _setInitialMasterPasswordCommand = Substitute.For(); + _setInitialMasterPasswordCommandV1 = Substitute.For(); _twoFactorIsEnabledQuery = Substitute.For(); + _tdeSetPasswordCommand = Substitute.For(); _tdeOffboardingPasswordCommand = Substitute.For(); _featureService = Substitute.For(); _userAccountKeysQuery = Substitute.For(); @@ -64,6 +71,8 @@ public class AccountsControllerTests : IDisposable _userService, _policyService, _setInitialMasterPasswordCommand, + _setInitialMasterPasswordCommandV1, + _tdeSetPasswordCommand, _tdeOffboardingPasswordCommand, _twoFactorIsEnabledQuery, _featureService, @@ -379,13 +388,13 @@ public class AccountsControllerTests : IDisposable [BitAutoData(true, null, "newPublicKey", false)] // reject overwriting existing keys [BitAutoData(true, "newPrivateKey", "newPublicKey", false)] - public async Task PostSetPasswordAsync_WhenUserExistsAndSettingPasswordSucceeds_ShouldHandleKeysCorrectlyAndReturn( + public async Task PostSetPasswordAsync_V1_WhenUserExistsAndSettingPasswordSucceeds_ShouldHandleKeysCorrectlyAndReturn( bool hasExistingKeys, string requestPrivateKey, string requestPublicKey, bool shouldSucceed, User user, - SetPasswordRequestModel setPasswordRequestModel) + SetInitialPasswordRequestModel setInitialPasswordRequestModel) { // Arrange const string existingPublicKey = "existingPublicKey"; @@ -402,13 +411,15 @@ public class AccountsControllerTests : IDisposable user.PrivateKey = null; } + UpdateSetInitialPasswordRequestModelToV1(setInitialPasswordRequestModel); + if (requestPrivateKey == null && requestPublicKey == null) { - setPasswordRequestModel.Keys = null; + setInitialPasswordRequestModel.Keys = null; } else { - setPasswordRequestModel.Keys = new KeysRequestModel + setInitialPasswordRequestModel.Keys = new KeysRequestModel { EncryptedPrivateKey = requestPrivateKey, PublicKey = requestPublicKey @@ -416,44 +427,44 @@ public class AccountsControllerTests : IDisposable } _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); - _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync( + _setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync( user, - setPasswordRequestModel.MasterPasswordHash, - setPasswordRequestModel.Key, - setPasswordRequestModel.OrgIdentifier) + setInitialPasswordRequestModel.MasterPasswordHash, + setInitialPasswordRequestModel.Key, + setInitialPasswordRequestModel.OrgIdentifier) .Returns(Task.FromResult(IdentityResult.Success)); // Act if (shouldSucceed) { - await _sut.PostSetPasswordAsync(setPasswordRequestModel); + await _sut.PostSetPasswordAsync(setInitialPasswordRequestModel); // Assert - await _setInitialMasterPasswordCommand.Received(1) + await _setInitialMasterPasswordCommandV1.Received(1) .SetInitialMasterPasswordAsync( Arg.Is(u => u == user), - Arg.Is(s => s == setPasswordRequestModel.MasterPasswordHash), - Arg.Is(s => s == setPasswordRequestModel.Key), - Arg.Is(s => s == setPasswordRequestModel.OrgIdentifier)); + Arg.Is(s => s == setInitialPasswordRequestModel.MasterPasswordHash), + Arg.Is(s => s == setInitialPasswordRequestModel.Key), + Arg.Is(s => s == setInitialPasswordRequestModel.OrgIdentifier)); // Additional Assertions for User object modifications - Assert.Equal(setPasswordRequestModel.MasterPasswordHint, user.MasterPasswordHint); - Assert.Equal(setPasswordRequestModel.Kdf, user.Kdf); - Assert.Equal(setPasswordRequestModel.KdfIterations, user.KdfIterations); - Assert.Equal(setPasswordRequestModel.KdfMemory, user.KdfMemory); - Assert.Equal(setPasswordRequestModel.KdfParallelism, user.KdfParallelism); - Assert.Equal(setPasswordRequestModel.Key, user.Key); + Assert.Equal(setInitialPasswordRequestModel.MasterPasswordHint, user.MasterPasswordHint); + Assert.Equal(setInitialPasswordRequestModel.Kdf, user.Kdf); + Assert.Equal(setInitialPasswordRequestModel.KdfIterations, user.KdfIterations); + Assert.Equal(setInitialPasswordRequestModel.KdfMemory, user.KdfMemory); + Assert.Equal(setInitialPasswordRequestModel.KdfParallelism, user.KdfParallelism); + Assert.Equal(setInitialPasswordRequestModel.Key, user.Key); } else { - await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(setPasswordRequestModel)); + await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel)); } } [Theory] [BitAutoData] - public async Task PostSetPasswordAsync_WhenUserExistsAndHasKeysAndKeysAreUpdated_ShouldThrowAsync( + public async Task PostSetPasswordAsync_V1_WhenUserExistsAndHasKeysAndKeysAreUpdated_ShouldThrowAsync( User user, - SetPasswordRequestModel setPasswordRequestModel) + SetInitialPasswordRequestModel setInitialPasswordRequestModel) { // Arrange const string existingPublicKey = "existingPublicKey"; @@ -465,47 +476,52 @@ public class AccountsControllerTests : IDisposable user.PublicKey = existingPublicKey; user.PrivateKey = existingEncryptedPrivateKey; - setPasswordRequestModel.Keys = new KeysRequestModel() + UpdateSetInitialPasswordRequestModelToV1(setInitialPasswordRequestModel); + + setInitialPasswordRequestModel.Keys = new KeysRequestModel() { PublicKey = newPublicKey, EncryptedPrivateKey = newEncryptedPrivateKey }; _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); - _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync( + _setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync( user, - setPasswordRequestModel.MasterPasswordHash, - setPasswordRequestModel.Key, - setPasswordRequestModel.OrgIdentifier) + setInitialPasswordRequestModel.MasterPasswordHash, + setInitialPasswordRequestModel.Key, + setInitialPasswordRequestModel.OrgIdentifier) .Returns(Task.FromResult(IdentityResult.Success)); // Act & Assert - await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(setPasswordRequestModel)); + await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel)); } [Theory] [BitAutoData] - public async Task PostSetPasswordAsync_WhenUserDoesNotExist_ShouldThrowUnauthorizedAccessException( - SetPasswordRequestModel setPasswordRequestModel) + public async Task PostSetPasswordAsync_V1_WhenUserDoesNotExist_ShouldThrowUnauthorizedAccessException( + SetInitialPasswordRequestModel setInitialPasswordRequestModel) { + UpdateSetInitialPasswordRequestModelToV1(setInitialPasswordRequestModel); + // Arrange _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult((User)null)); // Act & Assert - await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(setPasswordRequestModel)); + await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel)); } [Theory] [BitAutoData] - public async Task PostSetPasswordAsync_WhenSettingPasswordFails_ShouldThrowBadRequestException( + public async Task PostSetPasswordAsync_V1_WhenSettingPasswordFails_ShouldThrowBadRequestException( User user, - SetPasswordRequestModel model) + SetInitialPasswordRequestModel model) { + UpdateSetInitialPasswordRequestModelToV1(model); model.Keys = null; // Arrange _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); - _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + _setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(IdentityResult.Failed(new IdentityError { Description = "Some Error" }))); // Act & Assert @@ -845,5 +861,139 @@ public class AccountsControllerTests : IDisposable Assert.NotNull(result); Assert.Equal("keys", result.Object); } + + [Theory] + [BitAutoData] + public async Task PostSetPasswordAsync_V2_WhenUserExistsAndSettingPasswordSucceeds_ShouldSetInitialMasterPassword( + User user, + SetInitialPasswordRequestModel setInitialPasswordRequestModel) + { + // Arrange + UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel); + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(user, Arg.Any()) + .Returns(Task.CompletedTask); + + // Act + await _sut.PostSetPasswordAsync(setInitialPasswordRequestModel); + + // Assert + await _setInitialMasterPasswordCommand.Received(1) + .SetInitialMasterPasswordAsync( + Arg.Is(u => u == user), + Arg.Is(d => + d.MasterPasswordAuthentication != null && + d.MasterPasswordUnlock != null && + d.AccountKeys != null && + d.OrgSsoIdentifier == setInitialPasswordRequestModel.OrgIdentifier)); + } + + [Theory] + [BitAutoData] + public async Task PostSetPasswordAsync_V2_WithTdeSetPassword_ShouldCallTdeSetPasswordCommand( + User user, + SetInitialPasswordRequestModel setInitialPasswordRequestModel) + { + // Arrange + UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel, includeTdeSetPassword: true); + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + _tdeSetPasswordCommand.SetMasterPasswordAsync(user, Arg.Any()) + .Returns(Task.CompletedTask); + + // Act + await _sut.PostSetPasswordAsync(setInitialPasswordRequestModel); + + // Assert + await _tdeSetPasswordCommand.Received(1) + .SetMasterPasswordAsync( + Arg.Is(u => u == user), + Arg.Is(d => + d.MasterPasswordAuthentication != null && + d.MasterPasswordUnlock != null && + d.AccountKeys == null && + d.OrgSsoIdentifier == setInitialPasswordRequestModel.OrgIdentifier)); + } + + [Theory] + [BitAutoData] + public async Task PostSetPasswordAsync_V2_WhenUserDoesNotExist_ShouldThrowUnauthorizedAccessException( + SetInitialPasswordRequestModel setInitialPasswordRequestModel) + { + // Arrange + UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel); + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult((User)null)); + + // Act & Assert + await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel)); + } + + [Theory] + [BitAutoData] + public async Task PostSetPasswordAsync_V2_WhenSettingPasswordFails_ShouldThrowException( + User user, + SetInitialPasswordRequestModel setInitialPasswordRequestModel) + { + // Arrange + UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel); + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + _setInitialMasterPasswordCommand.SetInitialMasterPasswordAsync(user, Arg.Any()) + .Returns(Task.FromException(new Exception("Setting password failed"))); + + // Act & Assert + await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel)); + } + + private void UpdateSetInitialPasswordRequestModelToV1(SetInitialPasswordRequestModel model) + { + model.MasterPasswordAuthentication = null; + model.MasterPasswordUnlock = null; + model.AccountKeys = null; + } + + private void UpdateSetInitialPasswordRequestModelToV2(SetInitialPasswordRequestModel model, bool includeTdeSetPassword = false) + { + var kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }; + + model.MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }; + + model.MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + }; + + if (includeTdeSetPassword) + { + // TDE set password does not include AccountKeys + model.AccountKeys = null; + } + else + { + model.AccountKeys = new AccountKeysRequestModel + { + UserKeyEncryptedAccountPrivateKey = "privateKey", + AccountPublicKey = "publicKey" + }; + } + + // Clear V1 properties + model.MasterPasswordHash = null; + model.Key = null; + model.Keys = null; + model.Kdf = null; + model.KdfIterations = null; + model.KdfMemory = null; + model.KdfParallelism = null; + } } diff --git a/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs new file mode 100644 index 0000000000..ce8ba1811e --- /dev/null +++ b/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs @@ -0,0 +1,682 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Auth.Models.Request.Accounts; +using Bit.Api.KeyManagement.Models.Requests; +using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Api.Request; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Api.Test.Auth.Models.Request.Accounts; + +public class SetInitialPasswordRequestModelTests +{ + #region V2 Validation Tests + + [Theory] + [InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)] + [InlineData(KdfType.Argon2id, 3, 64, 4)] + public void Validate_V2Request_WithMatchingKdf_ReturnsNoErrors(KdfType kdfType, int iterations, int? memory, int? parallelism) + { + // Arrange + var kdf = new KdfRequestModel + { + KdfType = kdfType, + Iterations = iterations, + Memory = memory, + Parallelism = parallelism + }; + + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = "orgIdentifier", + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + }, + AccountKeys = new AccountKeysRequestModel + { + UserKeyEncryptedAccountPrivateKey = "privateKey", + AccountPublicKey = "publicKey" + } + }; + + // Act + var result = model.Validate(new ValidationContext(model)); + + // Assert + Assert.Empty(result); + } + + [Theory] + [BitAutoData] + public void Validate_V2Request_WithMismatchedKdfSettings_ReturnsValidationError(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 650000 // Different iterations + }, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + // Act + var result = model.Validate(new ValidationContext(model)).ToList(); + + // Assert + Assert.Single(result); + Assert.Contains("KDF settings must be equal", result[0].ErrorMessage); + var memberNames = result[0].MemberNames.ToList(); + Assert.Equal(2, memberNames.Count); + Assert.Contains("MasterPasswordAuthentication.Kdf", memberNames); + Assert.Contains("MasterPasswordUnlock.Kdf", memberNames); + } + + [Theory] + [BitAutoData] + public void Validate_V2Request_WithInvalidAuthenticationKdf_ReturnsValidationError(string orgIdentifier) + { + // Arrange + var kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 1 // Too low + }; + + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + // Act + var result = model.Validate(new ValidationContext(model)).ToList(); + + // Assert + Assert.NotEmpty(result); + Assert.Contains(result, r => r.ErrorMessage != null && r.ErrorMessage.Contains("KDF iterations must be between")); + } + + #endregion + + #region V1 Validation Tests (Obsolete) + + [Theory] + [BitAutoData] + public void Validate_V1Request_WithMissingMasterPasswordHash_ReturnsValidationError(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + Key = "key", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 600000 + }; + + // Act + var result = model.Validate(new ValidationContext(model)).ToList(); + + // Assert + Assert.Contains(result, r => r.ErrorMessage.Contains("MasterPasswordHash must be supplied")); + } + + [Theory] + [BitAutoData] + public void Validate_V1Request_WithMissingKey_ReturnsValidationError(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordHash = "hash", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 600000 + }; + + // Act + var result = model.Validate(new ValidationContext(model)).ToList(); + + // Assert + Assert.Contains(result, r => r.ErrorMessage.Contains("Key must be supplied")); + } + + [Theory] + [BitAutoData] + public void Validate_V1Request_WithMissingKdf_ReturnsValidationError(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordHash = "hash", + Key = "key", + KdfIterations = 600000 + }; + + // Act + var result = model.Validate(new ValidationContext(model)).ToList(); + + // Assert + Assert.Contains(result, r => r.ErrorMessage != null && r.ErrorMessage.Contains("Kdf must be supplied")); + } + + [Theory] + [BitAutoData] + public void Validate_V1Request_WithMissingKdfIterations_ReturnsValidationError(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordHash = "hash", + Key = "key", + Kdf = KdfType.PBKDF2_SHA256 + }; + + // Act + var result = model.Validate(new ValidationContext(model)).ToList(); + + // Assert + Assert.Contains(result, r => r.ErrorMessage != null && r.ErrorMessage.Contains("KdfIterations must be supplied")); + } + + [Theory] + [BitAutoData] + public void Validate_V1Request_WithArgon2idAndMissingMemory_ReturnsValidationError(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordHash = "hash", + Key = "key", + Kdf = KdfType.Argon2id, + KdfIterations = 3, + KdfParallelism = 4 + }; + + // Act + var result = model.Validate(new ValidationContext(model)).ToList(); + + // Assert + Assert.Contains(result, r => r.ErrorMessage.Contains("KdfMemory must be supplied when Kdf is Argon2id")); + } + + [Theory] + [BitAutoData] + public void Validate_V1Request_WithArgon2idAndMissingParallelism_ReturnsValidationError(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordHash = "hash", + Key = "key", + Kdf = KdfType.Argon2id, + KdfIterations = 3, + KdfMemory = 64 + }; + + // Act + var result = model.Validate(new ValidationContext(model)).ToList(); + + // Assert + Assert.Contains(result, r => r.ErrorMessage.Contains("KdfParallelism must be supplied when Kdf is Argon2id")); + } + + [Theory] + [BitAutoData] + public void Validate_V1Request_WithInvalidKdfSettings_ReturnsValidationError(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordHash = "hash", + Key = "key", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 5000 // Too low + }; + + // Act + var result = model.Validate(new ValidationContext(model)).ToList(); + + // Assert + Assert.NotEmpty(result); + Assert.Contains(result, r => r.ErrorMessage != null && r.ErrorMessage.Contains("KDF iterations must be between")); + } + + [Theory] + [InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)] + [InlineData(KdfType.Argon2id, 3, 64, 4)] + public void Validate_V1Request_WithValidSettings_ReturnsNoErrors(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = "orgIdentifier", + MasterPasswordHash = "hash", + Key = "key", + Kdf = kdfType, + KdfIterations = kdfIterations, + KdfMemory = kdfMemory, + KdfParallelism = kdfParallelism + }; + + // Act + var result = model.Validate(new ValidationContext(model)); + + // Assert + Assert.Empty(result); + } + + #endregion + + #region IsV2Request Tests + + [Theory] + [BitAutoData] + public void IsV2Request_WithV2Properties_ReturnsTrue(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + // Act + var result = model.IsV2Request(); + + // Assert + Assert.True(result); + } + + [Theory] + [BitAutoData] + public void IsV2Request_WithoutMasterPasswordAuthentication_ReturnsFalse(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + // Act + var result = model.IsV2Request(); + + // Assert + Assert.False(result); + } + + [Theory] + [BitAutoData] + public void IsV2Request_WithoutMasterPasswordUnlock_ReturnsFalse(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + } + }; + + // Act + var result = model.IsV2Request(); + + // Assert + Assert.False(result); + } + + [Theory] + [BitAutoData] + public void IsV2Request_WithV1Properties_ReturnsFalse(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordHash = "hash", + Key = "key", + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 600000 + }; + + // Act + var result = model.IsV2Request(); + + // Assert + Assert.False(result); + } + + #endregion + + #region IsTdeSetPasswordRequest Tests + + [Theory] + [BitAutoData] + public void IsTdeSetPasswordRequest_WithNullAccountKeys_ReturnsTrue(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + }, + AccountKeys = null + }; + + // Act + var result = model.IsTdeSetPasswordRequest(); + + // Assert + Assert.True(result); + } + + [Theory] + [BitAutoData] + public void IsTdeSetPasswordRequest_WithAccountKeys_ReturnsFalse(string orgIdentifier) + { + // Arrange + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + }, + AccountKeys = new AccountKeysRequestModel + { + UserKeyEncryptedAccountPrivateKey = "privateKey", + AccountPublicKey = "publicKey" + } + }; + + // Act + var result = model.IsTdeSetPasswordRequest(); + + // Assert + Assert.False(result); + } + + #endregion + + #region ToUser Tests (Obsolete) + + [Theory] + [InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)] + [InlineData(KdfType.Argon2id, 3, 64, 4)] + public void ToUser_WithKeys_MapsPropertiesCorrectly(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) + { + // Arrange + var existingUser = new User(); + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = "orgIdentifier", + MasterPasswordHash = "hash", + MasterPasswordHint = "hint", + Key = "key", + Kdf = kdfType, + KdfIterations = kdfIterations, + KdfMemory = kdfMemory, + KdfParallelism = kdfParallelism, + Keys = new KeysRequestModel + { + PublicKey = "publicKey", + EncryptedPrivateKey = "encryptedPrivateKey" + } + }; + + // Act + var result = model.ToUser(existingUser); + + // Assert + Assert.Same(existingUser, result); + Assert.Equal("hint", result.MasterPasswordHint); + Assert.Equal(kdfType, result.Kdf); + Assert.Equal(kdfIterations, result.KdfIterations); + Assert.Equal(kdfMemory, result.KdfMemory); + Assert.Equal(kdfParallelism, result.KdfParallelism); + Assert.Equal("key", result.Key); + Assert.Equal("publicKey", result.PublicKey); + Assert.Equal("encryptedPrivateKey", result.PrivateKey); + } + + [Theory] + [InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)] + [InlineData(KdfType.Argon2id, 3, 64, 4)] + public void ToUser_WithoutKeys_MapsPropertiesCorrectly(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) + { + // Arrange + var existingUser = new User(); + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = "orgIdentifier", + MasterPasswordHash = "hash", + MasterPasswordHint = "hint", + Key = "key", + Kdf = kdfType, + KdfIterations = kdfIterations, + KdfMemory = kdfMemory, + KdfParallelism = kdfParallelism, + Keys = null + }; + + // Act + var result = model.ToUser(existingUser); + + // Assert + Assert.Same(existingUser, result); + Assert.Equal("hint", result.MasterPasswordHint); + Assert.Equal(kdfType, result.Kdf); + Assert.Equal(kdfIterations, result.KdfIterations); + Assert.Equal(kdfMemory, result.KdfMemory); + Assert.Equal(kdfParallelism, result.KdfParallelism); + Assert.Equal("key", result.Key); + Assert.Null(result.PublicKey); + Assert.Null(result.PrivateKey); + } + + #endregion + + #region ToData Tests + + [Theory] + [BitAutoData] + public void ToData_MapsPropertiesCorrectly(string orgIdentifier) + { + // Arrange + var kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }; + + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordHint = "hint", + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + }, + AccountKeys = new AccountKeysRequestModel + { + UserKeyEncryptedAccountPrivateKey = "privateKey", + AccountPublicKey = "publicKey" + } + }; + + // Act + var result = model.ToData(); + + // Assert + Assert.NotNull(result); + Assert.Equal(orgIdentifier, result.OrgSsoIdentifier); + Assert.Equal("hint", result.MasterPasswordHint); + Assert.NotNull(result.MasterPasswordAuthentication); + Assert.NotNull(result.MasterPasswordUnlock); + Assert.NotNull(result.AccountKeys); + Assert.Equal("authHash", result.MasterPasswordAuthentication.MasterPasswordAuthenticationHash); + Assert.Equal("wrappedKey", result.MasterPasswordUnlock.MasterKeyWrappedUserKey); + } + + [Theory] + [BitAutoData] + public void ToData_WithNullAccountKeys_MapsCorrectly(string orgIdentifier) + { + // Arrange + var kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }; + + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + MasterPasswordHint = "hint", + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + }, + AccountKeys = null + }; + + // Act + var result = model.ToData(); + + // Assert + Assert.NotNull(result); + Assert.Equal(orgIdentifier, result.OrgSsoIdentifier); + Assert.Null(result.AccountKeys); + } + + #endregion +} diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandTests.cs index 1605d279a2..4040650954 100644 --- a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandTests.cs @@ -1,8 +1,10 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.UserFeatures.UserMasterPassword; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; @@ -21,106 +23,154 @@ public class SetInitialMasterPasswordCommandTests [Theory] [BitAutoData] public async Task SetInitialMasterPassword_Success(SutProvider sutProvider, - User user, string masterPassword, string key, string orgIdentifier, - Organization org, OrganizationUser orgUser) + User user, UserAccountKeysData accountKeys, KdfSettings kdfSettings, + Organization org, OrganizationUser orgUser, string serverSideHash, string masterPasswordHint) { // Arrange - user.MasterPassword = null; - - sutProvider.GetDependency() - .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) - .Returns(IdentityResult.Success); + user.Key = null; + var model = CreateValidModel(user, accountKeys, kdfSettings, org.Identifier, masterPasswordHint); sutProvider.GetDependency() - .GetByIdentifierAsync(orgIdentifier) + .GetByIdentifierAsync(org.Identifier) .Returns(org); sutProvider.GetDependency() .GetByOrganizationAsync(org.Id, user.Id) .Returns(orgUser); - // Act - var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); + sutProvider.GetDependency>() + .HashPassword(user, model.MasterPasswordAuthentication.MasterPasswordAuthenticationHash) + .Returns(serverSideHash); - // Assert - Assert.Equal(IdentityResult.Success, result); - } - - [Theory] - [BitAutoData] - public async Task SetInitialMasterPassword_UserIsNull_ThrowsArgumentNullException(SutProvider sutProvider, string masterPassword, string key, string orgIdentifier) - { - // Act & Assert - await Assert.ThrowsAsync(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(null, masterPassword, key, orgIdentifier)); - } - - [Theory] - [BitAutoData] - public async Task SetInitialMasterPassword_AlreadyHasPassword_ReturnsFalse(SutProvider sutProvider, User user, string masterPassword, string key, string orgIdentifier) - { - // Arrange - user.MasterPassword = "ExistingPassword"; + // Mock SetMasterPassword to return a specific UpdateUserData delegate + UpdateUserData mockUpdateUserData = (connection, transaction) => Task.CompletedTask; + sutProvider.GetDependency() + .SetMasterPassword(user.Id, model.MasterPasswordUnlock, serverSideHash, model.MasterPasswordHint) + .Returns(mockUpdateUserData); // Act - var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); + await sutProvider.Sut.SetInitialMasterPasswordAsync(user, model); // Assert - Assert.False(result.Succeeded); + await sutProvider.GetDependency().Received(1) + .SetV2AccountCryptographicStateAsync( + user.Id, + model.AccountKeys, + Arg.Do>(actions => + { + var actionsList = actions.ToList(); + Assert.Single(actionsList); + Assert.Same(mockUpdateUserData, actionsList[0]); + })); + + await sutProvider.GetDependency().Received(1) + .LogUserEventAsync(user.Id, EventType.User_ChangedPassword); + + await sutProvider.GetDependency().Received(1) + .AcceptOrgUserAsync(orgUser, user, sutProvider.GetDependency()); } [Theory] [BitAutoData] - public async Task SetInitialMasterPassword_NullOrgSsoIdentifier_ThrowsBadRequestException( - SutProvider sutProvider, User user, string masterPassword, string key) + public async Task SetInitialMasterPassword_UserAlreadyHasPassword_ThrowsBadRequestException( + SutProvider sutProvider, + User user, UserAccountKeysData accountKeys, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint) { // Arrange - user.MasterPassword = null; - string orgSsoIdentifier = null; - - sutProvider.GetDependency() - .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) - .Returns(IdentityResult.Success); + user.Key = "existing-key"; + var model = CreateValidModel(user, accountKeys, kdfSettings, orgSsoIdentifier, masterPasswordHint); // Act & Assert var exception = await Assert.ThrowsAsync( - async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgSsoIdentifier)); - Assert.Equal("Organization SSO Identifier required.", exception.Message); + async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, model)); + Assert.Equal("User already has a master password set.", exception.Message); } - [Theory] [BitAutoData] - public async Task SetInitialMasterPassword_InvalidOrganization_Throws(SutProvider sutProvider, User user, string masterPassword, string key, string orgIdentifier) + public async Task SetInitialMasterPassword_AccountKeysNull_ThrowsBadRequestException( + SutProvider sutProvider, + User user, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint) { // Arrange - user.MasterPassword = null; + user.Key = null; + var model = CreateValidModel(user, null, kdfSettings, orgSsoIdentifier, masterPasswordHint); - sutProvider.GetDependency() - .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) - .Returns(IdentityResult.Success); + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, model)); + Assert.Equal("Account keys are required.", exception.Message); + } + + [Theory] + [BitAutoData("wrong-salt", null)] + [BitAutoData([null, "wrong-salt"])] + [BitAutoData("wrong-salt", "different-wrong-salt")] + public async Task SetInitialMasterPassword_InvalidSalt_ThrowsBadRequestException( + string? authSaltOverride, string? unlockSaltOverride, + SutProvider sutProvider, + User user, UserAccountKeysData accountKeys, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint) + { + // Arrange + user.Key = null; + var correctSalt = user.GetMasterPasswordSalt(); + var model = new SetInitialMasterPasswordDataModel + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Salt = authSaltOverride ?? correctSalt, + MasterPasswordAuthenticationHash = "hash", + Kdf = kdfSettings + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Salt = unlockSaltOverride ?? correctSalt, + MasterKeyWrappedUserKey = "wrapped-key", + Kdf = kdfSettings + }, + AccountKeys = accountKeys, + OrgSsoIdentifier = orgSsoIdentifier, + MasterPasswordHint = masterPasswordHint + }; + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, model)); + Assert.Equal("Invalid master password salt.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_InvalidOrgSsoIdentifier_ThrowsBadRequestException( + SutProvider sutProvider, + User user, UserAccountKeysData accountKeys, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint) + { + // Arrange + user.Key = null; + var model = CreateValidModel(user, accountKeys, kdfSettings, orgSsoIdentifier, masterPasswordHint); sutProvider.GetDependency() - .GetByIdentifierAsync(orgIdentifier) + .GetByIdentifierAsync(orgSsoIdentifier) .ReturnsNull(); // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier)); - Assert.Equal("Organization invalid.", exception.Message); + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, model)); + Assert.Equal("Organization SSO identifier is invalid.", exception.Message); } [Theory] [BitAutoData] - public async Task SetInitialMasterPassword_UserNotFoundInOrganization_Throws(SutProvider sutProvider, User user, string masterPassword, string key, Organization org) + public async Task SetInitialMasterPassword_UserNotFoundInOrganization_ThrowsBadRequestException( + SutProvider sutProvider, + User user, UserAccountKeysData accountKeys, KdfSettings kdfSettings, Organization org, string masterPasswordHint) { // Arrange - user.MasterPassword = null; - - sutProvider.GetDependency() - .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) - .Returns(IdentityResult.Success); + user.Key = null; + var model = CreateValidModel(user, accountKeys, kdfSettings, org.Identifier, masterPasswordHint); sutProvider.GetDependency() - .GetByIdentifierAsync(Arg.Any()) + .GetByIdentifierAsync(org.Identifier) .Returns(org); sutProvider.GetDependency() @@ -128,67 +178,33 @@ public class SetInitialMasterPasswordCommandTests .ReturnsNull(); // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, org.Identifier)); + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, model)); Assert.Equal("User not found within organization.", exception.Message); } - [Theory] - [BitAutoData] - public async Task SetInitialMasterPassword_ConfirmedOrgUser_DoesNotCallAcceptOrgUser(SutProvider sutProvider, - User user, string masterPassword, string key, string orgIdentifier, Organization org, OrganizationUser orgUser) + private static SetInitialMasterPasswordDataModel CreateValidModel( + User user, UserAccountKeysData? accountKeys, KdfSettings kdfSettings, + string orgSsoIdentifier, string? masterPasswordHint) { - // Arrange - user.MasterPassword = null; - - sutProvider.GetDependency() - .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) - .Returns(IdentityResult.Success); - - sutProvider.GetDependency() - .GetByIdentifierAsync(orgIdentifier) - .Returns(org); - - orgUser.Status = OrganizationUserStatusType.Confirmed; - sutProvider.GetDependency() - .GetByOrganizationAsync(org.Id, user.Id) - .Returns(orgUser); - - - // Act - var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); - - // Assert - Assert.Equal(IdentityResult.Success, result); - await sutProvider.GetDependency().DidNotReceive().AcceptOrgUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + var salt = user.GetMasterPasswordSalt(); + return new SetInitialMasterPasswordDataModel + { + MasterPasswordAuthentication = new MasterPasswordAuthenticationData + { + Salt = salt, + MasterPasswordAuthenticationHash = "hash", + Kdf = kdfSettings + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Salt = salt, + MasterKeyWrappedUserKey = "wrapped-key", + Kdf = kdfSettings + }, + AccountKeys = accountKeys, + OrgSsoIdentifier = orgSsoIdentifier, + MasterPasswordHint = masterPasswordHint + }; } - - [Theory] - [BitAutoData] - public async Task SetInitialMasterPassword_InvitedOrgUser_CallsAcceptOrgUser(SutProvider sutProvider, - User user, string masterPassword, string key, string orgIdentifier, Organization org, OrganizationUser orgUser) - { - // Arrange - user.MasterPassword = null; - - sutProvider.GetDependency() - .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) - .Returns(IdentityResult.Success); - - sutProvider.GetDependency() - .GetByIdentifierAsync(orgIdentifier) - .Returns(org); - - orgUser.Status = OrganizationUserStatusType.Invited; - sutProvider.GetDependency() - .GetByOrganizationAsync(org.Id, user.Id) - .Returns(orgUser); - - // Act - var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); - - // Assert - Assert.Equal(IdentityResult.Success, result); - await sutProvider.GetDependency().Received(1).AcceptOrgUserAsync(orgUser, user, sutProvider.GetDependency()); - } - } diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1Tests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1Tests.cs new file mode 100644 index 0000000000..d87b273026 --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SetInitialMasterPasswordCommandV1Tests.cs @@ -0,0 +1,194 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.UserFeatures.UserMasterPassword; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace Bit.Core.Test.Auth.UserFeatures.UserMasterPassword; + +[SutProviderCustomize] +public class SetInitialMasterPasswordCommandV1Tests +{ + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_Success(SutProvider sutProvider, + User user, string masterPassword, string key, string orgIdentifier, + Organization org, OrganizationUser orgUser) + { + // Arrange + user.MasterPassword = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdentifierAsync(orgIdentifier) + .Returns(org); + + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .Returns(orgUser); + + // Act + var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); + + // Assert + Assert.Equal(IdentityResult.Success, result); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_UserIsNull_ThrowsArgumentNullException(SutProvider sutProvider, string masterPassword, string key, string orgIdentifier) + { + // Act & Assert + await Assert.ThrowsAsync(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(null, masterPassword, key, orgIdentifier)); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_AlreadyHasPassword_ReturnsFalse(SutProvider sutProvider, User user, string masterPassword, string key, string orgIdentifier) + { + // Arrange + user.MasterPassword = "ExistingPassword"; + + // Act + var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); + + // Assert + Assert.False(result.Succeeded); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_NullOrgSsoIdentifier_ThrowsBadRequestException( + SutProvider sutProvider, User user, string masterPassword, string key) + { + // Arrange + user.MasterPassword = null; + string orgSsoIdentifier = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) + .Returns(IdentityResult.Success); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgSsoIdentifier)); + Assert.Equal("Organization SSO Identifier required.", exception.Message); + } + + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_InvalidOrganization_Throws(SutProvider sutProvider, User user, string masterPassword, string key, string orgIdentifier) + { + // Arrange + user.MasterPassword = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdentifierAsync(orgIdentifier) + .ReturnsNull(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier)); + Assert.Equal("Organization invalid.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_UserNotFoundInOrganization_Throws(SutProvider sutProvider, User user, string masterPassword, string key, Organization org) + { + // Arrange + user.MasterPassword = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdentifierAsync(Arg.Any()) + .Returns(org); + + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .ReturnsNull(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, org.Identifier)); + Assert.Equal("User not found within organization.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_ConfirmedOrgUser_DoesNotCallAcceptOrgUser(SutProvider sutProvider, + User user, string masterPassword, string key, string orgIdentifier, Organization org, OrganizationUser orgUser) + { + // Arrange + user.MasterPassword = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdentifierAsync(orgIdentifier) + .Returns(org); + + orgUser.Status = OrganizationUserStatusType.Confirmed; + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .Returns(orgUser); + + + // Act + var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); + + // Assert + Assert.Equal(IdentityResult.Success, result); + await sutProvider.GetDependency().DidNotReceive().AcceptOrgUserAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task SetInitialMasterPassword_InvitedOrgUser_CallsAcceptOrgUser(SutProvider sutProvider, + User user, string masterPassword, string key, string orgIdentifier, Organization org, OrganizationUser orgUser) + { + // Arrange + user.MasterPassword = null; + + sutProvider.GetDependency() + .UpdatePasswordHash(Arg.Any(), Arg.Any(), true, false) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdentifierAsync(orgIdentifier) + .Returns(org); + + orgUser.Status = OrganizationUserStatusType.Invited; + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .Returns(orgUser); + + // Act + var result = await sutProvider.Sut.SetInitialMasterPasswordAsync(user, masterPassword, key, orgIdentifier); + + // Assert + Assert.Equal(IdentityResult.Success, result); + await sutProvider.GetDependency().Received(1).AcceptOrgUserAsync(orgUser, user, sutProvider.GetDependency()); + } + +} diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommandTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommandTests.cs new file mode 100644 index 0000000000..5a144b7042 --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommandTests.cs @@ -0,0 +1,223 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace Bit.Core.Test.Auth.UserFeatures.UserMasterPassword; + +[SutProviderCustomize] +public class TdeSetPasswordCommandTests +{ + [Theory] + [BitAutoData] + public async Task OnboardMasterPassword_Success(SutProvider sutProvider, + User user, KdfSettings kdfSettings, + Organization org, OrganizationUser orgUser, string serverSideHash, string masterPasswordHint) + { + // Arrange + user.Key = null; + user.PublicKey = "public-key"; + user.PrivateKey = "private-key"; + var model = CreateValidModel(user, kdfSettings, org.Identifier, masterPasswordHint); + + sutProvider.GetDependency() + .GetByIdentifierAsync(org.Identifier) + .Returns(org); + + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .Returns(orgUser); + + sutProvider.GetDependency>() + .HashPassword(user, model.MasterPasswordAuthentication.MasterPasswordAuthenticationHash) + .Returns(serverSideHash); + + // Mock SetMasterPassword to return a specific UpdateUserData delegate + UpdateUserData mockUpdateUserData = (connection, transaction) => Task.CompletedTask; + sutProvider.GetDependency() + .SetMasterPassword(user.Id, model.MasterPasswordUnlock, serverSideHash, model.MasterPasswordHint) + .Returns(mockUpdateUserData); + + // Act + await sutProvider.Sut.SetMasterPasswordAsync(user, model); + + // Assert + await sutProvider.GetDependency().Received(1) + .UpdateUserDataAsync(Arg.Do>(actions => + { + var actionsList = actions.ToList(); + Assert.Single(actionsList); + Assert.Same(mockUpdateUserData, actionsList[0]); + })); + + await sutProvider.GetDependency().Received(1) + .LogUserEventAsync(user.Id, EventType.User_ChangedPassword); + } + + [Theory] + [BitAutoData] + public async Task OnboardMasterPassword_UserAlreadyHasPassword_ThrowsBadRequestException( + SutProvider sutProvider, + User user, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint) + { + // Arrange + user.Key = "existing-key"; + var model = CreateValidModel(user, kdfSettings, orgSsoIdentifier, masterPasswordHint); + + // Act & Assert + var exception = + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.SetMasterPasswordAsync(user, model)); + Assert.Equal("User already has a master password set.", exception.Message); + } + + [Theory] + [BitAutoData([null, "private-key"])] + [BitAutoData("public-key", null)] + [BitAutoData([null, null])] + public async Task OnboardMasterPassword_MissingAccountKeys_ThrowsBadRequestException( + string? publicKey, string? privateKey, + SutProvider sutProvider, + User user, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint) + { + // Arrange + user.Key = null; + user.PublicKey = publicKey; + user.PrivateKey = privateKey; + var model = CreateValidModel(user, kdfSettings, orgSsoIdentifier, masterPasswordHint); + + // Act & Assert + var exception = + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.SetMasterPasswordAsync(user, model)); + Assert.Equal("TDE user account keys must be set before setting initial master password.", exception.Message); + } + + [Theory] + [BitAutoData("wrong-salt", null)] + [BitAutoData([null, "wrong-salt"])] + [BitAutoData("wrong-salt", "different-wrong-salt")] + public async Task OnboardMasterPassword_InvalidSalt_ThrowsBadRequestException( + string? authSaltOverride, string? unlockSaltOverride, + SutProvider sutProvider, + User user, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint) + { + // Arrange + user.Key = null; + user.PublicKey = "public-key"; + user.PrivateKey = "private-key"; + var correctSalt = user.GetMasterPasswordSalt(); + var model = new SetInitialMasterPasswordDataModel + { + MasterPasswordAuthentication = + new MasterPasswordAuthenticationData + { + Salt = authSaltOverride ?? correctSalt, + MasterPasswordAuthenticationHash = "hash", + Kdf = kdfSettings + }, + MasterPasswordUnlock = new MasterPasswordUnlockData + { + Salt = unlockSaltOverride ?? correctSalt, + MasterKeyWrappedUserKey = "wrapped-key", + Kdf = kdfSettings + }, + AccountKeys = null, + OrgSsoIdentifier = orgSsoIdentifier, + MasterPasswordHint = masterPasswordHint + }; + + // Act & Assert + var exception = + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.SetMasterPasswordAsync(user, model)); + Assert.Equal("Invalid master password salt.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task OnboardMasterPassword_InvalidOrgSsoIdentifier_ThrowsBadRequestException( + SutProvider sutProvider, + User user, KdfSettings kdfSettings, string orgSsoIdentifier, string masterPasswordHint) + { + // Arrange + user.Key = null; + user.PublicKey = "public-key"; + user.PrivateKey = "private-key"; + var model = CreateValidModel(user, kdfSettings, orgSsoIdentifier, masterPasswordHint); + + sutProvider.GetDependency() + .GetByIdentifierAsync(orgSsoIdentifier) + .ReturnsNull(); + + // Act & Assert + var exception = + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.SetMasterPasswordAsync(user, model)); + Assert.Equal("Organization SSO identifier is invalid.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task OnboardMasterPassword_UserNotFoundInOrganization_ThrowsBadRequestException( + SutProvider sutProvider, + User user, KdfSettings kdfSettings, Organization org, string masterPasswordHint) + { + // Arrange + user.Key = null; + user.PublicKey = "public-key"; + user.PrivateKey = "private-key"; + var model = CreateValidModel(user, kdfSettings, org.Identifier, masterPasswordHint); + + sutProvider.GetDependency() + .GetByIdentifierAsync(org.Identifier) + .Returns(org); + + sutProvider.GetDependency() + .GetByOrganizationAsync(org.Id, user.Id) + .ReturnsNull(); + + // Act & Assert + var exception = + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.SetMasterPasswordAsync(user, model)); + Assert.Equal("User not found within organization.", exception.Message); + } + + private static SetInitialMasterPasswordDataModel CreateValidModel( + User user, KdfSettings kdfSettings, string orgSsoIdentifier, string? masterPasswordHint) + { + var salt = user.GetMasterPasswordSalt(); + return new SetInitialMasterPasswordDataModel + { + MasterPasswordAuthentication = + new MasterPasswordAuthenticationData + { + Salt = salt, + MasterPasswordAuthenticationHash = "hash", + Kdf = kdfSettings + }, + MasterPasswordUnlock = + new MasterPasswordUnlockData + { + Salt = salt, + MasterKeyWrappedUserKey = "wrapped-key", + Kdf = kdfSettings + }, + AccountKeys = null, + OrgSsoIdentifier = orgSsoIdentifier, + MasterPasswordHint = masterPasswordHint + }; + } +} diff --git a/util/Migrator/DbScripts/2026-01-06_00_User_UpdateMasterPassword.sql b/util/Migrator/DbScripts/2026-01-06_00_User_UpdateMasterPassword.sql new file mode 100644 index 0000000000..bff5b7bc5e --- /dev/null +++ b/util/Migrator/DbScripts/2026-01-06_00_User_UpdateMasterPassword.sql @@ -0,0 +1,31 @@ +CREATE OR ALTER PROCEDURE [dbo].[User_UpdateMasterPassword] + @Id UNIQUEIDENTIFIER, + @MasterPassword NVARCHAR(300), + @MasterPasswordHint NVARCHAR(50) = NULL, + @Key VARCHAR(MAX), + @Kdf TINYINT, + @KdfIterations INT, + @KdfMemory INT = NULL, + @KdfParallelism INT = NULL, + @RevisionDate DATETIME2(7), + @AccountRevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[User] + SET + [MasterPassword] = @MasterPassword, + [MasterPasswordHint] = @MasterPasswordHint, + [Key] = @Key, + [Kdf] = @Kdf, + [KdfIterations] = @KdfIterations, + [KdfMemory] = @KdfMemory, + [KdfParallelism] = @KdfParallelism, + [RevisionDate] = @RevisionDate, + [AccountRevisionDate] = @AccountRevisionDate + WHERE + [Id] = @Id +END +GO