Skip to content

Commit 2778b0c

Browse files
committed
[PM-22696] send enumeration protection (bitwarden#6352)
* feat: add static enumeration helper class * test: add enumeration helper class unit tests * feat: implement NeverAuthenticateValidator * test: unit and integration tests SendNeverAuthenticateValidator * test: use static class for common integration test setup for Send Access unit and integration tests * test: update tests to use static helper
1 parent b1eef70 commit 2778b0c

19 files changed

+996
-294
lines changed

src/Core/Settings/GlobalSettings.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ public virtual string LicenseDirectory
9292

9393
public virtual bool EnableEmailVerification { get; set; }
9494
public virtual string KdfDefaultHashKey { get; set; }
95+
/// <summary>
96+
/// This Hash Key is used to prevent enumeration attacks against the Send Access feature.
97+
/// </summary>
98+
public virtual string SendDefaultHashKey { get; set; }
9599
public virtual string PricingUri { get; set; }
96100

97101
public string BuildExternalUri(string explicitValue, string name)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using System.Text;
2+
3+
namespace Bit.Core.Utilities;
4+
5+
public static class EnumerationProtectionHelpers
6+
{
7+
/// <summary>
8+
/// Use this method to get a consistent int result based on the inputString that is in the range.
9+
/// The same inputString will always return the same index result based on range input.
10+
/// </summary>
11+
/// <param name="hmacKey">Key used to derive the HMAC hash. Use a different key for each usage for optimal security</param>
12+
/// <param name="inputString">The string to derive an index result</param>
13+
/// <param name="range">The range of possible index values</param>
14+
/// <returns>An int between 0 and range - 1</returns>
15+
public static int GetIndexForInputHash(byte[] hmacKey, string inputString, int range)
16+
{
17+
if (hmacKey == null || range <= 0 || hmacKey.Length == 0)
18+
{
19+
return 0;
20+
}
21+
else
22+
{
23+
// Compute the HMAC hash of the salt
24+
var hmacMessage = Encoding.UTF8.GetBytes(inputString.Trim().ToLowerInvariant());
25+
using var hmac = new System.Security.Cryptography.HMACSHA256(hmacKey);
26+
var hmacHash = hmac.ComputeHash(hmacMessage);
27+
// Convert the hash to a number
28+
var hashHex = BitConverter.ToString(hmacHash).Replace("-", string.Empty).ToLowerInvariant();
29+
var hashFirst8Bytes = hashHex[..16];
30+
var hashNumber = long.Parse(hashFirst8Bytes, System.Globalization.NumberStyles.HexNumber);
31+
// Find the default KDF value for this hash number
32+
var hashIndex = (int)(Math.Abs(hashNumber) % range);
33+
return hashIndex;
34+
}
35+
}
36+
}

src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,30 +34,30 @@ public static class TokenRequest
3434
public const string Otp = "otp";
3535
}
3636

37-
public static class GrantValidatorResults
37+
public static class SendIdGuidValidatorResults
3838
{
3939
/// <summary>
40-
/// The sendId is valid and the request is well formed. Not returned in any response.
40+
/// The <see cref="TokenRequest.SendId"/> in the request is a valid GUID and the request is well formed. Not returned in any response.
4141
/// </summary>
4242
public const string ValidSendGuid = "valid_send_guid";
4343
/// <summary>
44-
/// The sendId is missing from the request.
44+
/// The <see cref="TokenRequest.SendId"/> is missing from the request.
4545
/// </summary>
4646
public const string SendIdRequired = "send_id_required";
4747
/// <summary>
48-
/// The sendId is invalid, does not match a known send.
48+
/// The <see cref="TokenRequest.SendId"/> is invalid, does not match a known send.
4949
/// </summary>
5050
public const string InvalidSendId = "send_id_invalid";
5151
}
5252

5353
public static class PasswordValidatorResults
5454
{
5555
/// <summary>
56-
/// The passwordHashB64 does not match the send's password hash.
56+
/// The <see cref="TokenRequest.ClientB64HashedPassword"/> does not match the send's password hash.
5757
/// </summary>
5858
public const string RequestPasswordDoesNotMatch = "password_hash_b64_invalid";
5959
/// <summary>
60-
/// The passwordHashB64 is missing from the request.
60+
/// The <see cref="TokenRequest.ClientB64HashedPassword"/> is missing from the request.
6161
/// </summary>
6262
public const string RequestPasswordIsRequired = "password_hash_b64_required";
6363
}
@@ -105,4 +105,14 @@ public static class OtpEmail
105105
{
106106
public const string Subject = "Your Deepsafer Send verification code is {0}";
107107
}
108+
109+
/// <summary>
110+
/// We use these static strings to help guide the enumeration protection logic.
111+
/// </summary>
112+
public static class EnumerationProtection
113+
{
114+
public const string Guid = "guid";
115+
public const string Password = "password";
116+
public const string Email = "email";
117+
}
108118
}

src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,19 @@ namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
1313

