diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 3cee44befacb..f3b58bbe94e6 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -13,6 +13,7 @@ using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; +using Bit.Core.Auth.UserFeatures.TempPassword.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Enums; @@ -25,6 +26,7 @@ using Bit.Core.Services; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; @@ -37,11 +39,13 @@ public class AccountsController : Controller private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IProviderUserRepository _providerUserRepository; private readonly IUserService _userService; + private readonly ISelfServicePasswordChangeCommand _selfServicePasswordChangeCommand; private readonly IPolicyService _policyService; private readonly ISetInitialMasterPasswordCommandV1 _setInitialMasterPasswordCommandV1; private readonly IFinishSsoJitProvisionMasterPasswordCommand _finishSsoJitProvisionMasterPasswordCommand; private readonly ITdeSetPasswordCommand _tdeSetPasswordCommand; private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; + private readonly IReplaceAdminSetTemporaryPasswordCommand _replaceAdminSetTemporaryPasswordCommand; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IFeatureService _featureService; private readonly IUserAccountKeysQuery _userAccountKeysQuery; @@ -54,11 +58,13 @@ public AccountsController( IOrganizationUserRepository organizationUserRepository, IProviderUserRepository providerUserRepository, IUserService userService, + ISelfServicePasswordChangeCommand selfServicePasswordChangeCommand, IPolicyService policyService, IFinishSsoJitProvisionMasterPasswordCommand finishSsoJitProvisionMasterPasswordCommand, ISetInitialMasterPasswordCommandV1 setInitialMasterPasswordCommandV1, ITdeSetPasswordCommand tdeSetPasswordCommand, ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand, + IReplaceAdminSetTemporaryPasswordCommand replaceAdminSetTemporaryPasswordCommand, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IFeatureService featureService, IUserAccountKeysQuery userAccountKeysQuery, @@ -71,11 +77,13 @@ IUserRepository userRepository _organizationUserRepository = organizationUserRepository; _providerUserRepository = providerUserRepository; _userService = userService; + _selfServicePasswordChangeCommand = selfServicePasswordChangeCommand; _policyService = policyService; _finishSsoJitProvisionMasterPasswordCommand = finishSsoJitProvisionMasterPasswordCommand; _setInitialMasterPasswordCommandV1 = setInitialMasterPasswordCommandV1; _tdeSetPasswordCommand = tdeSetPasswordCommand; _tdeOffboardingPasswordCommand = tdeOffboardingPasswordCommand; + _replaceAdminSetTemporaryPasswordCommand = replaceAdminSetTemporaryPasswordCommand; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _featureService = featureService; _userAccountKeysQuery = userAccountKeysQuery; @@ -197,8 +205,22 @@ public async Task PostPassword([FromBody] PasswordRequestModel model) throw new UnauthorizedAccessException(); } - var result = await _userService.ChangePasswordAsync(user, model.MasterPasswordHash, - model.NewMasterPasswordHash, model.MasterPasswordHint, model.Key); + IdentityResult result; + if (model.RequestHasNewDataTypes()) + { + result = await _selfServicePasswordChangeCommand.ChangePasswordAsync( + user, + model.MasterPasswordHash, + model.UnlockData!.ToData(), + model.AuthenticationData!.ToData(), + model.MasterPasswordHint); + } + else + { + result = await _userService.ChangePasswordAsync(user, model.MasterPasswordHash, + model.NewMasterPasswordHash, model.MasterPasswordHint, model.Key); + } + if (result.Succeeded) { return; @@ -288,7 +310,7 @@ public async Task PostVerifyPassword([FromBod } [HttpPost("kdf")] - public async Task PostKdf([FromBody] PasswordRequestModel model) + public async Task PostKdf([FromBody] ChangeKdfRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); if (user == null) @@ -646,7 +668,24 @@ public async Task PutUpdateTempPasswordAsync([FromBody] UpdateTempPasswordReques throw new UnauthorizedAccessException(); } - var result = await _userService.UpdateTempPasswordAsync(user, model.NewMasterPasswordHash, model.Key, model.MasterPasswordHint); + IdentityResult result; + if (model.RequestHasNewDataTypes()) + { + result = await _replaceAdminSetTemporaryPasswordCommand.ReplaceTemporaryPasswordAsync( + user, + model.UnlockData!.ToData(), + model.AuthenticationData!.ToData(), + model.MasterPasswordHint); + } + else + { + result = await _userService.UpdateTempPasswordAsync( + user, + model.NewMasterPasswordHash, + model.Key, + model.MasterPasswordHint); + } + if (result.Succeeded) { return; @@ -669,7 +708,24 @@ public async Task PutUpdateTdePasswordAsync([FromBody] UpdateTdeOffboardingPassw throw new UnauthorizedAccessException(); } - var result = await _tdeOffboardingPasswordCommand.UpdateTdeOffboardingPasswordAsync(user, model.NewMasterPasswordHash, model.Key, model.MasterPasswordHint); + IdentityResult result; + if (model.RequestHasNewDataTypes()) + { + result = await _tdeOffboardingPasswordCommand.UpdateTdeOffboardingPasswordAsync( + user, + model.UnlockData!.ToData(), + model.AuthenticationData!.ToData(), + model.MasterPasswordHint); + } + else + { + result = await _tdeOffboardingPasswordCommand.UpdateTdeOffboardingPasswordAsync( + user, + model.NewMasterPasswordHash, + model.Key, + model.MasterPasswordHint); + } + if (result.Succeeded) { return; diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index bd87e82c8a97..4e9f9bf96851 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -173,6 +173,28 @@ public async Task Takeover(Guid id) public async Task Password(Guid id, [FromBody] EmergencyAccessPasswordRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); + + if (model.RequestHasNewDataTypes()) + { + var result = await _emergencyAccessService.FinishRecoveryTakeoverAsync( + id, + user, + model.UnlockData!.ToData(), + model.AuthenticationData!.ToData()); + + if (result.Succeeded) + { + return; + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + throw new BadRequestException(ModelState); + } + await _emergencyAccessService.PasswordAsync(id, user, model.NewMasterPasswordHash, model.Key); } diff --git a/src/Api/Auth/Models/Request/Accounts/ChangeKdfRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/ChangeKdfRequestModel.cs new file mode 100644 index 000000000000..8ad68d02c8ee --- /dev/null +++ b/src/Api/Auth/Models/Request/Accounts/ChangeKdfRequestModel.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.KeyManagement.Models.Api.Request; +using Bit.Core.Utilities; + +namespace Bit.Api.Auth.Models.Request.Accounts; + +/// +/// Dual-shape request: validation accepts either the legacy +/// (, ) or new +/// (, ) payload so the wire contract +/// can stabilize ahead of caller wiring. PostKdf currently honors only the new shape; +/// legacy-shape dispatch arrives with ChangeKdfCommand's dual-path refactor. All legacy +/// fields are removed in PM-33141. +/// +public class ChangeKdfRequestModel : IValidatableObject +{ + [Required] + public required string MasterPasswordHash { get; set; } + [Obsolete("To be removed in PM-33141")] + [StringLength(300)] + public string? NewMasterPasswordHash { get; set; } + [Obsolete("To be removed in PM-33141")] + public string? Key { get; set; } + + // Should be made required in PM-33141 + public MasterPasswordAuthenticationDataRequestModel? AuthenticationData { get; set; } + // Should be made required in PM-33141 + public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + var hasNewPayloads = AuthenticationData is not null && UnlockData is not null; + var hasLegacyPayloads = NewMasterPasswordHash is not null && Key is not null; + + foreach (var validationResult in MasterPasswordPayloadVariantValidator.ValidateExclusivity( + hasNewPayloads, hasLegacyPayloads)) + { + yield return validationResult; + } + + if (hasNewPayloads) + { + foreach (var validationResult in KdfSettingsValidator.ValidateAuthenticationAndUnlockData( + AuthenticationData!.ToData(), UnlockData!.ToData())) + { + yield return validationResult; + } + } + } +} diff --git a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs index 899f1512a912..212f4a97dc05 100644 --- a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs @@ -1,50 +1,48 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.KeyManagement.Models.Api.Request; +using Bit.Core.Utilities; namespace Bit.Api.Auth.Models.Request.Accounts; -public class PasswordRequestModel : SecretVerificationRequestModel +public class PasswordRequestModel : IValidatableObject { [Required] + public required string MasterPasswordHash { get; set; } + [Obsolete("To be removed in PM-33141")] [StringLength(300)] - public required string NewMasterPasswordHash { get; set; } + public string? NewMasterPasswordHash { get; set; } + [Obsolete("To be removed in PM-33141")] + public string? Key { get; set; } [StringLength(50)] public string? MasterPasswordHint { get; set; } - [Required] - public required string Key { get; set; } - // Note: These will eventually become required, but not all consumers are moved over yet. + // Should be made required in PM-33141 public MasterPasswordAuthenticationDataRequestModel? AuthenticationData { get; set; } + // Should be made required in PM-33141 public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; } - public override IEnumerable Validate(ValidationContext validationContext) + public bool RequestHasNewDataTypes() { - foreach (var result in base.Validate(validationContext)) - { - yield return result; - } + return UnlockData is not null && AuthenticationData is not null; + } + + public IEnumerable Validate(ValidationContext validationContext) + { + var hasNewPayloads = AuthenticationData is not null && UnlockData is not null; + var hasLegacyPayloads = NewMasterPasswordHash is not null && Key is not null; - // Either both must be present or none should be present - if ((AuthenticationData == null) != (UnlockData == null)) + foreach (var validationResult in MasterPasswordPayloadVariantValidator.ValidateExclusivity( + hasNewPayloads, hasLegacyPayloads)) { - yield return new ValidationResult( - "AuthenticationData and UnlockData must be provided.", - [nameof(AuthenticationData), nameof(UnlockData)]); + yield return validationResult; } - if (AuthenticationData != null && UnlockData != null) - { - if (!AuthenticationData.HasSameKdfConfiguration(UnlockData)) - { - yield return new ValidationResult( - "AuthenticationData and UnlockData must have the same KDF configuration.", - [nameof(AuthenticationData), nameof(UnlockData)]); - } - if (!AuthenticationData.Salt.Equals(UnlockData.Salt)) + if (hasNewPayloads) + { + foreach (var validationResult in KdfSettingsValidator.ValidateAuthenticationAndUnlockData( + AuthenticationData!.ToData(), UnlockData!.ToData())) { - yield return new ValidationResult( - "AuthenticationData and UnlockData must have the same salt.", - [nameof(AuthenticationData), nameof(UnlockData)]); + yield return validationResult; } } } diff --git a/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs index e99c9907562e..cb0808292ed5 100644 --- a/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModel.cs @@ -1,17 +1,47 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +using Bit.Core.KeyManagement.Models.Api.Request; +using Bit.Core.Utilities; namespace Bit.Api.Auth.Models.Request.Accounts; -public class UpdateTdeOffboardingPasswordRequestModel +public class UpdateTdeOffboardingPasswordRequestModel : IValidatableObject { - [Required] + [Obsolete("To be removed in PM-33141")] [StringLength(300)] - public string NewMasterPasswordHash { get; set; } - [Required] - public string Key { get; set; } + public string? NewMasterPasswordHash { get; set; } + [Obsolete("To be removed in PM-33141")] + public string? Key { get; set; } [StringLength(50)] - public string MasterPasswordHint { get; set; } + public string? MasterPasswordHint { get; set; } + + // Should be made required in PM-33141 + public MasterPasswordAuthenticationDataRequestModel? AuthenticationData { get; set; } + // Should be made required in PM-33141 + public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; } + + public bool RequestHasNewDataTypes() + { + return UnlockData is not null && AuthenticationData is not null; + } + + public IEnumerable Validate(ValidationContext validationContext) + { + var hasNewPayloads = AuthenticationData is not null && UnlockData is not null; + var hasLegacyPayloads = NewMasterPasswordHash is not null && Key is not null; + + foreach (var validationResult in MasterPasswordPayloadVariantValidator.ValidateExclusivity( + hasNewPayloads, hasLegacyPayloads)) + { + yield return validationResult; + } + + if (hasNewPayloads) + { + foreach (var validationResult in KdfSettingsValidator.ValidateAuthenticationAndUnlockData( + AuthenticationData!.ToData(), UnlockData!.ToData())) + { + yield return validationResult; + } + } + } } diff --git a/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs index e071726edf8f..4c0f09f1b15b 100644 --- a/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModel.cs @@ -1,13 +1,47 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; -using Bit.Api.Models.Request.Organizations; +using System.ComponentModel.DataAnnotations; +using Bit.Core.KeyManagement.Models.Api.Request; +using Bit.Core.Utilities; namespace Bit.Api.Auth.Models.Request.Accounts; -public class UpdateTempPasswordRequestModel : OrganizationUserResetPasswordRequestModel +public class UpdateTempPasswordRequestModel : IValidatableObject { + [Obsolete("To be removed in PM-33141")] + [StringLength(300)] + public string? NewMasterPasswordHash { get; set; } + [Obsolete("To be removed in PM-33141")] + public string? Key { get; set; } [StringLength(50)] - public string MasterPasswordHint { get; set; } + public string? MasterPasswordHint { get; set; } + + // Should be made required in PM-33141 + public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; } + // Should be made required in PM-33141 + public MasterPasswordAuthenticationDataRequestModel? AuthenticationData { get; set; } + + public bool RequestHasNewDataTypes() + { + return UnlockData is not null && AuthenticationData is not null; + } + + public IEnumerable Validate(ValidationContext validationContext) + { + var hasNewPayloads = UnlockData is not null && AuthenticationData is not null; + var hasLegacyPayloads = NewMasterPasswordHash is not null && Key is not null; + + foreach (var validationResult in MasterPasswordPayloadVariantValidator.ValidateExclusivity( + hasNewPayloads, hasLegacyPayloads)) + { + yield return validationResult; + } + + if (hasNewPayloads) + { + foreach (var validationResult in KdfSettingsValidator.ValidateAuthenticationAndUnlockData( + AuthenticationData!.ToData(), UnlockData!.ToData())) + { + yield return validationResult; + } + } + } } diff --git a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs index 71e90f102acf..60615b5f4408 100644 --- a/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs +++ b/src/Api/Auth/Models/Request/EmergencyAccessRequestModels.cs @@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.Utilities; namespace Bit.Api.Auth.Models.Request; @@ -43,13 +44,44 @@ public EmergencyAccess ToEmergencyAccess(EmergencyAccess existingEmergencyAccess } } -public class EmergencyAccessPasswordRequestModel +public class EmergencyAccessPasswordRequestModel : IValidatableObject { - [Required] + [Obsolete("To be removed in PM-33141")] [StringLength(300)] public string NewMasterPasswordHash { get; set; } - [Required] + [Obsolete("To be removed in PM-33141")] public string Key { get; set; } + + // Should be made required in PM-33141 + public MasterPasswordUnlockDataRequestModel UnlockData { get; set; } + // Should be made required in PM-33141 + public MasterPasswordAuthenticationDataRequestModel AuthenticationData { get; set; } + + public bool RequestHasNewDataTypes() + { + return UnlockData is not null && AuthenticationData is not null; + } + + public IEnumerable Validate(ValidationContext validationContext) + { + var hasNewPayloads = UnlockData is not null && AuthenticationData is not null; + var hasLegacyPayloads = NewMasterPasswordHash is not null && Key is not null; + + foreach (var validationResult in MasterPasswordPayloadVariantValidator.ValidateExclusivity( + hasNewPayloads, hasLegacyPayloads)) + { + yield return validationResult; + } + + if (hasNewPayloads) + { + foreach (var validationResult in KdfSettingsValidator.ValidateAuthenticationAndUnlockData( + AuthenticationData.ToData(), UnlockData.ToData())) + { + yield return validationResult; + } + } + } } public class EmergencyAccessWithIdRequestModel : EmergencyAccessUpdateRequestModel diff --git a/src/Core/Auth/Models/Data/SetInitialMasterPasswordDataModel.cs b/src/Core/Auth/Models/Data/SetInitialMasterPasswordDataModel.cs index 82bcb3da5ebd..fcfc7449c8de 100644 --- a/src/Core/Auth/Models/Data/SetInitialMasterPasswordDataModel.cs +++ b/src/Core/Auth/Models/Data/SetInitialMasterPasswordDataModel.cs @@ -1,4 +1,5 @@ -using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.KeyManagement.Models.Data; namespace Bit.Core.Auth.Models.Data; @@ -20,4 +21,11 @@ public class SetInitialMasterPasswordDataModel /// public required UserAccountKeysData? AccountKeys { get; set; } public string? MasterPasswordHint { get; set; } + + public SetInitialPasswordData ToSetInitialPasswordData() => new SetInitialPasswordData + { + MasterPasswordAuthentication = MasterPasswordAuthentication, + MasterPasswordUnlock = MasterPasswordUnlock, + MasterPasswordHint = MasterPasswordHint + }; } diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs index f596dc471b62..4c4870c811b0 100644 --- a/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/EmergencyAccessService.cs @@ -9,9 +9,12 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; 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.Core.Settings; @@ -19,6 +22,7 @@ using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; using Bit.Core.Vault.Services; +using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.UserFeatures.EmergencyAccess; @@ -32,6 +36,7 @@ public class EmergencyAccessService : IEmergencyAccessService private readonly ICipherService _cipherService; private readonly IMailService _mailService; private readonly IUserService _userService; + private readonly IMasterPasswordService _masterPasswordService; private readonly GlobalSettings _globalSettings; private readonly IDataProtectorTokenFactory _dataProtectorTokenizer; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; @@ -46,6 +51,7 @@ public EmergencyAccessService( ICipherService cipherService, IMailService mailService, IUserService userService, + IMasterPasswordService masterPasswordService, GlobalSettings globalSettings, IDataProtectorTokenFactory dataProtectorTokenizer, IRemoveOrganizationUserCommand removeOrganizationUserCommand, @@ -59,6 +65,7 @@ public EmergencyAccessService( _cipherService = cipherService; _mailService = mailService; _userService = userService; + _masterPasswordService = masterPasswordService; _globalSettings = globalSettings; _dataProtectorTokenizer = dataProtectorTokenizer; _removeOrganizationUserCommand = removeOrganizationUserCommand; @@ -357,7 +364,60 @@ public async Task> GetPoliciesAsync(Guid emergencyAccessId, return (emergencyAccess, grantor); } - // TODO PM-21687: rename this to something like FinishRecoveryTakeoverAsync + public async Task FinishRecoveryTakeoverAsync( + Guid emergencyAccessId, + User granteeUser, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData) + { + var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); + + if (!IsValidRequest(emergencyAccess, granteeUser, EmergencyAccessType.Takeover)) + { + throw new BadRequestException("Emergency Access not valid."); + } + + var grantor = await _userRepository.GetByIdAsync(emergencyAccess.GrantorId); + + if (grantor == null) + { + throw new BadRequestException("Grantor not found when trying to finish recovery takeover."); + } + + var identityResult = await _masterPasswordService.PrepareSetInitialOrUpdateExistingMasterPasswordAsync( + user: grantor, + new SetInitialOrUpdateExistingPasswordData + { + MasterPasswordUnlock = unlockData, + MasterPasswordAuthentication = authenticationData, + }); + + if (identityResult.IsT1) + { + return IdentityResult.Failed(identityResult.AsT1); + } + + // Disable TwoFactor providers since they will otherwise block logins + grantor.SetTwoFactorProviders([]); + // Disable New Device Verification since it will otherwise block logins + grantor.VerifyDevices = false; + + await _userRepository.ReplaceAsync(grantor); + + // Remove grantor from all organizations unless Owner + var orgUser = await _organizationUserRepository.GetManyByUserAsync(grantor.Id); + foreach (var o in orgUser) + { + if (o.Type != OrganizationUserType.Owner) + { + await _removeOrganizationUserCommand.RemoveUserAsync(o.OrganizationId, grantor.Id); + } + } + + return IdentityResult.Success; + } + + [Obsolete("To be removed in PM-33141")] public async Task PasswordAsync(Guid emergencyAccessId, User granteeUser, string newMasterPasswordHash, string key) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(emergencyAccessId); diff --git a/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs b/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs index bfd725ac955f..153ed153c58b 100644 --- a/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs +++ b/src/Core/Auth/UserFeatures/EmergencyAccess/IEmergencyAccessService.cs @@ -3,8 +3,10 @@ using Bit.Core.Auth.Models.Data; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Services; using Bit.Core.Vault.Models.Data; +using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.UserFeatures.EmergencyAccess; @@ -110,6 +112,18 @@ public interface IEmergencyAccessService /// emergency access entity and the grantorUser Task<(Entities.EmergencyAccess, User)> TakeoverAsync(Guid emergencyAccessId, User granteeUser); /// + /// Finishes emergency access recovery takeover by setting or updating the grantor's master password + /// using structured cryptographic data via . + /// + /// Emergency Access Id being acted on + /// user making the request + /// new master password unlock data (encrypted user key, public/private key pair) + /// new master password authentication data (hash, salt, KDF configuration) + /// success or identity errors from validation + Task FinishRecoveryTakeoverAsync( + Guid emergencyAccessId, User granteeUser, + MasterPasswordUnlockData unlockData, MasterPasswordAuthenticationData authenticationData); + /// /// Updates the grantor's password hash and updates the key for the EmergencyAccess entity. /// /// Emergency Access Id being acted on @@ -117,6 +131,7 @@ public interface IEmergencyAccessService /// new password hash set by grantee user /// new encrypted user key /// void + [Obsolete("To be removed in PM-33141")] Task PasswordAsync(Guid emergencyAccessId, User granteeUser, string newMasterPasswordHash, string key); /// /// sends a reminder email that there is a pending request for recovery. diff --git a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs index 1ff64ffabb79..ae31b8f4d21d 100644 --- a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/Interfaces/ITdeOffboardingPasswordCommand.cs @@ -1,4 +1,5 @@ using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; @@ -9,6 +10,20 @@ namespace Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; /// public interface ITdeOffboardingPasswordCommand { + [Obsolete("To be removed in PM-33141")] public Task UpdateTdeOffboardingPasswordAsync(User user, string masterPassword, string key, - string orgSsoIdentifier); + string? masterPasswordHint); + + /// + /// Sets the master password for a TDE-offboarded user using structured cryptographic data via + /// . + /// Clears and logs the user out of all sessions. + /// + /// the TDE-offboarded user setting their master password + /// new master password unlock data (encrypted user key, public/private key pair) + /// new master password authentication data (hash, salt, KDF configuration) + /// optional hint for the new master password + /// success or identity errors from validation + public Task UpdateTdeOffboardingPasswordAsync(User user, MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, string? masterPasswordHint); } diff --git a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs index 719ff9ce9dcc..9184ef1ab8be 100644 --- a/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/TdeOffboardingPassword/TdeOffboardingPasswordCommand.cs @@ -1,8 +1,11 @@ using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -14,16 +17,17 @@ public class TdeOffboardingPasswordCommand : ITdeOffboardingPasswordCommand { private readonly IUserService _userService; private readonly IUserRepository _userRepository; + private readonly IMasterPasswordService _masterPasswordService; private readonly IEventService _eventService; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ISsoUserRepository _ssoUserRepository; private readonly ISsoConfigRepository _ssoConfigRepository; private readonly IPushNotificationService _pushService; - public TdeOffboardingPasswordCommand( IUserService userService, IUserRepository userRepository, + IMasterPasswordService masterPasswordService, IEventService eventService, IOrganizationUserRepository organizationUserRepository, ISsoUserRepository ssoUserRepository, @@ -32,6 +36,7 @@ public TdeOffboardingPasswordCommand( { _userService = userService; _userRepository = userRepository; + _masterPasswordService = masterPasswordService; _eventService = eventService; _organizationUserRepository = organizationUserRepository; _ssoUserRepository = ssoUserRepository; @@ -39,7 +44,9 @@ public TdeOffboardingPasswordCommand( _pushService = pushService; } - public async Task UpdateTdeOffboardingPasswordAsync(User user, string newMasterPassword, string key, string hint) + [Obsolete("To be removed in PM-33141")] + public async Task UpdateTdeOffboardingPasswordAsync(User user, string newMasterPassword, + string key, string? masterPasswordHint) { if (string.IsNullOrWhiteSpace(newMasterPassword)) { @@ -55,6 +62,7 @@ public async Task UpdateTdeOffboardingPasswordAsync(User user, s { throw new BadRequestException("User already has a master password."); } + var orgUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(user.Id); orgUserDetails = orgUserDetails.Where(x => x.UseSso).ToList(); if (orgUserDetails.Count == 0) @@ -62,19 +70,20 @@ public async Task UpdateTdeOffboardingPasswordAsync(User user, s throw new BadRequestException("User is not part of any organization that has SSO enabled."); } - var orgSSOUsers = await Task.WhenAll(orgUserDetails.Select(async x => await _ssoUserRepository.GetByUserIdOrganizationIdAsync(x.OrganizationId, user.Id))); - if (orgSSOUsers.Length != 1) + var orgSsoUsers = await Task.WhenAll(orgUserDetails.Select(async x => + await _ssoUserRepository.GetByUserIdOrganizationIdAsync(x.OrganizationId, user.Id))); + if (orgSsoUsers.Length != 1) { throw new BadRequestException("User is part of no or multiple SSO configurations."); } var orgUser = orgUserDetails.First(); - var orgSSOConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgUser.OrganizationId); - if (orgSSOConfig == null) + var orgSsoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgUser.OrganizationId); + if (orgSsoConfig == null) { throw new BadRequestException("Organization SSO configuration not found."); } - else if (orgSSOConfig.GetData().MemberDecryptionType != Enums.MemberDecryptionType.MasterPassword) + else if (orgSsoConfig.GetData().MemberDecryptionType != Enums.MemberDecryptionType.MasterPassword) { throw new BadRequestException("Organization SSO Member Decryption Type is not Master Password."); } @@ -88,7 +97,7 @@ public async Task UpdateTdeOffboardingPasswordAsync(User user, s user.RevisionDate = user.AccountRevisionDate = DateTime.UtcNow; user.ForcePasswordReset = false; user.Key = key; - user.MasterPasswordHint = hint; + user.MasterPasswordHint = masterPasswordHint; await _userRepository.ReplaceAsync(user); await _eventService.LogUserEventAsync(user.Id, EventType.User_TdeOffboardingPasswordSet); @@ -97,4 +106,57 @@ public async Task UpdateTdeOffboardingPasswordAsync(User user, s return IdentityResult.Success; } + public async Task UpdateTdeOffboardingPasswordAsync( + User user, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, + string? masterPasswordHint) + { + var orgUserDetails = await _organizationUserRepository.GetManyDetailsByUserAsync(user.Id); + orgUserDetails = orgUserDetails.Where(x => x.UseSso).ToList(); + if (orgUserDetails.Count == 0) + { + throw new BadRequestException("User is not part of any organization that has SSO enabled."); + } + + var orgSsoUsers = await Task.WhenAll(orgUserDetails.Select(async x => + await _ssoUserRepository.GetByUserIdOrganizationIdAsync(x.OrganizationId, user.Id))); + if (orgSsoUsers.Length != 1) + { + throw new BadRequestException("User is part of no or multiple SSO configurations."); + } + + var orgUser = orgUserDetails.First(); + var orgSsoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgUser.OrganizationId); + if (orgSsoConfig == null) + { + throw new BadRequestException("Organization SSO configuration not found."); + } + + if (orgSsoConfig.GetData().MemberDecryptionType != Enums.MemberDecryptionType.MasterPassword) + { + throw new BadRequestException("Organization SSO Member Decryption Type is not Master Password."); + } + + var identityResult = await _masterPasswordService.PrepareSetInitialMasterPasswordAsync(user, + new SetInitialPasswordData + { + MasterPasswordUnlock = unlockData, + MasterPasswordAuthentication = authenticationData, + MasterPasswordHint = masterPasswordHint + }); + + if (identityResult.IsT1) + { + return IdentityResult.Failed(identityResult.AsT1); + } + + user.ForcePasswordReset = false; + + await _userRepository.ReplaceAsync(user); + await _eventService.LogUserEventAsync(user.Id, EventType.User_TdeOffboardingPasswordSet); + await _pushService.PushLogOutAsync(user.Id); + + return IdentityResult.Success; + } } diff --git a/src/Core/Auth/UserFeatures/TempPassword/Interfaces/IReplaceAdminSetTemporaryPasswordCommand.cs b/src/Core/Auth/UserFeatures/TempPassword/Interfaces/IReplaceAdminSetTemporaryPasswordCommand.cs new file mode 100644 index 000000000000..2885aecd38e6 --- /dev/null +++ b/src/Core/Auth/UserFeatures/TempPassword/Interfaces/IReplaceAdminSetTemporaryPasswordCommand.cs @@ -0,0 +1,28 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Auth.UserFeatures.TempPassword.Interfaces; + +/// +/// Replaces an admin-set temporary password with a user-chosen master password. The user must +/// have set. Delegates cryptographic validation to +/// , +/// then clears the force-reset flag and notifies the user. +/// +public interface IReplaceAdminSetTemporaryPasswordCommand +{ + /// + /// Replaces the temporary password with a new master password. + /// + /// the user replacing their temporary password + /// new master password unlock data (encrypted user key, public/private key pair) + /// new master password authentication data (hash, salt, KDF configuration) + /// optional hint for the new master password + /// success or identity errors from validation + Task ReplaceTemporaryPasswordAsync( + User user, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, + string? masterPasswordHint); +} diff --git a/src/Core/Auth/UserFeatures/TempPassword/ReplaceAdminSetTemporaryPasswordCommand.cs b/src/Core/Auth/UserFeatures/TempPassword/ReplaceAdminSetTemporaryPasswordCommand.cs new file mode 100644 index 000000000000..7b9905bb298e --- /dev/null +++ b/src/Core/Auth/UserFeatures/TempPassword/ReplaceAdminSetTemporaryPasswordCommand.cs @@ -0,0 +1,55 @@ +using Bit.Core.Auth.UserFeatures.TempPassword.Interfaces; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Auth.UserFeatures.TempPassword; + +public class ReplaceAdminSetTemporaryPasswordCommand( + IMasterPasswordService masterPasswordService, + IUserRepository userRepository, + IMailService mailService, + IEventService eventService, + IPushNotificationService pushService) : IReplaceAdminSetTemporaryPasswordCommand +{ + public async Task ReplaceTemporaryPasswordAsync( + User user, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, + string? masterPasswordHint) + { + if (!user.ForcePasswordReset) + { + throw new BadRequestException("User does not have a temporary password to update."); + } + + var result = await masterPasswordService.PrepareUpdateExistingMasterPasswordAsync(user, + new UpdateExistingPasswordData + { + MasterPasswordUnlock = unlockData, + MasterPasswordAuthentication = authenticationData, + MasterPasswordHint = masterPasswordHint, + }); + + if (result.IsT1) + { + return IdentityResult.Failed(result.AsT1); + } + + user.ForcePasswordReset = false; + + await userRepository.ReplaceAsync(user); + await mailService.SendUpdatedTempPasswordEmailAsync(user.Email, user.Name ?? string.Empty); + await eventService.LogUserEventAsync(user.Id, EventType.User_UpdatedTempPassword); + await pushService.PushLogOutAsync(user.Id); + + return IdentityResult.Success; + } +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommand.cs index 87c2157f984a..90998ae1d0d0 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommand.cs @@ -6,7 +6,6 @@ using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Repositories; using Bit.Core.Services; -using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; @@ -14,23 +13,27 @@ public class FinishSsoJitProvisionMasterPasswordCommand : IFinishSsoJitProvision { private readonly IUserService _userService; private readonly IUserRepository _userRepository; + private readonly IMasterPasswordService _masterPasswordService; private readonly IAcceptOrgUserCommand _acceptOrgUserCommand; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationRepository _organizationRepository; - private readonly IPasswordHasher _passwordHasher; private readonly IEventService _eventService; - public FinishSsoJitProvisionMasterPasswordCommand(IUserService userService, IUserRepository userRepository, - IAcceptOrgUserCommand acceptOrgUserCommand, IOrganizationUserRepository organizationUserRepository, - IOrganizationRepository organizationRepository, IPasswordHasher passwordHasher, + public FinishSsoJitProvisionMasterPasswordCommand( + IUserService userService, + IUserRepository userRepository, + IMasterPasswordService masterPasswordService, + IAcceptOrgUserCommand acceptOrgUserCommand, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, IEventService eventService) { _userService = userService; _userRepository = userRepository; + _masterPasswordService = masterPasswordService; _acceptOrgUserCommand = acceptOrgUserCommand; _organizationUserRepository = organizationUserRepository; _organizationRepository = organizationRepository; - _passwordHasher = passwordHasher; _eventService = eventService; } @@ -47,10 +50,6 @@ public async Task FinishProvisionAsync(User user, throw new BadRequestException("Account keys are required."); } - // 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) { @@ -63,15 +62,13 @@ public async Task FinishProvisionAsync(User user, 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 updateUserData = + _masterPasswordService.BuildUpdateUserDelegateSetInitialMasterPassword( + user, + masterPasswordDataModel.ToSetInitialPasswordData()); - var setMasterPasswordTask = _userRepository.SetMasterPassword(user.Id, - masterPasswordDataModel.MasterPasswordUnlock, serverSideHashedMasterPasswordAuthenticationHash, - masterPasswordDataModel.MasterPasswordHint); await _userRepository.SetV2AccountCryptographicStateAsync(user.Id, masterPasswordDataModel.AccountKeys, - [setMasterPasswordTask]); + [updateUserData]); await _eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword); diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs index 34dbd67bf849..46f7c47c1c8e 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/IMasterPasswordService.cs @@ -71,7 +71,7 @@ namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; /// the server is the source of truth for fields that must not change — it validates the client's /// values match what's stored before applying the update. /// -internal interface IMasterPasswordService +public interface IMasterPasswordService { /// /// Inspects the user's current state and dispatches to either diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISelfServicePasswordChangeCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISelfServicePasswordChangeCommand.cs new file mode 100644 index 000000000000..3e189e7de018 --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/Interfaces/ISelfServicePasswordChangeCommand.cs @@ -0,0 +1,28 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; + +/// +/// Handles a user-initiated master password change, verifying the current password before +/// delegating to for cryptographic validation and persistence. +/// +public interface ISelfServicePasswordChangeCommand +{ + /// + /// Changes the user's master password after verifying the current password hash. + /// + /// the user changing their password + /// current master password hash for verification + /// new master password unlock data (encrypted user key, public/private key pair) + /// new master password authentication data (hash, salt, KDF configuration) + /// optional hint for the new master password + /// success or identity errors from verification/validation + Task ChangePasswordAsync( + User user, + string masterPasswordHash, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, + string? masterPasswordHint); +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/SelfServicePasswordChangeCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/SelfServicePasswordChangeCommand.cs new file mode 100644 index 000000000000..7b8c1fbe26af --- /dev/null +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/SelfServicePasswordChangeCommand.cs @@ -0,0 +1,49 @@ +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Platform.Push; +using Bit.Core.Services; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.Auth.UserFeatures.UserMasterPassword; + +public class SelfServicePasswordChangeCommand( + IUserService userService, + IMasterPasswordService masterPasswordService, + IdentityErrorDescriber identityErrorDescriber, + IEventService eventService, + IPushNotificationService pushService) : ISelfServicePasswordChangeCommand +{ + public async Task ChangePasswordAsync( + User user, + string masterPasswordHash, + MasterPasswordUnlockData unlockData, + MasterPasswordAuthenticationData authenticationData, + string? masterPasswordHint) + { + if (!await userService.CheckPasswordAsync(user, masterPasswordHash)) + { + return IdentityResult.Failed(identityErrorDescriber.PasswordMismatch()); + } + + var result = await masterPasswordService.SaveUpdateExistingMasterPasswordAsync(user, + new UpdateExistingPasswordData + { + MasterPasswordUnlock = unlockData, + MasterPasswordAuthentication = authenticationData, + MasterPasswordHint = masterPasswordHint + }); + + if (result.IsT1) + { + return IdentityResult.Failed(result.AsT1); + } + + await eventService.LogUserEventAsync(user.Id, EventType.User_ChangedPassword); + await pushService.PushLogOutAsync(user.Id, true); + + return IdentityResult.Success; + } +} diff --git a/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs b/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs index afd28e95d93e..3944b3ee9ce4 100644 --- a/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs +++ b/src/Core/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommand.cs @@ -1,49 +1,43 @@ using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.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 IMasterPasswordService _masterPasswordService; 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) + public TdeSetPasswordCommand( + IUserRepository userRepository, + IMasterPasswordService masterPasswordService, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IEventService eventService) { _userRepository = userRepository; + _masterPasswordService = masterPasswordService; _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) { @@ -56,13 +50,14 @@ public async Task SetMasterPasswordAsync(User user, SetInitialMasterPasswordData 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 = _masterPasswordService.BuildUpdateUserDelegateSetInitialMasterPassword(user, + new SetInitialPasswordData + { + MasterPasswordUnlock = masterPasswordDataModel.MasterPasswordUnlock, + MasterPasswordAuthentication = masterPasswordDataModel.MasterPasswordAuthentication, + MasterPasswordHint = masterPasswordDataModel.MasterPasswordHint, + }); - 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 79bad2bba6b4..97e23328f70d 100644 --- a/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs +++ b/src/Core/Auth/UserFeatures/UserServiceCollectionExtensions.cs @@ -4,6 +4,8 @@ using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.Registration.Implementations; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; +using Bit.Core.Auth.UserFeatures.TempPassword; +using Bit.Core.Auth.UserFeatures.TempPassword.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Implementations; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; @@ -31,6 +33,7 @@ public static void AddUserServices(this IServiceCollection services, IGlobalSett services.AddUserRegistrationCommands(); services.AddWebAuthnLoginCommands(); services.AddTdeOffboardingPasswordCommands(); + services.AddTempPasswordCommands(); services.AddTwoFactorCommandsQueries(); services.AddSsoQueries(); } @@ -50,6 +53,7 @@ private static void AddUserPasswordCommands(this IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void AddTdeOffboardingPasswordCommands(this IServiceCollection services) @@ -57,6 +61,11 @@ private static void AddTdeOffboardingPasswordCommands(this IServiceCollection se services.AddScoped(); } + private static void AddTempPasswordCommands(this IServiceCollection services) + { + services.AddScoped(); + } + private static void AddUserRegistrationCommands(this IServiceCollection services) { services.AddScoped(); diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 8559534a253b..9ca89407cdeb 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -29,12 +29,14 @@ public interface IUserService Task InitiateEmailChangeAsync(User user, string newEmail); Task ChangeEmailAsync(User user, string masterPassword, string newEmail, string newMasterPassword, string token, string key); + [Obsolete("Use ISelfServicePasswordChangeCommand instead. To be removed in PM-33141.")] Task ChangePasswordAsync(User user, string masterPassword, string newMasterPassword, string passwordHint, string key); // TODO removed with https://bitwarden.atlassian.net/browse/PM-27328 [Obsolete("Use ISetKeyConnectorKeyCommand instead. This method will be removed in a future version.")] Task SetKeyConnectorKeyAsync(User user, string key, string orgIdentifier); Task ConvertToKeyConnectorAsync(User user, string keyConnectorKeyWrappedUserKey); Task AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key); + [Obsolete("Use IReplaceAdminSetTemporaryPasswordCommand instead. To be removed in PM-33141.")] Task UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint); Task RefreshSecurityStampAsync(User user, string masterPasswordHash); Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type, bool setEnabled = true, bool logEvent = true); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index ef19666b04d6..1f2bc990ca2e 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -501,6 +501,7 @@ public async Task ValidateClaimedUserDomainAsync(User user, stri }); } + [Obsolete("Use ISelfServicePasswordChangeCommand instead. To be removed in PM-33141.")] public async Task ChangePasswordAsync(User user, string masterPassword, string newMasterPassword, string passwordHint, string key) { @@ -677,6 +678,7 @@ public async Task AdminResetPasswordAsync(OrganizationUserType c return IdentityResult.Success; } + [Obsolete("Use IReplaceAdminSetTemporaryPasswordCommand instead. To be removed in PM-33141.")] public async Task UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint) { if (!user.ForcePasswordReset) @@ -1047,6 +1049,7 @@ public static bool IsLegacyUser(User user) return user.Key == null && user.MasterPassword != null && user.PrivateKey != null; } + [Obsolete("Use MasterPasswordService.PrepareSetInitialMasterPasswordAsync or PrepareUpdateExistingMasterPasswordAsync instead. To be removed in PM-33141.")] private async Task ValidatePasswordInternal(User user, string password) { var errors = new List(); diff --git a/src/Core/Utilities/MasterPasswordPayloadVariantValidator.cs b/src/Core/Utilities/MasterPasswordPayloadVariantValidator.cs new file mode 100644 index 000000000000..baa5d8602789 --- /dev/null +++ b/src/Core/Utilities/MasterPasswordPayloadVariantValidator.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace Bit.Core.Utilities; + +public static class MasterPasswordPayloadVariantValidator +{ + /// + /// Validates that at least one variant of a master-password mutation payload is present: + /// either the new shape (AuthenticationData + UnlockData) or the legacy shape + /// (NewMasterPasswordHash + Key), or both. During the transition period, + /// clients may send both; callers prefer the new shape when present. + /// To be removed alongside the legacy fields in PM-33141. + /// + public static IEnumerable ValidateExclusivity(bool hasNewPayloads, bool hasLegacyPayloads) + { + if (!hasNewPayloads && !hasLegacyPayloads) + { + yield return new ValidationResult( + "Must provide either new payloads (UnlockData/AuthenticationData) or legacy payloads (NewMasterPasswordHash/Key).", + [ + "AuthenticationData", + "UnlockData", + "NewMasterPasswordHash", + "Key" + ]); + } + } +} diff --git a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs index 2439f37d1e6a..63f87d5855ad 100644 --- a/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs +++ b/test/Api.IntegrationTest/Controllers/AccountsControllerTest.cs @@ -307,7 +307,7 @@ public async Task PostKdf_ChangedSaltInAuthenticationData_BadRequest() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(); - Assert.Contains("AuthenticationData and UnlockData must have the same salt.", content); + Assert.Contains("Invalid master password salt.", content); } [Fact] @@ -333,7 +333,7 @@ public async Task PostKdf_ChangedSaltInUnlockData_BadRequest() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(); - Assert.Contains("AuthenticationData and UnlockData must have the same salt.", content); + Assert.Contains("Invalid master password salt.", content); } [Fact] @@ -363,10 +363,10 @@ public async Task PostKdf_KdfNotMatching_BadRequest() } [Theory] - [InlineData(KdfType.PBKDF2_SHA256, 1, null, null)] - [InlineData(KdfType.Argon2id, 4, null, 5)] - [InlineData(KdfType.Argon2id, 4, 65, null)] - public async Task PostKdf_InvalidKdf_BadRequest(KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism) + [InlineData(KdfType.PBKDF2_SHA256, 1, null, null, "KDF iterations must be between")] + [InlineData(KdfType.Argon2id, 4, null, 5, "Argon2 memory must be between")] + [InlineData(KdfType.Argon2id, 4, 65, null, "Argon2 parallelism must be between")] + public async Task PostKdf_InvalidKdf_BadRequest(KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism, string expectedError) { await _loginHelper.LoginAsync(_ownerEmail); @@ -382,7 +382,7 @@ public async Task PostKdf_InvalidKdf_BadRequest(KdfType kdf, int kdfIterations, Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var content = await response.Content.ReadAsStringAsync(); - Assert.Contains("KDF settings are invalid.", content); + Assert.Contains(expectedError, content); } [Fact] diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index 259fca3332bd..c886e7cd6b65 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -7,6 +7,7 @@ using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; +using Bit.Core.Auth.UserFeatures.TempPassword.Interfaces; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; @@ -33,12 +34,14 @@ public class AccountsControllerTests : IDisposable private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IUserService _userService; private readonly IProviderUserRepository _providerUserRepository; + private readonly ISelfServicePasswordChangeCommand _selfServicePasswordChangeCommand; private readonly IPolicyService _policyService; private readonly IFinishSsoJitProvisionMasterPasswordCommand _finishSsoJitProvisionMasterPasswordCommand; private readonly ISetInitialMasterPasswordCommandV1 _setInitialMasterPasswordCommandV1; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITdeSetPasswordCommand _tdeSetPasswordCommand; private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; + private readonly IReplaceAdminSetTemporaryPasswordCommand _replaceAdminSetTemporaryPasswordCommand; private readonly IFeatureService _featureService; private readonly IUserAccountKeysQuery _userAccountKeysQuery; private readonly ITwoFactorEmailService _twoFactorEmailService; @@ -51,12 +54,14 @@ public AccountsControllerTests() _organizationService = Substitute.For(); _organizationUserRepository = Substitute.For(); _providerUserRepository = Substitute.For(); + _selfServicePasswordChangeCommand = Substitute.For(); _policyService = Substitute.For(); _finishSsoJitProvisionMasterPasswordCommand = Substitute.For(); _setInitialMasterPasswordCommandV1 = Substitute.For(); _twoFactorIsEnabledQuery = Substitute.For(); _tdeSetPasswordCommand = Substitute.For(); _tdeOffboardingPasswordCommand = Substitute.For(); + _replaceAdminSetTemporaryPasswordCommand = Substitute.For(); _featureService = Substitute.For(); _userAccountKeysQuery = Substitute.For(); _twoFactorEmailService = Substitute.For(); @@ -68,11 +73,13 @@ public AccountsControllerTests() _organizationUserRepository, _providerUserRepository, _userService, + _selfServicePasswordChangeCommand, _policyService, _finishSsoJitProvisionMasterPasswordCommand, _setInitialMasterPasswordCommandV1, _tdeSetPasswordCommand, _tdeOffboardingPasswordCommand, + _replaceAdminSetTemporaryPasswordCommand, _twoFactorIsEnabledQuery, _featureService, _userAccountKeysQuery, @@ -639,7 +646,7 @@ public async Task ResendNewDeviceVerificationEmail_WhenTokenValid_SendsEmail(Use [Theory] [BitAutoData] - public async Task PostKdf_UserNotFound_ShouldFail(PasswordRequestModel model) + public async Task PostKdf_UserNotFound_ShouldFail(ChangeKdfRequestModel model) { _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(null)); @@ -650,7 +657,7 @@ public async Task PostKdf_UserNotFound_ShouldFail(PasswordRequestModel model) [Theory] [BitAutoData] public async Task PostKdf_WithNullAuthenticationData_ShouldFail( - User user, PasswordRequestModel model) + User user, ChangeKdfRequestModel model) { _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); model.AuthenticationData = null; @@ -664,7 +671,7 @@ public async Task PostKdf_WithNullAuthenticationData_ShouldFail( [Theory] [BitAutoData] public async Task PostKdf_WithNullUnlockData_ShouldFail( - User user, PasswordRequestModel model) + User user, ChangeKdfRequestModel model) { _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); model.UnlockData = null; @@ -678,7 +685,7 @@ public async Task PostKdf_WithNullUnlockData_ShouldFail( [Theory] [BitAutoData] public async Task PostKdf_ChangeKdfFailed_ShouldFail( - User user, PasswordRequestModel model) + User user, ChangeKdfRequestModel model) { _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); _changeKdfCommand.ChangeKdfAsync(Arg.Any(), Arg.Any(), @@ -696,7 +703,7 @@ public async Task PostKdf_ChangeKdfFailed_ShouldFail( [Theory] [BitAutoData] public async Task PostKdf_ChangeKdfSuccess_NoError( - User user, PasswordRequestModel model) + User user, ChangeKdfRequestModel model) { _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); _changeKdfCommand.ChangeKdfAsync(Arg.Any(), Arg.Any(), diff --git a/test/Api.Test/Auth/Models/Request/Accounts/ChangeKdfRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/Accounts/ChangeKdfRequestModelTests.cs new file mode 100644 index 000000000000..91b43b4d21bb --- /dev/null +++ b/test/Api.Test/Auth/Models/Request/Accounts/ChangeKdfRequestModelTests.cs @@ -0,0 +1,187 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Auth.Models.Request.Accounts; +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 ChangeKdfRequestModelTests +{ + [Theory] + [BitAutoData(KdfType.PBKDF2_SHA256, 600000, null, null)] + [BitAutoData(KdfType.Argon2id, 3, 64, 4)] + public void Validate_NewPayloadsOnly_NoErrors( + KdfType kdfType, int iterations, int? memory, int? parallelism) + { + var kdf = new KdfRequestModel + { + KdfType = kdfType, + Iterations = iterations, + Memory = memory, + Parallelism = parallelism + }; + + var model = new ChangeKdfRequestModel + { + MasterPasswordHash = "masterPasswordHash", + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + UnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(result); + } + + [Theory] + [BitAutoData] + public void Validate_NewPayloadsOnly_WithMismatchedKdfSettings_ReturnsKdfValidationError( + string masterPasswordHash) + { + var authKdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + var unlockKdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 650000 }; + + var model = new ChangeKdfRequestModel + { + MasterPasswordHash = masterPasswordHash, + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = authKdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + UnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = unlockKdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(result); + Assert.Contains("must have the same KDF configuration", result[0].ErrorMessage); + } + + [Theory] + [BitAutoData] + public void Validate_LegacyPayloadsOnly_NoErrors( + string masterPasswordHash, string newHash, string key) + { + var model = new ChangeKdfRequestModel + { + MasterPasswordHash = masterPasswordHash, + NewMasterPasswordHash = newHash, + Key = key + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(result); + } + + [Theory] + [BitAutoData] + public void Validate_BothNewAndLegacyPayloads_NoErrors( + string masterPasswordHash, string newHash, string key) + { + var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + + var model = new ChangeKdfRequestModel + { + MasterPasswordHash = masterPasswordHash, + NewMasterPasswordHash = newHash, + Key = key, + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + UnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(result); + } + + [Theory] + [BitAutoData] + public void Validate_NeitherNewNorLegacyPayloads_ReturnsError(string masterPasswordHash) + { + var model = new ChangeKdfRequestModel + { + MasterPasswordHash = masterPasswordHash + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(result); + Assert.Contains("Must provide either", result[0].ErrorMessage); + } + + [Theory] + [BitAutoData] + public void Validate_OnlyUnlockData_ReturnsError(string masterPasswordHash) + { + var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + + var model = new ChangeKdfRequestModel + { + MasterPasswordHash = masterPasswordHash, + UnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(result); + Assert.Contains("Must provide either", result[0].ErrorMessage); + } + + [Theory] + [BitAutoData] + public void Validate_OnlyAuthenticationData_ReturnsError(string masterPasswordHash) + { + var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + + var model = new ChangeKdfRequestModel + { + MasterPasswordHash = masterPasswordHash, + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(result); + Assert.Contains("Must provide either", result[0].ErrorMessage); + } +} diff --git a/test/Api.Test/Auth/Models/Request/Accounts/PasswordRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/Accounts/PasswordRequestModelTests.cs index aa2a5177dd89..8982a4f358ac 100644 --- a/test/Api.Test/Auth/Models/Request/Accounts/PasswordRequestModelTests.cs +++ b/test/Api.Test/Auth/Models/Request/Accounts/PasswordRequestModelTests.cs @@ -2,6 +2,7 @@ using Bit.Api.Auth.Models.Request.Accounts; 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; @@ -9,12 +10,11 @@ namespace Bit.Api.Test.Auth.Models.Request.Accounts; public class PasswordRequestModelTests { [Theory] - [InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)] - [InlineData(KdfType.Argon2id, 3, 64, 4)] - public void Validate_WhenBothAuthAndUnlockPresent_WithMatchingKdf_NoAuthUnlockErrors( + [BitAutoData(KdfType.PBKDF2_SHA256, 600000, null, null)] + [BitAutoData(KdfType.Argon2id, 3, 64, 4)] + public void Validate_NewPayloadsOnly_NoErrors( KdfType kdfType, int iterations, int? memory, int? parallelism) { - // Arrange var kdf = new KdfRequestModel { KdfType = kdfType, @@ -26,8 +26,6 @@ public void Validate_WhenBothAuthAndUnlockPresent_WithMatchingKdf_NoAuthUnlockEr var model = new PasswordRequestModel { MasterPasswordHash = "masterPasswordHash", - NewMasterPasswordHash = "newHash", - Key = "key", AuthenticationData = new MasterPasswordAuthenticationDataRequestModel { Kdf = kdf, @@ -42,39 +40,31 @@ public void Validate_WhenBothAuthAndUnlockPresent_WithMatchingKdf_NoAuthUnlockEr } }; - // Act var result = model.Validate(new ValidationContext(model)).ToList(); - // Assert Assert.Empty(result); } - [Fact] - public void Validate_WhenBothAuthAndUnlockPresent_WithMismatchedKdf_ReturnsError() + [Theory] + [BitAutoData] + public void Validate_NewPayloadsOnly_WithMismatchedKdfSettings_ReturnsKdfValidationError( + string masterPasswordHash) { - // Request model enforces matching KDF settings between AuthenticationData and UnlockData. + var authKdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + var unlockKdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 650000 }; + var model = new PasswordRequestModel { - MasterPasswordHash = "masterPasswordHash", - NewMasterPasswordHash = "newHash", - Key = "key", + MasterPasswordHash = masterPasswordHash, AuthenticationData = new MasterPasswordAuthenticationDataRequestModel { - Kdf = new KdfRequestModel - { - KdfType = KdfType.PBKDF2_SHA256, - Iterations = 600000 - }, + Kdf = authKdf, MasterPasswordAuthenticationHash = "authHash", Salt = "salt" }, UnlockData = new MasterPasswordUnlockDataRequestModel { - Kdf = new KdfRequestModel - { - KdfType = KdfType.PBKDF2_SHA256, - Iterations = 650000 - }, + Kdf = unlockKdf, MasterKeyWrappedUserKey = "wrappedKey", Salt = "salt" } @@ -82,11 +72,30 @@ public void Validate_WhenBothAuthAndUnlockPresent_WithMismatchedKdf_ReturnsError var result = model.Validate(new ValidationContext(model)).ToList(); - Assert.Contains(result, r => r.ErrorMessage == "AuthenticationData and UnlockData must have the same KDF configuration."); + Assert.Single(result); + Assert.Contains("must have the same KDF configuration", result[0].ErrorMessage); + } + + [Theory] + [BitAutoData] + public void Validate_LegacyPayloadsOnly_NoErrors(string masterPasswordHash, string newHash, string key) + { + var model = new PasswordRequestModel + { + MasterPasswordHash = masterPasswordHash, + NewMasterPasswordHash = newHash, + Key = key + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(result); } - [Fact] - public void Validate_WhenBothAuthAndUnlockPresent_WithMismatchedSalt_ReturnsError() + [Theory] + [BitAutoData] + public void Validate_BothNewAndLegacyPayloads_NoErrors( + string masterPasswordHash, string newHash, string key) { var kdf = new KdfRequestModel { @@ -96,88 +105,52 @@ public void Validate_WhenBothAuthAndUnlockPresent_WithMismatchedSalt_ReturnsErro var model = new PasswordRequestModel { - MasterPasswordHash = "masterPasswordHash", - NewMasterPasswordHash = "newHash", - Key = "key", + MasterPasswordHash = masterPasswordHash, + NewMasterPasswordHash = newHash, + Key = key, AuthenticationData = new MasterPasswordAuthenticationDataRequestModel { Kdf = kdf, MasterPasswordAuthenticationHash = "authHash", - Salt = "salt1" + Salt = "salt" }, UnlockData = new MasterPasswordUnlockDataRequestModel { Kdf = kdf, MasterKeyWrappedUserKey = "wrappedKey", - Salt = "salt2" + Salt = "salt" } }; var result = model.Validate(new ValidationContext(model)).ToList(); - Assert.Contains(result, r => r.ErrorMessage == "AuthenticationData and UnlockData must have the same salt."); + Assert.Empty(result); } - [Fact] - public void Validate_WhenBothAuthAndUnlockPresent_WithMismatchedKdfAndSalt_ReturnsBothErrors() + [Theory] + [BitAutoData] + public void Validate_NeitherNewNorLegacyPayloads_ReturnsError(string masterPasswordHash) { var model = new PasswordRequestModel { - MasterPasswordHash = "masterPasswordHash", - NewMasterPasswordHash = "newHash", - Key = "key", - AuthenticationData = new MasterPasswordAuthenticationDataRequestModel - { - Kdf = new KdfRequestModel - { - KdfType = KdfType.PBKDF2_SHA256, - Iterations = 600000 - }, - MasterPasswordAuthenticationHash = "authHash", - Salt = "salt1" - }, - UnlockData = new MasterPasswordUnlockDataRequestModel - { - Kdf = new KdfRequestModel - { - KdfType = KdfType.Argon2id, - Iterations = 3, - Memory = 64, - Parallelism = 4 - }, - MasterKeyWrappedUserKey = "wrappedKey", - Salt = "salt2" - } + MasterPasswordHash = masterPasswordHash }; var result = model.Validate(new ValidationContext(model)).ToList(); - Assert.Contains(result, r => r.ErrorMessage == "AuthenticationData and UnlockData must have the same KDF configuration."); - Assert.Contains(result, r => r.ErrorMessage == "AuthenticationData and UnlockData must have the same salt."); + Assert.Single(result); + Assert.Contains("Must provide either", result[0].ErrorMessage); } - [Fact] - public void Validate_WhenBothAuthAndUnlockPresent_WithBelowMinimumKdf_NoError() + [Theory] + [BitAutoData] + public void Validate_OnlyUnlockData_ReturnsError(string masterPasswordHash) { - // Regression guard: legacy users with sub-minimum KDF settings must be able to change - // their master password. KDF strength is enforced in the commands for registration / kdf change - var kdf = new KdfRequestModel - { - KdfType = KdfType.PBKDF2_SHA256, - Iterations = 1 - }; + var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; var model = new PasswordRequestModel { - MasterPasswordHash = "masterPasswordHash", - NewMasterPasswordHash = "newHash", - Key = "key", - AuthenticationData = new MasterPasswordAuthenticationDataRequestModel - { - Kdf = kdf, - MasterPasswordAuthenticationHash = "authHash", - Salt = "salt" - }, + MasterPasswordHash = masterPasswordHash, UnlockData = new MasterPasswordUnlockDataRequestModel { Kdf = kdf, @@ -188,112 +161,75 @@ public void Validate_WhenBothAuthAndUnlockPresent_WithBelowMinimumKdf_NoError() var result = model.Validate(new ValidationContext(model)).ToList(); - Assert.DoesNotContain(result, r => r.ErrorMessage != null && r.ErrorMessage.Contains("KDF iterations must be between")); + Assert.Single(result); + Assert.Contains("Must provide either", result[0].ErrorMessage); } - [Fact] - public void Validate_WhenOnlyAuthPresent_ReturnsError() + [Theory] + [BitAutoData] + public void Validate_OnlyAuthenticationData_ReturnsError(string masterPasswordHash) { - // Arrange + var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + var model = new PasswordRequestModel { - MasterPasswordHash = "masterPasswordHash", - NewMasterPasswordHash = "newHash", - Key = "key", + MasterPasswordHash = masterPasswordHash, AuthenticationData = new MasterPasswordAuthenticationDataRequestModel { - Kdf = new KdfRequestModel - { - KdfType = KdfType.PBKDF2_SHA256, - Iterations = 600000 - }, + Kdf = kdf, MasterPasswordAuthenticationHash = "authHash", Salt = "salt" - }, - UnlockData = null + } }; - // Act var result = model.Validate(new ValidationContext(model)).ToList(); - // Assert - Assert.Contains(result, r => r.ErrorMessage != null && r.ErrorMessage.Contains(nameof(PasswordRequestModel.UnlockData))); + Assert.Single(result); + Assert.Contains("Must provide either", result[0].ErrorMessage); } - [Fact] - public void Validate_WhenOnlyUnlockPresent_ReturnsError() + [Theory] + [BitAutoData] + public void RequestHasNewDataTypes_WithBothPresent_ReturnsTrue(string masterPasswordHash) { - // Arrange + var kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }; + var model = new PasswordRequestModel { - MasterPasswordHash = "masterPasswordHash", - NewMasterPasswordHash = "newHash", - Key = "key", - AuthenticationData = null, + MasterPasswordHash = masterPasswordHash, + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, UnlockData = new MasterPasswordUnlockDataRequestModel { - Kdf = new KdfRequestModel - { - KdfType = KdfType.PBKDF2_SHA256, - Iterations = 600000 - }, + Kdf = kdf, MasterKeyWrappedUserKey = "wrappedKey", Salt = "salt" } }; - // Act - var result = model.Validate(new ValidationContext(model)).ToList(); - - // Assert - Assert.Contains(result, r => r.ErrorMessage != null && r.ErrorMessage.Contains(nameof(PasswordRequestModel.AuthenticationData))); + Assert.True(model.RequestHasNewDataTypes()); } - [Fact] - public void Validate_WhenNeitherAuthNorUnlockPresent_NoAuthUnlockErrors() - { - // Arrange — backward compat: old clients send neither field - var model = new PasswordRequestModel - { - MasterPasswordHash = "masterPasswordHash", - NewMasterPasswordHash = "newHash", - Key = "key", - AuthenticationData = null, - UnlockData = null - }; - - // Act - var result = model.Validate(new ValidationContext(model)).ToList(); - - // Assert — no errors about AuthenticationData or UnlockData - Assert.DoesNotContain(result, r => - r.ErrorMessage != null && - (r.ErrorMessage.Contains(nameof(PasswordRequestModel.AuthenticationData)) || - r.ErrorMessage.Contains(nameof(PasswordRequestModel.UnlockData)))); - } - - [Fact] - public void Validate_LegacyValidationFailsFirst() + [Theory] + [BitAutoData] + public void RequestHasNewDataTypes_WithLegacyOnly_ReturnsFalse( + string masterPasswordHash, string newHash, string key) { - // Arrange — no MasterPasswordHash, OTP, or AuthRequestAccessCode var model = new PasswordRequestModel { - NewMasterPasswordHash = "newHash", - Key = "key", - AuthenticationData = null, - UnlockData = null + MasterPasswordHash = masterPasswordHash, + NewMasterPasswordHash = newHash, + Key = key }; - // Act - var result = model.Validate(new ValidationContext(model)).ToList(); - - // Assert — legacy validation should fail first - Assert.Contains(result, - r => r.ErrorMessage != null && - r.ErrorMessage.Contains(nameof(PasswordRequestModel.MasterPasswordHash)) && - // NOT auth/unlock errors - !r.ErrorMessage.Contains(nameof(PasswordRequestModel.AuthenticationData)) && - !r.ErrorMessage.Contains(nameof(PasswordRequestModel.UnlockData))); - + Assert.False(model.RequestHasNewDataTypes()); } } diff --git a/test/Api.Test/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModelTests.cs new file mode 100644 index 000000000000..269ff9f99174 --- /dev/null +++ b/test/Api.Test/Auth/Models/Request/Accounts/UpdateTdeOffboardingPasswordRequestModelTests.cs @@ -0,0 +1,210 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Auth.Models.Request.Accounts; +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 UpdateTdeOffboardingPasswordRequestModelTests +{ + [Theory] + [BitAutoData(KdfType.PBKDF2_SHA256, 600000, null, null)] + [BitAutoData(KdfType.Argon2id, 3, 64, 4)] + public void Validate_NewPayloadsOnly_NoErrors( + KdfType kdfType, int iterations, int? memory, int? parallelism) + { + var kdf = new KdfRequestModel + { + KdfType = kdfType, + Iterations = iterations, + Memory = memory, + Parallelism = parallelism + }; + + var model = new UpdateTdeOffboardingPasswordRequestModel + { + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + UnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(result); + } + + [Fact] + public void Validate_NewPayloadsOnly_WithMismatchedKdfSettings_ReturnsKdfValidationError() + { + var authKdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + var unlockKdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 650000 }; + + var model = new UpdateTdeOffboardingPasswordRequestModel + { + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = authKdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + UnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = unlockKdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(result); + Assert.Contains("must have the same KDF configuration", result[0].ErrorMessage); + } + + [Theory] + [BitAutoData] + public void Validate_LegacyPayloadsOnly_NoErrors(string newHash, string key) + { + var model = new UpdateTdeOffboardingPasswordRequestModel + { + NewMasterPasswordHash = newHash, + Key = key + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(result); + } + + [Theory] + [BitAutoData] + public void Validate_BothNewAndLegacyPayloads_NoErrors(string newHash, string key) + { + var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + + var model = new UpdateTdeOffboardingPasswordRequestModel + { + NewMasterPasswordHash = newHash, + Key = key, + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + UnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(result); + } + + [Fact] + public void Validate_NeitherNewNorLegacyPayloads_ReturnsError() + { + var model = new UpdateTdeOffboardingPasswordRequestModel(); + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(result); + Assert.Contains("Must provide either", result[0].ErrorMessage); + } + + [Fact] + public void Validate_OnlyUnlockData_ReturnsError() + { + var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + + var model = new UpdateTdeOffboardingPasswordRequestModel + { + UnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(result); + Assert.Contains("Must provide either", result[0].ErrorMessage); + } + + [Fact] + public void Validate_OnlyAuthenticationData_ReturnsError() + { + var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + + var model = new UpdateTdeOffboardingPasswordRequestModel + { + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(result); + Assert.Contains("Must provide either", result[0].ErrorMessage); + } + + [Theory] + [BitAutoData] + public void RequestHasNewDataTypes_WithBothPresent_ReturnsTrue(string hint) + { + var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + + var model = new UpdateTdeOffboardingPasswordRequestModel + { + MasterPasswordHint = hint, + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + UnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + Assert.True(model.RequestHasNewDataTypes()); + } + + [Theory] + [BitAutoData] + public void RequestHasNewDataTypes_WithLegacyOnly_ReturnsFalse(string newHash, string key) + { + var model = new UpdateTdeOffboardingPasswordRequestModel + { + NewMasterPasswordHash = newHash, + Key = key + }; + + Assert.False(model.RequestHasNewDataTypes()); + } +} diff --git a/test/Api.Test/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModelTests.cs new file mode 100644 index 000000000000..cbab9db32144 --- /dev/null +++ b/test/Api.Test/Auth/Models/Request/Accounts/UpdateTempPasswordRequestModelTests.cs @@ -0,0 +1,210 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Auth.Models.Request.Accounts; +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 UpdateTempPasswordRequestModelTests +{ + [Theory] + [BitAutoData(KdfType.PBKDF2_SHA256, 600000, null, null)] + [BitAutoData(KdfType.Argon2id, 3, 64, 4)] + public void Validate_NewPayloadsOnly_NoErrors( + KdfType kdfType, int iterations, int? memory, int? parallelism) + { + var kdf = new KdfRequestModel + { + KdfType = kdfType, + Iterations = iterations, + Memory = memory, + Parallelism = parallelism + }; + + var model = new UpdateTempPasswordRequestModel + { + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + UnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(result); + } + + [Fact] + public void Validate_NewPayloadsOnly_WithMismatchedKdfSettings_ReturnsKdfValidationError() + { + var authKdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + var unlockKdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 650000 }; + + var model = new UpdateTempPasswordRequestModel + { + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = authKdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + UnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = unlockKdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(result); + Assert.Contains("must have the same KDF configuration", result[0].ErrorMessage); + } + + [Theory] + [BitAutoData] + public void Validate_LegacyPayloadsOnly_NoErrors(string newHash, string key) + { + var model = new UpdateTempPasswordRequestModel + { + NewMasterPasswordHash = newHash, + Key = key + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(result); + } + + [Theory] + [BitAutoData] + public void Validate_BothNewAndLegacyPayloads_NoErrors(string newHash, string key) + { + var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + + var model = new UpdateTempPasswordRequestModel + { + NewMasterPasswordHash = newHash, + Key = key, + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + UnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(result); + } + + [Fact] + public void Validate_NeitherNewNorLegacyPayloads_ReturnsError() + { + var model = new UpdateTempPasswordRequestModel(); + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(result); + Assert.Contains("Must provide either", result[0].ErrorMessage); + } + + [Fact] + public void Validate_OnlyUnlockData_ReturnsError() + { + var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + + var model = new UpdateTempPasswordRequestModel + { + UnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(result); + Assert.Contains("Must provide either", result[0].ErrorMessage); + } + + [Fact] + public void Validate_OnlyAuthenticationData_ReturnsError() + { + var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + + var model = new UpdateTempPasswordRequestModel + { + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(result); + Assert.Contains("Must provide either", result[0].ErrorMessage); + } + + [Theory] + [BitAutoData] + public void RequestHasNewDataTypes_WithBothPresent_ReturnsTrue(string hint) + { + var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + + var model = new UpdateTempPasswordRequestModel + { + MasterPasswordHint = hint, + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + UnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + Assert.True(model.RequestHasNewDataTypes()); + } + + [Theory] + [BitAutoData] + public void RequestHasNewDataTypes_WithLegacyOnly_ReturnsFalse(string newHash, string key) + { + var model = new UpdateTempPasswordRequestModel + { + NewMasterPasswordHash = newHash, + Key = key + }; + + Assert.False(model.RequestHasNewDataTypes()); + } +} diff --git a/test/Api.Test/Auth/Models/Request/EmergencyAccessPasswordRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/EmergencyAccessPasswordRequestModelTests.cs new file mode 100644 index 000000000000..cc2c45354216 --- /dev/null +++ b/test/Api.Test/Auth/Models/Request/EmergencyAccessPasswordRequestModelTests.cs @@ -0,0 +1,208 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Auth.Models.Request; +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; + +public class EmergencyAccessPasswordRequestModelTests +{ + [Theory] + [BitAutoData(KdfType.PBKDF2_SHA256, 600000, null, null)] + [BitAutoData(KdfType.Argon2id, 3, 64, 4)] + public void Validate_NewPayloadsOnly_NoErrors( + KdfType kdfType, int iterations, int? memory, int? parallelism) + { + var kdf = new KdfRequestModel + { + KdfType = kdfType, + Iterations = iterations, + Memory = memory, + Parallelism = parallelism + }; + + var model = new EmergencyAccessPasswordRequestModel + { + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + UnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(result); + } + + [Fact] + public void Validate_NewPayloadsOnly_WithMismatchedKdfSettings_ReturnsKdfValidationError() + { + var authKdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + var unlockKdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 650000 }; + + var model = new EmergencyAccessPasswordRequestModel + { + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = authKdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + UnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = unlockKdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(result); + Assert.Contains("must have the same KDF configuration", result[0].ErrorMessage); + } + + [Theory] + [BitAutoData] + public void Validate_LegacyPayloadsOnly_NoErrors(string newHash, string key) + { + var model = new EmergencyAccessPasswordRequestModel + { + NewMasterPasswordHash = newHash, + Key = key + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(result); + } + + [Theory] + [BitAutoData] + public void Validate_BothNewAndLegacyPayloads_NoErrors(string newHash, string key) + { + var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + + var model = new EmergencyAccessPasswordRequestModel + { + NewMasterPasswordHash = newHash, + Key = key, + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + UnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(result); + } + + [Fact] + public void Validate_NeitherNewNorLegacyPayloads_ReturnsError() + { + var model = new EmergencyAccessPasswordRequestModel(); + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(result); + Assert.Contains("Must provide either", result[0].ErrorMessage); + } + + [Fact] + public void Validate_OnlyUnlockData_ReturnsError() + { + var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + + var model = new EmergencyAccessPasswordRequestModel + { + UnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(result); + Assert.Contains("Must provide either", result[0].ErrorMessage); + } + + [Fact] + public void Validate_OnlyAuthenticationData_ReturnsError() + { + var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + + var model = new EmergencyAccessPasswordRequestModel + { + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + } + }; + + var result = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(result); + Assert.Contains("Must provide either", result[0].ErrorMessage); + } + + [Fact] + public void RequestHasNewDataTypes_WithBothPresent_ReturnsTrue() + { + var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 }; + + var model = new EmergencyAccessPasswordRequestModel + { + AuthenticationData = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + UnlockData = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdf, + MasterKeyWrappedUserKey = "wrappedKey", + Salt = "salt" + } + }; + + Assert.True(model.RequestHasNewDataTypes()); + } + + [Theory] + [BitAutoData] + public void RequestHasNewDataTypes_WithLegacyOnly_ReturnsFalse(string newHash, string key) + { + var model = new EmergencyAccessPasswordRequestModel + { + NewMasterPasswordHash = newHash, + Key = key + }; + + Assert.False(model.RequestHasNewDataTypes()); + } +} diff --git a/test/Api.Test/Utilities/MasterPasswordPayloadVariantValidatorTests.cs b/test/Api.Test/Utilities/MasterPasswordPayloadVariantValidatorTests.cs new file mode 100644 index 000000000000..9a0766fbc6a2 --- /dev/null +++ b/test/Api.Test/Utilities/MasterPasswordPayloadVariantValidatorTests.cs @@ -0,0 +1,63 @@ +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Api.Test.Utilities; + +public class MasterPasswordPayloadVariantValidatorTests +{ + [Fact] + public void ValidateExclusivity_WhenOnlyNewVariantPresent_ReturnsNoErrors() + { + var results = MasterPasswordPayloadVariantValidator + .ValidateExclusivity(hasNewPayloads: true, hasLegacyPayloads: false) + .ToList(); + + Assert.Empty(results); + } + + [Fact] + public void ValidateExclusivity_WhenOnlyLegacyVariantPresent_ReturnsNoErrors() + { + var results = MasterPasswordPayloadVariantValidator + .ValidateExclusivity(hasNewPayloads: false, hasLegacyPayloads: true) + .ToList(); + + Assert.Empty(results); + } + + [Fact] + public void ValidateExclusivity_WhenBothVariantsPresent_ReturnsNoErrors() + { + var results = MasterPasswordPayloadVariantValidator + .ValidateExclusivity(hasNewPayloads: true, hasLegacyPayloads: true) + .ToList(); + + Assert.Empty(results); + } + + [Fact] + public void ValidateExclusivity_WhenNeitherVariantPresent_ReturnsMissingVariantError() + { + var results = MasterPasswordPayloadVariantValidator + .ValidateExclusivity(hasNewPayloads: false, hasLegacyPayloads: false) + .ToList(); + + Assert.Single(results); + Assert.Equal( + "Must provide either new payloads (UnlockData/AuthenticationData) or legacy payloads (NewMasterPasswordHash/Key).", + results[0].ErrorMessage); + } + + [Fact] + public void ValidateExclusivity_ValidationResultIncludesExpectedMemberNames() + { + var results = MasterPasswordPayloadVariantValidator + .ValidateExclusivity(hasNewPayloads: false, hasLegacyPayloads: false) + .ToList(); + + Assert.Single(results); + Assert.Equal( + new[] { "AuthenticationData", "UnlockData", "NewMasterPasswordHash", "Key" }, + results[0].MemberNames); + } +} diff --git a/test/Core.Test/Auth/UserFeatures/TempPassword/ReplaceAdminSetTemporaryPasswordCommandTests.cs b/test/Core.Test/Auth/UserFeatures/TempPassword/ReplaceAdminSetTemporaryPasswordCommandTests.cs new file mode 100644 index 000000000000..a60cd24bcf53 --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/TempPassword/ReplaceAdminSetTemporaryPasswordCommandTests.cs @@ -0,0 +1,125 @@ +using Bit.Core.Auth.UserFeatures.TempPassword; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Platform.Push; +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 OneOf; +using Xunit; +using static Bit.Core.Test.Auth.UserFeatures.UserMasterPassword.MasterPasswordTestData; + +namespace Bit.Core.Test.Auth.UserFeatures.TempPassword; + +[SutProviderCustomize] +public class ReplaceAdminSetTemporaryPasswordCommandTests +{ + [Theory] + [BitAutoData] + public async Task ReplaceTemporaryPasswordAsync_Success( + SutProvider sutProvider, + User user, string masterPasswordHint, + KdfSettings kdfSettings, string salt, string wrappedKey, string authHash) + { + user.ForcePasswordReset = true; + var unlockData = CreateUnlockData(kdfSettings, salt, wrappedKey); + var authenticationData = CreateAuthenticationData(kdfSettings, salt, authHash); + + sutProvider.GetDependency() + .PrepareUpdateExistingMasterPasswordAsync(user, Arg.Any()) + .Returns(OneOf.FromT0(user)); + + var result = await sutProvider.Sut.ReplaceTemporaryPasswordAsync(user, unlockData, authenticationData, masterPasswordHint); + + Assert.Equal(IdentityResult.Success, result); + Assert.False(user.ForcePasswordReset); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(user); + await sutProvider.GetDependency().Received(1) + .SendUpdatedTempPasswordEmailAsync(user.Email, user.Name ?? string.Empty); + await sutProvider.GetDependency().Received(1) + .LogUserEventAsync(user.Id, EventType.User_UpdatedTempPassword); + await sutProvider.GetDependency().Received(1) + .PushLogOutAsync(user.Id); + } + + [Theory] + [BitAutoData] + public async Task ReplaceTemporaryPasswordAsync_NotForcePasswordReset_ThrowsBadRequestException( + SutProvider sutProvider, + User user, string masterPasswordHint, + KdfSettings kdfSettings, string salt, string wrappedKey, string authHash) + { + user.ForcePasswordReset = false; + var unlockData = CreateUnlockData(kdfSettings, salt, wrappedKey); + var authenticationData = CreateAuthenticationData(kdfSettings, salt, authHash); + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.ReplaceTemporaryPasswordAsync(user, unlockData, authenticationData, masterPasswordHint)); + + Assert.Equal("User does not have a temporary password to update.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task ReplaceTemporaryPasswordAsync_MasterPasswordServiceFails_ReturnsErrors( + SutProvider sutProvider, + User user, string masterPasswordHint, + KdfSettings kdfSettings, string salt, string wrappedKey, string authHash) + { + user.ForcePasswordReset = true; + var unlockData = CreateUnlockData(kdfSettings, salt, wrappedKey); + var authenticationData = CreateAuthenticationData(kdfSettings, salt, authHash); + var identityErrors = new[] { new IdentityError { Code = "TestError", Description = "Test failure" } }; + + sutProvider.GetDependency() + .PrepareUpdateExistingMasterPasswordAsync(user, Arg.Any()) + .Returns(OneOf.FromT1(identityErrors)); + + var result = await sutProvider.Sut.ReplaceTemporaryPasswordAsync(user, unlockData, authenticationData, masterPasswordHint); + + Assert.False(result.Succeeded); + Assert.Contains(result.Errors, e => e.Code == "TestError"); + Assert.True(user.ForcePasswordReset); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().ReplaceAsync(default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .SendUpdatedTempPasswordEmailAsync(default!, default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .LogUserEventAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .PushLogOutAsync(default); + } + + [Theory] + [BitAutoData] + public async Task ReplaceTemporaryPasswordAsync_PassesCorrectDataToMasterPasswordService( + SutProvider sutProvider, + User user, string masterPasswordHint, + KdfSettings kdfSettings, string salt, string wrappedKey, string authHash) + { + user.ForcePasswordReset = true; + var unlockData = CreateUnlockData(kdfSettings, salt, wrappedKey); + var authenticationData = CreateAuthenticationData(kdfSettings, salt, authHash); + + sutProvider.GetDependency() + .PrepareUpdateExistingMasterPasswordAsync(user, Arg.Any()) + .Returns(OneOf.FromT0(user)); + + await sutProvider.Sut.ReplaceTemporaryPasswordAsync(user, unlockData, authenticationData, masterPasswordHint); + + await sutProvider.GetDependency().Received(1) + .PrepareUpdateExistingMasterPasswordAsync(user, + Arg.Is(d => + d.MasterPasswordUnlock == unlockData && + d.MasterPasswordAuthentication == authenticationData && + d.MasterPasswordHint == masterPasswordHint)); + } +} diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommandTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommandTests.cs index c1315fe46707..dea30a82010b 100644 --- a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/FinishSsoJitProvisionMasterPasswordCommandTests.cs @@ -1,6 +1,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.UserFeatures.UserMasterPassword; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -10,7 +12,6 @@ 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; @@ -24,7 +25,7 @@ public class FinishSsoJitProvisionMasterPasswordCommandTests [BitAutoData] public async Task FinishProvisionAsync_Success(SutProvider sutProvider, User user, UserAccountKeysData accountKeys, KdfSettings kdfSettings, - Organization org, OrganizationUser orgUser, string serverSideHash, string masterPasswordHint) + Organization org, OrganizationUser orgUser, string masterPasswordHint) { // Arrange user.Key = null; @@ -38,14 +39,9 @@ public async Task FinishProvisionAsync_Success(SutProvider>() - .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) + sutProvider.GetDependency() + .BuildUpdateUserDelegateSetInitialMasterPassword(user, Arg.Any()) .Returns(mockUpdateUserData); // Act @@ -102,85 +98,6 @@ public async Task FinishProvisionAsync_AccountKeysNull_ThrowsBadRequestException 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 FinishProvisionAsync_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.FinishProvisionAsync(user, model)); - Assert.Equal("Invalid master password salt.", exception.Message); - } - - [Theory] - [BitAutoData] - public async Task FinishProvisionAsync_NullSalt_UsesEmailFallback( - SutProvider sutProvider, - User user, UserAccountKeysData accountKeys, KdfSettings kdfSettings, - Organization org, OrganizationUser orgUser, string serverSideHash, string masterPasswordHint) - { - // Arrange - user.Key = null; - user.MasterPasswordSalt = null; - var expectedSalt = user.Email.ToLowerInvariant().Trim(); - var model = CreateValidModel(user, accountKeys, kdfSettings, org.Identifier, masterPasswordHint); - - // Verify the model uses the email-derived salt - Assert.Equal(expectedSalt, model.MasterPasswordUnlock.Salt); - Assert.Equal(expectedSalt, model.MasterPasswordAuthentication.Salt); - - 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); - - UpdateUserData mockUpdateUserData = (connection, transaction) => Task.CompletedTask; - sutProvider.GetDependency() - .SetMasterPassword(user.Id, model.MasterPasswordUnlock, serverSideHash, model.MasterPasswordHint) - .Returns(mockUpdateUserData); - - // Act — should not throw since email fallback provides a valid salt - await sutProvider.Sut.FinishProvisionAsync(user, model); - - // Assert - await sutProvider.GetDependency().Received(1) - .LogUserEventAsync(user.Id, EventType.User_ChangedPassword); - } - [Theory] [BitAutoData] public async Task FinishProvisionAsync_InvalidOrgSsoIdentifier_ThrowsBadRequestException( diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordTestData.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordTestData.cs new file mode 100644 index 000000000000..3c3f5135f5ec --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/MasterPasswordTestData.cs @@ -0,0 +1,24 @@ +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Core.Test.Auth.UserFeatures.UserMasterPassword; + +internal static class MasterPasswordTestData +{ + internal static MasterPasswordUnlockData CreateUnlockData( + KdfSettings kdfSettings, string salt, string masterKeyWrappedUserKey) => + new() + { + Salt = salt, + MasterKeyWrappedUserKey = masterKeyWrappedUserKey, + Kdf = kdfSettings + }; + + internal static MasterPasswordAuthenticationData CreateAuthenticationData( + KdfSettings kdfSettings, string salt, string masterPasswordAuthenticationHash) => + new() + { + Salt = salt, + MasterPasswordAuthenticationHash = masterPasswordAuthenticationHash, + Kdf = kdfSettings + }; +} diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SelfServicePasswordChangeCommandTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SelfServicePasswordChangeCommandTests.cs new file mode 100644 index 000000000000..70a0265fb34d --- /dev/null +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/SelfServicePasswordChangeCommandTests.cs @@ -0,0 +1,138 @@ +using Bit.Core.Auth.UserFeatures.UserMasterPassword; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Platform.Push; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using OneOf; +using Xunit; +using static Bit.Core.Test.Auth.UserFeatures.UserMasterPassword.MasterPasswordTestData; + +namespace Bit.Core.Test.Auth.UserFeatures.UserMasterPassword; + +[SutProviderCustomize] +public class SelfServicePasswordChangeCommandTests +{ + [Theory] + [BitAutoData] + public async Task ChangePasswordAsync_Success( + SutProvider sutProvider, + User user, string masterPasswordHash, string masterPasswordHint, + KdfSettings kdfSettings, string salt, string wrappedKey, string authHash) + { + var unlockData = CreateUnlockData(kdfSettings, salt, wrappedKey); + var authenticationData = CreateAuthenticationData(kdfSettings, salt, authHash); + + sutProvider.GetDependency() + .CheckPasswordAsync(user, masterPasswordHash) + .Returns(true); + + sutProvider.GetDependency() + .SaveUpdateExistingMasterPasswordAsync(user, Arg.Any()) + .Returns(OneOf.FromT0(user)); + + var result = await sutProvider.Sut.ChangePasswordAsync( + user, masterPasswordHash, unlockData, authenticationData, masterPasswordHint); + + Assert.Equal(IdentityResult.Success, result); + + await sutProvider.GetDependency().Received(1) + .LogUserEventAsync(user.Id, EventType.User_ChangedPassword); + await sutProvider.GetDependency().Received(1) + .PushLogOutAsync(user.Id, true); + } + + [Theory] + [BitAutoData] + public async Task ChangePasswordAsync_WrongPassword_ReturnsPasswordMismatch( + SutProvider sutProvider, + User user, string masterPasswordHash, string masterPasswordHint, + KdfSettings kdfSettings, string salt, string wrappedKey, string authHash) + { + var unlockData = CreateUnlockData(kdfSettings, salt, wrappedKey); + var authenticationData = CreateAuthenticationData(kdfSettings, salt, authHash); + + sutProvider.GetDependency() + .CheckPasswordAsync(user, masterPasswordHash) + .Returns(false); + + var result = await sutProvider.Sut.ChangePasswordAsync( + user, masterPasswordHash, unlockData, authenticationData, masterPasswordHint); + + Assert.False(result.Succeeded); + Assert.Contains(result.Errors, e => e.Code == "PasswordMismatch"); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .SaveUpdateExistingMasterPasswordAsync(default!, default!); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .LogUserEventAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .PushLogOutAsync(default, default); + } + + [Theory] + [BitAutoData] + public async Task ChangePasswordAsync_MasterPasswordServiceFails_ReturnsErrors( + SutProvider sutProvider, + User user, string masterPasswordHash, string masterPasswordHint, + KdfSettings kdfSettings, string salt, string wrappedKey, string authHash) + { + var unlockData = CreateUnlockData(kdfSettings, salt, wrappedKey); + var authenticationData = CreateAuthenticationData(kdfSettings, salt, authHash); + var identityErrors = new[] { new IdentityError { Code = "TestError", Description = "Test failure" } }; + + sutProvider.GetDependency() + .CheckPasswordAsync(user, masterPasswordHash) + .Returns(true); + + sutProvider.GetDependency() + .SaveUpdateExistingMasterPasswordAsync(user, Arg.Any()) + .Returns(OneOf.FromT1(identityErrors)); + + var result = await sutProvider.Sut.ChangePasswordAsync( + user, masterPasswordHash, unlockData, authenticationData, masterPasswordHint); + + Assert.False(result.Succeeded); + Assert.Contains(result.Errors, e => e.Code == "TestError"); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .LogUserEventAsync(default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .PushLogOutAsync(default, default); + } + + [Theory] + [BitAutoData] + public async Task ChangePasswordAsync_PassesCorrectDataToMasterPasswordService( + SutProvider sutProvider, + User user, string masterPasswordHash, string masterPasswordHint, + KdfSettings kdfSettings, string salt, string wrappedKey, string authHash) + { + var unlockData = CreateUnlockData(kdfSettings, salt, wrappedKey); + var authenticationData = CreateAuthenticationData(kdfSettings, salt, authHash); + + sutProvider.GetDependency() + .CheckPasswordAsync(user, masterPasswordHash) + .Returns(true); + + sutProvider.GetDependency() + .SaveUpdateExistingMasterPasswordAsync(user, Arg.Any()) + .Returns(OneOf.FromT0(user)); + + await sutProvider.Sut.ChangePasswordAsync( + user, masterPasswordHash, unlockData, authenticationData, masterPasswordHint); + + await sutProvider.GetDependency().Received(1) + .SaveUpdateExistingMasterPasswordAsync(user, + Arg.Is(d => + d.MasterPasswordUnlock == unlockData && + d.MasterPasswordAuthentication == authenticationData && + d.MasterPasswordHint == masterPasswordHint)); + } +} diff --git a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommandTests.cs b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommandTests.cs index 6aea42970063..3585fd3ad9d0 100644 --- a/test/Core.Test/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/UserMasterPassword/TdeSetPasswordCommandTests.cs @@ -1,6 +1,8 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.UserFeatures.UserMasterPassword; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Data; +using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -9,7 +11,6 @@ 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; @@ -23,7 +24,7 @@ public class TdeSetPasswordCommandTests [BitAutoData] public async Task OnboardMasterPassword_Success(SutProvider sutProvider, User user, KdfSettings kdfSettings, - Organization org, OrganizationUser orgUser, string serverSideHash, string masterPasswordHint) + Organization org, OrganizationUser orgUser, string masterPasswordHint) { // Arrange user.Key = null; @@ -39,14 +40,9 @@ public async Task OnboardMasterPassword_Success(SutProvider>() - .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) + sutProvider.GetDependency() + .BuildUpdateUserDelegateSetInitialMasterPassword(user, Arg.Any()) .Returns(mockUpdateUserData); // Act @@ -65,67 +61,6 @@ await sutProvider.GetDependency().Received(1) .LogUserEventAsync(user.Id, EventType.User_ChangedPassword); } - [Theory] - [BitAutoData] - public async Task OnboardMasterPassword_NullSalt_UsesEmailFallback( - 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"; - user.MasterPasswordSalt = null; - var expectedSalt = user.Email.ToLowerInvariant().Trim(); - var model = CreateValidModel(user, kdfSettings, org.Identifier, masterPasswordHint); - - // Verify the model uses the email-derived salt - Assert.Equal(expectedSalt, model.MasterPasswordUnlock.Salt); - Assert.Equal(expectedSalt, model.MasterPasswordAuthentication.Salt); - - 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); - - UpdateUserData mockUpdateUserData = (connection, transaction) => Task.CompletedTask; - sutProvider.GetDependency() - .SetMasterPassword(user.Id, model.MasterPasswordUnlock, serverSideHash, model.MasterPasswordHint) - .Returns(mockUpdateUserData); - - // Act — should not throw since email fallback provides a valid salt - await sutProvider.Sut.SetMasterPasswordAsync(user, model); - - // Assert - 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)] @@ -148,47 +83,6 @@ await Assert.ThrowsAsync(async () => 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(