diff --git a/src/Api/Tools/Controllers/SendsController.cs b/src/Api/Tools/Controllers/SendsController.cs index afb537c81402..86d8491405ec 100644 --- a/src/Api/Tools/Controllers/SendsController.cs +++ b/src/Api/Tools/Controllers/SendsController.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Azure.Messaging.EventGrid; -using Bit.Api.Models.Response; using Bit.Api.Tools.Models.Request; using Bit.Api.Tools.Models.Response; using Bit.Api.Utilities; @@ -21,6 +20,7 @@ using Bit.Core.Tools.SendFeatures.Services.Interfaces; using Bit.Core.Tools.Services; using Bit.Core.Utilities; +using Bit.HttpExtensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -298,13 +298,13 @@ public async Task AccessUsingAuth() } /* - * AccessCount is incremented differently for File and Text Send types: - * - Text Sends are incremented at every access + * AccessCount is incremented differently depending on Send type: + * - Text and Item Sends are incremented at every access * - File Sends are incremented only when the file is downloaded * * Note that this endpoint is initially called for all Send types */ - if (send.Type == SendType.Text) + if (send.Type == SendType.Text || send.Type == SendType.Item) { send.AccessCount++; await _sendRepository.ReplaceAsync(send); diff --git a/src/Api/Tools/Models/Request/SendRequestModel.cs b/src/Api/Tools/Models/Request/SendRequestModel.cs index 2063c4d4ec87..1ea5ea55e4b7 100644 --- a/src/Api/Tools/Models/Request/SendRequestModel.cs +++ b/src/Api/Tools/Models/Request/SendRequestModel.cs @@ -87,6 +87,12 @@ public class SendRequestModel /// public SendTextModel? Text { get; set; } + /// + /// String containing secret Send data + /// + [StringLength(500000)] + public string? Data { get; set; } + /// /// Base64-encoded byte array of a password hash that grants access to the send. /// Mutually exclusive with . @@ -165,6 +171,9 @@ public Send UpdateSend(Send existingSend, ISendAuthorizationService sendAuthoriz case SendType.Text: existingSend.Data = JsonSerializer.Serialize(ToSendTextData(), JsonHelpers.IgnoreWritingNull); break; + case SendType.Item: + existingSend.Data = Data ?? throw new ArgumentNullException(nameof(Data), "Data is required for Sends of type Item"); + break; default: throw new ArgumentException("Unsupported type: " + nameof(Type) + "."); } diff --git a/src/Api/Tools/Models/Response/SendAccessResponseModel.cs b/src/Api/Tools/Models/Response/SendAccessResponseModel.cs index 447a4ea51840..d35cee7f2555 100644 --- a/src/Api/Tools/Models/Response/SendAccessResponseModel.cs +++ b/src/Api/Tools/Models/Response/SendAccessResponseModel.cs @@ -34,7 +34,6 @@ public SendAccessResponseModel(Send send) Type = send.Type; AuthType = send.AuthType; - SendData sendData; switch (send.Type) { case SendType.File: @@ -43,7 +42,7 @@ public SendAccessResponseModel(Send send) throw new NullReferenceException( "Send Data is required")) ?? throw new JsonException("Failed to deserialize send file data."); - sendData = fileData; + Name = fileData.Name; File = new SendFileModel(fileData); break; case SendType.Text: @@ -52,14 +51,17 @@ public SendAccessResponseModel(Send send) throw new NullReferenceException( "Send Data is required")) ?? throw new JsonException("Failed to deserialize send text data."); - sendData = textData; + Name = textData.Name; Text = new SendTextModel(textData); break; + case SendType.Item: + Name = string.Empty; + Data = send.Data ?? throw new NullReferenceException("Send Data is required"); + break; default: throw new ArgumentException("Unsupported " + nameof(Type) + "."); } - Name = sendData.Name; ExpirationDate = send.ExpirationDate; } @@ -101,6 +103,11 @@ public SendAccessResponseModel(Send send) /// public SendTextModel? Text { get; set; } + /// + /// Encrypted string containing secret Send data + /// + public string? Data { get; set; } + /// /// The date after which a send cannot be accessed. When this value is /// , there is no expiration date. diff --git a/src/Api/Tools/Models/Response/SendResponseModel.cs b/src/Api/Tools/Models/Response/SendResponseModel.cs index 31235e6290ec..0196b04e37ea 100644 --- a/src/Api/Tools/Models/Response/SendResponseModel.cs +++ b/src/Api/Tools/Models/Response/SendResponseModel.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Bit.Api.Tools.Utilities; -using Bit.Core.Models.Api; using Bit.Core.Tools.Entities; using Bit.Core.Tools.Enums; using Bit.Core.Tools.Models.Data; @@ -12,7 +11,7 @@ namespace Bit.Api.Tools.Models.Response; /// A response issued to a Bitwarden client in response to ownership operations. /// /// -public class SendResponseModel : ResponseModel +public class SendResponseModel : HttpExtensions.ResponseModel { /// /// Instantiates a send response model @@ -47,7 +46,6 @@ public SendResponseModel(Send send) Disabled = send.Disabled; HideEmail = send.HideEmail.GetValueOrDefault(); - SendData sendData; switch (send.Type) { case SendType.File: @@ -56,7 +54,8 @@ public SendResponseModel(Send send) throw new NullReferenceException( "Send Data is required")) ?? throw new JsonException("Failed to deserialize send file data."); - sendData = fileData; + Name = fileData.Name; + Notes = fileData.Notes; File = new SendFileModel(fileData); break; case SendType.Text: @@ -65,15 +64,20 @@ public SendResponseModel(Send send) throw new NullReferenceException( "Send Data is required")) ?? throw new JsonException("Failed to deserialize send text data."); - sendData = textData; + Name = textData.Name; + Notes = textData.Notes; Text = new SendTextModel(textData); break; + case SendType.Item: + // These fields are included in send.Data, but since the entire + // object is encrypted as a blob we can't extract them here. + Name = string.Empty; + Notes = string.Empty; + Data = send.Data ?? throw new NullReferenceException("Send Data is required"); + break; default: throw new ArgumentException("Unsupported " + nameof(Type) + "."); } - - Name = sendData.Name; - Notes = sendData.Notes; } /// @@ -126,6 +130,11 @@ public SendResponseModel(Send send) /// public SendTextModel? Text { get; set; } + /// + /// Encrypted string containing secret Send data + /// + public string? Data { get; set; } + /// /// A base64-encoded byte array containing the Send's encryption key. /// It's also provided to send recipients in the Send's URL. diff --git a/src/Core/Tools/Enums/SendType.cs b/src/Core/Tools/Enums/SendType.cs index cd8f53292b5b..289e016d4135 100644 --- a/src/Core/Tools/Enums/SendType.cs +++ b/src/Core/Tools/Enums/SendType.cs @@ -3,5 +3,6 @@ public enum SendType : byte { Text = 0, - File = 1 + File = 1, + Item = 2 } diff --git a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs index 313be7a2ca76..945287c291cb 100644 --- a/test/Api.Test/Tools/Controllers/SendsControllerTests.cs +++ b/test/Api.Test/Tools/Controllers/SendsControllerTests.cs @@ -2,7 +2,6 @@ using System.Text; using System.Text.Json; using AutoFixture.Xunit2; -using Bit.Api.Models.Response; using Bit.Api.Tools.Controllers; using Bit.Api.Tools.Models; using Bit.Api.Tools.Models.Request; @@ -24,6 +23,7 @@ using Bit.Core.Tools.SendFeatures.Services.Interfaces; using Bit.Core.Tools.Services; using Bit.Core.Utilities; +using Bit.HttpExtensions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; diff --git a/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs b/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs index 144729119ce9..b32b8e9243d7 100644 --- a/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs +++ b/test/Api.Test/Tools/Models/Request/SendRequestModelTests.cs @@ -59,6 +59,68 @@ public void ToSend_Text_Success() Assert.Equal("encrypted_name", name); } + [Fact] + public void ToSend_Item_Success() + { + var deletionDate = DateTime.UtcNow.AddDays(5); + var sendRequest = new SendRequestModel + { + AuthType = AuthType.Password, + DeletionDate = deletionDate, + Disabled = false, + ExpirationDate = null, + HideEmail = false, + Key = "encrypted_key", + MaxAccessCount = null, + Name = "encrypted_name", + Notes = null, + Password = "Password", + Data = "encrypted_data", + Type = SendType.Item, + }; + + var sendAuthorizationService = Substitute.For(); + sendAuthorizationService.HashPassword(Arg.Any()) + .Returns((info) => $"hashed_{(string)info[0]}"); + + var send = sendRequest.ToSend(Guid.NewGuid(), sendAuthorizationService); + + Assert.Equal(deletionDate, send.DeletionDate); + Assert.False(send.Disabled); + Assert.Null(send.ExpirationDate); + Assert.False(send.HideEmail); + Assert.Equal("encrypted_key", send.Key); + Assert.Equal("hashed_Password", send.Password); + Assert.Equal("encrypted_data", send.Data); + } + + [Fact] + public void ToSend_Item_NullData() + { + var deletionDate = DateTime.UtcNow.AddDays(5); + var sendRequest = new SendRequestModel + { + AuthType = AuthType.Password, + DeletionDate = deletionDate, + Disabled = false, + ExpirationDate = null, + HideEmail = false, + Key = "encrypted_key", + MaxAccessCount = null, + Name = "encrypted_name", + Notes = null, + Password = "Password", + Data = null, + Type = SendType.Item, + }; + + var sendAuthorizationService = Substitute.For(); + sendAuthorizationService.HashPassword(Arg.Any()) + .Returns((info) => $"hashed_{(string)info[0]}"); + + Assert.Throws(() => sendRequest.ToSend(Guid.NewGuid(), sendAuthorizationService)); + } + [Fact] public void ValidateEdit_DeletionDateInPast_ThrowsBadRequestException() { diff --git a/test/Api.Test/Tools/Models/Response/SendAccessResponseModelTests.cs b/test/Api.Test/Tools/Models/Response/SendAccessResponseModelTests.cs new file mode 100644 index 000000000000..316cc209ffdd --- /dev/null +++ b/test/Api.Test/Tools/Models/Response/SendAccessResponseModelTests.cs @@ -0,0 +1,114 @@ +using System.Text.Json; +using Bit.Api.Tools.Models.Response; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Data; +using Xunit; + +namespace Bit.Api.Test.Models.Response; + +public class SendAccessResponseModelTests +{ + [Fact] + public void NullSend_Throws() + { + Send send = null; + SendAccessResponseModel responseModel; + Assert.Throws(() => responseModel = new SendAccessResponseModel(send)); + } + + [Fact] + public void TextSend_NullData_Throws() + { + var send = new Send + { + Type = SendType.Text, + Data = null + }; + SendAccessResponseModel responseModel; + Assert.Throws(() => responseModel = new SendAccessResponseModel(send)); + } + + [Fact] + public void TextSend_NonDeserializableData_Throws() + { + var send = new Send + { + Type = SendType.Text, + Data = "bad_data" + }; + SendAccessResponseModel responseModel; + Assert.Throws(() => responseModel = new SendAccessResponseModel(send)); + } + + [Fact] + public void FileSend_NullData_Throws() + { + var send = new Send + { + Type = SendType.File, + Data = null + }; + SendAccessResponseModel responseModel; + Assert.Throws(() => responseModel = new SendAccessResponseModel(send)); + } + + [Fact] + public void FileSend_NonDeserializableData_Throws() + { + var send = new Send + { + Type = SendType.File, + Data = "bad_data" + }; + SendAccessResponseModel responseModel; + Assert.Throws(() => responseModel = new SendAccessResponseModel(send)); + } + + [Fact] + public void ItemSend_NullData_Throws() + { + var send = new Send + { + Type = SendType.Item, + Data = null + }; + SendAccessResponseModel responseModel; + Assert.Throws(() => responseModel = new SendAccessResponseModel(send)); + } + + [Fact] + public void FromSend_Success() + { + var send = new Send + { + Id = Guid.NewGuid(), + AuthType = AuthType.None, + Key = "encrypted_key", + MaxAccessCount = 5, + AccessCount = 0, + RevisionDate = new DateTime(), + ExpirationDate = new DateTime(), + DeletionDate = new DateTime(), + Password = null, + Emails = null, + Disabled = false, + HideEmail = false, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData + { + Hidden = false, + Name = "encrypted_name", + Notes = null, + Text = "encrypted_text" + }) + }; + var responseModel = new SendAccessResponseModel(send); + Assert.Equal(send.Type, responseModel.Type); + Assert.Equal(send.AuthType, responseModel.AuthType); + Assert.Equal("encrypted_name", responseModel.Name); + Assert.Equal("encrypted_text", responseModel.Text.Text); + Assert.False(responseModel.Text.Hidden); + Assert.Equal(send.ExpirationDate, responseModel.ExpirationDate); + } +} diff --git a/test/Api.Test/Tools/Models/Response/SendResponseModelTests.cs b/test/Api.Test/Tools/Models/Response/SendResponseModelTests.cs new file mode 100644 index 000000000000..3b65619b3490 --- /dev/null +++ b/test/Api.Test/Tools/Models/Response/SendResponseModelTests.cs @@ -0,0 +1,125 @@ +using System.Text.Json; +using Bit.Api.Tools.Models.Response; +using Bit.Core.Tools.Entities; +using Bit.Core.Tools.Enums; +using Bit.Core.Tools.Models.Data; +using Xunit; + +namespace Bit.Api.Test.Models.Response; + +public class SendResponseModelTests +{ + [Fact] + public void NullSend_Throws() + { + Send send = null; + SendResponseModel responseModel; + Assert.Throws(() => responseModel = new SendResponseModel(send)); + } + + [Fact] + public void TextSend_NullData_Throws() + { + var send = new Send + { + Type = SendType.Text, + Data = null + }; + SendResponseModel responseModel; + Assert.Throws(() => responseModel = new SendResponseModel(send)); + } + + [Fact] + public void TextSend_NonDeserializableData_Throws() + { + var send = new Send + { + Type = SendType.Text, + Data = "bad_data" + }; + SendResponseModel responseModel; + Assert.Throws(() => responseModel = new SendResponseModel(send)); + } + + [Fact] + public void FileSend_NullData_Throws() + { + var send = new Send + { + Type = SendType.File, + Data = null + }; + SendResponseModel responseModel; + Assert.Throws(() => responseModel = new SendResponseModel(send)); + } + + [Fact] + public void FileSend_NonDeserializableData_Throws() + { + var send = new Send + { + Type = SendType.File, + Data = "bad_data" + }; + SendResponseModel responseModel; + Assert.Throws(() => responseModel = new SendResponseModel(send)); + } + + [Fact] + public void ItemSend_NullData_Throws() + { + var send = new Send + { + Type = SendType.Item, + Data = null + }; + SendResponseModel responseModel; + Assert.Throws(() => responseModel = new SendResponseModel(send)); + } + + [Fact] + public void FromSend_Success() + { + var send = new Send + { + Id = Guid.NewGuid(), + AuthType = AuthType.None, + Key = "encrypted_key", + MaxAccessCount = 5, + AccessCount = 0, + RevisionDate = new DateTime(), + ExpirationDate = new DateTime(), + DeletionDate = new DateTime(), + Password = null, + Emails = null, + Disabled = false, + HideEmail = false, + Type = SendType.Text, + Data = JsonSerializer.Serialize(new SendTextData + { + Hidden = false, + Name = "encrypted_name", + Notes = null, + Text = "encrypted_text" + }) + }; + var responseModel = new SendResponseModel(send); + Assert.Equal(send.Id, responseModel.Id); + Assert.Equal(send.AuthType, responseModel.AuthType); + Assert.Equal(send.Key, responseModel.Key); + Assert.Equal(send.MaxAccessCount, responseModel.MaxAccessCount); + Assert.Equal(send.AccessCount, responseModel.AccessCount); + Assert.Equal(send.RevisionDate, responseModel.RevisionDate); + Assert.Equal(send.ExpirationDate, responseModel.ExpirationDate); + Assert.Equal(send.DeletionDate, responseModel.DeletionDate); + Assert.Equal(send.Password, responseModel.Password); + Assert.Equal(send.Emails, responseModel.Emails); + Assert.Equal(send.Disabled, responseModel.Disabled); + Assert.Equal(send.HideEmail, responseModel.HideEmail); + Assert.Equal(send.Type, responseModel.Type); + Assert.False(responseModel.Text.Hidden); + Assert.Equal("encrypted_name", responseModel.Name); + Assert.Null(responseModel.Notes); + Assert.Equal("encrypted_text", responseModel.Text.Text); + } +}