1
0
mirror of https://github.com/bitwarden/server synced 2025-12-06 00:03:34 +00:00

[PM-22736] Send password hasher (#6112)

* feat: 
  - Add SendPasswordHasher class and interface
  - DI for SendPasswordHasher to use Marker class allowing us to use custom options for the SendPasswordHasher without impacting other PasswordHashers.
* test: Unit tests for SendPasswordHasher implementation
* doc: docs for interface and comments

Co-authored-by: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com>
This commit is contained in:
Ike
2025-07-24 12:49:15 -04:00
committed by GitHub
parent 76d1a2e875
commit 05398ad8a4
7 changed files with 208 additions and 1 deletions

View File

@@ -0,0 +1,6 @@
namespace Bit.Core.Auth.UserFeatures.PasswordValidation;
public static class PasswordValidationConstants
{
public const int PasswordHasherKdfIterations = 100000;
}

View File

@@ -0,0 +1,21 @@
namespace Bit.Core.KeyManagement.Sends;
public interface ISendPasswordHasher
{
/// <summary>
/// Matches the send password hash against the user provided client password hash. The send password is server hashed and the client
/// password hash is hashed by the server for comparison <see cref="HashOfClientPasswordHash"/> in this method.
/// </summary>
/// <param name="sendPasswordHash">The send password that is hashed by the server.</param>
/// <param name="clientPasswordHash">The user provided password hash that has not yet been hashed by the server for comparison.</param>
/// <returns>true if hashes match false otherwise</returns>
/// <exception cref="InvalidOperationException">Thrown if the server password hash or client password hash is null or empty.</exception>
bool PasswordHashMatches(string sendPasswordHash, string clientPasswordHash);
/// <summary>
/// Accepts a client hashed send password and returns a server hashed password.
/// </summary>
/// <param name="clientHashedPassword"></param>
/// <returns>server hashed password</returns>
string HashOfClientPasswordHash(string clientHashedPassword);
}

View File

@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Identity;
namespace Bit.Core.KeyManagement.Sends;
internal class SendPasswordHasher(IPasswordHasher<SendPasswordHasherMarker> passwordHasher) : ISendPasswordHasher
{
private readonly IPasswordHasher<SendPasswordHasherMarker> _passwordHasher = passwordHasher;
/// <summary>
/// <inheritdoc cref="ISendPasswordHasher.PasswordHashMatches"/>
/// </summary>
public bool PasswordHashMatches(string sendPasswordHash, string inputPasswordHash)
{
if (string.IsNullOrWhiteSpace(sendPasswordHash) || string.IsNullOrWhiteSpace(inputPasswordHash))
{
return false;
}
var passwordResult = _passwordHasher.VerifyHashedPassword(SendPasswordHasherMarker.Instance, sendPasswordHash, inputPasswordHash);
/*
In our use-case we input a high-entropy, pre-hashed secret sent by the client. Thus, we don't really care
about if the hash needs to be rehashed. Sends also only live for 30 days max.
*/
return passwordResult is PasswordVerificationResult.Success or PasswordVerificationResult.SuccessRehashNeeded;
}
/// <summary>
/// <inheritdoc cref="ISendPasswordHasher.HashOfClientPasswordHash"/>
/// </summary>
public string HashOfClientPasswordHash(string clientHashedPassword)
{
return _passwordHasher.HashPassword(SendPasswordHasherMarker.Instance, clientHashedPassword);
}
}

View File

@@ -0,0 +1,10 @@
namespace Bit.Core.KeyManagement.Sends;
// This should not be used except for DI as open generic marker class for use with
// the SendPasswordHasher.
public class SendPasswordHasherMarker
{
// We know we will pass a single instance that isn't used to the PasswordHasher so we
// gain an efficiency benefit of not creating multiple marker classes.
public static readonly SendPasswordHasherMarker Instance = new();
}

View File

@@ -0,0 +1,31 @@
using Bit.Core.Auth.UserFeatures.PasswordValidation;
using Bit.Core.KeyManagement.Sends;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
public static class SendPasswordHasherServiceCollectionExtensions
{
public static void AddSendPasswordServices(this IServiceCollection services)
{
const string sendPasswordHasherMarkerName = "SendPasswordHasherMarker";
services.AddOptions<PasswordHasherOptions>(sendPasswordHasherMarkerName)
.Configure(options => options.IterationCount = PasswordValidationConstants.PasswordHasherKdfIterations);
services.TryAddScoped<IPasswordHasher<SendPasswordHasherMarker>>(sp =>
{
var opts = sp
.GetRequiredService<IOptionsMonitor<PasswordHasherOptions>>()
.Get(sendPasswordHasherMarkerName);
var optionsAccessor = Options.Create(opts);
return new PasswordHasher<SendPasswordHasherMarker>(optionsAccessor);
});
services.TryAddScoped<ISendPasswordHasher, SendPasswordHasher>();
}
}

View File

@@ -23,6 +23,7 @@ using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.Services;
using Bit.Core.Auth.Services.Implementations;
using Bit.Core.Auth.UserFeatures;
using Bit.Core.Auth.UserFeatures.PasswordValidation;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations;
using Bit.Core.Billing.TrialInitiation;
@@ -381,7 +382,7 @@ public static class ServiceCollectionExtensions
services.TryAddTransient(typeof(IOtpTokenProvider<>), typeof(OtpTokenProvider<>));
services.AddScoped<IOrganizationDuoUniversalTokenProvider, OrganizationDuoUniversalTokenProvider>();
services.Configure<PasswordHasherOptions>(options => options.IterationCount = 100000);
services.Configure<PasswordHasherOptions>(options => options.IterationCount = PasswordValidationConstants.PasswordHasherKdfIterations);
services.Configure<TwoFactorRememberTokenProviderOptions>(options =>
{
options.TokenLifespan = TimeSpan.FromDays(30);

View File

@@ -0,0 +1,103 @@
using Bit.Core.KeyManagement.Sends;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Identity;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.KeyManagement.Sends;
[SutProviderCustomize]
public class SendPasswordHasherTests
{
[Theory]
[BitAutoData(PasswordVerificationResult.Success)]
[BitAutoData(PasswordVerificationResult.SuccessRehashNeeded)]
void VerifyPasswordHash_WithValidMatching_ReturnsTrue(
PasswordVerificationResult passwordVerificationResult,
SutProvider<SendPasswordHasher> sutProvider,
string sendPasswordHash,
string inputPasswordHash)
{
// Arrange
sutProvider.GetDependency<IPasswordHasher<SendPasswordHasherMarker>>()
.VerifyHashedPassword(Arg.Any<SendPasswordHasherMarker>(), sendPasswordHash, inputPasswordHash)
.Returns(passwordVerificationResult);
// Act
var result = sutProvider.Sut.PasswordHashMatches(sendPasswordHash, inputPasswordHash);
// Assert
Assert.True(result);
sutProvider.GetDependency<IPasswordHasher<SendPasswordHasherMarker>>()
.Received(1)
.VerifyHashedPassword(Arg.Any<SendPasswordHasherMarker>(), sendPasswordHash, inputPasswordHash);
}
[Theory, BitAutoData]
void VerifyPasswordHash_WithNonMatchingPasswords_ReturnsFalse(
SutProvider<SendPasswordHasher> sutProvider,
string sendPasswordHash,
string inputPasswordHash)
{
// Arrange
sutProvider.GetDependency<IPasswordHasher<SendPasswordHasherMarker>>()
.VerifyHashedPassword(Arg.Any<SendPasswordHasherMarker>(), sendPasswordHash, inputPasswordHash)
.Returns(PasswordVerificationResult.Failed);
// Act
var result = sutProvider.Sut.PasswordHashMatches(sendPasswordHash, inputPasswordHash);
// Assert
Assert.False(result);
sutProvider.GetDependency<IPasswordHasher<SendPasswordHasherMarker>>()
.Received(1)
.VerifyHashedPassword(Arg.Any<SendPasswordHasherMarker>(), sendPasswordHash, inputPasswordHash);
}
[Theory]
[InlineData(null, "inputPassword")]
[InlineData("", "inputPassword")]
[InlineData(" ", "inputPassword")]
[InlineData("sendPassword", null)]
[InlineData("sendPassword", "")]
[InlineData("sendPassword", " ")]
[InlineData(null, null)]
[InlineData("", "")]
public void VerifyPasswordHash_WithNullOrEmptyParameters_ReturnsFalse(
string? sendPasswordHash,
string? inputPasswordHash)
{
// Arrange
var passwordHasher = Substitute.For<IPasswordHasher<SendPasswordHasherMarker>>();
var sut = new SendPasswordHasher(passwordHasher);
// Act
var result = sut.PasswordHashMatches(sendPasswordHash, inputPasswordHash);
// Assert
Assert.False(result);
passwordHasher.DidNotReceive().VerifyHashedPassword(Arg.Any<SendPasswordHasherMarker>(), Arg.Any<string>(), Arg.Any<string>());
}
[Theory, BitAutoData]
void HashPasswordHash_WithValidInput_ReturnsHashedPassword(
SutProvider<SendPasswordHasher> sutProvider,
string clientHashedPassword,
string expectedHashedResult)
{
// Arrange
sutProvider.GetDependency<IPasswordHasher<SendPasswordHasherMarker>>()
.HashPassword(Arg.Any<SendPasswordHasherMarker>(), clientHashedPassword)
.Returns(expectedHashedResult);
// Act
var result = sutProvider.Sut.HashOfClientPasswordHash(clientHashedPassword);
// Assert
Assert.Equal(expectedHashedResult, result);
sutProvider.GetDependency<IPasswordHasher<SendPasswordHasherMarker>>()
.Received(1)
.HashPassword(Arg.Any<SendPasswordHasherMarker>(), clientHashedPassword);
}
}