mirror of
https://github.com/bitwarden/server.git
synced 2026-06-01 01:55:55 -05:00
* 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>
314 lines
15 KiB
C#
314 lines
15 KiB
C#
using System.Net;
|
|
using Bit.Api.Auth.Models.Request;
|
|
using Bit.Api.IntegrationTest.Factories;
|
|
using Bit.Api.IntegrationTest.Helpers;
|
|
using Bit.Core.Auth.Entities;
|
|
using Bit.Core.Auth.Enums;
|
|
using Bit.Core.Auth.Models;
|
|
using Bit.Core.Billing.Services;
|
|
using Bit.Core.Entities;
|
|
using Bit.Core.Enums;
|
|
using Bit.Core.KeyManagement.Models.Api.Request;
|
|
using Bit.Core.Platform.Push;
|
|
using Bit.Core.Repositories;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Xunit;
|
|
|
|
namespace Bit.Api.IntegrationTest.Controllers;
|
|
|
|
public class EmergencyAccessControllerTest : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
|
|
{
|
|
private static readonly string _masterKeyWrappedUserKey =
|
|
"2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=";
|
|
|
|
private static readonly string _newMasterPasswordHash = "new_master_password_hash";
|
|
|
|
private static readonly KdfRequestModel _defaultKdfRequest =
|
|
new() { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 };
|
|
|
|
private readonly HttpClient _client;
|
|
private readonly ApiApplicationFactory _factory;
|
|
private readonly LoginHelper _loginHelper;
|
|
private readonly IUserRepository _userRepository;
|
|
private readonly IEmergencyAccessRepository _emergencyAccessRepository;
|
|
private readonly IPasswordHasher<User> _passwordHasher;
|
|
|
|
private string _grantorEmail = null!;
|
|
private string _granteeEmail = null!;
|
|
private Guid _emergencyAccessId;
|
|
|
|
public EmergencyAccessControllerTest(ApiApplicationFactory factory)
|
|
{
|
|
_factory = factory;
|
|
_factory.SubstituteService<IPushNotificationService>(_ => { });
|
|
_factory.SubstituteService<IStripeSyncService>(_ => { });
|
|
_client = factory.CreateClient();
|
|
_loginHelper = new LoginHelper(_factory, _client);
|
|
_userRepository = _factory.GetService<IUserRepository>();
|
|
_emergencyAccessRepository = _factory.GetService<IEmergencyAccessRepository>();
|
|
_passwordHasher = _factory.GetService<IPasswordHasher<User>>();
|
|
}
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
// Two distinct registered accounts: the grantee (caller) and the grantor
|
|
// (whose master password is replaced). Only the grantee needs auth tokens.
|
|
var suffix = Guid.NewGuid();
|
|
_grantorEmail = $"emergency-access-grantor-{suffix}@bitwarden.com";
|
|
_granteeEmail = $"emergency-access-grantee-{suffix}@bitwarden.com";
|
|
await _factory.LoginWithNewAccount(_grantorEmail);
|
|
await _factory.LoginWithNewAccount(_granteeEmail);
|
|
|
|
// Seed an emergency access in RecoveryApproved/Takeover — the only state
|
|
// pair IsValidRequest accepts for the Password endpoint. Without this the
|
|
// controller would 400 before model-binding errors get a chance to surface.
|
|
var grantor = await _userRepository.GetByEmailAsync(_grantorEmail);
|
|
var grantee = await _userRepository.GetByEmailAsync(_granteeEmail);
|
|
Assert.NotNull(grantor);
|
|
Assert.NotNull(grantee);
|
|
|
|
var emergencyAccess = await _emergencyAccessRepository.CreateAsync(new EmergencyAccess
|
|
{
|
|
GrantorId = grantor.Id,
|
|
GranteeId = grantee.Id,
|
|
Type = EmergencyAccessType.Takeover,
|
|
Status = EmergencyAccessStatusType.RecoveryApproved,
|
|
WaitTimeDays = 1,
|
|
KeyEncrypted = _masterKeyWrappedUserKey,
|
|
});
|
|
_emergencyAccessId = emergencyAccess.Id;
|
|
}
|
|
|
|
public Task DisposeAsync()
|
|
{
|
|
_client.Dispose();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// Builders for the dual-payload auth + unlock blocks used by every V2
|
|
// password test below. Tests vary KDF and salt; everything else is constant.
|
|
private static MasterPasswordAuthenticationDataRequestModel BuildAuthData(KdfRequestModel kdf, string salt) =>
|
|
new() { Kdf = kdf, MasterPasswordAuthenticationHash = _newMasterPasswordHash, Salt = salt };
|
|
|
|
private static MasterPasswordUnlockDataRequestModel BuildUnlockData(KdfRequestModel kdf, string salt) =>
|
|
new() { Kdf = kdf, MasterKeyWrappedUserKey = _masterKeyWrappedUserKey, Salt = salt };
|
|
|
|
// Builds an (auth, unlock) pair where one side is perturbed so the agreement
|
|
// validator must fire. Used by PostPassword_V2_MismatchedKdfOrSalt_BadRequest.
|
|
// mismatchKind: "kdf" | "salt" — which field disagrees
|
|
// perturbedSide: "auth" | "unlock" — which half carries the bad value
|
|
private (MasterPasswordAuthenticationDataRequestModel auth, MasterPasswordUnlockDataRequestModel unlock)
|
|
BuildMismatchedAuthAndUnlock(string mismatchKind, string perturbedSide)
|
|
{
|
|
var perturbedKdf = mismatchKind == "kdf"
|
|
? new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = 700_000 }
|
|
: _defaultKdfRequest;
|
|
var perturbedSalt = mismatchKind == "salt" ? "different-salt@bitwarden.com" : _grantorEmail;
|
|
|
|
return perturbedSide == "auth"
|
|
? (BuildAuthData(perturbedKdf, perturbedSalt), BuildUnlockData(_defaultKdfRequest, _grantorEmail))
|
|
: (BuildAuthData(_defaultKdfRequest, _grantorEmail), BuildUnlockData(perturbedKdf, perturbedSalt));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies the dual-payload emergency-access takeover path accepts grantors
|
|
/// whose stored KDF predates the current minimum. <c>ValidateKdfAndSaltAgreement</c>
|
|
/// on this endpoint must enforce agreement between <c>AuthenticationData</c> and
|
|
/// <c>UnlockData</c>, not range — otherwise a grantee cannot rescue a legacy
|
|
/// account because the new auth hash is derived client-side against the
|
|
/// grantor's existing KDF.
|
|
/// <para>
|
|
/// Scope: end-to-end through the V2 path; also asserts the grantor's KDF is
|
|
/// left untouched (the <c>PrepareUpdateExistingMasterPasswordAsync</c> path
|
|
/// updates the password only), the security stamp rotated, and the takeover
|
|
/// side effects fired (2FA cleared, device verification disabled) so the
|
|
/// grantee can subsequently log in as the grantor.
|
|
/// </para>
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(KdfType.PBKDF2_SHA256, 100_000, null, null)] // plausible legacy: real pre-600k users
|
|
[InlineData(KdfType.PBKDF2_SHA256, 5_000, null, null)] // far below minimum: no soft floor either
|
|
[InlineData(KdfType.Argon2id, 2, 14, 4)] // barely sub-minimum: 1mb below memory floor
|
|
[InlineData(KdfType.Argon2id, 1, 8, 1)] // far below minimum: no Argon2-specific gate
|
|
public async Task PostPassword_V2_LegacyKdfBelowMinimum_Success(
|
|
KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism)
|
|
{
|
|
// Arrange: downgrade the grantor's KDF to a sub-minimum value to
|
|
// simulate a real legacy-KDF account that predates the current floor.
|
|
// ValidateDataForUser will only accept the request if its KDF matches
|
|
// the grantor's stored KDF, so the request must echo this downgrade.
|
|
// Also prime 2FA so the post-takeover "cleared" assertion is meaningful
|
|
// (test-factory users have no providers configured by default).
|
|
var grantor = await _userRepository.GetByEmailAsync(_grantorEmail);
|
|
Assert.NotNull(grantor);
|
|
grantor.Kdf = kdf;
|
|
grantor.KdfIterations = kdfIterations;
|
|
grantor.KdfMemory = kdfMemory;
|
|
grantor.KdfParallelism = kdfParallelism;
|
|
grantor.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
|
|
{
|
|
[TwoFactorProviderType.Email] = new() { Enabled = true },
|
|
});
|
|
await _userRepository.ReplaceAsync(grantor);
|
|
var grantorSecurityStampBefore = grantor.SecurityStamp;
|
|
var grantorTwoFactorProvidersBefore = grantor.TwoFactorProviders;
|
|
|
|
await _loginHelper.LoginAsync(_granteeEmail);
|
|
|
|
var legacyKdfRequest = new KdfRequestModel
|
|
{
|
|
KdfType = kdf,
|
|
Iterations = kdfIterations,
|
|
Memory = kdfMemory,
|
|
Parallelism = kdfParallelism,
|
|
};
|
|
|
|
// Salt must equal the grantor's stored salt (falls back to email when
|
|
// MasterPasswordSalt is null, which is the case for test-factory users).
|
|
var requestModel = new EmergencyAccessPasswordRequestModel
|
|
{
|
|
AuthenticationData = BuildAuthData(legacyKdfRequest, _grantorEmail),
|
|
UnlockData = BuildUnlockData(legacyKdfRequest, _grantorEmail),
|
|
};
|
|
|
|
// Act: hit the real endpoint so model binding, validation, auth filter,
|
|
// command dispatch, and repository write all run end-to-end.
|
|
using var message = new HttpRequestMessage(
|
|
HttpMethod.Post, $"/emergency-access/{_emergencyAccessId}/password");
|
|
message.Content = JsonContent.Create(requestModel);
|
|
var response = await _client.SendAsync(message);
|
|
|
|
// Surface the error body on failure — a bare EnsureSuccessStatusCode
|
|
// hides the validator message that points at any future range-check regression.
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
var errorContent = await response.Content.ReadAsStringAsync();
|
|
Assert.Fail($"Expected success but got {response.StatusCode}. Error: {errorContent}");
|
|
}
|
|
|
|
// Assert: grantor's new password was persisted (rules out a silent no-op).
|
|
var updatedGrantor = await _userRepository.GetByEmailAsync(_grantorEmail);
|
|
Assert.NotNull(updatedGrantor);
|
|
Assert.Equal(PasswordVerificationResult.Success,
|
|
_passwordHasher.VerifyHashedPassword(
|
|
updatedGrantor, updatedGrantor.MasterPassword!, _newMasterPasswordHash));
|
|
|
|
// KDF must be unchanged — PrepareUpdateExistingMasterPasswordAsync changes
|
|
// the password only. A silent bump to current minimum would corrupt the
|
|
// account: the new auth hash was derived client-side against the legacy KDF.
|
|
Assert.Equal(kdf, updatedGrantor.Kdf);
|
|
Assert.Equal(kdfIterations, updatedGrantor.KdfIterations);
|
|
Assert.Equal(kdfMemory, updatedGrantor.KdfMemory);
|
|
Assert.Equal(kdfParallelism, updatedGrantor.KdfParallelism);
|
|
|
|
// Key (master-key-wrapped user key) and grantor-takeover side effects
|
|
// applied: security stamp rotated, device verification turned off,
|
|
// 2FA providers cleared (otherwise they'd block the grantee's login).
|
|
Assert.Equal(_masterKeyWrappedUserKey, updatedGrantor.Key);
|
|
Assert.NotEqual(grantorSecurityStampBefore, updatedGrantor.SecurityStamp);
|
|
Assert.False(updatedGrantor.VerifyDevices);
|
|
Assert.NotEqual(grantorTwoFactorProvidersBefore, updatedGrantor.TwoFactorProviders);
|
|
Assert.Empty(updatedGrantor.GetTwoFactorProviders() ?? []);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies the boundary validator's agreement checks fire on
|
|
/// <c>POST /emergency-access/{id}/password</c>: a mismatched KDF or salt
|
|
/// between <c>AuthenticationData</c> and <c>UnlockData</c> is rejected with
|
|
/// 400. Complements the legacy-KDF success test by proving the agreement
|
|
/// invariant isn't passively letting everything through.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("kdf", "unlock", "AuthenticationData and UnlockData must have the same KDF configuration.")]
|
|
[InlineData("kdf", "auth", "AuthenticationData and UnlockData must have the same KDF configuration.")]
|
|
[InlineData("salt", "unlock", "Invalid master password salt.")]
|
|
[InlineData("salt", "auth", "Invalid master password salt.")]
|
|
public async Task PostPassword_V2_MismatchedKdfOrSalt_BadRequest(
|
|
string mismatchKind, string perturbedSide, string expectedError)
|
|
{
|
|
await _loginHelper.LoginAsync(_granteeEmail);
|
|
|
|
var (auth, unlock) = BuildMismatchedAuthAndUnlock(mismatchKind, perturbedSide);
|
|
var requestModel = new EmergencyAccessPasswordRequestModel
|
|
{
|
|
AuthenticationData = auth,
|
|
UnlockData = unlock,
|
|
};
|
|
|
|
using var message = new HttpRequestMessage(
|
|
HttpMethod.Post, $"/emergency-access/{_emergencyAccessId}/password");
|
|
message.Content = JsonContent.Create(requestModel);
|
|
var response = await _client.SendAsync(message);
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
var content = await response.Content.ReadAsStringAsync();
|
|
Assert.Contains(expectedError, content);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies the controller's domain precondition fires: a Password call
|
|
/// against an emergency access not in <c>RecoveryApproved</c> state must
|
|
/// be rejected with 400 before the master-password service runs.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(EmergencyAccessStatusType.Invited)]
|
|
[InlineData(EmergencyAccessStatusType.Accepted)]
|
|
[InlineData(EmergencyAccessStatusType.Confirmed)]
|
|
[InlineData(EmergencyAccessStatusType.RecoveryInitiated)]
|
|
public async Task PostPassword_V2_NotRecoveryApproved_BadRequest(
|
|
EmergencyAccessStatusType invalidStatus)
|
|
{
|
|
// Arrange: mutate the seeded record into a non-RecoveryApproved state.
|
|
// IsValidRequest accepts only RecoveryApproved; everything else throws
|
|
// "Emergency Access not valid." before the V2 handler runs — so a well-
|
|
// formed payload still 400s on the precondition rather than the
|
|
// validator.
|
|
var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(_emergencyAccessId);
|
|
Assert.NotNull(emergencyAccess);
|
|
emergencyAccess.Status = invalidStatus;
|
|
await _emergencyAccessRepository.ReplaceAsync(emergencyAccess);
|
|
|
|
await _loginHelper.LoginAsync(_granteeEmail);
|
|
|
|
var requestModel = new EmergencyAccessPasswordRequestModel
|
|
{
|
|
AuthenticationData = BuildAuthData(_defaultKdfRequest, _grantorEmail),
|
|
UnlockData = BuildUnlockData(_defaultKdfRequest, _grantorEmail),
|
|
};
|
|
|
|
using var message = new HttpRequestMessage(
|
|
HttpMethod.Post, $"/emergency-access/{_emergencyAccessId}/password");
|
|
message.Content = JsonContent.Create(requestModel);
|
|
var response = await _client.SendAsync(message);
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
var content = await response.Content.ReadAsStringAsync();
|
|
Assert.Contains("Emergency Access not valid.", content);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies the dual-presence validator fires when the request carries
|
|
/// neither the V2 (UnlockData/AuthenticationData) nor the V1 (legacy)
|
|
/// payload — the controller must reject empty bodies with 400 rather than
|
|
/// dispatch to either path.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task PostPassword_NoPayload_BadRequest()
|
|
{
|
|
await _loginHelper.LoginAsync(_granteeEmail);
|
|
|
|
var requestModel = new EmergencyAccessPasswordRequestModel();
|
|
|
|
using var message = new HttpRequestMessage(
|
|
HttpMethod.Post, $"/emergency-access/{_emergencyAccessId}/password");
|
|
message.Content = JsonContent.Create(requestModel);
|
|
var response = await _client.SendAsync(message);
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
var content = await response.Content.ReadAsStringAsync();
|
|
Assert.Contains(
|
|
"Must provide either new payloads (UnlockData/AuthenticationData) or legacy payloads",
|
|
content);
|
|
}
|
|
}
|