1414
public class SendAccessGrantValidator(
1515
ISendAuthenticationQuery _sendAuthenticationQuery,
16+
ISendAuthenticationMethodValidator<NeverAuthenticate> _sendNeverAuthenticateValidator,
1617
ISendAuthenticationMethodValidator<ResourcePassword> _sendPasswordRequestValidator,
1718
ISendAuthenticationMethodValidator<EmailOtp> _sendEmailOtpRequestValidator,
18-
IFeatureService _featureService)
19-
: IExtensionGrantValidator
19+
IFeatureService _featureService) : IExtensionGrantValidator
2020
{
2121
string IExtensionGrantValidator.GrantType => CustomGrantTypes.SendAccess;
2222

23-
private static readonly Dictionary<string, string>
24-
_sendGrantValidatorErrorDescriptions = new()
23+
private static readonly Dictionary<string, string> _sendGrantValidatorErrorDescriptions = new()
2524
{
26-
{ SendAccessConstants.GrantValidatorResults.SendIdRequired, $"{SendAccessConstants.TokenRequest.SendId} is required." },
27-
{ SendAccessConstants.GrantValidatorResults.InvalidSendId, $"{SendAccessConstants.TokenRequest.SendId} is invalid." }
25+
{ SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired, $"{SendAccessConstants.TokenRequest.SendId} is required." },
26+
{ SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId, $"{SendAccessConstants.TokenRequest.SendId} is invalid." }
2827
};
2928

30-
3129
public async Task ValidateAsync(ExtensionGrantValidationContext context)
3230
{
3331
// Check the feature flag
@@ -38,7 +36,7 @@ public async Task ValidateAsync(ExtensionGrantValidationContext context)
3836
}
3937

4038
var (sendIdGuid, result) = GetRequestSendId(context);
41-
if (result != SendAccessConstants.GrantValidatorResults.ValidSendGuid)
39+
if (result != SendAccessConstants.SendIdGuidValidatorResults.ValidSendGuid)
4240
{
4341
context.Result = BuildErrorResult(result);
4442
return;
@@ -49,15 +47,10 @@ public async Task ValidateAsync(ExtensionGrantValidationContext context)
4947

5048
switch (method)
5149
{
52-
case NeverAuthenticate:
50+
case NeverAuthenticate never:
5351
// null send scenario.
54-
// TODO PM-22675: Add send enumeration protection here (primarily benefits self hosted instances).
55-
// We should only map to password or email + OTP protected.
56-
// If user submits password guess for a falsely protected send, then we will return invalid password.
57-
// If user submits email + OTP guess for a falsely protected send, then we will return email sent, do not actually send an email.
58-
context.Result = BuildErrorResult(SendAccessConstants.GrantValidatorResults.InvalidSendId);
52+
context.Result = await _sendNeverAuthenticateValidator.ValidateRequestAsync(context, never, sendIdGuid);
5953
return;
60-
6154
case NotAuthenticated:
6255
// automatically issue access token
6356
context.Result = BuildBaseSuccessResult(sendIdGuid);
@@ -90,7 +83,7 @@ private static (Guid, string) GetRequestSendId(ExtensionGrantValidationContext c
9083
// if the sendId is null then the request is the wrong shape and the request is invalid
9184
if (sendId == null)
9285
{
93-
return (Guid.Empty, SendAccessConstants.GrantValidatorResults.SendIdRequired);
86+
return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired);
9487
}
9588
// the send_id is not null so the request is the correct shape, so we will attempt to parse it
9689
try
@@ -100,20 +93,20 @@ private static (Guid, string) GetRequestSendId(ExtensionGrantValidationContext c
10093
// Guid.Empty indicates an invalid send_id return invalid grant
10194
if (sendGuid == Guid.Empty)
10295
{
103-
return (Guid.Empty, SendAccessConstants.GrantValidatorResults.InvalidSendId);
96+
return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId);
10497
}
105-
return (sendGuid, SendAccessConstants.GrantValidatorResults.ValidSendGuid);
98+
return (sendGuid, SendAccessConstants.SendIdGuidValidatorResults.ValidSendGuid);
10699
}
107100
catch
108101
{
109-
return (Guid.Empty, SendAccessConstants.GrantValidatorResults.InvalidSendId);
102+
return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId);
110103
}
111104
}
112105

