1
0
mirror of https://github.com/bitwarden/server synced 2026-01-29 07:43:22 +00:00

[PM-30920] Server changes to encrypt send access email list (#6867)

* models, entity, and stored procs updated to work with EmailHashes with migrations

* configure data protection for EmailHashes

* update SendAuthenticationQuery to use EmailHashes and perform validation

* respond to Claude's comments and update tests

* fix send.sql alignment

Co-authored-by: mkincaid-bw <mkincaid@bitwarden.com>

---------

Co-authored-by: Alex Dragovich <46065570+itsadrago@users.noreply.github.com>
Co-authored-by: mkincaid-bw <mkincaid@bitwarden.com>
This commit is contained in:
John Harrington
2026-01-28 07:13:25 -07:00
committed by GitHub
parent 2c39e336e0
commit fa06fe41ab
22 changed files with 11125 additions and 260 deletions

View File

@@ -239,12 +239,6 @@ public class SendsController : Controller
{
throw new BadRequestException("Could not locate send");
}
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
send.DeletionDate < DateTime.UtcNow)
{
throw new NotFoundException();
}
var sendResponse = new SendAccessResponseModel(send);
if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault())
@@ -272,12 +266,6 @@ public class SendsController : Controller
{
throw new BadRequestException("Could not locate send");
}
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
send.DeletionDate < DateTime.UtcNow)
{
throw new NotFoundException();
}
var url = await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId);

View File

@@ -102,9 +102,17 @@ public class SendRequestModel
/// Comma-separated list of emails that may access the send using OTP
/// authentication. Mutually exclusive with <see cref="Password"/>.
/// </summary>
[StringLength(4000)]
[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; }
/// <summary>
/// When <see langword="true"/>, send access is disabled.
/// Defaults to <see langword="false"/>.
@@ -253,6 +261,7 @@ 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,6 +81,15 @@ 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

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

View File

@@ -37,8 +37,11 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
SendAuthenticationMethod method = send switch
{
null => NEVER_AUTHENTICATE,
var s when s.AccessCount >= s.MaxAccessCount => NEVER_AUTHENTICATE,
var s when s.AuthType == AuthType.Email && s.Emails is not null => emailOtp(s.Emails),
var s when s.Disabled => NEVER_AUTHENTICATE,
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.Password && s.Password is not null => new ResourcePassword(s.Password),
_ => NOT_AUTHENTICATED
};
@@ -46,9 +49,13 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
return method;
}
private EmailOtp emailOtp(string emails)
private static EmailOtp EmailOtp(string? emailHashes)
{
var list = emails.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (string.IsNullOrWhiteSpace(emailHashes))
{
return new EmailOtp([]);
}
var list = emailHashes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return new EmailOtp(list);
}
}

View File

