Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ public static void AddAdminConsoleAuthorizationHandlers(this IServiceCollection

services.TryAddEnumerable([
ServiceDescriptor.Scoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, CollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, GroupAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, OrganizationRequirementHandler>(),
]);
ServiceDescriptor.Scoped<IAuthorizationHandler, CollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, GroupAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, OrganizationRequirementHandler>(),
]);
}
}
4 changes: 4 additions & 0 deletions src/Core/Settings/GlobalSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@
public virtual int SendAccessTokenLifetimeInMinutes { get; set; } = 5;
public virtual bool EnableEmailVerification { get; set; }
public virtual string KdfDefaultHashKey { get; set; }
/// <summary>
/// This Hash Key is used to prevent enumeration attacks against the Send Access feature.
/// </summary>
public virtual string SendDefaultHashKey { get; set; }
public virtual string PricingUri { get; set; }

public string BuildExternalUri(string explicitValue, string name)
Expand All @@ -117,7 +121,7 @@
{
return null;
}
return string.Format("http://{0}:5000", name);

Check warning on line 124 in src/Core/Settings/GlobalSettings.cs

View workflow job for this annotation

GitHub Actions / Sonar / Quality scan

Using http protocol is insecure. Use https instead. (https://rules.sonarsource.com/csharp/RSPEC-5332)
}

public string BuildDirectory(string explicitValue, string appendedPath)
Expand Down
36 changes: 36 additions & 0 deletions src/Core/Utilities/EnumerationProtectionHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
๏ปฟusing System.Text;

namespace Bit.Core.Utilities;

