diff --git a/src/Core/Models/Data/UserKdfInformation.cs b/src/Core/Models/Data/UserKdfInformation.cs index 14f525bb82..0e5696e581 100644 --- a/src/Core/Models/Data/UserKdfInformation.cs +++ b/src/Core/Models/Data/UserKdfInformation.cs @@ -4,8 +4,8 @@ namespace Bit.Core.Models.Data; public class UserKdfInformation { - public KdfType Kdf { get; set; } - public int KdfIterations { get; set; } + public required KdfType Kdf { get; set; } + public required int KdfIterations { get; set; } public int? KdfMemory { get; set; } public int? KdfParallelism { get; set; } } diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index cc146800af..108efe79ba 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -195,16 +195,35 @@ public class AccountsController : Controller throw new BadRequestException(ModelState); } - // Moved from API, If you modify this endpoint, please update API as well. Self hosted installs still use the API endpoints. [HttpPost("prelogin")] - public async Task PostPrelogin([FromBody] PreloginRequestModel model) + [Obsolete("Migrating to use a more descriptive endpoint that would support different types of prelogins. " + + "Use prelogin/password instead. This endpoint has no EOL at the time of writing.")] + public async Task PostPrelogin([FromBody] PasswordPreloginRequestModel model) + { + // Same as PostPasswordPrelogin to maintain compatibility. Do not make changes in this function body, + // only make changes in MakePasswordPreloginCall + return await MakePasswordPreloginCall(model); + } + + // There are two functions done this way because the open api docs that get generated in our build pipeline + // cannot handle two of the same post attributes on the same function call. That is why there is a + // PostPrelogin and the more appropriate PostPasswordPrelogin. + [HttpPost("prelogin/password")] + public async Task PostPasswordPrelogin([FromBody] PasswordPreloginRequestModel model) + { + // Same as PostPrelogin to maintain backwards compatibility. Do not make changes in this function body, + // only make changes in MakePasswordPreloginCall + return await MakePasswordPreloginCall(model); + } + + private async Task MakePasswordPreloginCall(PasswordPreloginRequestModel model) { var kdfInformation = await _userRepository.GetKdfInformationByEmailAsync(model.Email); if (kdfInformation == null) { kdfInformation = GetDefaultKdf(model.Email); } - return new PreloginResponseModel(kdfInformation); + return new PasswordPreloginResponseModel(kdfInformation, model.Email); } [HttpGet("webauthn/assertion-options")] @@ -228,19 +247,17 @@ public class AccountsController : Controller { return _defaultKdfResults[0]; } - else - { - // Compute the HMAC hash of the email - var hmacMessage = Encoding.UTF8.GetBytes(email.Trim().ToLowerInvariant()); - using var hmac = new System.Security.Cryptography.HMACSHA256(_defaultKdfHmacKey); - var hmacHash = hmac.ComputeHash(hmacMessage); - // Convert the hash to a number - var hashHex = BitConverter.ToString(hmacHash).Replace("-", string.Empty).ToLowerInvariant(); - var hashFirst8Bytes = hashHex.Substring(0, 16); - var hashNumber = long.Parse(hashFirst8Bytes, System.Globalization.NumberStyles.HexNumber); - // Find the default KDF value for this hash number - var hashIndex = (int)(Math.Abs(hashNumber) % _defaultKdfResults.Count); - return _defaultKdfResults[hashIndex]; - } + + // Compute the HMAC hash of the email + var hmacMessage = Encoding.UTF8.GetBytes(email.Trim().ToLowerInvariant()); + using var hmac = new System.Security.Cryptography.HMACSHA256(_defaultKdfHmacKey); + var hmacHash = hmac.ComputeHash(hmacMessage); + // Convert the hash to a number + var hashHex = BitConverter.ToString(hmacHash).Replace("-", string.Empty).ToLowerInvariant(); + var hashFirst8Bytes = hashHex.Substring(0, 16); + var hashNumber = long.Parse(hashFirst8Bytes, System.Globalization.NumberStyles.HexNumber); + // Find the default KDF value for this hash number + var hashIndex = (int)(Math.Abs(hashNumber) % _defaultKdfResults.Count); + return _defaultKdfResults[hashIndex]; } } diff --git a/src/Identity/Models/Request/Accounts/PreloginRequestModel.cs b/src/Identity/Models/Request/Accounts/PasswordPreloginRequestModel.cs similarity index 87% rename from src/Identity/Models/Request/Accounts/PreloginRequestModel.cs rename to src/Identity/Models/Request/Accounts/PasswordPreloginRequestModel.cs index a7dba7ce1d..b5cf5699c4 100644 --- a/src/Identity/Models/Request/Accounts/PreloginRequestModel.cs +++ b/src/Identity/Models/Request/Accounts/PasswordPreloginRequestModel.cs @@ -5,7 +5,7 @@ using System.ComponentModel.DataAnnotations; namespace Bit.Identity.Models.Request.Accounts; -public class PreloginRequestModel +public class PasswordPreloginRequestModel { [Required] [EmailAddress] diff --git a/src/Identity/Models/Response/Accounts/PasswordPreloginResponseModel.cs b/src/Identity/Models/Response/Accounts/PasswordPreloginResponseModel.cs new file mode 100644 index 0000000000..747b268088 --- /dev/null +++ b/src/Identity/Models/Response/Accounts/PasswordPreloginResponseModel.cs @@ -0,0 +1,38 @@ +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Models.Data; + +namespace Bit.Identity.Models.Response.Accounts; + +public class PasswordPreloginResponseModel +{ + public PasswordPreloginResponseModel(UserKdfInformation kdfInformation, string? salt = null) + { + // PM-28143 Cleanup + Kdf = kdfInformation.Kdf; + KdfIterations = kdfInformation.KdfIterations; + KdfMemory = kdfInformation.KdfMemory; + KdfParallelism = kdfInformation.KdfParallelism; + // End Cleanup + + KdfSettings = new KdfSettings() + { + KdfType = kdfInformation.Kdf, + Iterations = kdfInformation.KdfIterations, + Memory = kdfInformation.KdfMemory, + Parallelism = kdfInformation.KdfParallelism, + }; + Salt = salt; + } + + // Old Data Types + public KdfType? Kdf { get; set; } // PM-28143 Remove with cleanup + public int? KdfIterations { get; set; } // PM-28143 Remove with cleanup + public int? KdfMemory { get; set; } // PM-28143 Remove with cleanup + public int? KdfParallelism { get; set; } // PM-28143 Remove with cleanup + + // New Data Types + public KdfSettings? KdfSettings { get; set; } // PM-28143 With cleanup make this not nullish + public string? Salt { get; set; } // PM-28143 With cleanup make this not nullish. Not used yet, + // just the email from the request at this time. +} diff --git a/src/Identity/Models/Response/Accounts/PreloginResponseModel.cs b/src/Identity/Models/Response/Accounts/PreloginResponseModel.cs deleted file mode 100644 index 129aa3e7a9..0000000000 --- a/src/Identity/Models/Response/Accounts/PreloginResponseModel.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Bit.Core.Enums; -using Bit.Core.Models.Data; - -namespace Bit.Identity.Models.Response.Accounts; - -public class PreloginResponseModel -{ - public PreloginResponseModel(UserKdfInformation kdfInformation) - { - Kdf = kdfInformation.Kdf; - KdfIterations = kdfInformation.KdfIterations; - KdfMemory = kdfInformation.KdfMemory; - KdfParallelism = kdfInformation.KdfParallelism; - } - - public KdfType Kdf { get; set; } - public int KdfIterations { get; set; } - public int? KdfMemory { get; set; } - public int? KdfParallelism { get; set; } -} diff --git a/test/Identity.Test/Controllers/AccountsControllerTests.cs b/test/Identity.Test/Controllers/AccountsControllerTests.cs index bff33cc679..d089c8ec57 100644 --- a/test/Identity.Test/Controllers/AccountsControllerTests.cs +++ b/test/Identity.Test/Controllers/AccountsControllerTests.cs @@ -75,7 +75,7 @@ public class AccountsControllerTests : IDisposable } [Fact] - public async Task PostPrelogin_WhenUserExists_ShouldReturnUserKdfInfo() + public async Task PostPasswordPrelogin_WhenUserExists_ShouldReturnUserKdfInfo() { var userKdfInfo = new UserKdfInformation { @@ -84,30 +84,113 @@ public class AccountsControllerTests : IDisposable }; _userRepository.GetKdfInformationByEmailAsync(Arg.Any()).Returns(userKdfInfo); - var response = await _sut.PostPrelogin(new PreloginRequestModel { Email = "user@example.com" }); + var response = await _sut.PostPasswordPrelogin(new PasswordPreloginRequestModel { Email = "user@example.com" }); Assert.Equal(userKdfInfo.Kdf, response.Kdf); Assert.Equal(userKdfInfo.KdfIterations, response.KdfIterations); } [Fact] - public async Task PostPrelogin_WhenUserDoesNotExistAndNoDefaultKdfHmacKeySet_ShouldDefaultToPBKDF() + public async Task PostPrelogin_And_PostPasswordPrelogin_ShouldUseSamePreloginLogic() + { + // Arrange: No user exists and no default HMAC key to force default path + var email = "same-user@example.com"; + SetDefaultKdfHmacKey(null); + _userRepository.GetKdfInformationByEmailAsync(Arg.Any()).Returns(Task.FromResult(null)); + + // Act + var legacyResponse = await _sut.PostPrelogin(new PasswordPreloginRequestModel { Email = email }); + var newResponse = await _sut.PostPasswordPrelogin(new PasswordPreloginRequestModel { Email = email }); + + // Assert: Both endpoints yield identical results, implying shared logic path + Assert.Equal(legacyResponse.Kdf, newResponse.Kdf); + Assert.Equal(legacyResponse.KdfIterations, newResponse.KdfIterations); + Assert.Equal(legacyResponse.KdfMemory, newResponse.KdfMemory); + Assert.Equal(legacyResponse.KdfParallelism, newResponse.KdfParallelism); + Assert.Equal(legacyResponse.Salt, newResponse.Salt); + Assert.NotNull(legacyResponse.KdfSettings); + Assert.NotNull(newResponse.KdfSettings); + Assert.Equal(legacyResponse.KdfSettings!.KdfType, newResponse.KdfSettings!.KdfType); + Assert.Equal(legacyResponse.KdfSettings!.Iterations, newResponse.KdfSettings!.Iterations); + Assert.Equal(legacyResponse.KdfSettings!.Memory, newResponse.KdfSettings!.Memory); + Assert.Equal(legacyResponse.KdfSettings!.Parallelism, newResponse.KdfSettings!.Parallelism); + + // Both methods should consult the repository once each with the same email + await _userRepository.Received(2).GetKdfInformationByEmailAsync(Arg.Is(e => e == email)); + } + + [Fact] + public async Task PostPasswordPrelogin_WhenUserExists_ReturnsNewFieldsAlignedWithLegacy_Argon2() + { + var email = "user@example.com"; + var userKdfInfo = new UserKdfInformation + { + Kdf = KdfType.Argon2id, + KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default, + KdfMemory = AuthConstants.ARGON2_MEMORY.Default, + KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default + }; + _userRepository.GetKdfInformationByEmailAsync(Arg.Any()).Returns(userKdfInfo); + + var response = await _sut.PostPasswordPrelogin(new PasswordPreloginRequestModel { Email = email }); + + // New fields exist and match repository values + Assert.NotNull(response.KdfSettings); + Assert.Equal(userKdfInfo.Kdf, response.KdfSettings!.KdfType); + Assert.Equal(userKdfInfo.KdfIterations, response.KdfSettings!.Iterations); + Assert.Equal(userKdfInfo.KdfMemory, response.KdfSettings!.Memory); + Assert.Equal(userKdfInfo.KdfParallelism, response.KdfSettings!.Parallelism); + + // New and legacy fields are aligned during migration + Assert.Equal(response.Kdf, response.KdfSettings!.KdfType); + Assert.Equal(response.KdfIterations, response.KdfSettings!.Iterations); + Assert.Equal(response.KdfMemory, response.KdfSettings!.Memory); + Assert.Equal(response.KdfParallelism, response.KdfSettings!.Parallelism); + + // Salt is set to the input email during migration + Assert.Equal(email, response.Salt); + } + + [Fact] + public async Task PostPasswordPrelogin_WhenUserDoesNotExistAndNoDefaultKdfHmacKeySet_ShouldDefaultToPBKDF() { SetDefaultKdfHmacKey(null); _userRepository.GetKdfInformationByEmailAsync(Arg.Any()).Returns(Task.FromResult(null)); - var response = await _sut.PostPrelogin(new PreloginRequestModel { Email = "user@example.com" }); + var response = await _sut.PostPasswordPrelogin(new PasswordPreloginRequestModel { Email = "user@example.com" }); Assert.Equal(KdfType.PBKDF2_SHA256, response.Kdf); Assert.Equal(AuthConstants.PBKDF2_ITERATIONS.Default, response.KdfIterations); } + [Fact] + public async Task PostPasswordPrelogin_NoUser_NoDefaultHmacKey_ReturnsAlignedNewFieldsAndSalt() + { + var email = "user@example.com"; + SetDefaultKdfHmacKey(null); + _userRepository.GetKdfInformationByEmailAsync(Arg.Any()).Returns(Task.FromResult(null)); + + var response = await _sut.PostPasswordPrelogin(new PasswordPreloginRequestModel { Email = email }); + + // New fields exist + Assert.NotNull(response.KdfSettings); + + // New and legacy fields are aligned during migration + Assert.Equal(response.Kdf, response.KdfSettings!.KdfType); + Assert.Equal(response.KdfIterations, response.KdfSettings!.Iterations); + Assert.Equal(response.KdfMemory, response.KdfSettings!.Memory); + Assert.Equal(response.KdfParallelism, response.KdfSettings!.Parallelism); + + // Salt is set to the input email during migration + Assert.Equal(email, response.Salt); + } + [Theory] [BitAutoData] - public async Task PostPrelogin_WhenUserDoesNotExistAndDefaultKdfHmacKeyIsSet_ShouldComputeHmacAndReturnExpectedKdf(string email) + public async Task PostPasswordPrelogin_WhenUserDoesNotExistAndDefaultKdfHmacKeyIsSet_ShouldComputeHmacAndReturnExpectedKdf(string email) { // Arrange: - var defaultKey = Encoding.UTF8.GetBytes("my-secret-key"); + var defaultKey = "my-secret-key"u8.ToArray(); SetDefaultKdfHmacKey(defaultKey); _userRepository.GetKdfInformationByEmailAsync(Arg.Any()).Returns(Task.FromResult(null)); @@ -122,7 +205,7 @@ public class AccountsControllerTests : IDisposable var expectedKdf = defaultKdfResults[expectedIndex]; // Act - var response = await _sut.PostPrelogin(new PreloginRequestModel { Email = email }); + var response = await _sut.PostPasswordPrelogin(new PasswordPreloginRequestModel { Email = email }); // Assert: Ensure the returned KDF matches the expected one from the computed hash Assert.Equal(expectedKdf.Kdf, response.Kdf); @@ -132,6 +215,16 @@ public class AccountsControllerTests : IDisposable Assert.Equal(expectedKdf.KdfMemory, response.KdfMemory); Assert.Equal(expectedKdf.KdfParallelism, response.KdfParallelism); } + + // New and legacy fields are aligned during migration + Assert.NotNull(response.KdfSettings); + Assert.Equal(response.Kdf, response.KdfSettings!.KdfType); + Assert.Equal(response.KdfIterations, response.KdfSettings!.Iterations); + Assert.Equal(response.KdfMemory, response.KdfSettings!.Memory); + Assert.Equal(response.KdfParallelism, response.KdfSettings!.Parallelism); + + // Salt is set to the input email during migration + Assert.Equal(email, response.Salt); } [Theory]