diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 420151a34f..97d66aed53 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -82,6 +82,7 @@ public class GlobalSettings : IGlobalSettings public virtual ILaunchDarklySettings LaunchDarkly { get; set; } = new LaunchDarklySettings(); public virtual string DevelopmentDirectory { get; set; } public virtual bool EnableEmailVerification { get; set; } + public virtual string KdfDefaultHashKey { get; set; } public virtual string PricingUri { get; set; } public string BuildExternalUri(string explicitValue, string name) diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index 40c926bda0..c1ecff9620 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text; using Bit.Core; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Api.Request.Accounts; @@ -15,6 +16,7 @@ using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Settings; using Bit.Core.Tokens; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; @@ -44,6 +46,41 @@ public class AccountsController : Controller private readonly IFeatureService _featureService; private readonly IDataProtectorTokenFactory _registrationEmailVerificationTokenDataFactory; + private readonly byte[] _defaultKdfHmacKey = null; + private static readonly List _defaultKdfResults = + [ + // The first result (index 0) should always return the "normal" default. + new() + { + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + }, + // We want more weight for this default, so add it again + new() + { + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, + }, + // Add some other possible defaults... + new() + { + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 100_000, + }, + new() + { + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 5_000, + }, + new() + { + Kdf = KdfType.Argon2id, + KdfIterations = AuthConstants.ARGON2_ITERATIONS.Default, + KdfMemory = AuthConstants.ARGON2_MEMORY.Default, + KdfParallelism = AuthConstants.ARGON2_PARALLELISM.Default, + } + ]; + public AccountsController( ICurrentContext currentContext, ILogger logger, @@ -55,7 +92,8 @@ public class AccountsController : Controller ISendVerificationEmailForRegistrationCommand sendVerificationEmailForRegistrationCommand, IReferenceEventService referenceEventService, IFeatureService featureService, - IDataProtectorTokenFactory registrationEmailVerificationTokenDataFactory + IDataProtectorTokenFactory registrationEmailVerificationTokenDataFactory, + GlobalSettings globalSettings ) { _currentContext = currentContext; @@ -69,6 +107,11 @@ public class AccountsController : Controller _referenceEventService = referenceEventService; _featureService = featureService; _registrationEmailVerificationTokenDataFactory = registrationEmailVerificationTokenDataFactory; + + if (CoreHelpers.SettingHasValue(globalSettings.KdfDefaultHashKey)) + { + _defaultKdfHmacKey = Encoding.UTF8.GetBytes(globalSettings.KdfDefaultHashKey); + } } [HttpPost("register")] @@ -217,11 +260,7 @@ public class AccountsController : Controller var kdfInformation = await _userRepository.GetKdfInformationByEmailAsync(model.Email); if (kdfInformation == null) { - kdfInformation = new UserKdfInformation - { - Kdf = KdfType.PBKDF2_SHA256, - KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default, - }; + kdfInformation = GetDefaultKdf(model.Email); } return new PreloginResponseModel(kdfInformation); } @@ -240,4 +279,26 @@ public class AccountsController : Controller Token = token }; } + + private UserKdfInformation GetDefaultKdf(string email) + { + if (_defaultKdfHmacKey == null) + { + 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]; + } + } } diff --git a/test/Identity.Test/Controllers/AccountsControllerTests.cs b/test/Identity.Test/Controllers/AccountsControllerTests.cs index 8acebbabe0..03db0a5904 100644 --- a/test/Identity.Test/Controllers/AccountsControllerTests.cs +++ b/test/Identity.Test/Controllers/AccountsControllerTests.cs @@ -1,4 +1,6 @@ -using Bit.Core; +using System.Reflection; +using System.Text; +using Bit.Core; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Services; @@ -11,6 +13,7 @@ using Bit.Core.Exceptions; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Settings; using Bit.Core.Tokens; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Business; @@ -42,6 +45,7 @@ public class AccountsControllerTests : IDisposable private readonly IReferenceEventService _referenceEventService; private readonly IFeatureService _featureService; private readonly IDataProtectorTokenFactory _registrationEmailVerificationTokenDataFactory; + private readonly GlobalSettings _globalSettings; public AccountsControllerTests() @@ -57,6 +61,7 @@ public class AccountsControllerTests : IDisposable _referenceEventService = Substitute.For(); _featureService = Substitute.For(); _registrationEmailVerificationTokenDataFactory = Substitute.For>(); + _globalSettings = Substitute.For(); _sut = new AccountsController( _currentContext, @@ -69,7 +74,8 @@ public class AccountsControllerTests : IDisposable _sendVerificationEmailForRegistrationCommand, _referenceEventService, _featureService, - _registrationEmailVerificationTokenDataFactory + _registrationEmailVerificationTokenDataFactory, + _globalSettings ); } @@ -95,8 +101,9 @@ public class AccountsControllerTests : IDisposable } [Fact] - public async Task PostPrelogin_WhenUserDoesNotExist_ShouldDefaultToPBKDF() + public async Task PostPrelogin_WhenUserDoesNotExistAndNoDefaultKdfHmacKeySet_ShouldDefaultToPBKDF() { + SetDefaultKdfHmacKey(null); _userRepository.GetKdfInformationByEmailAsync(Arg.Any()).Returns(Task.FromResult(null)); var response = await _sut.PostPrelogin(new PreloginRequestModel { Email = "user@example.com" }); @@ -105,6 +112,38 @@ public class AccountsControllerTests : IDisposable Assert.Equal(AuthConstants.PBKDF2_ITERATIONS.Default, response.KdfIterations); } + [Theory] + [BitAutoData] + public async Task PostPrelogin_WhenUserDoesNotExistAndDefaultKdfHmacKeyIsSet_ShouldComputeHmacAndReturnExpectedKdf(string email) + { + // Arrange: + var defaultKey = Encoding.UTF8.GetBytes("my-secret-key"); + SetDefaultKdfHmacKey(defaultKey); + + _userRepository.GetKdfInformationByEmailAsync(Arg.Any()).Returns(Task.FromResult(null)); + + var fieldInfo = typeof(AccountsController).GetField("_defaultKdfResults", BindingFlags.NonPublic | BindingFlags.Static); + if (fieldInfo == null) + throw new InvalidOperationException("Field '_defaultKdfResults' not found."); + + var defaultKdfResults = (List)fieldInfo.GetValue(null)!; + + var expectedIndex = GetExpectedKdfIndex(email, defaultKey, defaultKdfResults); + var expectedKdf = defaultKdfResults[expectedIndex]; + + // Act + var response = await _sut.PostPrelogin(new PreloginRequestModel { Email = email }); + + // Assert: Ensure the returned KDF matches the expected one from the computed hash + Assert.Equal(expectedKdf.Kdf, response.Kdf); + Assert.Equal(expectedKdf.KdfIterations, response.KdfIterations); + if (expectedKdf.Kdf == KdfType.Argon2id) + { + Assert.Equal(expectedKdf.KdfMemory, response.KdfMemory); + Assert.Equal(expectedKdf.KdfParallelism, response.KdfParallelism); + } + } + [Fact] public async Task PostRegister_ShouldRegisterUser() { @@ -484,6 +523,28 @@ public class AccountsControllerTests : IDisposable )); } + private void SetDefaultKdfHmacKey(byte[]? newKey) + { + var fieldInfo = typeof(AccountsController).GetField("_defaultKdfHmacKey", BindingFlags.NonPublic | BindingFlags.Instance); + if (fieldInfo == null) + { + throw new InvalidOperationException("Field '_defaultKdfHmacKey' not found."); + } + fieldInfo.SetValue(_sut, newKey); + } + private int GetExpectedKdfIndex(string email, byte[] defaultKey, List defaultKdfResults) + { + // Compute the HMAC hash of the email + var hmacMessage = Encoding.UTF8.GetBytes(email.Trim().ToLowerInvariant()); + using var hmac = new System.Security.Cryptography.HMACSHA256(defaultKey); + var hmacHash = hmac.ComputeHash(hmacMessage); + + // Convert the hash to a number and calculate the index + var hashHex = BitConverter.ToString(hmacHash).Replace("-", string.Empty).ToLowerInvariant(); + var hashFirst8Bytes = hashHex.Substring(0, 16); + var hashNumber = long.Parse(hashFirst8Bytes, System.Globalization.NumberStyles.HexNumber); + return (int)(Math.Abs(hashNumber) % defaultKdfResults.Count); + } }