diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 0542d5b00473..9b9aa273262c 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -250,48 +250,58 @@ public async Task PostSetPasswordAsync([FromBody] SetInitialPasswordRequestModel throw new UnauthorizedAccessException(); } - if (model.IsV2Request()) + // V2 encryption - TDE user with "manage account recovery" permission + if ( + model.HasAuthAndUnlockData() && + model.IsTdeSetPasswordRequest() && + _featureService.IsEnabled(FeatureFlagKeys.V2RegistrationTDEJIT)) { - if (model.IsTdeSetPasswordRequest()) - { - await _tdeSetPasswordCommand.SetMasterPasswordAsync(user, model.ToData()); - } - else - { - await _finishSsoJitProvisionMasterPasswordCommand.FinishProvisionAsync(user, model.ToData()); - } + await _tdeSetPasswordCommand.SetMasterPasswordAsync(user, model.ToData()); + return; } - else + + // V2 encryption - MP JIT + if ( + model.HasAuthAndUnlockData() && + model.IsJitMpSetPasswordRequest() && + _featureService.IsEnabled(FeatureFlagKeys.EnableAccountEncryptionV2JitPasswordRegistration)) { - // TODO removed with https://bitwarden.atlassian.net/browse/PM-27327 - try - { - user = model.ToUser(user); - } - catch (Exception e) - { - ModelState.AddModelError(string.Empty, e.Message); - throw new BadRequestException(ModelState); - } + await _finishSsoJitProvisionMasterPasswordCommand.FinishProvisionAsync(user, model.ToData()); + return; + } - var result = await _setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync( - user, - model.MasterPasswordHash, - model.Key, - model.OrgIdentifier); + // V1 encryption — handles both modern clients (sending MPAD/MPUD + legacy Keys) and + // legacy clients (sending only legacy fields). The model's ToUser() handles the fallback. + // TODO: removal requires that BOTH flags have been removed: + // - https://bitwarden.atlassian.net/browse/PM-27327 (MP) + // - https://bitwarden.atlassian.net/browse/PM-27329 (TDE) + try + { + user = model.ToUser(user); + } + catch (Exception e) + { + ModelState.AddModelError(string.Empty, e.Message); + throw new BadRequestException(ModelState); + } - if (result.Succeeded) - { - return; - } + var result = await _setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync( + user, + model.MasterPasswordAuthentication?.MasterPasswordAuthenticationHash ?? model.MasterPasswordHash, + model.MasterPasswordUnlock?.MasterKeyWrappedUserKey ?? model.Key, + model.OrgIdentifier); - foreach (var error in result.Errors) - { - ModelState.AddModelError(string.Empty, error.Description); - } + if (result.Succeeded) + { + return; + } - throw new BadRequestException(ModelState); + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); } + + throw new BadRequestException(ModelState); } [HttpPost("verify-password")] diff --git a/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs index f88938358bf7..7487439d5b0f 100644 --- a/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/SetInitialPasswordRequestModel.cs @@ -10,7 +10,9 @@ namespace Bit.Api.Auth.Models.Request.Accounts; public class SetInitialPasswordRequestModel : IValidatableObject { - // TODO will be removed with https://bitwarden.atlassian.net/browse/PM-27327 + // TODO: removal requires that BOTH flags have been removed: + // - https://bitwarden.atlassian.net/browse/PM-27327 (MP) + // - https://bitwarden.atlassian.net/browse/PM-27329 (TDE) [Obsolete("Use MasterPasswordAuthentication instead")] [StringLength(300)] public string? MasterPasswordHash { get; set; } @@ -43,22 +45,26 @@ public class SetInitialPasswordRequestModel : IValidatableObject [Required] public required string OrgIdentifier { get; set; } - // TODO removed with https://bitwarden.atlassian.net/browse/PM-27327 + // Reads KDF/key from MasterPasswordAuthentication/MasterPasswordUnlock when present (modern clients), + // and falls back to the top-level legacy properties when not (clients ≤3 releases back). + // TODO: removal requires that BOTH flags have been removed: + // - https://bitwarden.atlassian.net/browse/PM-27327 (MP) + // - https://bitwarden.atlassian.net/browse/PM-27329 (TDE) public User ToUser(User existingUser) { existingUser.MasterPasswordHint = MasterPasswordHint; - existingUser.Kdf = Kdf!.Value; - existingUser.KdfIterations = KdfIterations!.Value; - existingUser.KdfMemory = KdfMemory; - existingUser.KdfParallelism = KdfParallelism; - existingUser.Key = Key; + existingUser.Kdf = MasterPasswordAuthentication?.Kdf.KdfType ?? Kdf!.Value; + existingUser.KdfIterations = MasterPasswordAuthentication?.Kdf.Iterations ?? KdfIterations!.Value; + existingUser.KdfMemory = MasterPasswordAuthentication?.Kdf.Memory ?? KdfMemory; + existingUser.KdfParallelism = MasterPasswordAuthentication?.Kdf.Parallelism ?? KdfParallelism; + existingUser.Key = MasterPasswordUnlock?.MasterKeyWrappedUserKey ?? Key; Keys?.ToUser(existingUser); return existingUser; } public IEnumerable Validate(ValidationContext validationContext) { - if (IsV2Request()) + if (HasAuthAndUnlockData()) { // V2 registration - validate KDF equality, salt equality, and KDF settings foreach (var validationResult in KdfSettingsValidator.ValidateAuthenticationAndUnlockData( @@ -71,7 +77,9 @@ public IEnumerable Validate(ValidationContext validationContex } // V1 registration - // TODO removed with https://bitwarden.atlassian.net/browse/PM-27327 + // TODO: removal requires that BOTH flags have been removed: + // - https://bitwarden.atlassian.net/browse/PM-27327 (MP) + // - https://bitwarden.atlassian.net/browse/PM-27329 (TDE) if (string.IsNullOrEmpty(MasterPasswordHash)) { yield return new ValidationResult("MasterPasswordHash must be supplied."); @@ -115,15 +123,30 @@ public IEnumerable Validate(ValidationContext validationContex } } - public bool IsV2Request() + /// + /// True when the request uses the new data shape (MasterPasswordAuthentication + MasterPasswordUnlock). + /// This is a shape check, NOT a guarantee that V2 encryption will run. It is possible for V1 encryption + /// to run even when the request contains these new data types (see `set-password` endpoint). + /// Feature flags and AccountKeys presence determine the actual flow (V1 or V2). + /// + public bool HasAuthAndUnlockData() { // AccountKeys can be null for TDE users, so we don't check that here return MasterPasswordAuthentication != null && MasterPasswordUnlock != null; } + // TDE users don't send any key material (their keypair already exists). + // Checks both AccountKeys (new) and Keys (legacy) so the predicate is correct for + // the transitional period where clients may send either key shape. public bool IsTdeSetPasswordRequest() { - return AccountKeys == null; + return AccountKeys == null && Keys == null; + } + + // MP JIT users send new key material — either via the new AccountKeys shape or the legacy Keys shape. + public bool IsJitMpSetPasswordRequest() + { + return AccountKeys != null || Keys != null; } public SetInitialMasterPasswordDataModel ToData() diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index db07a1bc4d8c..72e8b8e2ef10 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -928,6 +928,7 @@ public async Task PostSetPasswordAsync_V2_WhenUserExistsAndSettingPasswordSuccee { // Arrange UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel); + _featureService.IsEnabled(FeatureFlagKeys.EnableAccountEncryptionV2JitPasswordRegistration).Returns(true); _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); _finishSsoJitProvisionMasterPasswordCommand.FinishProvisionAsync(user, Arg.Any()) .Returns(Task.CompletedTask); @@ -954,6 +955,7 @@ public async Task PostSetPasswordAsync_V2_WithTdeSetPassword_ShouldCallTdeSetPas { // Arrange UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel, includeTdeSetPassword: true); + _featureService.IsEnabled(FeatureFlagKeys.V2RegistrationTDEJIT).Returns(true); _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); _tdeSetPasswordCommand.SetMasterPasswordAsync(user, Arg.Any()) .Returns(Task.CompletedTask); @@ -993,6 +995,7 @@ public async Task PostSetPasswordAsync_V2_WhenSettingPasswordFails_ShouldThrowEx { // Arrange UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel); + _featureService.IsEnabled(FeatureFlagKeys.EnableAccountEncryptionV2JitPasswordRegistration).Returns(true); _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); _finishSsoJitProvisionMasterPasswordCommand.FinishProvisionAsync(user, Arg.Any()) .Returns(Task.FromException(new Exception("Setting password failed"))); @@ -1001,6 +1004,105 @@ public async Task PostSetPasswordAsync_V2_WhenSettingPasswordFails_ShouldThrowEx await Assert.ThrowsAsync(() => _sut.PostSetPasswordAsync(setInitialPasswordRequestModel)); } + // V1 encryption with new data types (transitional path — V2 flags off, modern client carries MPAD/MPUD + legacy Keys) + // TODO: removal requires that BOTH flags have been removed: + // - https://bitwarden.atlassian.net/browse/PM-27327 (MP) + // - https://bitwarden.atlassian.net/browse/PM-27329 (TDE) + [Theory] + [BitAutoData] + public async Task PostSetPasswordAsync_V1_NewClientMpJit_UsesMpadMpudValues_ShouldCallV1CommandAsync( + User user, + SetInitialPasswordRequestModel setInitialPasswordRequestModel) + { + // Arrange — modern MP JIT client: sends MPAD + MPUD + legacy Keys (no AccountKeys, no V2 flag). + // ToUser() should map KDF from MPAD and the wrapped user key from MPUD; legacy Keys?.ToUser sets the keypair. + UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel); + setInitialPasswordRequestModel.AccountKeys = null; + setInitialPasswordRequestModel.Keys = new KeysRequestModel + { + PublicKey = "newPublicKey", + EncryptedPrivateKey = "newEncryptedPrivateKey" + }; + user.PublicKey = null; + user.PrivateKey = null; + + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + _setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync( + user, + setInitialPasswordRequestModel.MasterPasswordAuthentication.MasterPasswordAuthenticationHash, + setInitialPasswordRequestModel.MasterPasswordUnlock.MasterKeyWrappedUserKey, + setInitialPasswordRequestModel.OrgIdentifier) + .Returns(Task.FromResult(IdentityResult.Success)); + + // Act + await _sut.PostSetPasswordAsync(setInitialPasswordRequestModel); + + // Assert — V1 command called with MPAD hash + MPUD wrapped key (not legacy MasterPasswordHash/Key) + await _setInitialMasterPasswordCommandV1.Received(1) + .SetInitialMasterPasswordAsync( + Arg.Is(u => u == user), + Arg.Is(s => s == setInitialPasswordRequestModel.MasterPasswordAuthentication.MasterPasswordAuthenticationHash), + Arg.Is(s => s == setInitialPasswordRequestModel.MasterPasswordUnlock.MasterKeyWrappedUserKey), + Arg.Is(s => s == setInitialPasswordRequestModel.OrgIdentifier)); + + // KDF mapped from MPAD + Assert.Equal(setInitialPasswordRequestModel.MasterPasswordHint, user.MasterPasswordHint); + Assert.Equal(setInitialPasswordRequestModel.MasterPasswordAuthentication.Kdf.KdfType, user.Kdf); + Assert.Equal(setInitialPasswordRequestModel.MasterPasswordAuthentication.Kdf.Iterations, user.KdfIterations); + + // Public/private keys mapped from legacy Keys + Assert.Equal("newPublicKey", user.PublicKey); + Assert.Equal("newEncryptedPrivateKey", user.PrivateKey); + + // V2 commands not called + await _finishSsoJitProvisionMasterPasswordCommand.DidNotReceiveWithAnyArgs() + .FinishProvisionAsync(Arg.Any(), Arg.Any()); + await _tdeSetPasswordCommand.DidNotReceiveWithAnyArgs() + .SetMasterPasswordAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task PostSetPasswordAsync_V1_NewClientTde_UsesMpadMpudValues_DoesNotMutateExistingKeysAsync( + User user, + SetInitialPasswordRequestModel setInitialPasswordRequestModel) + { + // Arrange — modern TDE client: sends MPAD + MPUD with both AccountKeys and Keys null (V2 TDE flag off). + // TDE users already have a keypair; Keys?.ToUser is a no-op and the existing keys are left alone. + UpdateSetInitialPasswordRequestModelToV2(setInitialPasswordRequestModel, includeTdeSetPassword: true); + + const string existingPublicKey = "tdeUserExistingPublicKey"; + const string existingPrivateKey = "tdeUserExistingPrivateKey"; + user.PublicKey = existingPublicKey; + user.PrivateKey = existingPrivateKey; + + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + _setInitialMasterPasswordCommandV1.SetInitialMasterPasswordAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(IdentityResult.Success)); + + // Act + await _sut.PostSetPasswordAsync(setInitialPasswordRequestModel); + + // Assert — V1 command called with MPAD hash + MPUD wrapped key + await _setInitialMasterPasswordCommandV1.Received(1) + .SetInitialMasterPasswordAsync( + Arg.Is(u => u == user), + Arg.Is(s => s == setInitialPasswordRequestModel.MasterPasswordAuthentication.MasterPasswordAuthenticationHash), + Arg.Is(s => s == setInitialPasswordRequestModel.MasterPasswordUnlock.MasterKeyWrappedUserKey), + Arg.Is(s => s == setInitialPasswordRequestModel.OrgIdentifier)); + + // Existing keypair preserved + Assert.Equal(existingPublicKey, user.PublicKey); + Assert.Equal(existingPrivateKey, user.PrivateKey); + + // V2 commands not called + await _finishSsoJitProvisionMasterPasswordCommand.DidNotReceiveWithAnyArgs() + .FinishProvisionAsync(Arg.Any(), Arg.Any()); + await _tdeSetPasswordCommand.DidNotReceiveWithAnyArgs() + .SetMasterPasswordAsync(Arg.Any(), Arg.Any()); + } + private void UpdateSetInitialPasswordRequestModelToV1(SetInitialPasswordRequestModel model) { model.MasterPasswordAuthentication = null; diff --git a/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs index e62fb62468f1..2c3e3b0d281c 100644 --- a/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs +++ b/test/Api.Test/Auth/Models/Request/Accounts/SetInitialPasswordRequestModelTests.cs @@ -11,12 +11,12 @@ namespace Bit.Api.Test.Auth.Models.Request.Accounts; public class SetInitialPasswordRequestModelTests { - #region V2 Validation Tests + #region Validation Tests (Auth + Unlock Data) [Theory] [InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)] [InlineData(KdfType.Argon2id, 3, 64, 4)] - public void Validate_V2Request_WithMatchingKdfAndSalt_ReturnsNoErrors(KdfType kdfType, int iterations, int? memory, int? parallelism) + public void Validate_AuthAndUnlockData_WithMatchingKdfAndSalt_ReturnsNoErrors(KdfType kdfType, int iterations, int? memory, int? parallelism) { // Arrange — uses separate KDF object instances with identical values to verify value equality var model = new SetInitialPasswordRequestModel @@ -62,7 +62,7 @@ public void Validate_V2Request_WithMatchingKdfAndSalt_ReturnsNoErrors(KdfType kd [Theory] [BitAutoData] - public void Validate_V2Request_WithMismatchedKdfSettings_ReturnsValidationError(string orgIdentifier) + public void Validate_AuthAndUnlockData_WithMismatchedKdfSettings_ReturnsValidationError(string orgIdentifier) { // Arrange var model = new SetInitialPasswordRequestModel @@ -103,7 +103,7 @@ public void Validate_V2Request_WithMismatchedKdfSettings_ReturnsValidationError( [Theory] [BitAutoData] - public void Validate_V2Request_WithMismatchedSalt_ReturnsValidationError(string orgIdentifier) + public void Validate_AuthAndUnlockData_WithMismatchedSalt_ReturnsValidationError(string orgIdentifier) { // Arrange var kdf = new KdfRequestModel @@ -138,7 +138,7 @@ public void Validate_V2Request_WithMismatchedSalt_ReturnsValidationError(string [Theory] [BitAutoData] - public void Validate_V2Request_WithInvalidAuthenticationKdf_ReturnsValidationError(string orgIdentifier) + public void Validate_AuthAndUnlockData_WithInvalidAuthenticationKdf_ReturnsValidationError(string orgIdentifier) { // Arrange var kdf = new KdfRequestModel @@ -174,11 +174,11 @@ public void Validate_V2Request_WithInvalidAuthenticationKdf_ReturnsValidationErr #endregion - #region V1 Validation Tests (Obsolete) + #region Validation Tests (Legacy Data) (Obsolete) [Theory] [BitAutoData] - public void Validate_V1Request_WithMissingMasterPasswordHash_ReturnsValidationError(string orgIdentifier) + public void Validate_LegacyData_WithMissingMasterPasswordHash_ReturnsValidationError(string orgIdentifier) { // Arrange var model = new SetInitialPasswordRequestModel @@ -198,7 +198,7 @@ public void Validate_V1Request_WithMissingMasterPasswordHash_ReturnsValidationEr [Theory] [BitAutoData] - public void Validate_V1Request_WithMissingKey_ReturnsValidationError(string orgIdentifier) + public void Validate_LegacyData_WithMissingKey_ReturnsValidationError(string orgIdentifier) { // Arrange var model = new SetInitialPasswordRequestModel @@ -218,7 +218,7 @@ public void Validate_V1Request_WithMissingKey_ReturnsValidationError(string orgI [Theory] [BitAutoData] - public void Validate_V1Request_WithMissingKdf_ReturnsValidationError(string orgIdentifier) + public void Validate_LegacyData_WithMissingKdf_ReturnsValidationError(string orgIdentifier) { // Arrange var model = new SetInitialPasswordRequestModel @@ -238,7 +238,7 @@ public void Validate_V1Request_WithMissingKdf_ReturnsValidationError(string orgI [Theory] [BitAutoData] - public void Validate_V1Request_WithMissingKdfIterations_ReturnsValidationError(string orgIdentifier) + public void Validate_LegacyData_WithMissingKdfIterations_ReturnsValidationError(string orgIdentifier) { // Arrange var model = new SetInitialPasswordRequestModel @@ -258,7 +258,7 @@ public void Validate_V1Request_WithMissingKdfIterations_ReturnsValidationError(s [Theory] [BitAutoData] - public void Validate_V1Request_WithArgon2idAndMissingMemory_ReturnsValidationError(string orgIdentifier) + public void Validate_LegacyData_WithArgon2idAndMissingMemory_ReturnsValidationError(string orgIdentifier) { // Arrange var model = new SetInitialPasswordRequestModel @@ -280,7 +280,7 @@ public void Validate_V1Request_WithArgon2idAndMissingMemory_ReturnsValidationErr [Theory] [BitAutoData] - public void Validate_V1Request_WithArgon2idAndMissingParallelism_ReturnsValidationError(string orgIdentifier) + public void Validate_LegacyData_WithArgon2idAndMissingParallelism_ReturnsValidationError(string orgIdentifier) { // Arrange var model = new SetInitialPasswordRequestModel @@ -302,7 +302,7 @@ public void Validate_V1Request_WithArgon2idAndMissingParallelism_ReturnsValidati [Theory] [BitAutoData] - public void Validate_V1Request_WithInvalidKdfSettings_ReturnsValidationError(string orgIdentifier) + public void Validate_LegacyData_WithInvalidKdfSettings_ReturnsValidationError(string orgIdentifier) { // Arrange var model = new SetInitialPasswordRequestModel @@ -325,7 +325,7 @@ public void Validate_V1Request_WithInvalidKdfSettings_ReturnsValidationError(str [Theory] [InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)] [InlineData(KdfType.Argon2id, 3, 64, 4)] - public void Validate_V1Request_WithValidSettings_ReturnsNoErrors(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) + public void Validate_LegacyData_WithValidSettings_ReturnsNoErrors(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) { // Arrange var model = new SetInitialPasswordRequestModel @@ -348,11 +348,11 @@ public void Validate_V1Request_WithValidSettings_ReturnsNoErrors(KdfType kdfType #endregion - #region IsV2Request Tests + #region HasAuthAndUnlockData Tests [Theory] [BitAutoData] - public void IsV2Request_WithV2Properties_ReturnsTrue(string orgIdentifier) + public void HasAuthAndUnlockData_WithBothPresent_ReturnsTrue(string orgIdentifier) { // Arrange var model = new SetInitialPasswordRequestModel @@ -381,7 +381,7 @@ public void IsV2Request_WithV2Properties_ReturnsTrue(string orgIdentifier) }; // Act - var result = model.IsV2Request(); + var result = model.HasAuthAndUnlockData(); // Assert Assert.True(result); @@ -389,7 +389,7 @@ public void IsV2Request_WithV2Properties_ReturnsTrue(string orgIdentifier) [Theory] [BitAutoData] - public void IsV2Request_WithoutMasterPasswordAuthentication_ReturnsFalse(string orgIdentifier) + public void HasAuthAndUnlockData_WithoutMasterPasswordAuthentication_ReturnsFalse(string orgIdentifier) { // Arrange var model = new SetInitialPasswordRequestModel @@ -408,7 +408,7 @@ public void IsV2Request_WithoutMasterPasswordAuthentication_ReturnsFalse(string }; // Act - var result = model.IsV2Request(); + var result = model.HasAuthAndUnlockData(); // Assert Assert.False(result); @@ -416,7 +416,7 @@ public void IsV2Request_WithoutMasterPasswordAuthentication_ReturnsFalse(string [Theory] [BitAutoData] - public void IsV2Request_WithoutMasterPasswordUnlock_ReturnsFalse(string orgIdentifier) + public void HasAuthAndUnlockData_WithoutMasterPasswordUnlock_ReturnsFalse(string orgIdentifier) { // Arrange var model = new SetInitialPasswordRequestModel @@ -435,7 +435,7 @@ public void IsV2Request_WithoutMasterPasswordUnlock_ReturnsFalse(string orgIdent }; // Act - var result = model.IsV2Request(); + var result = model.HasAuthAndUnlockData(); // Assert Assert.False(result); @@ -443,7 +443,7 @@ public void IsV2Request_WithoutMasterPasswordUnlock_ReturnsFalse(string orgIdent [Theory] [BitAutoData] - public void IsV2Request_WithV1Properties_ReturnsFalse(string orgIdentifier) + public void HasAuthAndUnlockData_WithLegacyPropertiesOnly_ReturnsFalse(string orgIdentifier) { // Arrange var model = new SetInitialPasswordRequestModel @@ -456,7 +456,7 @@ public void IsV2Request_WithV1Properties_ReturnsFalse(string orgIdentifier) }; // Act - var result = model.IsV2Request(); + var result = model.HasAuthAndUnlockData(); // Assert Assert.False(result); @@ -468,9 +468,9 @@ public void IsV2Request_WithV1Properties_ReturnsFalse(string orgIdentifier) [Theory] [BitAutoData] - public void IsTdeSetPasswordRequest_WithNullAccountKeys_ReturnsTrue(string orgIdentifier) + public void IsTdeSetPasswordRequest_WithBothAccountKeysAndKeysNull_ReturnsTrue(string orgIdentifier) { - // Arrange + // Arrange — TDE user sends no key material at all (they already have a keypair) var model = new SetInitialPasswordRequestModel { OrgIdentifier = orgIdentifier, @@ -494,7 +494,8 @@ public void IsTdeSetPasswordRequest_WithNullAccountKeys_ReturnsTrue(string orgId MasterKeyWrappedUserKey = "wrappedKey", Salt = "salt" }, - AccountKeys = null + AccountKeys = null, + Keys = null }; // Act @@ -546,6 +547,99 @@ public void IsTdeSetPasswordRequest_WithAccountKeys_ReturnsFalse(string orgIdent Assert.False(result); } + [Theory] + [BitAutoData] + public void IsTdeSetPasswordRequest_WithLegacyKeysPresent_ReturnsFalse(string orgIdentifier) + { + // Arrange — MP JIT request shape: AccountKeys null but legacy Keys populated. + // Without checking Keys, this would be misclassified as TDE. + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + AccountKeys = null, + Keys = new KeysRequestModel + { + PublicKey = "publicKey", + EncryptedPrivateKey = "encryptedPrivateKey" + } + }; + + // Act + var result = model.IsTdeSetPasswordRequest(); + + // Assert + Assert.False(result); + } + + #endregion + + #region IsJitMpSetPasswordRequest Tests + + [Theory] + [BitAutoData] + public void IsJitMpSetPasswordRequest_WithAccountKeys_ReturnsTrue(string orgIdentifier) + { + // Arrange — new client / future MP JIT shape + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + AccountKeys = new AccountKeysRequestModel + { + UserKeyEncryptedAccountPrivateKey = "privateKey", + AccountPublicKey = "publicKey" + }, + Keys = null + }; + + // Act + var result = model.IsJitMpSetPasswordRequest(); + + // Assert + Assert.True(result); + } + + [Theory] + [BitAutoData] + public void IsJitMpSetPasswordRequest_WithLegacyKeys_ReturnsTrue(string orgIdentifier) + { + // Arrange — modern MP JIT client (Option Y) sends legacy Keys, no AccountKeys + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + AccountKeys = null, + Keys = new KeysRequestModel + { + PublicKey = "publicKey", + EncryptedPrivateKey = "encryptedPrivateKey" + } + }; + + // Act + var result = model.IsJitMpSetPasswordRequest(); + + // Assert + Assert.True(result); + } + + [Theory] + [BitAutoData] + public void IsJitMpSetPasswordRequest_WithBothAccountKeysAndKeysNull_ReturnsFalse(string orgIdentifier) + { + // Arrange — TDE shape: no key material at all + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = orgIdentifier, + AccountKeys = null, + Keys = null + }; + + // Act + var result = model.IsJitMpSetPasswordRequest(); + + // Assert + Assert.False(result); + } + #endregion #region ToUser Tests (Obsolete) @@ -624,6 +718,169 @@ public void ToUser_WithoutKeys_MapsPropertiesCorrectly(KdfType kdfType, int kdfI Assert.Null(result.PrivateKey); } + [Theory] + [InlineData(KdfType.PBKDF2_SHA256, 600000, null, null)] + [InlineData(KdfType.Argon2id, 3, 64, 4)] + public void ToUser_WithMasterPasswordAuthAndUnlock_AndKeys_ReadsKdfAndKeyFromNewData( + KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) + { + // Arrange — modern client: MPAD + MPUD + legacy Keys, no top-level legacy KDF/key fields + var existingUser = new User(); + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = "orgIdentifier", + MasterPasswordHint = "hint", + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = kdfType, + Iterations = kdfIterations, + Memory = kdfMemory, + Parallelism = kdfParallelism + }, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = kdfType, + Iterations = kdfIterations, + Memory = kdfMemory, + Parallelism = kdfParallelism + }, + MasterKeyWrappedUserKey = "wrappedKeyFromMpud", + Salt = "salt" + }, + Keys = new KeysRequestModel + { + PublicKey = "publicKey", + EncryptedPrivateKey = "encryptedPrivateKey" + } + }; + + // Act + var result = model.ToUser(existingUser); + + // Assert — KDF mapped from MPAD, user.Key from MPUD, public/private from legacy Keys + Assert.Same(existingUser, result); + Assert.Equal("hint", result.MasterPasswordHint); + Assert.Equal(kdfType, result.Kdf); + Assert.Equal(kdfIterations, result.KdfIterations); + Assert.Equal(kdfMemory, result.KdfMemory); + Assert.Equal(kdfParallelism, result.KdfParallelism); + Assert.Equal("wrappedKeyFromMpud", result.Key); + Assert.Equal("publicKey", result.PublicKey); + Assert.Equal("encryptedPrivateKey", result.PrivateKey); + } + + [Fact] + public void ToUser_WithBothNewAndLegacyFieldsSet_PrefersNewData() + { + // Arrange — defensive: if a request somehow includes both new and legacy KDF/key fields, + // ToUser should source from MPAD/MPUD, not the legacy top-level properties. + // Uses Argon2id in MPAD so Memory/Parallelism are populated (not null) on the new shape; + // verifies the new values win for every non-nullable field. + var existingUser = new User(); + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = "orgIdentifier", + MasterPasswordHint = "hint", + + // Legacy top-level (should NOT win) + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 600000, + KdfMemory = 999, + KdfParallelism = 9, + Key = "legacyKey", + + // New shape (should win) + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.Argon2id, + Iterations = 3, + Memory = 64, + Parallelism = 4 + }, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.Argon2id, + Iterations = 3, + Memory = 64, + Parallelism = 4 + }, + MasterKeyWrappedUserKey = "wrappedKeyFromMpud", + Salt = "salt" + } + }; + + // Act + var result = model.ToUser(existingUser); + + // Assert — values came from MPAD/MPUD, not legacy fields + Assert.Equal(KdfType.Argon2id, result.Kdf); + Assert.Equal(3, result.KdfIterations); + Assert.Equal(64, result.KdfMemory); + Assert.Equal(4, result.KdfParallelism); + Assert.Equal("wrappedKeyFromMpud", result.Key); + } + + [Fact] + public void ToUser_WithMasterPasswordAuthAndUnlock_AndNullKeys_DoesNotMutateExistingPublicPrivateKey() + { + // Arrange — TDE flow: modern client sends MPAD + MPUD with no key material. + // Existing user has a keypair that must not be replaced. + var existingUser = new User + { + PublicKey = "existingPublicKey", + PrivateKey = "existingPrivateKey" + }; + var model = new SetInitialPasswordRequestModel + { + OrgIdentifier = "orgIdentifier", + MasterPasswordHint = "hint", + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterPasswordAuthenticationHash = "authHash", + Salt = "salt" + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = new KdfRequestModel + { + KdfType = KdfType.PBKDF2_SHA256, + Iterations = 600000 + }, + MasterKeyWrappedUserKey = "wrappedKeyFromMpud", + Salt = "salt" + }, + Keys = null + }; + + // Act + var result = model.ToUser(existingUser); + + // Assert — KDF/Key mapped from new data, public/private kept intact + Assert.Equal(KdfType.PBKDF2_SHA256, result.Kdf); + Assert.Equal("wrappedKeyFromMpud", result.Key); + Assert.Equal("existingPublicKey", result.PublicKey); + Assert.Equal("existingPrivateKey", result.PrivateKey); + } + #endregion #region ToData Tests