@@ -1,6 +1,7 @@
#nullable enable
using System.Data;
using Bit.Core;
using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
@@ -8,6 +9,7 @@ using Bit.Core.Tools.Repositories;
using Bit.Infrastructure.Dapper.Repositories;
using Bit.Infrastructure.Dapper.Tools.Helpers;
using Dapper;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Data.SqlClient;
namespace Bit.Infrastructure.Dapper.Tools.Repositories;
@@ -15,13 +17,24 @@ namespace Bit.Infrastructure.Dapper.Tools.Repositories;
/// <inheritdoc cref="ISendRepository" />
public class SendRepository : Repository<Send, Guid>, ISendRepository
{
public SendRepository(GlobalSettings globalSettings)
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
private readonly IDataProtector _dataProtector;
public SendRepository(GlobalSettings globalSettings, IDataProtectionProvider dataProtectionProvider)
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString, dataProtectionProvider)
{ }
public SendRepository(string connectionString, string readOnlyConnectionString)
public SendRepository(string connectionString, string readOnlyConnectionString, IDataProtectionProvider dataProtectionProvider)
: base(connectionString, readOnlyConnectionString)
{ }
{
_dataProtector = dataProtectionProvider.CreateProtector(Constants.DatabaseFieldProtectorPurpose);
}
public override async Task<Send?> GetByIdAsync(Guid id)
{
var send = await base.GetByIdAsync(id);
UnprotectData(send);
return send;
}
/// <inheritdoc />
public async Task<ICollection<Send>> GetManyByUserIdAsync(Guid userId)
@@ -33,7 +46,9 @@ public class SendRepository : Repository<Send, Guid>, ISendRepository
new { UserId = userId },
commandType: CommandType.StoredProcedure);
return results.ToList();
var sends = results.ToList();
UnprotectData(sends);
return sends;
}
}
@@ -47,15 +62,35 @@ public class SendRepository : Repository<Send, Guid>, ISendRepository
new { DeletionDate = deletionDateBefore },
commandType: CommandType.StoredProcedure);
return results.ToList();
var sends = results.ToList();
UnprotectData(sends);
return sends;
}
}
public override async Task<Send> CreateAsync(Send send)
{
await ProtectDataAndSaveAsync(send, async () => await base.CreateAsync(send));
return send;
}
public override async Task ReplaceAsync(Send send)
{
await ProtectDataAndSaveAsync(send, async () => await base.ReplaceAsync(send));
}
/// <inheritdoc />
public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid userId, IEnumerable<Send> sends)
{
return async (connection, transaction) =>
{
// Protect all sends before bulk update
var sendsList = sends.ToList();
foreach (var send in sendsList)
{
ProtectData(send);
}
// Create temp table
var sqlCreateTemp = @"
SELECT TOP 0 *
@@ -71,7 +106,7 @@ public class SendRepository : Repository<Send, Guid>, ISendRepository
using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction))
{
bulkCopy.DestinationTableName = "#TempSend";
var sendsTable = sends.ToDataTable();
var sendsTable = sendsList.ToDataTable();
foreach (DataColumn col in sendsTable.Columns)
{
bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName);
@@ -101,6 +136,69 @@ public class SendRepository : Repository<Send, Guid>, ISendRepository
cmd.Parameters.Add("@UserId", SqlDbType.UniqueIdentifier).Value = userId;
cmd.ExecuteNonQuery();
}
// Unprotect after save
foreach (var send in sendsList)
{
UnprotectData(send);
}
};
}
private async Task ProtectDataAndSaveAsync(Send send, Func<Task> saveTask)
{
if (send == null)
{
await saveTask();
return;
}
// Capture original value
var originalEmailHashes = send.EmailHashes;
// Protect value
ProtectData(send);
// Save
await saveTask();
// Restore original value
send.EmailHashes = originalEmailHashes;
}
private void ProtectData(Send send)
{
if (!send.EmailHashes?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false)
{
send.EmailHashes = string.Concat(Constants.DatabaseFieldProtectedPrefix,
_dataProtector.Protect(send.EmailHashes!));
}
}
private void UnprotectData(Send? send)
{
if (send == null)
{
return;
}
if (send.EmailHashes?.StartsWith(Constants.DatabaseFieldProtectedPrefix) ?? false)
{
send.EmailHashes = _dataProtector.Unprotect(
send.EmailHashes.Substring(Constants.DatabaseFieldProtectedPrefix.Length));
}
}
private void UnprotectData(IEnumerable<Send> sends)
{
if (sends == null)
{
return;
}
foreach (var send in sends)
{
UnprotectData(send);
}
}
}

View File

