1
0
mirror of https://github.com/bitwarden/server synced 2026-02-12 06:23:28 +00:00

[PM-31684] Remove email hashing for send access (#6945)

* [PM-31684] Remove email hashing for send access

* [PM-31684] switching the order of migration files

* [PM-31684] adding more migrations

* [PM-31684] Removing anon access emails field  and reusing emails field

* [PM-31684] cleanup before adding migrations back

* [PM-31684] restore original snapshots

* [PM-31684] restore original postgres snapshots

* [PM-31684] adding migrations

* [PM-31684] removing encryption attributes from emails request model

* [PM-31684] adding missing stored proc alters

* [PM-31684] Improved formatting for stored proc defs

* [PM-31684] adding necessary comment back

* [PM-31684] adding case-insensitive check on the server for send auth
This commit is contained in:
Alex Dragovich
2026-02-09 12:58:57 -08:00
committed by GitHub
parent 40c64a51d5
commit 6d43cc43e3
24 changed files with 10788 additions and 123 deletions

View File

@@ -102,16 +102,8 @@ public class SendRequestModel
/// Comma-separated list of emails that may access the send using OTP
/// authentication. Mutually exclusive with <see cref="Password"/>.
/// </summary>
[EncryptedString]
[EncryptedStringLength(4000)]
public string Emails { get; set; }
/// <summary>
/// Comma-separated list of email **hashes** that may access the send using OTP
/// authentication. Mutually exclusive with <see cref="Password"/>.
/// </summary>
[StringLength(4000)]
public string EmailHashes { get; set; }
public string Emails { get; set; }
/// <summary>
/// When <see langword="true"/>, send access is disabled.
@@ -261,7 +253,6 @@ public class SendRequestModel
// normalize encoding
var emails = Emails.Split(',', RemoveEmptyEntries | TrimEntries);
existingSend.Emails = string.Join(",", emails);
existingSend.EmailHashes = EmailHashes;
existingSend.Password = null;
existingSend.AuthType = Core.Tools.Enums.AuthType.Email;
}

View File

@@ -81,15 +81,6 @@ public class Send : ITableObject<Guid>
[MaxLength(4000)]
public string? Emails { get; set; }
/// <summary>
/// Comma-separated list of email **hashes** for OTP authentication.
/// </summary>
/// <remarks>
/// This field is mutually exclusive with <see cref="Password" />
/// </remarks>
[MaxLength(4000)]
public string? EmailHashes { get; set; }
/// <summary>
/// The send becomes unavailable to API callers when
/// <see cref="AccessCount"/> &gt;= <see cref="MaxAccessCount"/>.

View File

@@ -44,7 +44,7 @@ public record ResourcePassword(string Hash) : SendAuthenticationMethod;
/// <summary>
/// Create a send claim by requesting a one time password (OTP) confirmation code.
/// </summary>
/// <param name="EmailHashes">
/// The list of email address **hashes** permitted access to the send.
/// <param name="emails">
/// The list of email addresses permitted access to the send.
/// </param>
public record EmailOtp(string[] EmailHashes) : SendAuthenticationMethod;
public record EmailOtp(string[] emails) : SendAuthenticationMethod;

View File

@@ -41,7 +41,7 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
var s when s.AccessCount >= s.MaxAccessCount.GetValueOrDefault(int.MaxValue) => NEVER_AUTHENTICATE,
var s when s.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow => NEVER_AUTHENTICATE,
var s when s.DeletionDate <= DateTime.UtcNow => NEVER_AUTHENTICATE,
var s when s.AuthType == AuthType.Email && s.EmailHashes is not null => EmailOtp(s.EmailHashes),
var s when s.AuthType == AuthType.Email && s.Emails is not null => EmailOtp(s.Emails),
var s when s.AuthType == AuthType.Password && s.Password is not null => new ResourcePassword(s.Password),
_ => NOT_AUTHENTICATED
};
@@ -49,13 +49,13 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
return method;
}
private static EmailOtp EmailOtp(string? emailHashes)
private static EmailOtp EmailOtp(string? emails)
{
if (string.IsNullOrWhiteSpace(emailHashes))
if (string.IsNullOrWhiteSpace(emails))
{
return new EmailOtp([]);
}
var list = emailHashes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var list = emails.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return new EmailOtp(list);
}
}

View File

