Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9da7f80
feat: add salt to GetDefaultKdf
ike-kottlowski Apr 13, 2026
220a37d
test: add test to check for salt property
ike-kottlowski Apr 13, 2026
aa571ea
Merge branch 'main' into auth/pm-31631/update-password-prelogin-salt-…
ike-kottlowski Apr 14, 2026
037e6d8
Merge branch 'main' into auth/pm-31631/update-password-prelogin-salt-…
ike-kottlowski Apr 14, 2026
f1feaf5
doc: add comments
ike-kottlowski Apr 14, 2026
9a7f56a
Merge branch 'main' into auth/pm-31631/update-password-prelogin-salt-…
ike-kottlowski Apr 14, 2026
8208824
Merge branch 'main' into auth/pm-31631/update-password-prelogin-salt-…
ike-kottlowski Apr 17, 2026
b2b5389
Merge branch 'main' into auth/pm-31631/update-password-prelogin-salt-…
ike-kottlowski Apr 18, 2026
786e1b3
Merge branch 'main' into auth/pm-31631/update-password-prelogin-salt-…
ike-kottlowski Apr 20, 2026
50a60e1
Merge branch 'main' into auth/pm-31631/update-password-prelogin-salt-…
ike-kottlowski Apr 23, 2026
4352146
fix: normalize email
ike-kottlowski Apr 24, 2026
0e37d64
test: add integration tests and enforce nullable
ike-kottlowski Apr 24, 2026
ab44bec
Merge branch 'main' into auth/pm-31631/update-password-prelogin-salt-…
ike-kottlowski Apr 27, 2026
a2e7e2a
Merge branch 'main' into auth/pm-31631/update-password-prelogin-salt-…
ike-kottlowski Apr 28, 2026
ee9b014
Merge branch 'main' into auth/pm-31631/update-password-prelogin-salt-…
ike-kottlowski Apr 29, 2026
49f2691
Merge branch 'auth/pm-31631/update-password-prelogin-salt-response' o…
ike-kottlowski Apr 29, 2026
285a350
doc: add startup warning for self hosted users
ike-kottlowski Apr 29, 2026
dfbc28a
fix: Use CoreHelpers to check for value
ike-kottlowski Apr 30, 2026
460cdb5
docs: add comment in accounts controller
ike-kottlowski May 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/Core/Utilities/EnumerationProtectionHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,22 @@ public static class EnumerationProtectionHelpers
/// <param name="inputString">The string to derive an index result</param>
/// <param name="range">The range of possible index values</param>
/// <returns>An int between 0 and range - 1</returns>
public static int GetIndexForInputHash(byte[] hmacKey, string inputString, int range)
public static int GetIndexForInputHash(byte[]? hmacKey, string inputString, int range)
{
if (hmacKey == null || range <= 0 || hmacKey.Length == 0)
{
return 0;
}
else
{
// Compute the HMAC hash of the salt
// Compute the HMAC hash of the input string
var hmacMessage = Encoding.UTF8.GetBytes(inputString.Trim().ToLowerInvariant());
using var hmac = new System.Security.Cryptography.HMACSHA256(hmacKey);
var hmacHash = hmac.ComputeHash(hmacMessage);
// Convert the hash to a number
var hashHex = BitConverter.ToString(hmacHash).Replace("-", string.Empty).ToLowerInvariant();
var hashFirst8Bytes = hashHex[..16];
var hashNumber = long.Parse(hashFirst8Bytes, System.Globalization.NumberStyles.HexNumber);
var hashNumber = long.Parse(hashFirst8Bytes, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture);
// Find the default KDF value for this hash number
var hashIndex = (int)(Math.Abs(hashNumber) % range);
return hashIndex;
Expand Down
49 changes: 18 additions & 31 deletions src/Identity/Controllers/AccountsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,11 @@
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.UserFeatures.Registration;
using Bit.Core.Auth.UserFeatures.WebAuthnLogin;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
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.Utilities;
Expand All @@ -28,14 +26,11 @@ namespace Bit.Identity.Controllers;
[ExceptionHandlerFilter]
public class AccountsController : Controller
{
private readonly ICurrentContext _currentContext;
private readonly ILogger<AccountsController> _logger;
private readonly IUserRepository _userRepository;
private readonly IRegisterUserCommand _registerUserCommand;
private readonly IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> _assertionOptionsDataProtector;
private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand;
private readonly ISendVerificationEmailForRegistrationCommand _sendVerificationEmailForRegistrationCommand;
private readonly IFeatureService _featureService;
private readonly IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> _registrationEmailVerificationTokenDataFactory;

private readonly byte[]? _defaultKdfHmacKey = null;
Expand Down Expand Up @@ -74,26 +69,20 @@ public class AccountsController : Controller
];

public AccountsController(
ICurrentContext currentContext,
ILogger<AccountsController> logger,
IUserRepository userRepository,
IRegisterUserCommand registerUserCommand,
IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> assertionOptionsDataProtector,
IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand,
ISendVerificationEmailForRegistrationCommand sendVerificationEmailForRegistrationCommand,
IFeatureService featureService,
IDataProtectorTokenFactory<RegistrationEmailVerificationTokenable> registrationEmailVerificationTokenDataFactory,
GlobalSettings globalSettings
)
{
_currentContext = currentContext;
_logger = logger;
_userRepository = userRepository;
_registerUserCommand = registerUserCommand;
_assertionOptionsDataProtector = assertionOptionsDataProtector;
_getWebAuthnLoginCredentialAssertionOptionsCommand = getWebAuthnLoginCredentialAssertionOptionsCommand;
_sendVerificationEmailForRegistrationCommand = sendVerificationEmailForRegistrationCommand;
_featureService = featureService;
_registrationEmailVerificationTokenDataFactory = registrationEmailVerificationTokenDataFactory;

if (CoreHelpers.SettingHasValue(globalSettings.KdfDefaultHashKey))
Expand Down Expand Up @@ -226,12 +215,8 @@ public async Task<PasswordPreloginResponseModel> PostPasswordPrelogin([FromBody]

private async Task<PasswordPreloginResponseModel> MakePasswordPreloginCall(PasswordPreloginRequestModel model)
{
var kdfInformation = await _userRepository.GetKdfInformationByEmailAsync(model.Email);
if (kdfInformation == null)
{
kdfInformation = GetDefaultKdf(model.Email);
}
return new PasswordPreloginResponseModel(kdfInformation, model.Email);
var kdfInformation = await _userRepository.GetKdfInformationByEmailAsync(model.Email) ?? GetDefaultKdf(model.Email);
return new PasswordPreloginResponseModel(kdfInformation, kdfInformation.MasterPasswordSalt);
}

[HttpGet("webauthn/assertion-options")]
Expand All @@ -251,21 +236,23 @@ public WebAuthnLoginAssertionOptionsResponseModel GetWebAuthnLoginAssertionOptio

private UserKdfInformation GetDefaultKdf(string email)
{
if (_defaultKdfHmacKey == null)
var normalizedEmail = email.Trim().ToLowerInvariant();
Comment thread
JaredSnider-Bitwarden marked this conversation as resolved.
var kdfIndex = EnumerationProtectionHelpers.GetIndexForInputHash(_defaultKdfHmacKey, normalizedEmail, _defaultKdfResults.Count);
// PM-31702: In the future we may need to generate a deterministic random salt, for the time being we will use email and null.
var saltOptions = new string?[] { normalizedEmail, null };
// we add the suffix ":salt" so the calculated index is independent of the kdfIndex calculation.
var saltIndex = EnumerationProtectionHelpers.GetIndexForInputHash(_defaultKdfHmacKey, normalizedEmail + ":salt", saltOptions.Length);

// deep copy to avoid thread issues with the static list
var result = new UserKdfInformation()
{
return _defaultKdfResults[0];
}
Kdf = _defaultKdfResults[kdfIndex].Kdf,
KdfIterations = _defaultKdfResults[kdfIndex].KdfIterations,
KdfMemory = _defaultKdfResults[kdfIndex].KdfMemory,
KdfParallelism = _defaultKdfResults[kdfIndex].KdfParallelism,
MasterPasswordSalt = saltOptions[saltIndex]
};

// 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];
return result;
}
}
6 changes: 6 additions & 0 deletions src/Identity/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,12 @@ public void Configure(
{
app.UsePathBase("/identity");
app.UseForwardedHeaders(globalSettings);

if (!CoreHelpers.SettingHasValue(globalSettings.KdfDefaultHashKey))
{
logger.LogWarning(
"globalSettings__kdfdefaulthashkey is not set. This value not being set degrades account enumeration protections. Set this value to a strong random secret (used as an HMAC key) in production environments.");
}
}

// Default Middleware
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
ο»Ώusing System.ComponentModel.DataAnnotations;
using System.Text;
using System.Text.Json;
using Bit.Core;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Api.Request.Accounts;
Expand All @@ -10,8 +11,10 @@
using Bit.Core.Repositories;
using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Bit.Identity.Models.Request.Accounts;
using Bit.IntegrationTestCommon.Factories;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
Expand Down Expand Up @@ -568,7 +571,7 @@ public async Task PostRegisterVerificationEmailClicked_Success(
Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
}

private async Task<User> CreateUserAsync(string email, string name, IdentityApplicationFactory factory = null)
private async Task<User> CreateUserAsync(string email, string name, IdentityApplicationFactory factory = null, string masterPasswordSalt = null)
{
var factoryToUse = factory ?? _factory;

Expand All @@ -581,11 +584,91 @@ private async Task<User> CreateUserAsync(string email, string name, IdentityAppl
Name = name,
SecurityStamp = Guid.NewGuid().ToString(),
ApiKey = "test_api_key",
MasterPasswordSalt = masterPasswordSalt,
};

await userRepository.CreateAsync(user);

return user;
}

[Theory, BitAutoData]
public async Task PostPrelogin_WhenUserExistsWithSalt_ReturnsStoredSalt([Required] string name)
{
var localFactory = new IdentityApplicationFactory();
var email = $"test+prelogin+{name}@email.com";
await CreateUserAsync(email, name, localFactory, masterPasswordSalt: email);

var context = await localFactory.PostPreloginAsync(new PasswordPreloginRequestModel { Email = email });

Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
using var body = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
Assert.Equal(email, body.RootElement.GetProperty("salt").GetString());
}

[Theory, BitAutoData]
public async Task PostPrelogin_WhenUserExistsWithNullSalt_ReturnsNullSalt([Required] string name)
{
var localFactory = new IdentityApplicationFactory();
var email = $"test+prelogin+{name}@email.com";
await CreateUserAsync(email, name, localFactory, masterPasswordSalt: null);

var context = await localFactory.PostPreloginAsync(new PasswordPreloginRequestModel { Email = email });

Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
using var body = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
Assert.Equal(JsonValueKind.Null, body.RootElement.GetProperty("salt").ValueKind);
}

[Theory, BitAutoData]
public async Task PostPrelogin_WhenUserDoesNotExistAndDefaultHashKeyConfigured_ReturnsDeterministicResult([Required] string name)
{
var localFactory = new IdentityApplicationFactory();
localFactory.UpdateConfiguration("globalSettings:kdfDefaultHashKey", "test-default-hash-key");
var email = $"nonexistent+prelogin+{name}@email.com";

var first = await localFactory.PostPreloginAsync(new PasswordPreloginRequestModel { Email = email });
var second = await localFactory.PostPreloginAsync(new PasswordPreloginRequestModel { Email = email });

Assert.Equal(StatusCodes.Status200OK, first.Response.StatusCode);
Assert.Equal(StatusCodes.Status200OK, second.Response.StatusCode);
using var firstBody = await AssertHelper.AssertResponseTypeIs<JsonDocument>(first);
using var secondBody = await AssertHelper.AssertResponseTypeIs<JsonDocument>(second);
Assert.Equal(firstBody.RootElement.GetProperty("salt").GetRawText(), secondBody.RootElement.GetProperty("salt").GetRawText());
Assert.Equal(firstBody.RootElement.GetProperty("kdf").GetRawText(), secondBody.RootElement.GetProperty("kdf").GetRawText());
Assert.Equal(firstBody.RootElement.GetProperty("kdfIterations").GetRawText(), secondBody.RootElement.GetProperty("kdfIterations").GetRawText());
}

[Theory, BitAutoData]
public async Task PostPrelogin_WhenUserDoesNotExistAndNoDefaultHashKey_ReturnsEmailAsSalt([Required] string name)
{
var localFactory = new IdentityApplicationFactory();
localFactory.UpdateConfiguration("globalSettings:kdfDefaultHashKey", null);
var email = $"nonexistent+prelogin+{name}@email.com";

var context = await localFactory.PostPreloginAsync(new PasswordPreloginRequestModel { Email = email });

Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
using var body = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
Assert.Equal(email, body.RootElement.GetProperty("salt").GetString());
}

[Theory, BitAutoData]
public async Task PostPrelogin_WhenUserDoesNotExist_ReturnsSaltIndependentOfInputCasing([Required] string name)
{
var localFactory = new IdentityApplicationFactory();
localFactory.UpdateConfiguration("globalSettings:kdfDefaultHashKey", "test-default-hash-key");
var lowercaseEmail = $"nonexistent+prelogin+{name}@email.com";
var mixedCaseEmail = lowercaseEmail.ToUpperInvariant();

var lowercase = await localFactory.PostPreloginAsync(new PasswordPreloginRequestModel { Email = lowercaseEmail });
var mixedCase = await localFactory.PostPreloginAsync(new PasswordPreloginRequestModel { Email = mixedCaseEmail });

Assert.Equal(StatusCodes.Status200OK, lowercase.Response.StatusCode);
Assert.Equal(StatusCodes.Status200OK, mixedCase.Response.StatusCode);
using var lowercaseBody = await AssertHelper.AssertResponseTypeIs<JsonDocument>(lowercase);
using var mixedCaseBody = await AssertHelper.AssertResponseTypeIs<JsonDocument>(mixedCase);
Assert.Equal(lowercaseBody.RootElement.GetProperty("salt").GetRawText(), mixedCaseBody.RootElement.GetProperty("salt").GetRawText());
}

}
Loading
Loading