@@ -119,6 +119,7 @@ public class DatabaseContext : DbContext
var eOrganizationDomain = builder.Entity<OrganizationDomain>();
var aWebAuthnCredential = builder.Entity<WebAuthnCredential>();
var eOrganizationMemberBaseDetail = builder.Entity<OrganizationMemberBaseDetail>();
var eSend = builder.Entity<Send>();
// Shadow property configurations go here
@@ -148,6 +149,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);
if (Database.IsNpgsql())
{

View File

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

View File

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

View File

@@ -1,22 +1,24 @@
CREATE TABLE [dbo].[Send] (
CREATE TABLE [dbo].[Send]
(
[Id] UNIQUEIDENTIFIER NOT NULL,
[UserId] UNIQUEIDENTIFIER NULL,
[OrganizationId] UNIQUEIDENTIFIER NULL,
[Type] TINYINT NOT NULL,
[Data] VARCHAR(MAX) NOT NULL,
[Key] VARCHAR (MAX) NOT NULL,
[Password] NVARCHAR (300) NULL,
[Emails] NVARCHAR (4000) NULL,
[Key] VARCHAR(MAX) NOT NULL,
[Password] NVARCHAR(300) NULL,
[Emails] NVARCHAR(4000) NULL,
[MaxAccessCount] INT NULL,
[AccessCount] INT NOT NULL,
[CreationDate] DATETIME2 (7) NOT NULL,
[RevisionDate] DATETIME2 (7) NOT NULL,
[ExpirationDate] DATETIME2 (7) NULL,
[DeletionDate] DATETIME2 (7) NOT NULL,
[CreationDate] DATETIME2(7) NOT NULL,
[RevisionDate] DATETIME2(7) NOT NULL,
[ExpirationDate] DATETIME2(7) NULL,
[DeletionDate] DATETIME2(7) NOT NULL,
[Disabled] BIT NOT NULL,
[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]),
@@ -26,9 +28,9 @@
GO
CREATE NONCLUSTERED INDEX [IX_Send_UserId_OrganizationId]
ON [dbo].[Send]([UserId] ASC, [OrganizationId] ASC);
ON [dbo].[Send] ([UserId] ASC, [OrganizationId] ASC);
GO
CREATE NONCLUSTERED INDEX [IX_Send_DeletionDate]
ON [dbo].[Send]([DeletionDate] ASC);
ON [dbo].[Send] ([DeletionDate] ASC);

View File

@@ -981,205 +981,6 @@ public class SendsControllerTests : IDisposable
Assert.Equal(expectedUrl, response.Url);
}
#region AccessUsingAuth Validation Tests
[Theory, AutoData]
public async Task AccessUsingAuth_WithExpiredSend_ThrowsNotFoundException(Guid sendId)
{
var send = new Send
{
Id = sendId,
UserId = Guid.NewGuid(),
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = DateTime.UtcNow.AddDays(-1), // Expired yesterday
Disabled = false,
AccessCount = 0,
MaxAccessCount = null
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
[Theory, AutoData]
public async Task AccessUsingAuth_WithDeletedSend_ThrowsNotFoundException(Guid sendId)
{
var send = new Send
{
Id = sendId,
UserId = Guid.NewGuid(),
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
DeletionDate = DateTime.UtcNow.AddDays(-1), // Should have been deleted yesterday
ExpirationDate = null,
Disabled = false,
AccessCount = 0,
MaxAccessCount = null
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
[Theory, AutoData]
public async Task AccessUsingAuth_WithDisabledSend_ThrowsNotFoundException(Guid sendId)
{
var send = new Send
{
Id = sendId,
UserId = Guid.NewGuid(),
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = null,
Disabled = true, // Disabled
AccessCount = 0,
MaxAccessCount = null
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
[Theory, AutoData]
public async Task AccessUsingAuth_WithAccessCountExceeded_ThrowsNotFoundException(Guid sendId)
{
var send = new Send
{
Id = sendId,
UserId = Guid.NewGuid(),
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = null,
Disabled = false,
AccessCount = 5,
MaxAccessCount = 5 // Limit reached
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
#endregion
#region GetSendFileDownloadDataUsingAuth Validation Tests
[Theory, AutoData]
public async Task GetSendFileDownloadDataUsingAuth_WithExpiredSend_ThrowsNotFoundException(
Guid sendId, string fileId)
{
var send = new Send
{
Id = sendId,
Type = SendType.File,
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = DateTime.UtcNow.AddDays(-1), // Expired
Disabled = false,
AccessCount = 0,
MaxAccessCount = null
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
[Theory, AutoData]
public async Task GetSendFileDownloadDataUsingAuth_WithDeletedSend_ThrowsNotFoundException(
Guid sendId, string fileId)
{
var send = new Send
{
Id = sendId,
Type = SendType.File,
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
DeletionDate = DateTime.UtcNow.AddDays(-1), // Deleted
ExpirationDate = null,
Disabled = false,
AccessCount = 0,
MaxAccessCount = null
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
[Theory, AutoData]
public async Task GetSendFileDownloadDataUsingAuth_WithDisabledSend_ThrowsNotFoundException(
Guid sendId, string fileId)
{
var send = new Send
{
Id = sendId,
Type = SendType.File,
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = null,
Disabled = true, // Disabled
AccessCount = 0,
MaxAccessCount = null
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
[Theory, AutoData]
public async Task GetSendFileDownloadDataUsingAuth_WithAccessCountExceeded_ThrowsNotFoundException(
Guid sendId, string fileId)
{
var send = new Send
{
Id = sendId,
Type = SendType.File,
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = null,
Disabled = false,
AccessCount = 10,
MaxAccessCount = 10 // Limit reached
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
#endregion
#endregion

View File

@@ -43,12 +43,12 @@ public class SendAuthenticationQueryTests
}
[Theory]
[MemberData(nameof(EmailParsingTestCases))]
public async Task GetAuthenticationMethod_WithEmails_ParsesEmailsCorrectly(string emailString, string[] expectedEmails)
[MemberData(nameof(EmailHashesParsingTestCases))]
public async Task GetAuthenticationMethod_WithEmailHashes_ParsesEmailHashesCorrectly(string emailHashString, string[] expectedEmailHashes)
{
// Arrange
var sendId = Guid.NewGuid();
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: emailString, password: null, AuthType.Email);
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: emailHashString, password: null, AuthType.Email);
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
@@ -56,15 +56,15 @@ public class SendAuthenticationQueryTests
// Assert
var emailOtp = Assert.IsType<EmailOtp>(result);
Assert.Equal(expectedEmails, emailOtp.Emails);
Assert.Equal(expectedEmailHashes, emailOtp.Emails);
}
[Fact]
public async Task GetAuthenticationMethod_WithBothEmailsAndPassword_ReturnsEmailOtp()
public async Task GetAuthenticationMethod_WithBothEmailHashesAndPassword_ReturnsEmailOtp()
{
// Arrange
var sendId = Guid.NewGuid();
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: "hashedpassword", AuthType.Email);
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: "hashedemail", password: "hashedpassword", AuthType.Email);
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
@@ -79,7 +79,7 @@ public class SendAuthenticationQueryTests
{
// Arrange
var sendId = Guid.NewGuid();
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null, AuthType.None);
var send = CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: null, password: null, AuthType.None);
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
@@ -106,32 +106,218 @@ public class SendAuthenticationQueryTests
public static IEnumerable<object[]> AuthenticationMethodTestCases()
{
yield return new object[] { null, typeof(NeverAuthenticate) };
yield return new object[] { CreateSend(accessCount: 5, maxAccessCount: 5, emails: null, password: null, AuthType.None), typeof(NeverAuthenticate) };
yield return new object[] { CreateSend(accessCount: 6, maxAccessCount: 5, emails: null, password: null, AuthType.None), typeof(NeverAuthenticate) };
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: "test@example.com", password: null, AuthType.Email), typeof(EmailOtp) };
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: "hashedpassword", AuthType.Password), typeof(ResourcePassword) };
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emails: null, password: null, AuthType.None), typeof(NotAuthenticated) };
yield return new object[] { CreateSend(accessCount: 5, maxAccessCount: 5, emailHashes: null, password: null, AuthType.None), typeof(NeverAuthenticate) };
yield return new object[] { CreateSend(accessCount: 6, maxAccessCount: 5, emailHashes: null, password: null, AuthType.None), typeof(NeverAuthenticate) };
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: "hashedemail", password: null, AuthType.Email), typeof(EmailOtp) };
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: null, password: "hashedpassword", AuthType.Password), typeof(ResourcePassword) };
yield return new object[] { CreateSend(accessCount: 0, maxAccessCount: 10, emailHashes: null, password: null, AuthType.None), typeof(NotAuthenticated) };
}
public static IEnumerable<object[]> EmailParsingTestCases()
[Fact]
public async Task GetAuthenticationMethod_WithDisabledSend_ReturnsNeverAuthenticate()
{
yield return new object[] { "test@example.com", new[] { "test@example.com" } };
yield return new object[] { "test1@example.com,test2@example.com", new[] { "test1@example.com", "test2@example.com" } };
yield return new object[] { " test@example.com , other@example.com ", new[] { "test@example.com", "other@example.com" } };
yield return new object[] { "test@example.com,,other@example.com", new[] { "test@example.com", "other@example.com" } };
yield return new object[] { " , test@example.com, ,other@example.com, ", new[] { "test@example.com", "other@example.com" } };
// Arrange
var sendId = Guid.NewGuid();
var send = new Send
{
Id = sendId,
AccessCount = 0,
MaxAccessCount = 10,
EmailHashes = "hashedemail",
Password = null,
AuthType = AuthType.Email,
Disabled = true,
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = null
};
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId);
// Assert
Assert.IsType<NeverAuthenticate>(result);
}
private static Send CreateSend(int accessCount, int? maxAccessCount, string? emails, string? password, AuthType? authType)
[Fact]
public async Task GetAuthenticationMethod_WithExpiredSend_ReturnsNeverAuthenticate()
{
// Arrange
var sendId = Guid.NewGuid();
var send = new Send
{
Id = sendId,
AccessCount = 0,
MaxAccessCount = 10,
EmailHashes = "hashedemail",
Password = null,
AuthType = AuthType.Email,
Disabled = false,
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = DateTime.UtcNow.AddDays(-1) // Expired yesterday
};
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId);
// Assert
Assert.IsType<NeverAuthenticate>(result);
}
[Fact]
public async Task GetAuthenticationMethod_WithDeletionDatePassed_ReturnsNeverAuthenticate()
{
// Arrange
var sendId = Guid.NewGuid();
var send = new Send
{
Id = sendId,
AccessCount = 0,
MaxAccessCount = 10,
EmailHashes = "hashedemail",
Password = null,
AuthType = AuthType.Email,
Disabled = false,
DeletionDate = DateTime.UtcNow.AddDays(-1), // Should have been deleted yesterday
ExpirationDate = null
};
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId);
// Assert
Assert.IsType<NeverAuthenticate>(result);
}
[Fact]
public async Task GetAuthenticationMethod_WithDeletionDateEqualToNow_ReturnsNeverAuthenticate()
{
// Arrange
var sendId = Guid.NewGuid();
var now = DateTime.UtcNow;
var send = new Send
{
Id = sendId,
AccessCount = 0,
MaxAccessCount = 10,
EmailHashes = "hashedemail",
Password = null,
AuthType = AuthType.Email,
Disabled = false,
DeletionDate = now, // DeletionDate <= DateTime.UtcNow
ExpirationDate = null
};
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId);
// Assert
Assert.IsType<NeverAuthenticate>(result);
}
[Fact]
public async Task GetAuthenticationMethod_WithAccessCountEqualToMaxAccessCount_ReturnsNeverAuthenticate()
{
// Arrange
var sendId = Guid.NewGuid();
var send = new Send
{
Id = sendId,
AccessCount = 5,
MaxAccessCount = 5,
EmailHashes = "hashedemail",
Password = null,
AuthType = AuthType.Email,
Disabled = false,
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = null
};
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId);
// Assert
Assert.IsType<NeverAuthenticate>(result);
}
[Fact]
public async Task GetAuthenticationMethod_WithNullMaxAccessCount_DoesNotRestrictAccess()
{
// Arrange
var sendId = Guid.NewGuid();
var send = new Send
{
Id = sendId,
AccessCount = 1000,
MaxAccessCount = null, // No limit
EmailHashes = "hashedemail",
Password = null,
AuthType = AuthType.Email,
Disabled = false,
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = null
};
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId);
// Assert
Assert.IsType<EmailOtp>(result);
}
[Fact]
public async Task GetAuthenticationMethod_WithNullExpirationDate_DoesNotExpire()
{
// Arrange
var sendId = Guid.NewGuid();
var send = new Send
{
Id = sendId,
AccessCount = 0,
MaxAccessCount = 10,
EmailHashes = "hashedemail",
Password = null,
AuthType = AuthType.Email,
Disabled = false,
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = null // No expiration
};
_sendRepository.GetByIdAsync(sendId).Returns(send);
// Act
var result = await _sendAuthenticationQuery.GetAuthenticationMethod(sendId);
// Assert
Assert.IsType<EmailOtp>(result);
}
public static IEnumerable<object[]> EmailHashesParsingTestCases()
{
yield return new object[] { "hash1", new[] { "hash1" } };
yield return new object[] { "hash1,hash2", new[] { "hash1", "hash2" } };
yield return new object[] { " hash1 , hash2 ", new[] { "hash1", "hash2" } };
yield return new object[] { "hash1,,hash2", new[] { "hash1", "hash2" } };
yield return new object[] { " , hash1, ,hash2, ", new[] { "hash1", "hash2" } };
}
private static Send CreateSend(int accessCount, int? maxAccessCount, string? emailHashes, string? password, AuthType? authType)
{
return new Send
{
Id = Guid.NewGuid(),
AccessCount = accessCount,
MaxAccessCount = maxAccessCount,
Emails = emails,
EmailHashes = emailHashes,
Password = password,
AuthType = authType
AuthType = authType,
Disabled = false,
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = null
};
}
}