public static class EnumerationProtectionHelpers
{
/// <summary>
/// Use this method to get a consistent int result based on the salt that is in the range.
/// The same salt will always return the same index result based on range input.
/// </summary>
/// <param name="hmacKey">Key used to derive the HMAC hash. Use a different key for each usage for optimal security</param>
/// <param name="salt">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</returns>
public static int GetIndexForSaltHash(byte[] hmacKey, string salt, int range)
{
if (hmacKey == null || range <= 0 || hmacKey.Length == 0)
{
return 0;
}
else
{
// Compute the HMAC hash of the salt
var hmacMessage = Encoding.UTF8.GetBytes(salt.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);
// Find the default KDF value for this hash number
var hashIndex = (int)(Math.Abs(hashNumber) % range);
return hashIndex;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
/// <summary>
/// The sendId is valid and the request is well formed. Not returned in any response.
/// </summary>
public const string ValidSendGuid = "valid_send_guid";
public const string ValidGuid = "valid_send_guid";
/// <summary>
/// The sendId is missing from the request.
/// </summary>
Expand Down Expand Up @@ -105,4 +105,14 @@
{
public const string Subject = "Your Bitwarden Send verification code is {0}";
}

/// <summary>
/// We use these static strings to help guide the enumeration protection logic.
/// </summary>
public static class EnumerationProtection
{
public const string Guid = "guid";
public const string Password = "password";
public const string Email = "email";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,19 @@ namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;

public class SendAccessGrantValidator(
ISendAuthenticationQuery _sendAuthenticationQuery,
ISendAuthenticationMethodValidator<NeverAuthenticate> _sendNeverAuthenticateValidator,
ISendAuthenticationMethodValidator<ResourcePassword> _sendPasswordRequestValidator,
ISendAuthenticationMethodValidator<EmailOtp> _sendEmailOtpRequestValidator,
IFeatureService _featureService)
: IExtensionGrantValidator
IFeatureService _featureService) : IExtensionGrantValidator
{
string IExtensionGrantValidator.GrantType => CustomGrantTypes.SendAccess;

private static readonly Dictionary<string, string>
_sendGrantValidatorErrorDescriptions = new()
private static readonly Dictionary<string, string> _sendGrantValidatorErrorDescriptions = new()
{
{ SendAccessConstants.GrantValidatorResults.SendIdRequired, $"{SendAccessConstants.TokenRequest.SendId} is required." },
{ SendAccessConstants.GrantValidatorResults.InvalidSendId, $"{SendAccessConstants.TokenRequest.SendId} is invalid." }
};


public async Task ValidateAsync(ExtensionGrantValidationContext context)
{
// Check the feature flag
Expand All @@ -38,7 +36,7 @@ public async Task ValidateAsync(ExtensionGrantValidationContext context)
}

var (sendIdGuid, result) = GetRequestSendId(context);
if (result != SendAccessConstants.GrantValidatorResults.ValidSendGuid)
if (result != SendAccessConstants.GrantValidatorResults.ValidGuid)
{
context.Result = BuildErrorResult(result);
return;
Expand All @@ -49,15 +47,10 @@ public async Task ValidateAsync(ExtensionGrantValidationContext context)

switch (method)
{
case NeverAuthenticate:
case NeverAuthenticate never:
// null send scenario.
// TODO PM-22675: Add send enumeration protection here (primarily benefits self hosted instances).
// We should only map to password or email + OTP protected.
// If user submits password guess for a falsely protected send, then we will return invalid password.
// If user submits email + OTP guess for a falsely protected send, then we will return email sent, do not actually send an email.
context.Result = BuildErrorResult(SendAccessConstants.GrantValidatorResults.InvalidSendId);
context.Result = await _sendNeverAuthenticateValidator.ValidateRequestAsync(context, never, sendIdGuid);
return;

case NotAuthenticated:
// automatically issue access token
context.Result = BuildBaseSuccessResult(sendIdGuid);
Expand Down Expand Up @@ -102,7 +95,7 @@ private static (Guid, string) GetRequestSendId(ExtensionGrantValidationContext c
{
return (Guid.Empty, SendAccessConstants.GrantValidatorResults.InvalidSendId);
}
return (sendGuid, SendAccessConstants.GrantValidatorResults.ValidSendGuid);
return (sendGuid, SendAccessConstants.GrantValidatorResults.ValidGuid);
}
catch
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
๏ปฟusing System.Text;
using Bit.Core.Settings;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Utilities;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Validation;

namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;

public class SendNeverAuthenticateRequestValidator(GlobalSettings globalSettings) : ISendAuthenticationMethodValidator<NeverAuthenticate>
{
private readonly string[] _errorOptions =
[
SendAccessConstants.EnumerationProtection.Guid,
SendAccessConstants.EnumerationProtection.Password,
SendAccessConstants.EnumerationProtection.Email
];

public Task<GrantValidationResult> ValidateRequestAsync(
ExtensionGrantValidationContext context,
NeverAuthenticate authMethod,
Guid sendId)
{
var neverAuthenticateError = GetErrorIndex(sendId, _errorOptions.Length);
var request = context.Request.Raw;
var errorType = neverAuthenticateError;

switch (neverAuthenticateError)
{
case SendAccessConstants.EnumerationProtection.Guid:
errorType = SendAccessConstants.GrantValidatorResults.InvalidSendId;
break;
case SendAccessConstants.EnumerationProtection.Email:
var hasEmail = request.Get(SendAccessConstants.TokenRequest.Email) is not null;
errorType = hasEmail ? SendAccessConstants.EmailOtpValidatorResults.EmailInvalid
: SendAccessConstants.EmailOtpValidatorResults.EmailRequired;
break;
case SendAccessConstants.EnumerationProtection.Password:
var hasPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword) is not null;
errorType = hasPassword ? SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch
: SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired;
break;
}

return Task.FromResult(BuildErrorResult(errorType));
}

private static GrantValidationResult BuildErrorResult(string errorType)
{
// Create error response with custom response data
var customResponse = new Dictionary<string, object>
{
{ SendAccessConstants.SendAccessError, errorType }
};

var requestError = errorType switch
{
SendAccessConstants.EnumerationProtection.Guid => TokenRequestErrors.InvalidGrant,
SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired => TokenRequestErrors.InvalidGrant,
SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch => TokenRequestErrors.InvalidRequest,
SendAccessConstants.EmailOtpValidatorResults.EmailInvalid => TokenRequestErrors.InvalidGrant,
SendAccessConstants.EmailOtpValidatorResults.EmailRequired => TokenRequestErrors.InvalidRequest,
_ => TokenRequestErrors.InvalidGrant
};

return new GrantValidationResult(requestError, errorType, customResponse);
}

private string GetErrorIndex(Guid sendId, int range)
{
var salt = sendId.ToString();
byte[] hmacKey = [];
if (CoreHelpers.SettingHasValue(globalSettings.SendDefaultHashKey))
{
hmacKey = Encoding.UTF8.GetBytes(globalSettings.SendDefaultHashKey);
}

var index = EnumerationProtectionHelpers.GetIndexForSaltHash(hmacKey, salt, range);
return _errorOptions[index];
}
}
1 change: 1 addition & 0 deletions src/Identity/Utilities/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public static IIdentityServerBuilder AddCustomIdentityServerServices(this IServi
services.AddTransient<ILoginApprovingClientTypes, LoginApprovingClientTypes>();
services.AddTransient<ISendAuthenticationMethodValidator<ResourcePassword>, SendPasswordRequestValidator>();
services.AddTransient<ISendAuthenticationMethodValidator<EmailOtp>, SendEmailOtpRequestValidator>();
services.AddTransient<ISendAuthenticationMethodValidator<NeverAuthenticate>, SendNeverAuthenticateRequestValidator>();

var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity);
var identityServerBuilder = services
Expand Down
Loading
Loading