mirror of
https://github.com/bitwarden/server.git
synced 2026-06-01 12:26:46 -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>
270 lines
8.8 KiB
C#
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"));
|
|
}
|
|
}
|