113106
/// <summary>
114107
/// Builds an error result for the specified error type.
115108
/// </summary>
116-
/// <param name="error">This error is a constant string from <see cref="SendAccessConstants.GrantValidatorResults"/></param>
109+
/// <param name="error">This error is a constant string from <see cref="SendAccessConstants.SendIdGuidValidatorResults"/></param>
117110
/// <returns>The error result.</returns>
118111
private static GrantValidationResult BuildErrorResult(string error)
119112
{
@@ -125,12 +118,12 @@ private static GrantValidationResult BuildErrorResult(string error)
125118
return error switch
126119
{
127120
// Request is the wrong shape
128-
SendAccessConstants.GrantValidatorResults.SendIdRequired => new GrantValidationResult(
121+
SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired => new GrantValidationResult(
129122
TokenRequestErrors.InvalidRequest,
130123
errorDescription: _sendGrantValidatorErrorDescriptions[error],
131124
customResponse),
132125
// Request is correct shape but data is bad
133-
SendAccessConstants.GrantValidatorResults.InvalidSendId => new GrantValidationResult(
126+
SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId => new GrantValidationResult(
134127
TokenRequestErrors.InvalidGrant,
135128
errorDescription: _sendGrantValidatorErrorDescriptions[error],
136129
customResponse),
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using System.Text;
2+
using Bit.Core.Settings;
3+
using Bit.Core.Tools.Models.Data;
4+
using Bit.Core.Utilities;
5+
using Duende.IdentityServer.Models;
6+
using Duende.IdentityServer.Validation;
7+
8+
namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
9+
10+
/// <summary>
11+
/// This class is used to protect our system from enumeration attacks. This Validator will always return an error result.
12+
/// We hash the SendId Guid passed into the request to select the an error from the list of possible errors. This ensures
13+
/// that the same error is always returned for the same SendId.
14+
/// </summary>
15+
/// <param name="globalSettings">We need access to a hash key to generate the error index.</param>
16+
public class SendNeverAuthenticateRequestValidator(GlobalSettings globalSettings) : ISendAuthenticationMethodValidator<NeverAuthenticate>
17+
{
18+
private readonly string[] _errorOptions =
19+
[
20+
SendAccessConstants.EnumerationProtection.Guid,
21+
SendAccessConstants.EnumerationProtection.Password,
22+
SendAccessConstants.EnumerationProtection.Email
23+
];
24+
25+
public Task<GrantValidationResult> ValidateRequestAsync(
26+
ExtensionGrantValidationContext context,
27+
NeverAuthenticate authMethod,
28+
Guid sendId)
29+
{
30+
var neverAuthenticateError = GetErrorIndex(sendId, _errorOptions.Length);
31+
var request = context.Request.Raw;
32+
var errorType = neverAuthenticateError;
33+
34+
switch (neverAuthenticateError)
35+
{
36+
case SendAccessConstants.EnumerationProtection.Guid:
37+
errorType = SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId;
38+
break;
39+
case SendAccessConstants.EnumerationProtection.Email:
40+
var hasEmail = request.Get(SendAccessConstants.TokenRequest.Email) is not null;
41+
errorType = hasEmail ? SendAccessConstants.EmailOtpValidatorResults.EmailInvalid
42+
: SendAccessConstants.EmailOtpValidatorResults.EmailRequired;
43+
break;
44+
case SendAccessConstants.EnumerationProtection.Password:
45+
var hasPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword) is not null;
46+
errorType = hasPassword ? SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch
47+
: SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired;
48+
break;
49+
}
50+
51+
return Task.FromResult(BuildErrorResult(errorType));
52+
}
53+
54+
private static GrantValidationResult BuildErrorResult(string errorType)
55+
{
56+
// Create error response with custom response data
57+
var customResponse = new Dictionary<string, object>
58+
{
59+
{ SendAccessConstants.SendAccessError, errorType }
60+
};
61+
62+
var requestError = errorType switch
63+
{
64+
SendAccessConstants.EnumerationProtection.Guid => TokenRequestErrors.InvalidGrant,
65+
SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired => TokenRequestErrors.InvalidGrant,
66+
SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch => TokenRequestErrors.InvalidRequest,
67+
SendAccessConstants.EmailOtpValidatorResults.EmailInvalid => TokenRequestErrors.InvalidGrant,
68+
SendAccessConstants.EmailOtpValidatorResults.EmailRequired => TokenRequestErrors.InvalidRequest,
69+
_ => TokenRequestErrors.InvalidGrant
70+
};
71+
72+
return new GrantValidationResult(requestError, errorType, customResponse);
73+
}
74+
75+
private string GetErrorIndex(Guid sendId, int range)
76+
{
77+
var salt = sendId.ToString();
78+
byte[] hmacKey = [];
79+
if (CoreHelpers.SettingHasValue(globalSettings.SendDefaultHashKey))
80+
{
81+
hmacKey = Encoding.UTF8.GetBytes(globalSettings.SendDefaultHashKey);
82+
}
83+
84+
var index = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range);
85+
return _errorOptions[index];
86+
}
87+
}

src/Identity/Utilities/ServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public static IIdentityServerBuilder AddCustomIdentityServerServices(this IServi
2929
services.AddTransient<ILoginApprovingClientTypes, LoginApprovingClientTypes>();
3030
services.AddTransient<ISendAuthenticationMethodValidator<ResourcePassword>, SendPasswordRequestValidator>();
3131
services.AddTransient<ISendAuthenticationMethodValidator<EmailOtp>, SendEmailOtpRequestValidator>();
32+
services.AddTransient<ISendAuthenticationMethodValidator<NeverAuthenticate>, SendNeverAuthenticateRequestValidator>();
3233

3334
var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity);
3435
var identityServerBuilder = services

0 commit comments

Comments
 (0)