Files
server/test/Api.Test/Utilities/KdfSettingsValidatorTests.cs
Dave 59ded309df feat(kdf-settings-validator): Enforce salt cannot be empty string. (#7628)
* feat(kdf-settings-validator): Enforce salt cannot be empty string.

* fix(kdf-settings-validator): Prefer IsNullOrWhitespace.

* feat(salt): Make AllowEmptyStrings false for request models.
2026-05-20 17:53:48 -04:00

341 lines
14 KiB
C#

using Bit.Core.Enums;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Utilities;
using Xunit;
namespace Bit.Api.Test.Utilities;
public class KdfSettingsValidatorTests
{
[Theory]
[InlineData(KdfType.PBKDF2_SHA256, 1_000_000, null, null)] // Somewhere in the middle
[InlineData(KdfType.PBKDF2_SHA256, 600_000, null, null)] // Right on the lower boundary
[InlineData(KdfType.PBKDF2_SHA256, 2_000_000, null, null)] // Right on the upper boundary
[InlineData(KdfType.Argon2id, 5, 500, 8)] // Somewhere in the middle
[InlineData(KdfType.Argon2id, 2, 15, 1)] // Right on the lower boundary
[InlineData(KdfType.Argon2id, 10, 1024, 16)] // Right on the upper boundary
public void Validate_IsValid(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism)
{
var results = KdfSettingsValidator.Validate(kdfType, kdfIterations, kdfMemory, kdfParallelism);
Assert.Empty(results);
}
[Theory]
[InlineData(KdfType.PBKDF2_SHA256, 500_000, null, null, 1)] // Too few iterations
[InlineData(KdfType.PBKDF2_SHA256, 2_000_001, null, null, 1)] // Too many iterations
[InlineData(KdfType.Argon2id, 0, 30, 8, 1)] // Iterations must be greater than 0
[InlineData(KdfType.Argon2id, 10, 14, 8, 1)] // Too little memory
[InlineData(KdfType.Argon2id, 10, 14, 0, 1)] // Too small of a parallelism value
[InlineData(KdfType.Argon2id, 10, 1025, 8, 1)] // Too much memory
[InlineData(KdfType.Argon2id, 10, 512, 17, 1)] // Too big of a parallelism value
public void Validate_Fails(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism, int expectedFailures)
{
var results = KdfSettingsValidator.Validate(kdfType, kdfIterations, kdfMemory, kdfParallelism);
Assert.NotEmpty(results);
Assert.Equal(expectedFailures, results.Count());
}
[Fact]
public void ValidateAuthenticationAndUnlockData_WhenMatchingKdfAndSalt_ReturnsNoErrors()
{
var kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 };
var authentication = new MasterPasswordAuthenticationData
{
Kdf = kdf,
MasterPasswordAuthenticationHash = "hash",
Salt = "salt"
};
var unlock = new MasterPasswordUnlockData
{
Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 },
MasterKeyWrappedUserKey = "wrapped",
Salt = "salt"
};
var results = KdfSettingsValidator.ValidateAuthenticationAndUnlockData(authentication, unlock).ToList();
Assert.Empty(results);
}
[Fact]
public void ValidateAuthenticationAndUnlockData_WhenKdfMismatch_ReturnsKdfEqualityError()
{
var authentication = new MasterPasswordAuthenticationData
{
Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 },
MasterPasswordAuthenticationHash = "hash",
Salt = "salt"
};
var unlock = new MasterPasswordUnlockData
{
Kdf = new KdfSettings { KdfType = KdfType.Argon2id, Iterations = 3, Memory = 64, Parallelism = 4 },
MasterKeyWrappedUserKey = "wrapped",
Salt = "salt"
};
var results = KdfSettingsValidator.ValidateAuthenticationAndUnlockData(authentication, unlock).ToList();
Assert.Single(results);
Assert.Equal("AuthenticationData and UnlockData must have the same KDF configuration.", results[0].ErrorMessage);
}
[Fact]
public void ValidateAuthenticationAndUnlockData_WhenSaltMismatch_ReturnsSaltEqualityError()
{
var authentication = new MasterPasswordAuthenticationData
{
Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 },
MasterPasswordAuthenticationHash = "hash",
Salt = "salt-auth"
};
var unlock = new MasterPasswordUnlockData
{
Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 },
MasterKeyWrappedUserKey = "wrapped",
Salt = "salt-unlock"
};
var results = KdfSettingsValidator.ValidateAuthenticationAndUnlockData(authentication, unlock).ToList();
Assert.Single(results);
Assert.Equal("Invalid master password salt.", results[0].ErrorMessage);
}
[Fact]
public void ValidateAuthenticationAndUnlockData_WhenKdfInvalid_ReturnsKdfValidationError()
{
// Matching but out-of-range KDF (iterations below minimum for PBKDF2)
var authentication = new MasterPasswordAuthenticationData
{
Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 100 },
MasterPasswordAuthenticationHash = "hash",
Salt = "salt"
};
var unlock = new MasterPasswordUnlockData
{
Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 100 },
MasterKeyWrappedUserKey = "wrapped",
Salt = "salt"
};
var results = KdfSettingsValidator.ValidateAuthenticationAndUnlockData(authentication, unlock).ToList();
Assert.Single(results);
Assert.Contains("KDF iterations must be between", results[0].ErrorMessage);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t")]
[InlineData("\n")]
public void ValidateAuthenticationAndUnlockData_WhenAuthSaltIsEmptyOrWhitespace_ReturnsSaltError(string salt)
{
var authentication = new MasterPasswordAuthenticationData
{
Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 },
MasterPasswordAuthenticationHash = "hash",
Salt = salt
};
var unlock = new MasterPasswordUnlockData
{
Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 },
MasterKeyWrappedUserKey = "wrapped",
Salt = "salt"
};
var results = KdfSettingsValidator.ValidateAuthenticationAndUnlockData(authentication, unlock).ToList();
Assert.Single(results);
Assert.Equal("Master password salt must not be empty.", results[0].ErrorMessage);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t")]
[InlineData("\n")]
public void ValidateAuthenticationAndUnlockData_WhenUnlockSaltIsEmptyOrWhitespace_ReturnsSaltError(string salt)
{
var authentication = new MasterPasswordAuthenticationData
{
Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 },
MasterPasswordAuthenticationHash = "hash",
Salt = "salt"
};
var unlock = new MasterPasswordUnlockData
{
Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 },
MasterKeyWrappedUserKey = "wrapped",
Salt = salt
};
var results = KdfSettingsValidator.ValidateAuthenticationAndUnlockData(authentication, unlock).ToList();
Assert.Single(results);
Assert.Equal("Master password salt must not be empty.", results[0].ErrorMessage);
}
[Fact]
public void ValidateKdfAndSaltAgreement_WhenMatchingKdfAndSalt_ReturnsNoErrors()
{
var kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 };
var authentication = new MasterPasswordAuthenticationData
{
Kdf = kdf,
MasterPasswordAuthenticationHash = "hash",
Salt = "salt"
};
var unlock = new MasterPasswordUnlockData
{
Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 },
MasterKeyWrappedUserKey = "wrapped",
Salt = "salt"
};
var results = KdfSettingsValidator.ValidateKdfAndSaltAgreement(authentication, unlock).ToList();
Assert.Empty(results);
}
[Fact]
public void ValidateKdfAndSaltAgreement_WhenKdfMismatch_ReturnsKdfEqualityErrorAndShortCircuits()
{
var authentication = new MasterPasswordAuthenticationData
{
Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 },
MasterPasswordAuthenticationHash = "hash",
Salt = "salt-auth"
};
var unlock = new MasterPasswordUnlockData
{
Kdf = new KdfSettings { KdfType = KdfType.Argon2id, Iterations = 3, Memory = 64, Parallelism = 4 },
MasterKeyWrappedUserKey = "wrapped",
Salt = "salt-unlock"
};
var results = KdfSettingsValidator.ValidateKdfAndSaltAgreement(authentication, unlock).ToList();
// Asserts both that the KDF mismatch is reported AND that the salt-mismatch
// check does not also fire — KDF mismatch should short-circuit the validator,
// matching the behavior of ValidateAuthenticationAndUnlockData.
Assert.Single(results);
Assert.Equal("AuthenticationData and UnlockData must have the same KDF configuration.", results[0].ErrorMessage);
}
[Fact]
public void ValidateKdfAndSaltAgreement_WhenSaltMismatch_ReturnsSaltEqualityError()
{
var authentication = new MasterPasswordAuthenticationData
{
Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 },
MasterPasswordAuthenticationHash = "hash",
Salt = "salt-auth"
};
var unlock = new MasterPasswordUnlockData
{
Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 },
MasterKeyWrappedUserKey = "wrapped",
Salt = "salt-unlock"
};
var results = KdfSettingsValidator.ValidateKdfAndSaltAgreement(authentication, unlock).ToList();
Assert.Single(results);
Assert.Equal("Invalid master password salt.", results[0].ErrorMessage);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t")]
[InlineData("\n")]
public void ValidateKdfAndSaltAgreement_WhenAuthSaltIsEmptyOrWhitespace_ReturnsSaltError(string salt)
{
var authentication = new MasterPasswordAuthenticationData
{
Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 },
MasterPasswordAuthenticationHash = "hash",
Salt = salt
};
var unlock = new MasterPasswordUnlockData
{
Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 },
MasterKeyWrappedUserKey = "wrapped",
Salt = "salt"
};
var results = KdfSettingsValidator.ValidateKdfAndSaltAgreement(authentication, unlock).ToList();
Assert.Single(results);
Assert.Equal("Master password salt must not be empty.", results[0].ErrorMessage);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t")]
[InlineData("\n")]
public void ValidateKdfAndSaltAgreement_WhenUnlockSaltIsEmptyOrWhitespace_ReturnsSaltError(string salt)
{
var authentication = new MasterPasswordAuthenticationData
{
Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 },
MasterPasswordAuthenticationHash = "hash",
Salt = "salt"
};
var unlock = new MasterPasswordUnlockData
{
Kdf = new KdfSettings { KdfType = KdfType.PBKDF2_SHA256, Iterations = 600_000 },
MasterKeyWrappedUserKey = "wrapped",
Salt = salt
};
var results = KdfSettingsValidator.ValidateKdfAndSaltAgreement(authentication, unlock).ToList();
Assert.Single(results);
Assert.Equal("Master password salt must not be empty.", results[0].ErrorMessage);
}
[Theory]
[InlineData(KdfType.PBKDF2_SHA256, 1, null, null)] // far below minimum
[InlineData(KdfType.PBKDF2_SHA256, 100_000, null, null)] // sub-minimum but plausible legacy
[InlineData(KdfType.Argon2id, 1, 8, 1)] // sub-minimum Argon2id
public void ValidateKdfAndSaltAgreement_WhenKdfBelowMinimum_DoesNotEmitRangeError(
KdfType kdfType, int iterations, int? memory, int? parallelism)
{
// PM-27892 regression guard: this validator MUST NOT range-check the KDF — it
// exists specifically so legacy-KDF users (whose stored KDF predates current
// minimums) are not locked out of flows backed by UpdateExistingPasswordData
// (change password, replace temp password, emergency-access takeover for
// grantors with master password). Range enforcement at the boundary is the
// job of ValidateAuthenticationAndUnlockData and is reserved for KDF-being-
// chosen flows. If this test fails, the validator has been incorrectly
// expanded to perform range validation and legacy-KDF users will be silently
// rejected at the boundary.
var kdf = new KdfSettings
{
KdfType = kdfType,
Iterations = iterations,
Memory = memory,
Parallelism = parallelism
};
var authentication = new MasterPasswordAuthenticationData
{
Kdf = kdf,
MasterPasswordAuthenticationHash = "hash",
Salt = "salt"
};
var unlock = new MasterPasswordUnlockData
{
Kdf = kdf,
MasterKeyWrappedUserKey = "wrapped",
Salt = "salt"
};
var results = KdfSettingsValidator.ValidateKdfAndSaltAgreement(authentication, unlock).ToList();
Assert.Empty(results);
}
}