@@ -1,6 +1,4 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Services;
@@ -39,17 +37,14 @@ public class SendEmailOtpRequestValidator(
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailRequired);
}
// email hash must be in the list of email hashes in the EmailOtp array
byte[] hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(email));
string hashEmailHex = Convert.ToHexString(hashBytes).ToUpperInvariant();
/*
* This is somewhat contradictory to our process where a poor shape means invalid_request and invalid
* data is invalid_grant.
* In this case the shape is correct and the data is invalid but to protect against enumeration we treat incorrect emails
* as invalid requests. The response for a request with a correct email which needs an OTP and a request
* that has an invalid email need to be the same otherwise an attacker could enumerate until a valid email is found.
*/
if (!authMethod.EmailHashes.Contains(hashEmailHex))
*/
if (!authMethod.emails.Contains(email, StringComparer.OrdinalIgnoreCase))
{
return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailAndOtpRequired);
}

View File

@@ -154,7 +154,7 @@ public class SendRepository : Repository<Send, Guid>, ISendRepository
}
// Capture original value
var originalEmailHashes = send.EmailHashes;
var emails = send.Emails;
// Protect value
ProtectData(send);
@@ -163,15 +163,15 @@ public class SendRepository : Repository<Send, Guid>, ISendRepository
await saveTask();
// Restore original value
send.EmailHashes = originalEmailHashes;
send.Emails = emails;
}
private void ProtectData(Send send)
{
if (!send.EmailHashes?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false)
if (!send.Emails?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false)
{
send.EmailHashes = string.Concat(Constants.DatabaseFieldProtectedPrefix,
_dataProtector.Protect(send.EmailHashes!));
send.Emails = string.Concat(Constants.DatabaseFieldProtectedPrefix,
_dataProtector.Protect(send.Emails!));
}
}
@@ -182,10 +182,10 @@ public class SendRepository : Repository<Send, Guid>, ISendRepository
return;
}
if (send.EmailHashes?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false)
if (send.Emails?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false)
{
send.EmailHashes = _dataProtector.Unprotect(
send.EmailHashes.Substring(Constants.DatabaseFieldProtectedPrefix.Length));
send.Emails = _dataProtector.Unprotect(
send.Emails.Substring(Constants.DatabaseFieldProtectedPrefix.Length));
}
}

View File

@@ -150,7 +150,7 @@ public class DatabaseContext : DbContext
var dataProtectionConverter = new DataProtectionConverter(dataProtector);
eUser.Property(c => c.Key).HasConversion(dataProtectionConverter);
eUser.Property(c => c.MasterPassword).HasConversion(dataProtectionConverter);
eSend.Property(c => c.EmailHashes).HasConversion(dataProtectionConverter);
eSend.Property(c => c.Emails).HasConversion(dataProtectionConverter);
if (Database.IsNpgsql())
{

View File

@@ -18,8 +18,7 @@
-- FIXME: remove null default value once this argument has been
-- in 2 server releases
@Emails NVARCHAR(4000) = NULL,
@AuthType TINYINT = NULL,
@EmailHashes NVARCHAR(4000) = NULL
@AuthType TINYINT = NULL
AS
BEGIN
SET NOCOUNT ON
@@ -43,8 +42,7 @@ BEGIN
[HideEmail],
[CipherId],
[Emails],
[AuthType],
[EmailHashes]
[AuthType]
)
VALUES
(
@@ -65,8 +63,7 @@ BEGIN
@HideEmail,
@CipherId,
@Emails,
@AuthType,
@EmailHashes
@AuthType
)
IF @UserId IS NOT NULL

View File

@@ -16,8 +16,7 @@
@HideEmail BIT,
@CipherId UNIQUEIDENTIFIER = NULL,
@Emails NVARCHAR(4000) = NULL,
@AuthType TINYINT = NULL,
@EmailHashes NVARCHAR(4000) = NULL
@AuthType TINYINT = NULL
AS
BEGIN
SET NOCOUNT ON
@@ -41,8 +40,7 @@ BEGIN
[HideEmail] = @HideEmail,
[CipherId] = @CipherId,
[Emails] = @Emails,
[AuthType] = @AuthType,
[EmailHashes] = @EmailHashes
[AuthType] = @AuthType
WHERE
[Id] = @Id

View File

@@ -18,7 +18,6 @@
[HideEmail] BIT NULL,
[CipherId] UNIQUEIDENTIFIER NULL,
[AuthType] TINYINT NULL,
[EmailHashes] NVARCHAR(4000) NULL,
CONSTRAINT [PK_Send] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_Send_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]),
CONSTRAINT [FK_Send_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]),