View File

@@ -0,0 +1,148 @@
-- Update Send table to add EmailHashes Column
IF COL_LENGTH('[dbo].[Send]', 'EmailHashes') IS NULL
BEGIN
ALTER TABLE [dbo].[Send]
ADD [EmailHashes] NVARCHAR(4000) NULL;
END
GO
-- Update Send_Create to include EmailHashes column
CREATE OR ALTER PROCEDURE [dbo].[Send_Create]
@Id UNIQUEIDENTIFIER OUTPUT,
@UserId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Type TINYINT,
@Data VARCHAR(MAX),
@Key VARCHAR(MAX),
@Password NVARCHAR(300),
@MaxAccessCount INT,
@AccessCount INT,
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@ExpirationDate DATETIME2(7),
@DeletionDate DATETIME2(7),
@Disabled BIT,
@HideEmail BIT,
@CipherId UNIQUEIDENTIFIER = NULL,
@Emails NVARCHAR(4000) = NULL,
@AuthType TINYINT = NULL,
@EmailHashes NVARCHAR(4000) = NULL
AS
BEGIN
SET NOCOUNT ON
INSERT INTO [dbo].[Send]
(
[Id],
[UserId],
[OrganizationId],
[Type],
[Data],
[Key],
[Password],
[MaxAccessCount],
[AccessCount],
[CreationDate],
[RevisionDate],
[ExpirationDate],
[DeletionDate],
[Disabled],
[HideEmail],
[CipherId],
[Emails],
[AuthType],
[EmailHashes]
)
VALUES
(
@Id,
@UserId,
@OrganizationId,
@Type,
@Data,
@Key,
@Password,
@MaxAccessCount,
@AccessCount,
@CreationDate,
@RevisionDate,
@ExpirationDate,
@DeletionDate,
@Disabled,
@HideEmail,
@CipherId,
@Emails,
@AuthType,
@EmailHashes
)
IF @UserId IS NOT NULL
BEGIN
IF @Type = 1 --File
BEGIN
EXEC [dbo].[User_UpdateStorage] @UserId
END
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
END
-- TODO: OrganizationId bump?
END
GO
-- Update Send_Update to include EmailHashes column
CREATE OR ALTER PROCEDURE [dbo].[Send_Update]
@Id UNIQUEIDENTIFIER,
@UserId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Type TINYINT,
@Data VARCHAR(MAX),
@Key VARCHAR(MAX),
@Password NVARCHAR(300),
@MaxAccessCount INT,
@AccessCount INT,
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@ExpirationDate DATETIME2(7),
@DeletionDate DATETIME2(7),
@Disabled BIT,
@HideEmail BIT,
@CipherId UNIQUEIDENTIFIER = NULL,
@Emails NVARCHAR(4000) = NULL,
@AuthType TINYINT = NULL,
@EmailHashes NVARCHAR(4000) = NULL
AS
BEGIN
SET NOCOUNT ON
UPDATE
[dbo].[Send]
SET
[UserId] = @UserId,
[OrganizationId] = @OrganizationId,
[Type] = @Type,
[Data] = @Data,
[Key] = @Key,
[Password] = @Password,
[MaxAccessCount] = @MaxAccessCount,
[AccessCount] = @AccessCount,
[CreationDate] = @CreationDate,
[RevisionDate] = @RevisionDate,
[ExpirationDate] = @ExpirationDate,
[DeletionDate] = @DeletionDate,
[Disabled] = @Disabled,
[HideEmail] = @HideEmail,
[CipherId] = @CipherId,
[Emails] = @Emails,
[AuthType] = @AuthType,
[EmailHashes] = @EmailHashes
WHERE
[Id] = @Id
IF @UserId IS NOT NULL
BEGIN
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId
END
-- TODO: OrganizationId bump?
END
GO
EXECUTE sp_refreshview N'[dbo].[SendView]'
GO

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.MySqlMigrations.Migrations;
/// <inheritdoc />
public partial class _20260117_00_Send_EmailHashes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "EmailHashes",
table: "Send",
type: "varchar(4000)",
maxLength: 4000,
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EmailHashes",
table: "Send");
}
}

