Files
server/test/Api.Test/Auth/Models/Request/EmergencyAccessPasswordRequestModelTests.cs
Dave 25e78ceba3 [PM-35393] MasterPasswordService auth integration (#7575)
* feat(mp-service) Wire commands to MasterPasswordService.

* feat(self-service) Add logout-and-log to self-service command.

* feat(mp-service) Add dual-path request models and wire controller
routing.

Add structured cryptographic data support to all Auth password endpoints,
routing new payloads to MasterPasswordService-backed commands while
preserving legacy paths for backward compatibility (PM-33141 removal).

* refactor(mp-service) Mark legacy password entry points [Obsolete].

* test(mp-service) Add testing.

* refactor(mp-service) Rename ReplaceTemporaryPasswordAsync to be more descriptive.

* refactor(mp-service) Add variant validator and tests.

* fix(mp-service) Adjust payload variance validation.

* test(mp-service) Update integration tests to support payload variants and model validation returns.

* fix(password-request): Restore KDF regression guard.

* refactor(data-models): Collapse RequestHasNewDataTypes into local check.

* test(emergency-access): Update Emergency Access tests.

* refactor(mp-payload-variant-validator): Move to Auth utilities.

* test(self-service): Combine side-effects and password change into single test.

* feat(validation): Add kdf-salt agreement-only validation.

* refactor(password-request-model): consolidate onto ValidateKdfAndSaltAgreement.

* test(auth): Cover ValidateKdfAndSaltAgreement and enshrine legacy KDF acceptance.

* feat(validate-exclusivity): Throw on both payload variants present.

* test(accounts-controller): Update tests for exclusivity validation at the boundary.

* fix(request-models): Request models must accept both payload variants.

* PM-35393 - Add V2 dual-payload integration tests for password-modification flows

End-to-end coverage for the new AuthenticationData / UnlockData payload
across every endpoint that mutates a master password:

- POST /accounts/password — legacy-KDF acceptance, mismatch rejection,
  auth, current-password check.
- PUT /accounts/update-temp-password — legacy-KDF acceptance, mismatch
  rejection, auth, ForcePasswordReset precondition.
- PUT /accounts/update-tde-offboarding-password — sub-minimum KDF
  rejection (this flow intentionally enforces range), mismatch rejection,
  auth.
- POST /emergency-access/{id}/password — legacy-KDF acceptance, mismatch
  rejection, no-payload rejection, non-RecoveryApproved precondition.

Also extracts BuildAuthData / BuildUnlockData / BuildMismatchedAuthAndUnlock
helpers in AccountsControllerTest and rewrites the existing PostKdf_* tests
to use them (no behavior change).

15 new test methods, 41 cases. 155/155 controller-suite tests pass.

---------

Co-authored-by: Jared Snider <jsnider@bitwarden.com>
Co-authored-by: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com>
2026-05-20 12:28:30 -04:00

270 lines
8.8 KiB
C#

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());
}
[Fact]
public void Validate_NewPayloadsOnly_WithMismatchedSalts_ReturnsSaltValidationError()
{
var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600000 };
var model = new EmergencyAccessPasswordRequestModel
{
AuthenticationData = new MasterPasswordAuthenticationDataRequestModel
{
Kdf = kdf,
MasterPasswordAuthenticationHash = "authHash",
Salt = "salt-auth"
},
UnlockData = new MasterPasswordUnlockDataRequestModel
{
Kdf = kdf,
MasterKeyWrappedUserKey = "wrappedKey",
Salt = "salt-unlock"
}
};
var result = model.Validate(new ValidationContext(model)).ToList();
Assert.Single(result);
Assert.Equal("Invalid master password salt.", result[0].ErrorMessage);
}
[Fact]
public void Validate_NewPayloadsOnly_WithBelowMinimumKdf_NoError()
{
// PM-27892 regression guard: emergency-access takeover for legacy-KDF
// grantors must complete. The grantor cannot self-rescue (they have lost
// access to the account by the premise of takeover); the grantee's scope
// does not authorize calling /accounts/kdf on the grantor's behalf. The
// downstream UpdateExistingPasswordData contract requires the inbound KDF
// to match the grantor's stored KDF unchanged, so a legacy grantor's
// takeover request must carry their legacy KDF — which range enforcement
// here would silently reject. Mirrors PM-35306 for change-password.
var kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 1 };
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.DoesNotContain(result, r => r.ErrorMessage != null && r.ErrorMessage.Contains("KDF iterations must be between"));
}
}