mirror of
https://github.com/bitwarden/server
synced 2026-02-01 01:03:25 +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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"/> >= <see cref="MaxAccessCount"/>.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user