View File

@@ -1689,6 +1689,10 @@ namespace Bit.MySqlMigrations.Migrations
b.Property<bool>("Disabled")
.HasColumnType("tinyint(1)");
b.Property<string>("EmailHashes")
.HasMaxLength(4000)
.HasColumnType("varchar(4000)");
b.Property<string>("Emails")
.HasMaxLength(4000)
.HasColumnType("varchar(4000)");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.PostgresMigrations.Migrations;
/// <inheritdoc />
public partial class _20260117_00_Send_EmailHashes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "EmailHashes",
table: "Send",
type: "character varying(4000)",
maxLength: 4000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EmailHashes",
table: "Send");
}
}

View File

@@ -1694,6 +1694,10 @@ namespace Bit.PostgresMigrations.Migrations
b.Property<bool>("Disabled")
.HasColumnType("boolean");
b.Property<string>("EmailHashes")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("Emails")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bit.SqliteMigrations.Migrations;
/// <inheritdoc />
public partial class _20260117_00_Send_EmailHashes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "EmailHashes",
table: "Send",
type: "TEXT",
maxLength: 4000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EmailHashes",
table: "Send");
}
}

View File

@@ -1678,6 +1678,10 @@ namespace Bit.SqliteMigrations.Migrations
b.Property<bool>("Disabled")
.HasColumnType("INTEGER");
b.Property<string>("EmailHashes")
.HasMaxLength(4000)
.HasColumnType("TEXT");
b.Property<string>("Emails")
.HasMaxLength(4000)
.HasColumnType("TEXT");