diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 833e87305..2a2e52ab1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -63,6 +63,9 @@ jobs: - name: Restore packages run: nuget restore + - name: Run Core Tests + run: dotnet test test/Core.Test/Core.Test.csproj + - name: Build Play Store publisher run: dotnet build ./store/google/Publisher/Publisher.csproj -p:Configuration=Release diff --git a/bitwarden-mobile.sln b/bitwarden-mobile.sln index c4ba4b160..cddec0fe1 100644 --- a/bitwarden-mobile.sln +++ b/bitwarden-mobile.sln @@ -41,6 +41,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "iOS.Extension", "src\iOS.Ex EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "iOS.Autofill", "src\iOS.Autofill\iOS.Autofill.csproj", "{8A3ECD75-3EC8-4CB3-B3A2-A73A724C279A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common", "test\Common\Common.csproj", "{4085B0A5-12A9-4993-B8B8-4ACE72E62E39}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.Test", "test\Core.Test\Core.Test.csproj", "{8AE548D9-A567-4E97-995E-93EC7DB0FDE0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Ad-Hoc|Any CPU = Ad-Hoc|Any CPU @@ -351,6 +355,66 @@ Global {8A3ECD75-3EC8-4CB3-B3A2-A73A724C279A}.Release|iPhone.Build.0 = Release|iPhone {8A3ECD75-3EC8-4CB3-B3A2-A73A724C279A}.Release|iPhoneSimulator.ActiveCfg = Release|iPhoneSimulator {8A3ECD75-3EC8-4CB3-B3A2-A73A724C279A}.Release|iPhoneSimulator.Build.0 = Release|iPhoneSimulator + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.Ad-Hoc|Any CPU.ActiveCfg = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.Ad-Hoc|Any CPU.Build.0 = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.Ad-Hoc|iPhone.ActiveCfg = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.Ad-Hoc|iPhone.Build.0 = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.Ad-Hoc|iPhoneSimulator.Build.0 = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.AppStore|Any CPU.ActiveCfg = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.AppStore|Any CPU.Build.0 = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.AppStore|iPhone.ActiveCfg = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.AppStore|iPhone.Build.0 = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.AppStore|iPhoneSimulator.ActiveCfg = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.AppStore|iPhoneSimulator.Build.0 = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.Debug|iPhone.Build.0 = Debug|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.FDroid|Any CPU.ActiveCfg = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.FDroid|Any CPU.Build.0 = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.FDroid|iPhone.ActiveCfg = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.FDroid|iPhone.Build.0 = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.FDroid|iPhoneSimulator.ActiveCfg = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.FDroid|iPhoneSimulator.Build.0 = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.Release|Any CPU.Build.0 = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.Release|iPhone.ActiveCfg = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.Release|iPhone.Build.0 = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.Ad-Hoc|Any CPU.ActiveCfg = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.Ad-Hoc|Any CPU.Build.0 = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.Ad-Hoc|iPhone.ActiveCfg = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.Ad-Hoc|iPhone.Build.0 = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.Ad-Hoc|iPhoneSimulator.ActiveCfg = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.Ad-Hoc|iPhoneSimulator.Build.0 = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.AppStore|Any CPU.ActiveCfg = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.AppStore|Any CPU.Build.0 = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.AppStore|iPhone.ActiveCfg = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.AppStore|iPhone.Build.0 = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.AppStore|iPhoneSimulator.ActiveCfg = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.AppStore|iPhoneSimulator.Build.0 = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.Debug|iPhone.Build.0 = Debug|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.FDroid|Any CPU.ActiveCfg = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.FDroid|Any CPU.Build.0 = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.FDroid|iPhone.ActiveCfg = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.FDroid|iPhone.Build.0 = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.FDroid|iPhoneSimulator.ActiveCfg = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.FDroid|iPhoneSimulator.Build.0 = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.Release|Any CPU.Build.0 = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.Release|iPhone.ActiveCfg = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.Release|iPhone.Build.0 = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0}.Release|iPhoneSimulator.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -366,6 +430,8 @@ Global {599E0201-420A-4C3E-A7BA-5349F72E0B15} = {D10CA4A9-F866-40E1-B658-F69051236C71} {324BE76C-38FA-4F11-8BB1-95C7B3B1B545} = {D10CA4A9-F866-40E1-B658-F69051236C71} {8A3ECD75-3EC8-4CB3-B3A2-A73A724C279A} = {D10CA4A9-F866-40E1-B658-F69051236C71} + {4085B0A5-12A9-4993-B8B8-4ACE72E62E39} = {8904C536-C67D-420F-9971-51B26574C3AA} + {8AE548D9-A567-4E97-995E-93EC7DB0FDE0} = {8904C536-C67D-420F-9971-51B26574C3AA} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7D436EA3-8B7E-45D2-8D14-0730BD2E0410} diff --git a/src/Android/Android.csproj b/src/Android/Android.csproj index abad97bb6..2f7df60bd 100644 --- a/src/Android/Android.csproj +++ b/src/Android/Android.csproj @@ -206,7 +206,7 @@ - {9F1742A7-7D03-4BB3-8FCD-41BC3002B00A} + {EE44C6A1-2A85-45FE-8D9B-BF1D5F88809C} App diff --git a/src/Core/Abstractions/IApiService.cs b/src/Core/Abstractions/IApiService.cs index be692c8d4..0f79493bd 100644 --- a/src/Core/Abstractions/IApiService.cs +++ b/src/Core/Abstractions/IApiService.cs @@ -53,5 +53,12 @@ namespace Bit.Core.Abstractions Task PostTwoFactorEmailAsync(TwoFactorEmailRequest request); Task PutDeviceTokenAsync(string identifier, DeviceTokenRequest request); Task PostEventsCollectAsync(IEnumerable request); + + Task GetSendAsync(string id); + Task PostSendAsync(SendRequest request); + Task PostSendFileAsync(MultipartFormDataContent data); + Task PutSendAsync(string id, SendRequest request); + Task PutSendRemovePasswordAsync(string id); + Task DeleteSendAsync(string id); } } diff --git a/src/Core/Abstractions/ICryptoFunctionService.cs b/src/Core/Abstractions/ICryptoFunctionService.cs index cb1e5a2a7..98e58bf48 100644 --- a/src/Core/Abstractions/ICryptoFunctionService.cs +++ b/src/Core/Abstractions/ICryptoFunctionService.cs @@ -10,6 +10,12 @@ namespace Bit.Core.Abstractions Task Pbkdf2Async(byte[] password, string salt, CryptoHashAlgorithm algorithm, int iterations); Task Pbkdf2Async(string password, byte[] salt, CryptoHashAlgorithm algorithm, int iterations); Task Pbkdf2Async(byte[] password, byte[] salt, CryptoHashAlgorithm algorithm, int iterations); + Task HkdfAsync(byte[] ikm, string salt, string info, int outputByteSize, HkdfAlgorithm algorithm); + Task HkdfAsync(byte[] ikm, byte[] salt, string info, int outputByteSize, HkdfAlgorithm algorithm); + Task HkdfAsync(byte[] ikm, string salt, byte[] info, int outputByteSize, HkdfAlgorithm algorithm); + Task HkdfAsync(byte[] ikm, byte[] salt, byte[] info, int outputByteSize, HkdfAlgorithm algorithm); + Task HkdfExpandAsync(byte[] prk, string info, int outputByteSize, HkdfAlgorithm algorithm); + Task HkdfExpandAsync(byte[] prk, byte[] info, int outputByteSize, HkdfAlgorithm algorithm); Task HashAsync(string value, CryptoHashAlgorithm algorithm); Task HashAsync(byte[] value, CryptoHashAlgorithm algorithm); Task HmacAsync(byte[] value, byte[] key, CryptoHashAlgorithm algorithm); diff --git a/src/Core/Abstractions/ICryptoService.cs b/src/Core/Abstractions/ICryptoService.cs index 6bcfbebcc..1d579fca8 100644 --- a/src/Core/Abstractions/ICryptoService.cs +++ b/src/Core/Abstractions/ICryptoService.cs @@ -40,6 +40,7 @@ namespace Bit.Core.Abstractions Task> MakeKeyPairAsync(SymmetricCryptoKey key = null); Task MakePinKeyAysnc(string pin, string salt, KdfType kdf, int kdfIterations); Task> MakeShareKeyAsync(); + Task MakeSendKeyAsync(byte[] keyMaterial); Task RandomNumberAsync(int min, int max); Task> RemakeEncKeyAsync(SymmetricCryptoKey key); Task RsaEncryptAsync(byte[] data, byte[] publicKey = null); diff --git a/src/Core/Abstractions/ISendService.cs b/src/Core/Abstractions/ISendService.cs new file mode 100644 index 000000000..ef0e1c54e --- /dev/null +++ b/src/Core/Abstractions/ISendService.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Bit.Core.Models.Data; +using Bit.Core.Models.Domain; +using Bit.Core.Models.View; + +namespace Bit.Core.Abstractions +{ + public interface ISendService + { + void ClearCache(); + Task<(Send send, CipherString encryptedFileData)> EncryptAsync(SendView model, byte[] fileData, string password, + SymmetricCryptoKey key = null); + Task GetAsync(string id); + Task> GetAllAsync(); + Task> GetAllDecryptedAsync(); + Task SaveWithServerAsync(Send sendData, byte[] encryptedFileData); + Task UpsertAsync(params SendData[] send); + Task ReplaceAsync(Dictionary sends); + Task ClearAsync(string userId); + Task DeleteAsync(params string[] ids); + Task DeleteWithServerAsync(string id); + Task RemovePasswordWithServerAsync(string id); + } +} diff --git a/src/Core/Enums/HdkfAlgorithm.cs b/src/Core/Enums/HdkfAlgorithm.cs new file mode 100644 index 000000000..5837a3fa3 --- /dev/null +++ b/src/Core/Enums/HdkfAlgorithm.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Enums +{ + public enum HkdfAlgorithm : byte + { + Sha256 = 1, + Sha512 = 2, + } +} diff --git a/src/Core/Enums/SendType.cs b/src/Core/Enums/SendType.cs new file mode 100644 index 000000000..f8f06f13d --- /dev/null +++ b/src/Core/Enums/SendType.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Enums +{ + public enum SendType + { + Text = 0, + File = 1, + } +} diff --git a/src/Core/Models/Api/SendFileApi.cs b/src/Core/Models/Api/SendFileApi.cs new file mode 100644 index 000000000..aeb7093c3 --- /dev/null +++ b/src/Core/Models/Api/SendFileApi.cs @@ -0,0 +1,12 @@ +namespace Bit.Core.Models.Api +{ + public class SendFileApi + { + public string Id { get; set; } + public string Url { get; set; } + public string FileName { get; set; } + public string Key { get; set; } + public string Size { get; set; } + public string SizeName { get; set; } + } +} diff --git a/src/Core/Models/Api/SendTextApi.cs b/src/Core/Models/Api/SendTextApi.cs new file mode 100644 index 000000000..9520031da --- /dev/null +++ b/src/Core/Models/Api/SendTextApi.cs @@ -0,0 +1,8 @@ +namespace Bit.Core.Models.Api +{ + public class SendTextApi + { + public string Text { get; set; } + public bool Hidden { get; set; } + } +} diff --git a/src/Core/Models/Data/SendData.cs b/src/Core/Models/Data/SendData.cs new file mode 100644 index 000000000..e25be5e34 --- /dev/null +++ b/src/Core/Models/Data/SendData.cs @@ -0,0 +1,58 @@ +using System; +using Bit.Core.Enums; +using Bit.Core.Models.Response; + +namespace Bit.Core.Models.Data +{ + public class SendData : Data + { + public SendData() { } + + public SendData(SendResponse response, string userId) + { + Id = response.Id; + AccessId = response.AccessId; + UserId = userId; + Type = response.Type; + Name = response.Name; + Notes = response.Notes; + Key = response.Key; + MaxAccessCount = response.MaxAccessCount; + AccessCount = response.AccessCount; + RevisionDate = response.RevisionDate; + ExpirationDate = response.ExpirationDate; + DeletionDate = response.DeletionDate; + Password = response.Password; + Disabled = response.Disabled; + + switch (Type) + { + case SendType.File: + File = new SendFileData(response.File); + break; + case SendType.Text: + Text = new SendTextData(response.Text); + break; + default: + break; + } + } + + public string Id { get; set; } + public string AccessId { get; set; } + public string UserId { get; set; } + public SendType Type { get; set; } + public string Name { get; set; } + public string Notes { get; set; } + public SendFileData File { get; set; } + public SendTextData Text { get; set; } + public string Key { get; set; } + public int? MaxAccessCount { get; set; } + public int AccessCount { get; set; } + public DateTime RevisionDate { get; set; } + public DateTime? ExpirationDate { get; set; } + public DateTime DeletionDate { get; set; } + public string Password { get; set; } + public bool Disabled { get; set; } + } +} diff --git a/src/Core/Models/Data/SendFileData.cs b/src/Core/Models/Data/SendFileData.cs new file mode 100644 index 000000000..b9e308bb6 --- /dev/null +++ b/src/Core/Models/Data/SendFileData.cs @@ -0,0 +1,27 @@ +using System.Drawing; +using Bit.Core.Models.Api; + +namespace Bit.Core.Models.Data +{ + public class SendFileData : Data + { + public SendFileData() { } + + public SendFileData(SendFileApi data) + { + Id = data.Id; + Url = data.Url; + FileName = data.FileName; + Key = data.Key; + Size = data.Size; + SizeName = data.SizeName; + } + + public string Id { get; set; } + public string Url { get; set; } + public string FileName { get; set; } + public string Key { get; set; } + public string Size { get; set; } + public string SizeName { get; set; } + } +} diff --git a/src/Core/Models/Data/SendTextData.cs b/src/Core/Models/Data/SendTextData.cs new file mode 100644 index 000000000..2e83ca1a0 --- /dev/null +++ b/src/Core/Models/Data/SendTextData.cs @@ -0,0 +1,19 @@ +using System.Drawing; +using Bit.Core.Models.Api; + +namespace Bit.Core.Models.Data +{ + public class SendTextData : Data + { + public SendTextData() { } + + public SendTextData(SendTextApi data) + { + Text = data.Text; + Hidden = data.Hidden; + } + + public string Text { get; set; } + public bool Hidden { get; set; } + } +} diff --git a/src/Core/Models/Domain/CipherString.cs b/src/Core/Models/Domain/CipherString.cs index 9a6956b83..82086d571 100644 --- a/src/Core/Models/Domain/CipherString.cs +++ b/src/Core/Models/Domain/CipherString.cs @@ -99,7 +99,7 @@ namespace Bit.Core.Models.Domain public string Data { get; private set; } public string Mac { get; private set; } - public async Task DecryptAsync(string orgId = null) + public async Task DecryptAsync(string orgId = null, SymmetricCryptoKey key = null) { if (_decryptedValue != null) { @@ -109,8 +109,11 @@ namespace Bit.Core.Models.Domain var cryptoService = ServiceContainer.Resolve("cryptoService"); try { - var orgKey = await cryptoService.GetOrgKeyAsync(orgId); - _decryptedValue = await cryptoService.DecryptToUtf8Async(this, orgKey); + if (key == null) + { + key = await cryptoService.GetOrgKeyAsync(orgId); + } + _decryptedValue = await cryptoService.DecryptToUtf8Async(this, key); } catch { diff --git a/src/Core/Models/Domain/Domain.cs b/src/Core/Models/Domain/Domain.cs index 5ff7e3f0b..6624288c6 100644 --- a/src/Core/Models/Domain/Domain.cs +++ b/src/Core/Models/Domain/Domain.cs @@ -52,7 +52,7 @@ namespace Bit.Core.Models.Domain } } - protected async Task DecryptObjAsync(V viewModel, D domain, HashSet map, string orgId) + protected async Task DecryptObjAsync(V viewModel, D domain, HashSet map, string orgId, SymmetricCryptoKey key = null) where V : View.View { var viewModelType = viewModel.GetType(); @@ -64,7 +64,7 @@ namespace Bit.Core.Models.Domain string val = null; if (domainPropInfo.GetValue(domain) is CipherString domainProp) { - val = await domainProp.DecryptAsync(orgId); + val = await domainProp.DecryptAsync(orgId, key); } var viewModelPropInfo = viewModelType.GetProperty(propName); viewModelPropInfo.SetValue(viewModel, val, null); diff --git a/src/Core/Models/Domain/Send.cs b/src/Core/Models/Domain/Send.cs new file mode 100644 index 000000000..8d86c22f8 --- /dev/null +++ b/src/Core/Models/Domain/Send.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Bit.Core.Abstractions; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.View; +using Bit.Core.Utilities; + +namespace Bit.Core.Models.Domain +{ + public class Send : Domain + { + public string Id { get; set; } + public string AccessId { get; set; } + public string UserId { get; set; } + public SendType Type { get; set; } + public CipherString Name { get; set; } + public CipherString Notes { get; set; } + public SendFile File { get; set; } + public SendText Text { get; set; } + public CipherString Key { get; set; } + public int? MaxAccessCount { get; set; } + public int AccessCount { get; set; } + public DateTime RevisionDate { get; set; } + public DateTime? ExpirationDate { get; set; } + public DateTime DeletionDate { get; set; } + public string Password { get; set; } + public bool Disabled { get; set; } + + public Send() : base() { } + + public Send(SendData data, bool alreadyEncrypted = false) : base() + { + BuildDomainModel(this, data, new HashSet{ + "Id", + "AccessId", + "UserId", + "Name", + "Notes", + "Key", + }, alreadyEncrypted, new HashSet { "Id", "AccessId", "UserId" }); + + Type = data.Type; + MaxAccessCount = data.MaxAccessCount; + AccessCount = data.AccessCount; + Password = data.Password; + Disabled = data.Disabled; + RevisionDate = data.RevisionDate; + DeletionDate = data.DeletionDate; + ExpirationDate = data.ExpirationDate; + + switch (Type) + { + case SendType.Text: + Text = new SendText(data.Text, alreadyEncrypted); + break; + case SendType.File: + File = new SendFile(data.File, alreadyEncrypted); + break; + default: + break; + } + } + + public async Task DecryptAsync() + { + var view = new SendView(this); + + var cryptoService = ServiceContainer.Resolve("cryptoService"); + + view.Key = await cryptoService.DecryptToBytesAsync(Key, null); + view.CryptoKey = await cryptoService.MakeSendKeyAsync(view.Key); + + await DecryptObjAsync(view, this, new HashSet { "Name", "Notes" }, null, view.CryptoKey); + + switch (Type) + { + case SendType.File: + view.File = await this.File.DecryptAsync(view.CryptoKey); + break; + case SendType.Text: + view.Text = await this.Text.DecryptAsync(view.CryptoKey); + break; + default: + break; + } + return view; + } + } +} diff --git a/src/Core/Models/Domain/SendFile.cs b/src/Core/Models/Domain/SendFile.cs new file mode 100644 index 000000000..1a0e80f2e --- /dev/null +++ b/src/Core/Models/Domain/SendFile.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Bit.Core.Models.Data; +using Bit.Core.Models.View; + +namespace Bit.Core.Models.Domain +{ + public class SendFile : Domain + { + public string Id { get; set; } + public string Url { get; set; } + public string Size { get; set; } + public string SizeName { get; set; } + public CipherString FileName { get; set; } + + public SendFile() : base() { } + + public SendFile(SendFileData file, bool alreadyEncrypted = false) : base() + { + Size = file.Size; + BuildDomainModel(this, file, new HashSet { "Id", "Url", "SizeName", "FileName" }, alreadyEncrypted, new HashSet { "Id", "Url", "SizeName" }); + } + + public Task DecryptAsync(SymmetricCryptoKey key) => + DecryptObjAsync(new SendFileView(this), this, new HashSet { "FileName" }, null, key); + } +} diff --git a/src/Core/Models/Domain/SendText.cs b/src/Core/Models/Domain/SendText.cs new file mode 100644 index 000000000..c9cb8e4e3 --- /dev/null +++ b/src/Core/Models/Domain/SendText.cs @@ -0,0 +1,25 @@ + +using System.Collections.Generic; +using System.Threading.Tasks; +using Bit.Core.Models.Data; +using Bit.Core.Models.View; + +namespace Bit.Core.Models.Domain +{ + public class SendText : Domain + { + public CipherString Text { get; set; } + public bool Hidden { get; set; } + + public SendText() : base() { } + + public SendText(SendTextData data, bool alreadyEncrypted = false) : base() + { + Hidden = data.Hidden; + BuildDomainModel(this, data, new HashSet { "Text" }, alreadyEncrypted); + } + + public Task DecryptAsync(SymmetricCryptoKey key) => + DecryptObjAsync(new SendTextView(this), this, new HashSet { "Text" }, null, key); + } +} diff --git a/src/Core/Models/Request/SendRequest.cs b/src/Core/Models/Request/SendRequest.cs new file mode 100644 index 000000000..a52836256 --- /dev/null +++ b/src/Core/Models/Request/SendRequest.cs @@ -0,0 +1,54 @@ +using System; +using Bit.Core.Enums; +using Bit.Core.Models.Api; +using Bit.Core.Models.Domain; + +namespace Bit.Core.Models.Request +{ + public class SendRequest + { + public SendType Type { get; set; } + public string Name { get; set; } + public string Notes { get; set; } + public string Key { get; set; } + public int? MaxAccessCount { get; set; } + public DateTime? ExpirationDate { get; set; } + public DateTime DeletionDate { get; set; } + public SendTextApi Text { get; set; } + public SendFileApi File { get; set; } + public string Password { get; set; } + public bool Disabled { get; set; } + + public SendRequest(Send send) + { + Type = send.Type; + Name = send.Name?.EncryptedString; + Notes = send.Notes?.EncryptedString; + MaxAccessCount = send.MaxAccessCount; + ExpirationDate = send.ExpirationDate; + DeletionDate = send.DeletionDate; + Key = send.Key?.EncryptedString; + Password = send.Password; + Disabled = send.Disabled; + + switch (Type) + { + case SendType.Text: + Text = new SendTextApi + { + Text = send.Text?.Text?.EncryptedString, + Hidden = send.Text.Hidden + }; + break; + case SendType.File: + File = new SendFileApi + { + FileName = send.File?.FileName?.EncryptedString + }; + break; + default: + break; + } + } + } +} diff --git a/src/Core/Models/Response/SendResponse.cs b/src/Core/Models/Response/SendResponse.cs new file mode 100644 index 000000000..508c49a43 --- /dev/null +++ b/src/Core/Models/Response/SendResponse.cs @@ -0,0 +1,25 @@ +using System; +using Bit.Core.Enums; +using Bit.Core.Models.Api; + +namespace Bit.Core.Models.Response +{ + public class SendResponse + { + public string Id { get; set; } + public string AccessId { get; set; } + public SendType Type { get; set; } + public string Name { get; set; } + public string Notes { get; set; } + public SendFileApi File { get; set; } + public SendTextApi Text { get; set; } + public string Key { get; set; } + public int? MaxAccessCount { get; set; } + public int AccessCount { get; internal set; } + public DateTime RevisionDate { get; internal set; } + public DateTime? ExpirationDate { get; internal set; } + public DateTime DeletionDate { get; internal set; } + public string Password { get; set; } + public bool Disabled { get; set; } + } +} diff --git a/src/Core/Models/Response/SyncResponse.cs b/src/Core/Models/Response/SyncResponse.cs index c5f60cf49..2442367bb 100644 --- a/src/Core/Models/Response/SyncResponse.cs +++ b/src/Core/Models/Response/SyncResponse.cs @@ -10,5 +10,6 @@ namespace Bit.Core.Models.Response public List Ciphers { get; set; } = new List(); public DomainsResponse Domains { get; set; } public List Policies { get; set; } = new List(); + public List Sends { get; set; } = new List(); } } diff --git a/src/Core/Models/View/SendFileView.cs b/src/Core/Models/View/SendFileView.cs new file mode 100644 index 000000000..783ccd630 --- /dev/null +++ b/src/Core/Models/View/SendFileView.cs @@ -0,0 +1,25 @@ +using System.Dynamic; +using Bit.Core.Models.Domain; + +namespace Bit.Core.Models.View +{ + public class SendFileView : View + { + public SendFileView() : base() { } + + public SendFileView(SendFile file) + { + Id = file.Id; + Url = file.Url; + Size = file.Size; + SizeName = file.SizeName; + } + + public string Id { get; set; } + public string Url { get; set; } + public string Size { get; set; } + public string SizeName { get; set; } + public string FileName { get; set; } + public int FileSize => int.TryParse(Size ?? "0", out var sizeInt) ? sizeInt : 0; + } +} diff --git a/src/Core/Models/View/SendTextView.cs b/src/Core/Models/View/SendTextView.cs new file mode 100644 index 000000000..f61a8166c --- /dev/null +++ b/src/Core/Models/View/SendTextView.cs @@ -0,0 +1,17 @@ +using Bit.Core.Models.Domain; + +namespace Bit.Core.Models.View +{ + public class SendTextView : View + { + public SendTextView() : base() { } + public SendTextView(SendText text) + { + Hidden = text.Hidden; + } + + public string Text { get; set; } = null; + public bool Hidden { get; set; } + public string MaskedText => Text != null ? "••••••••" : null; + } +} diff --git a/src/Core/Models/View/SendView.cs b/src/Core/Models/View/SendView.cs new file mode 100644 index 000000000..fff949d6a --- /dev/null +++ b/src/Core/Models/View/SendView.cs @@ -0,0 +1,45 @@ +using System; +using Bit.Core.Enums; +using Bit.Core.Models.Domain; +using Bit.Core.Utilities; + +namespace Bit.Core.Models.View +{ + public class SendView : View + { + public SendView(Send send) : base() + { + Id = send.Id; + AccessId = send.AccessId; + Type = send.Type; + MaxAccessCount = send.MaxAccessCount; + AccessCount = send.AccessCount; + RevisionDate = send.RevisionDate; + DeletionDate = send.DeletionDate; + ExpirationDate = send.ExpirationDate; + Disabled = send.Disabled; + Password = send.Password; + } + + public string Id { get; set; } + public string AccessId { get; set; } + public string Name { get; set; } + public string Notes { get; set; } + public byte[] Key { get; set; } + public SymmetricCryptoKey CryptoKey { get; set; } + public SendType Type { get; set; } + public SendTextView Text { get; set; } = new SendTextView(); + public SendFileView File { get; set; } = new SendFileView(); + public int? MaxAccessCount { get; set; } + public int AccessCount { get; set; } + public DateTime RevisionDate { get; set; } + public DateTime DeletionDate { get; set; } + public DateTime? ExpirationDate { get; set; } + public string Password { get; set; } + public bool Disabled { get; set; } + public string UrlB64Key => Key == null ? null : CoreHelpers.Base64UrlEncode(Key); + public bool MaxAccessCountReached => MaxAccessCount.HasValue && AccessCount >= MaxAccessCount.Value; + public bool Expired => ExpirationDate.HasValue && ExpirationDate.Value <= DateTime.UtcNow; + public bool PendingDelete => DeletionDate <= DateTime.UtcNow; + } +} diff --git a/src/Core/Services/ApiService.cs b/src/Core/Services/ApiService.cs index 6fac879c1..d1a09e06b 100644 --- a/src/Core/Services/ApiService.cs +++ b/src/Core/Services/ApiService.cs @@ -215,6 +215,28 @@ namespace Bit.Core.Services #endregion + #region Send APIs + + public Task GetSendAsync(string id) => + SendAsync(HttpMethod.Get, $"/sends/{id}", null, true, true); + + public Task PostSendAsync(SendRequest request) => + SendAsync(HttpMethod.Post, "/sends", request, true, true); + + public Task PostSendFileAsync(MultipartFormDataContent data) => + SendAsync(HttpMethod.Post, "/sends/file", data, true, true); + + public Task PutSendAsync(string id, SendRequest request) => + SendAsync(HttpMethod.Put, $"/sends/{id}", request, true, true); + + public Task PutSendRemovePasswordAsync(string id) => + SendAsync(HttpMethod.Put, $"/sends/{id}", null, true, true); + + public Task DeleteSendAsync(string id) => + SendAsync(HttpMethod.Delete, $"/sends/{id}", null, true, false); + + #endregion + #region Cipher APIs public Task GetCipherAsync(string id) @@ -346,7 +368,7 @@ namespace Bit.Core.Services } if (!response.IsSuccessStatusCode) { - var error = await HandleErrorAsync(response, false); + var error = await HandleErrorAsync(response, false, false); throw new ApiException(error); } } @@ -398,7 +420,7 @@ namespace Bit.Core.Services } if (!response.IsSuccessStatusCode) { - var error = await HandleErrorAsync(response, false); + var error = await HandleErrorAsync(response, false, true); throw new ApiException(error); } return null; @@ -458,7 +480,7 @@ namespace Bit.Core.Services } else if (!response.IsSuccessStatusCode) { - var error = await HandleErrorAsync(response, false); + var error = await HandleErrorAsync(response, false, authed); throw new ApiException(error); } return (TResponse)(object)null; @@ -506,7 +528,7 @@ namespace Bit.Core.Services } else { - var error = await HandleErrorAsync(response, true); + var error = await HandleErrorAsync(response, true, true); throw new ApiException(error); } } @@ -520,10 +542,10 @@ namespace Bit.Core.Services }; } - private async Task HandleErrorAsync(HttpResponseMessage response, bool tokenError) + private async Task HandleErrorAsync(HttpResponseMessage response, bool tokenError, bool authed) { - if ((tokenError && response.StatusCode == HttpStatusCode.BadRequest) || - response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden) + if (authed && ((tokenError && response.StatusCode == HttpStatusCode.BadRequest) || + response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden)) { await _logoutCallbackAsync(true); return null; diff --git a/src/Core/Services/CryptoService.cs b/src/Core/Services/CryptoService.cs index 681bbd1aa..7195071d3 100644 --- a/src/Core/Services/CryptoService.cs +++ b/src/Core/Services/CryptoService.cs @@ -220,7 +220,7 @@ namespace Bit.Core.Services throw new Exception("No public key available."); } var keyFingerprint = await _cryptoFunctionService.HashAsync(publicKey, CryptoHashAlgorithm.Sha256); - var userFingerprint = await HkdfExpandAsync(keyFingerprint, Encoding.UTF8.GetBytes(userId), 32); + var userFingerprint = await _cryptoFunctionService.HkdfExpandAsync(keyFingerprint, Encoding.UTF8.GetBytes(userId), 32, HkdfAlgorithm.Sha256); return HashPhrase(userFingerprint); } @@ -427,6 +427,12 @@ namespace Bit.Core.Services return await StretchKeyAsync(pinKey); } + public async Task MakeSendKeyAsync(byte[] keyMaterial) + { + var sendKey = await _cryptoFunctionService.HkdfAsync(keyMaterial, "bitwarden-send", "send", 65, HkdfAlgorithm.Sha256); + return new SymmetricCryptoKey(sendKey); + } + public async Task HashPasswordAsync(string password, SymmetricCryptoKey key) { if (key == null) @@ -772,32 +778,13 @@ namespace Bit.Core.Services private async Task StretchKeyAsync(SymmetricCryptoKey key) { var newKey = new byte[64]; - var enc = await HkdfExpandAsync(key.Key, Encoding.UTF8.GetBytes("enc"), 32); + var enc = await _cryptoFunctionService.HkdfExpandAsync(key.Key, Encoding.UTF8.GetBytes("enc"), 32, HkdfAlgorithm.Sha256); Buffer.BlockCopy(enc, 0, newKey, 0, 32); - var mac = await HkdfExpandAsync(key.Key, Encoding.UTF8.GetBytes("mac"), 32); + var mac = await _cryptoFunctionService.HkdfExpandAsync(key.Key, Encoding.UTF8.GetBytes("mac"), 32, HkdfAlgorithm.Sha256); Buffer.BlockCopy(mac, 0, newKey, 32, 32); return new SymmetricCryptoKey(newKey); } - // ref: https://tools.ietf.org/html/rfc5869 - private async Task HkdfExpandAsync(byte[] prk, byte[] info, int size) - { - var hashLen = 32; // sha256 - var okm = new byte[size]; - var previousT = new byte[0]; - var n = (int)Math.Ceiling((double)size / hashLen); - for (var i = 0; i < n; i++) - { - var t = new byte[previousT.Length + info.Length + 1]; - previousT.CopyTo(t, 0); - info.CopyTo(t, previousT.Length); - t[t.Length - 1] = (byte)(i + 1); - previousT = await _cryptoFunctionService.HmacAsync(t, prk, CryptoHashAlgorithm.Sha256); - previousT.CopyTo(okm, i * hashLen); - } - return okm; - } - private List HashPhrase(byte[] hash, int minimumEntropy = 64) { var wordLength = EEFLongWordList.Instance.List.Count; diff --git a/src/Core/Services/PclCryptoFunctionService.cs b/src/Core/Services/PclCryptoFunctionService.cs index 503f4fe3e..8299f8218 100644 --- a/src/Core/Services/PclCryptoFunctionService.cs +++ b/src/Core/Services/PclCryptoFunctionService.cs @@ -2,6 +2,7 @@ using Bit.Core.Enums; using PCLCrypto; using System; +using System.Linq; using System.Text; using System.Threading.Tasks; using static PCLCrypto.WinRTCrypto; @@ -43,6 +44,61 @@ namespace Bit.Core.Services return Task.FromResult(_cryptoPrimitiveService.Pbkdf2(password, salt, algorithm, iterations)); } + public async Task HkdfAsync(byte[] ikm, string salt, string info, int outputByteSize, HkdfAlgorithm algorithm) => + await HkdfAsync(ikm, Encoding.UTF8.GetBytes(salt), Encoding.UTF8.GetBytes(info), outputByteSize, algorithm); + + public async Task HkdfAsync(byte[] ikm, byte[] salt, string info, int outputByteSize, HkdfAlgorithm algorithm) => + await HkdfAsync(ikm, salt, Encoding.UTF8.GetBytes(info), outputByteSize, algorithm); + + public async Task HkdfAsync(byte[] ikm, string salt, byte[] info, int outputByteSize, HkdfAlgorithm algorithm) => + await HkdfAsync(ikm, Encoding.UTF8.GetBytes(salt), info, outputByteSize, algorithm); + + public async Task HkdfAsync(byte[] ikm, byte[] salt, byte[] info, int outputByteSize, HkdfAlgorithm algorithm) + { + var prk = await HmacAsync(ikm, salt, HkdfAlgorithmToCryptoHashAlgorithm(algorithm)); + return await HkdfExpandAsync(prk, info, outputByteSize, algorithm); + } + + public async Task HkdfExpandAsync(byte[] prk, string info, int outputByteSize, HkdfAlgorithm algorithm) => + await HkdfExpandAsync(prk, Encoding.UTF8.GetBytes(info), outputByteSize, algorithm); + + // ref: https://tools.ietf.org/html/rfc5869 + public async Task HkdfExpandAsync(byte[] prk, byte[] info, int outputByteSize, HkdfAlgorithm algorithm) + { + var hashLen = algorithm == HkdfAlgorithm.Sha256 ? 32 : 64; + + var maxOutputByteSize = 255 * hashLen; + if (outputByteSize > maxOutputByteSize) + { + throw new ArgumentException($"{nameof(outputByteSize)} is too large. Max is {maxOutputByteSize}, received {outputByteSize}"); + } + if (prk.Length < hashLen) + { + throw new ArgumentException($"{nameof(prk)} length is too small. Must be at least {hashLen} for {algorithm}"); + } + + var cryptoHashAlgorithm = HkdfAlgorithmToCryptoHashAlgorithm(algorithm); + var previousT = new byte[0]; + var runningOkmLength = 0; + var n = (int)Math.Ceiling((double)outputByteSize / hashLen); + var okm = new byte[n * hashLen]; + for (var i = 0; i < n; i++) + { + var t = new byte[previousT.Length + info.Length + 1]; + previousT.CopyTo(t, 0); + info.CopyTo(t, previousT.Length); + t[t.Length - 1] = (byte)(i + 1); + previousT = await HmacAsync(t, prk, cryptoHashAlgorithm); + previousT.CopyTo(okm, runningOkmLength); + runningOkmLength = previousT.Length; + if (runningOkmLength >= outputByteSize) + { + break; + } + } + return okm.Take(outputByteSize).ToArray(); + } + public Task HashAsync(string value, CryptoHashAlgorithm algorithm) { return HashAsync(Encoding.UTF8.GetBytes(value), algorithm); @@ -217,5 +273,18 @@ namespace Bit.Core.Services .Replace("\n", " ") // New line => space .Replace(" ", " "); // No-break space (00A0) => space } + + private CryptoHashAlgorithm HkdfAlgorithmToCryptoHashAlgorithm(HkdfAlgorithm hkdfAlgorithm) + { + switch (hkdfAlgorithm) + { + case HkdfAlgorithm.Sha256: + return CryptoHashAlgorithm.Sha256; + case HkdfAlgorithm.Sha512: + return CryptoHashAlgorithm.Sha512; + default: + throw new ArgumentException($"Invalid hkdf algorithm type, {hkdfAlgorithm}"); + } + } } } diff --git a/src/Core/Services/SendService.cs b/src/Core/Services/SendService.cs new file mode 100644 index 000000000..94281a14e --- /dev/null +++ b/src/Core/Services/SendService.cs @@ -0,0 +1,279 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Bit.Core.Abstractions; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.Domain; +using Bit.Core.Models.Request; +using Bit.Core.Models.Response; +using Bit.Core.Models.View; +using Newtonsoft.Json; + +namespace Bit.Core.Services +{ + public class SendService : ISendService + { + private List _decryptedSendsCache; + private readonly ICryptoService _cryptoService; + private readonly IUserService _userService; + private readonly IApiService _apiService; + private readonly IStorageService _storageService; + private readonly II18nService _i18nService; + private readonly ICryptoFunctionService _cryptoFunctionService; + private Task> _getAllDecryptedTask; + + public SendService( + ICryptoService cryptoService, + IUserService userService, + IApiService apiService, + IStorageService storageService, + II18nService i18nService, + ICryptoFunctionService cryptoFunctionService) + { + _cryptoService = cryptoService; + _userService = userService; + _apiService = apiService; + _storageService = storageService; + _i18nService = i18nService; + _cryptoFunctionService = cryptoFunctionService; + } + + public static string GetSendKey(string userId) => string.Format("sends_{0}", userId); + + public async Task ClearAsync(string userId) + { + await _storageService.RemoveAsync(GetSendKey(userId)); + ClearCache(); + } + + public void ClearCache() => _decryptedSendsCache = null; + + public async Task DeleteAsync(params string[] ids) + { + var userId = await _userService.GetUserIdAsync(); + var sends = await _storageService.GetAsync>(GetSendKey(userId)); + + if (sends == null) + { + return; + } + + foreach (var id in ids) + { + sends.Remove(id); + } + + await _storageService.SaveAsync(GetSendKey(userId), sends); + ClearCache(); + } + + public async Task DeleteWithServerAsync(string id) + { + await _apiService.DeleteSendAsync(id); + await DeleteAsync(id); + } + + public async Task<(Send send, CipherString encryptedFileData)> EncryptAsync(SendView model, byte[] fileData, + string password, SymmetricCryptoKey key = null) + { + if (model.Key == null) + { + model.Key = _cryptoFunctionService.RandomBytes(16); + model.CryptoKey = await _cryptoService.MakeSendKeyAsync(model.Key); + } + + var send = new Send + { + Id = model.Id, + Type = model.Type, + Disabled = model.Disabled, + MaxAccessCount = model.MaxAccessCount, + Key = await _cryptoService.EncryptAsync(model.Key, key), + Name = await _cryptoService.EncryptAsync(model.Name, model.CryptoKey), + Notes = await _cryptoService.EncryptAsync(model.Notes, model.CryptoKey), + }; + CipherString encryptedFileData = null; + + if (password != null) + { + var passwordHash = await _cryptoFunctionService.Pbkdf2Async(password, model.Key, + CryptoHashAlgorithm.Sha256, 100000); + send.Password = Convert.ToBase64String(passwordHash); + } + + switch (send.Type) + { + case SendType.Text: + send.Text = new SendText + { + Text = await _cryptoService.EncryptAsync(model.Text.Text, model.CryptoKey), + Hidden = model.Text.Hidden + }; + break; + case SendType.File: + send.File = new SendFile(); + if (fileData != null) + { + send.File.FileName = await _cryptoService.EncryptAsync(model.File.FileName, model.CryptoKey); + encryptedFileData = await _cryptoService.EncryptAsync(fileData, model.CryptoKey); + } + break; + default: + break; + } + + return (send, encryptedFileData); + } + + public async Task> GetAllAsync() + { + var userId = await _userService.GetUserIdAsync(); + var sends = await _storageService.GetAsync>(GetSendKey(userId)); + return sends.Select(kvp => new Send(kvp.Value)).ToList(); + } + + public async Task> GetAllDecryptedAsync() + { + if (_decryptedSendsCache != null) + { + return _decryptedSendsCache; + } + + var hasKey = await _cryptoService.HasKeyAsync(); + if (!hasKey) + { + throw new Exception("No Key."); + } + + if (_getAllDecryptedTask != null && !_getAllDecryptedTask.IsCompleted && !_getAllDecryptedTask.IsFaulted) + { + return await _getAllDecryptedTask; + } + + async Task> doTask() + { + var decSends = new List(); + + async Task decryptAndAddSendAsync(Send send) => decSends.Add(await send.DecryptAsync()); + await Task.WhenAll((await GetAllAsync()).Select(s => decryptAndAddSendAsync(s))); + + decSends.OrderBy(s => s, new SendLocaleComparer(_i18nService)).ToList(); + _decryptedSendsCache = decSends; + return _decryptedSendsCache; + } + + _getAllDecryptedTask = doTask(); + return await _getAllDecryptedTask; + } + + public async Task GetAsync(string id) + { + var userId = await _userService.GetUserIdAsync(); + var sends = await _storageService.GetAsync>(GetSendKey(userId)); + + if (sends == null || !sends.ContainsKey(id)) + { + return null; + } + + return new Send(sends[id]); + } + + public async Task ReplaceAsync(Dictionary sends) + { + var userId = await _userService.GetUserIdAsync(); + await _storageService.SaveAsync(GetSendKey(userId), sends); + _decryptedSendsCache = null; + } + + public async Task SaveWithServerAsync(Send send, byte[] encryptedFileData) + { + + var request = new SendRequest(send); + SendResponse response; + if (send.Id == null) + { + switch (send.Type) + { + case SendType.Text: + response = await _apiService.PostSendAsync(request); + break; + case SendType.File: + var fd = new MultipartFormDataContent($"--BWMobileFormBoundary{DateTime.UtcNow.Ticks}") + { + { new StringContent(JsonConvert.SerializeObject(request)), "model" }, + { new ByteArrayContent(encryptedFileData), "data", send.File.FileName.EncryptedString } + }; + + response = await _apiService.PostSendFileAsync(fd); + break; + default: + throw new NotImplementedException($"Cannot save unknown Send type {send.Type}"); + } + send.Id = response.Id; + } + else + { + response = await _apiService.PutSendAsync(send.Id, request); + } + + var userId = await _userService.GetUserIdAsync(); + await UpsertAsync(new SendData(response, userId)); + } + + public async Task UpsertAsync(params SendData[] sends) + { + var userId = await _userService.GetUserIdAsync(); + var knownSends = await _storageService.GetAsync>(GetSendKey(userId)) ?? + new Dictionary(); + + foreach (var send in sends) + { + knownSends[send.Id] = send; + } + + await _storageService.SaveAsync(GetSendKey(userId), knownSends); + _decryptedSendsCache = null; + } + + public async Task RemovePasswordWithServerAsync(string id) + { + var response = await _apiService.PutSendRemovePasswordAsync(id); + var userId = await _userService.GetUserIdAsync(); + await UpsertAsync(new SendData(response, userId)); + } + + private class SendLocaleComparer : IComparer + { + private readonly II18nService _i18nService; + + public SendLocaleComparer(II18nService i18nService) + { + _i18nService = i18nService; + } + + public int Compare(SendView a, SendView b) + { + var aName = a?.Name; + var bName = b?.Name; + if (aName == null && bName != null) + { + return -1; + } + if (aName != null && bName == null) + { + return 1; + } + if (aName == null && bName == null) + { + return 0; + } + return _i18nService.StringComparer.Compare(aName, bName); + } + } + } +} diff --git a/src/Core/Services/SyncService.cs b/src/Core/Services/SyncService.cs index bd29b2c8c..d60ed6764 100644 --- a/src/Core/Services/SyncService.cs +++ b/src/Core/Services/SyncService.cs @@ -24,6 +24,7 @@ namespace Bit.Core.Services private readonly IStorageService _storageService; private readonly IMessagingService _messagingService; private readonly IPolicyService _policyService; + private readonly ISendService _sendService; private readonly Func _logoutCallbackAsync; public SyncService( @@ -37,6 +38,7 @@ namespace Bit.Core.Services IStorageService storageService, IMessagingService messagingService, IPolicyService policyService, + ISendService sendService, Func logoutCallbackAsync) { _userService = userService; @@ -49,6 +51,7 @@ namespace Bit.Core.Services _storageService = storageService; _messagingService = messagingService; _policyService = policyService; + _sendService = sendService; _logoutCallbackAsync = logoutCallbackAsync; } @@ -104,7 +107,8 @@ namespace Bit.Core.Services await SyncCollectionsAsync(response.Collections); await SyncCiphersAsync(userId, response.Ciphers); await SyncSettingsAsync(userId, response.Domains); - await SyncPolicies(response.Policies); + await SyncPoliciesAsync(response.Policies); + await SyncSendsAsync(userId, response.Sends); await SetLastSyncAsync(now); return SyncCompleted(true); } @@ -363,11 +367,14 @@ namespace Bit.Core.Services await _settingsService.SetEquivalentDomainsAsync(eqDomains); } - private async Task SyncPolicies(List response) + private async Task SyncPoliciesAsync(List response) { var policies = response?.ToDictionary(p => p.Id, p => new PolicyData(p)) ?? new Dictionary(); await _policyService.Replace(policies); } + + private Task SyncSendsAsync(string userId, List sends) => + _sendService.ReplaceAsync(sends.ToDictionary(s => userId, s => new SendData(s, userId))); } } diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index 45e88d303..0799906bb 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -244,5 +244,10 @@ namespace Bit.Core.Utilities // Standard base64 decoder return Convert.FromBase64String(output); } + + public static T Clone(T obj) + { + return JsonConvert.DeserializeObject(JsonConvert.SerializeObject(obj)); + } } } diff --git a/src/Core/Utilities/ServiceContainer.cs b/src/Core/Utilities/ServiceContainer.cs index a6a0c194d..0d8b8725b 100644 --- a/src/Core/Utilities/ServiceContainer.cs +++ b/src/Core/Utilities/ServiceContainer.cs @@ -54,8 +54,10 @@ namespace Bit.Core.Utilities return Task.FromResult(0); }); var policyService = new PolicyService(storageService, userService); + var sendService = new SendService(cryptoService, userService, apiService, storageService, i18nService, + cryptoFunctionService); var syncService = new SyncService(userService, apiService, settingsService, folderService, - cipherService, cryptoService, collectionService, storageService, messagingService, policyService, + cipherService, cryptoService, collectionService, storageService, messagingService, policyService, sendService, (bool expired) => { messagingService.Send("logout", expired); diff --git a/test/Common/AutoFixture/Attributes/AutoSubDataAttribute.cs b/test/Common/AutoFixture/Attributes/AutoSubDataAttribute.cs new file mode 100644 index 000000000..fc4db5fe9 --- /dev/null +++ b/test/Common/AutoFixture/Attributes/AutoSubDataAttribute.cs @@ -0,0 +1,10 @@ +using AutoFixture.AutoNSubstitute; + +namespace Bit.Test.Common.AutoFixture.Attributes +{ + public class AutoSubstitutionData : CustomAutoDataAttribute + { + public AutoSubstitutionData() : base(typeof(AutoNSubstituteCustomization)) + { } + } +} diff --git a/test/Common/AutoFixture/Attributes/CustomAutoDataAttribute.cs b/test/Common/AutoFixture/Attributes/CustomAutoDataAttribute.cs new file mode 100644 index 000000000..cd2feebca --- /dev/null +++ b/test/Common/AutoFixture/Attributes/CustomAutoDataAttribute.cs @@ -0,0 +1,25 @@ +using System; +using System.Linq; +using AutoFixture; +using AutoFixture.Xunit2; + +namespace Bit.Test.Common.AutoFixture.Attributes +{ + public class CustomAutoDataAttribute : AutoDataAttribute + { + public CustomAutoDataAttribute(params Type[] iCustomizationTypes) : this(iCustomizationTypes + .Select(t => (ICustomization)Activator.CreateInstance(t)).ToArray()) + { } + + public CustomAutoDataAttribute(params ICustomization[] customizations) : base(() => + { + var fixture = new Fixture(); + foreach (var customization in customizations) + { + fixture.Customize(customization); + } + return fixture; + }) + { } + } +} diff --git a/test/Common/AutoFixture/Attributes/InlineAutoSubDataAttribute.cs b/test/Common/AutoFixture/Attributes/InlineAutoSubDataAttribute.cs new file mode 100644 index 000000000..feccc4e76 --- /dev/null +++ b/test/Common/AutoFixture/Attributes/InlineAutoSubDataAttribute.cs @@ -0,0 +1,10 @@ +using AutoFixture.AutoNSubstitute; + +namespace Bit.Test.Common.AutoFixture.Attributes +{ + public class InlineAutoSubstitutionData : InlineCustomAutoDataAttribute + { + public InlineAutoSubstitutionData(params object[] values) : base(new[] { typeof(AutoNSubstituteCustomization) }, values) + { } + } +} diff --git a/test/Common/AutoFixture/Attributes/InlineCustomAutoDataAttribute.cs b/test/Common/AutoFixture/Attributes/InlineCustomAutoDataAttribute.cs new file mode 100644 index 000000000..d36f963a4 --- /dev/null +++ b/test/Common/AutoFixture/Attributes/InlineCustomAutoDataAttribute.cs @@ -0,0 +1,23 @@ +using System; +using Xunit; +using Xunit.Sdk; +using AutoFixture.Xunit2; +using AutoFixture; + +namespace Bit.Test.Common.AutoFixture.Attributes +{ + public class InlineCustomAutoDataAttribute : CompositeDataAttribute + { + public InlineCustomAutoDataAttribute(Type[] iCustomizationTypes, params object[] values) : base(new DataAttribute[] { + new InlineDataAttribute(values), + new CustomAutoDataAttribute(iCustomizationTypes) + }) + { } + + public InlineCustomAutoDataAttribute(ICustomization[] customizations, params object[] values) : base(new DataAttribute[] { + new InlineDataAttribute(values), + new CustomAutoDataAttribute(customizations) + }) + { } + } +} diff --git a/test/Common/AutoFixture/Attributes/InlineSutAutoDataAttribute.cs b/test/Common/AutoFixture/Attributes/InlineSutAutoDataAttribute.cs new file mode 100644 index 000000000..89eebad8c --- /dev/null +++ b/test/Common/AutoFixture/Attributes/InlineSutAutoDataAttribute.cs @@ -0,0 +1,20 @@ +using System; +using System.Linq; +using AutoFixture; + +namespace Bit.Test.Common.AutoFixture.Attributes +{ + public class InlineSutAutoDataAttribute : InlineCustomAutoDataAttribute + { + public InlineSutAutoDataAttribute(params object[] values) : base( + new Type[] { typeof(SutProviderCustomization) }, values) + { } + public InlineSutAutoDataAttribute(Type[] iCustomizationTypes, params object[] values) : base( + iCustomizationTypes.Append(typeof(SutProviderCustomization)).ToArray(), values) + { } + + public InlineSutAutoDataAttribute(ICustomization[] customizations, params object[] values) : base( + customizations.Append(new SutProviderCustomization()).ToArray(), values) + { } + } +} diff --git a/test/Common/AutoFixture/Attributes/SutAutoDataAttribute.cs b/test/Common/AutoFixture/Attributes/SutAutoDataAttribute.cs new file mode 100644 index 000000000..e7c06a88f --- /dev/null +++ b/test/Common/AutoFixture/Attributes/SutAutoDataAttribute.cs @@ -0,0 +1,12 @@ +using System; +using System.Linq; + +namespace Bit.Test.Common.AutoFixture.Attributes +{ + public class SutAutoDataAttribute : CustomAutoDataAttribute + { + public SutAutoDataAttribute(params Type[] iCustomizationTypes) : base( + iCustomizationTypes.Append(typeof(SutProviderCustomization)).ToArray()) + { } + } +} diff --git a/test/Common/AutoFixture/FixtureExtensions.cs b/test/Common/AutoFixture/FixtureExtensions.cs new file mode 100644 index 000000000..a23eb7d0c --- /dev/null +++ b/test/Common/AutoFixture/FixtureExtensions.cs @@ -0,0 +1,11 @@ +using AutoFixture; +using AutoFixture.AutoNSubstitute; + +namespace Bit.Test.Common.AutoFixture +{ + public static class FixtureExtensions + { + public static IFixture WithAutoNSubstitutions(this IFixture fixture) => + fixture.Customize(new AutoNSubstituteCustomization()); + } +} diff --git a/test/Common/AutoFixture/ISutProvider.cs b/test/Common/AutoFixture/ISutProvider.cs new file mode 100644 index 000000000..c72dc4a27 --- /dev/null +++ b/test/Common/AutoFixture/ISutProvider.cs @@ -0,0 +1,10 @@ +using System; + +namespace Bit.Test.Common.AutoFixture +{ + public interface ISutProvider + { + Type SutType { get; } + ISutProvider Create(); + } +} diff --git a/test/Common/AutoFixture/SutProvider.cs b/test/Common/AutoFixture/SutProvider.cs new file mode 100644 index 000000000..e3149afc6 --- /dev/null +++ b/test/Common/AutoFixture/SutProvider.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using AutoFixture; +using AutoFixture.Kernel; +using System.Reflection; +using System.Linq; + +namespace Bit.Test.Common.AutoFixture +{ + public class SutProvider : ISutProvider + { + private Dictionary> _dependencies; + private readonly IFixture _fixture; + private readonly ConstructorParameterRelay _constructorParameterRelay; + + public TSut Sut { get; private set; } + public Type SutType => typeof(TSut); + + public SutProvider() + { + _dependencies = new Dictionary>(); + _fixture = new Fixture().WithAutoNSubstitutions(); + _constructorParameterRelay = new ConstructorParameterRelay(this, _fixture); + _fixture.Customizations.Add(_constructorParameterRelay); + } + + public SutProvider SetDependency(T dependency, string parameterName = "") => + SetDependency(typeof(T), dependency, parameterName); + public SutProvider SetDependency(Type dependencyType, object dependency, string parameterName = "") + { + if (_dependencies.ContainsKey(dependencyType)) + { + _dependencies[dependencyType][parameterName] = dependency; + } + else + { + _dependencies[dependencyType] = new Dictionary { { parameterName, dependency } }; + } + + return this; + } + + public T GetDependency(string parameterName = "") => (T)GetDependency(typeof(T), parameterName); + public object GetDependency(Type dependencyType, string parameterName = "") + { + if (DependencyIsSet(dependencyType, parameterName)) + { + return _dependencies[dependencyType].ContainsKey(parameterName) ? _dependencies[dependencyType][parameterName] : _dependencies[dependencyType][""]; + } + else if (_dependencies.ContainsKey(dependencyType)) + { + var knownDependencies = _dependencies[dependencyType]; + if (knownDependencies.Values.Count == 1) + { + return _dependencies[dependencyType].Values.Single(); + } + else + { + throw new ArgumentException(string.Concat($"Dependency of type {dependencyType.Name} and name ", + $"{parameterName} does not exist. Available dependency names are: ", + string.Join(", ", knownDependencies.Keys))); + } + } + else + { + throw new ArgumentException($"Dependency of type {dependencyType.Name} and name {parameterName} has not been set."); + } + } + + public void Reset() + { + _dependencies = new Dictionary>(); + Sut = default; + } + + ISutProvider ISutProvider.Create() => Create(); + public SutProvider Create() + { + Sut = _fixture.Create(); + return this; + } + + private bool DependencyIsSet(Type dependencyType, string parameterName = "") => + _dependencies.ContainsKey(dependencyType) && (_dependencies[dependencyType].ContainsKey(parameterName) || _dependencies[dependencyType].ContainsKey("")); + + private object GetDefault(Type type) => type.IsValueType ? Activator.CreateInstance(type) : null; + + private class ConstructorParameterRelay : ISpecimenBuilder + { + private readonly SutProvider _sutProvider; + private readonly IFixture _fixture; + + public ConstructorParameterRelay(SutProvider sutProvider, IFixture fixture) + { + _sutProvider = sutProvider; + _fixture = fixture; + } + + public object Create(object request, ISpecimenContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + if (!(request is ParameterInfo parameterInfo)) + { + return new NoSpecimen(); + } + if (parameterInfo.Member.DeclaringType != typeof(T) || + parameterInfo.Member.MemberType != MemberTypes.Constructor) + { + return new NoSpecimen(); + } + + if (_sutProvider.DependencyIsSet(parameterInfo.ParameterType, parameterInfo.Name)) + { + return _sutProvider.GetDependency(parameterInfo.ParameterType, parameterInfo.Name); + } + + + // This is the equivalent of _fixture.Create, but no overload for + // Create(Type type) exists. + var dependency = new SpecimenContext(_fixture).Resolve(new SeededRequest(parameterInfo.ParameterType, + _sutProvider.GetDefault(parameterInfo.ParameterType))); + _sutProvider.SetDependency(parameterInfo.ParameterType, dependency, parameterInfo.Name); + return dependency; + } + } + } +} diff --git a/test/Common/AutoFixture/SutProviderCustomization.cs b/test/Common/AutoFixture/SutProviderCustomization.cs new file mode 100644 index 000000000..b5bc84d1d --- /dev/null +++ b/test/Common/AutoFixture/SutProviderCustomization.cs @@ -0,0 +1,32 @@ +using System; +using AutoFixture; +using AutoFixture.Kernel; + +namespace Bit.Test.Common.AutoFixture +{ + public class SutProviderCustomization : ICustomization, ISpecimenBuilder + { + public object Create(object request, ISpecimenContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + if (!(request is Type typeRequest)) + { + return new NoSpecimen(); + } + if (!typeof(ISutProvider).IsAssignableFrom(typeRequest)) + { + return new NoSpecimen(); + } + + return ((ISutProvider)Activator.CreateInstance(typeRequest)).Create(); + } + + public void Customize(IFixture fixture) + { + fixture.Customizations.Add(this); + } + } +} diff --git a/test/Common/Common.csproj b/test/Common/Common.csproj new file mode 100644 index 000000000..775399091 --- /dev/null +++ b/test/Common/Common.csproj @@ -0,0 +1,21 @@ + + + + netcoreapp3.1 + false + Bit.Test.Common + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + diff --git a/test/Common/TestHelper.cs b/test/Common/TestHelper.cs new file mode 100644 index 000000000..7ce60f325 --- /dev/null +++ b/test/Common/TestHelper.cs @@ -0,0 +1,58 @@ +using System.Reflection; +using System.IO; +using System.Linq; +using Xunit; +using System; +using Newtonsoft.Json; + +namespace Bit.Test.Common +{ + public static class TestHelper + { + public static void AssertPropertyEqual(object expected, object actual, params string[] excludedPropertyStrings) + { + var relevantExcludedProperties = excludedPropertyStrings.Where(name => !name.Contains('.')).ToList(); + if (expected == null) + { + Assert.Null(actual); + return; + } + + if (actual == null) + { + throw new Exception("Expected object is null but actual is not"); + } + + foreach (var expectedPi in expected.GetType().GetProperties().Where(pi => !relevantExcludedProperties.Contains(pi.Name))) + { + var actualPi = actual.GetType().GetProperty(expectedPi.Name); + + if (actualPi == null) + { + var settings = new JsonSerializerSettings { Formatting = Formatting.Indented }; + throw new Exception(string.Concat($"Expected actual object to contain a property named {expectedPi.Name}, but it does not\n", + $"Expected:\n{JsonConvert.SerializeObject(expected, settings)}\n", + $"Actual:\n{JsonConvert.SerializeObject(actual, new JsonSerializerSettings { Formatting = Formatting.Indented })}")); + } + + if (expectedPi.PropertyType == typeof(string) || expectedPi.PropertyType.IsValueType) + { + Assert.Equal(expectedPi.GetValue(expected), actualPi.GetValue(actual)); + } + else + { + var prefix = $"{expectedPi.PropertyType.Name}."; + var nextExcludedProperties = excludedPropertyStrings.Where(name => name.StartsWith(prefix)) + .Select(name => name[prefix.Length..]).ToArray(); + AssertPropertyEqual(expectedPi.GetValue(expected), actualPi.GetValue(actual), nextExcludedProperties); + } + } + } + + public static Predicate AssertEqualExpectedPredicate(T expected) => (actual) => + { + Assert.Equal(expected, actual); + return true; + }; + } +} diff --git a/test/Core.Test/AutoFixture/Domain/SymmetricCryptoKeyCustomization.cs b/test/Core.Test/AutoFixture/Domain/SymmetricCryptoKeyCustomization.cs new file mode 100644 index 000000000..41355b86d --- /dev/null +++ b/test/Core.Test/AutoFixture/Domain/SymmetricCryptoKeyCustomization.cs @@ -0,0 +1,16 @@ +using System; +using AutoFixture; +using Bit.Core.Models.Domain; +using Bit.Core.Services; + +namespace Bit.Core.Test.AutoFixture +{ + public class SymmetricCryptoKeyCustomization : ICustomization + { + public void Customize(IFixture fixture) + { + var keyMaterial = (new PclCryptoFunctionService(null)).RandomBytes(32); + fixture.Register(() => new SymmetricCryptoKey(keyMaterial)); + } + } +} diff --git a/test/Core.Test/AutoFixture/Send/SendCustomizations.cs b/test/Core.Test/AutoFixture/Send/SendCustomizations.cs new file mode 100644 index 000000000..617ba8c8e --- /dev/null +++ b/test/Core.Test/AutoFixture/Send/SendCustomizations.cs @@ -0,0 +1,65 @@ +using AutoFixture; +using Bit.Core.Models.Api; +using Bit.Core.Models.Data; +using Bit.Core.Models.Domain; +using Bit.Core.Models.Request; +using Bit.Core.Models.Response; +using Bit.Core.Models.View; +using Bit.Core.Enums; + +namespace Bit.Core.Test.AutoFixture +{ + internal class TextSendCustomization : ICustomization + { + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(c => c.Type, SendType.Text) + .With(c => c.Text, fixture.Create()) + .Without(c => c.File)); + fixture.Customize(composer => composer + .With(c => c.Type, SendType.Text) + .With(c => c.Text, fixture.Create()) + .Without(c => c.File)); + fixture.Customize(composer => composer + .With(c => c.Type, SendType.Text) + .With(c => c.Text, fixture.Create()) + .Without(c => c.File)); + fixture.Customize(composer => composer + .With(c => c.Type, SendType.Text) + .With(c => c.Text, fixture.Create()) + .Without(c => c.File)); + fixture.Customize(composer => composer + .With(c => c.Type, SendType.Text) + .With(c => c.Text, fixture.Create()) + .Without(c => c.File)); + } + } + + internal class FileSendCustomization : ICustomization + { + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(c => c.Type, SendType.File) + .With(c => c.File, fixture.Create()) + .Without(c => c.Text)); + fixture.Customize(composer => composer + .With(c => c.Type, SendType.File) + .With(c => c.File, fixture.Create()) + .Without(c => c.Text)); + fixture.Customize(composer => composer + .With(c => c.Type, SendType.File) + .With(c => c.File, fixture.Create()) + .Without(c => c.Text)); + fixture.Customize(composer => composer + .With(c => c.Type, SendType.File) + .With(c => c.File, fixture.Create()) + .Without(c => c.Text)); + fixture.Customize(composer => composer + .With(c => c.Type, SendType.File) + .With(c => c.File, fixture.Create()) + .Without(c => c.Text)); + } + } +} diff --git a/test/Core.Test/Core.Test.csproj b/test/Core.Test/Core.Test.csproj new file mode 100644 index 000000000..1b1beb4ed --- /dev/null +++ b/test/Core.Test/Core.Test.csproj @@ -0,0 +1,26 @@ + + + + netcoreapp3.1 + false + Bit.Core.Test + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + + diff --git a/test/Core.Test/Models/Data/SendDataTests.cs b/test/Core.Test/Models/Data/SendDataTests.cs new file mode 100644 index 000000000..06bb54b12 --- /dev/null +++ b/test/Core.Test/Models/Data/SendDataTests.cs @@ -0,0 +1,23 @@ +using Bit.Core.Models.Data; +using Bit.Core.Models.Response; +using Bit.Core.Test.AutoFixture; +using Bit.Test.Common; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.Models.Data +{ + public class SendDataTests + { + [Theory] + [InlineCustomAutoData(new[] { typeof(TextSendCustomization) })] + [InlineCustomAutoData(new[] { typeof(FileSendCustomization) })] + public void SendData_FromSendResponse_Success(string userId, SendResponse response) + { + var data = new SendData(response, userId); + + TestHelper.AssertPropertyEqual(response, data, "UserId"); + Assert.Equal(data.UserId, userId); + } + } +} diff --git a/test/Core.Test/Models/Domain/SendTests.cs b/test/Core.Test/Models/Domain/SendTests.cs new file mode 100644 index 000000000..c8a0e53bb --- /dev/null +++ b/test/Core.Test/Models/Domain/SendTests.cs @@ -0,0 +1,78 @@ +using System; +using System.Linq; +using Bit.Core.Models.Data; +using Bit.Core.Models.Domain; +using Bit.Test.Common; +using System.Text; +using Bit.Core.Abstractions; +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; +using Bit.Core.Test.AutoFixture; +using AutoFixture.AutoNSubstitute; + +namespace Bit.Core.Test.Models.Domain +{ + public class SendTests + { + [Theory] + [InlineCustomAutoData(new[] { typeof(FileSendCustomization) })] + [InlineCustomAutoData(new[] { typeof(TextSendCustomization) })] + public void Send_FromSendData_Success(SendData data) + { + var send = new Send(data); + + TestHelper.AssertPropertyEqual(data, send, "Name", "Notes", "Key", "SendFileData.FileName", "SendFileData.Key", "SendTextData.Text"); + Assert.Equal(data.Name, send.Name?.EncryptedString); + Assert.Equal(data.Notes, send.Notes?.EncryptedString); + Assert.Equal(data.Key, send.Key?.EncryptedString); + Assert.Equal(data.Text?.Text, send.Text?.Text?.EncryptedString); + Assert.Equal(data.File?.FileName, send.File?.FileName?.EncryptedString); + } + + [Theory] + [InlineCustomAutoData(new[] { typeof(AutoNSubstituteCustomization), typeof(TextSendCustomization) })] + [InlineCustomAutoData(new[] { typeof(AutoNSubstituteCustomization), typeof(FileSendCustomization) })] + public async void DecryptAsync_Success(ICryptoService cryptoService, Send send) + { + var prefix = "decrypted_"; + var prefixBytes = Encoding.UTF8.GetBytes(prefix); + + cryptoService.DecryptToBytesAsync(Arg.Any(), Arg.Any()) + .Returns(info => prefixBytes.Concat(Encoding.UTF8.GetBytes(((CipherString)info[0]).EncryptedString)).ToArray()); + cryptoService.DecryptFromBytesAsync(Arg.Any(), Arg.Any()) + .Returns(info => prefixBytes.Concat((byte[])info[0]).ToArray()); + cryptoService.DecryptToUtf8Async(Arg.Any(), Arg.Any()) + .Returns(info => $"{prefix}{((CipherString)info[0]).EncryptedString}"); + ServiceContainer.Register("cryptoService", cryptoService); + + var view = await send.DecryptAsync(); + + string expectedDecryptionString(CipherString encryptedString) => + encryptedString?.EncryptedString == null ? null : $"{prefix}{encryptedString.EncryptedString}"; + + TestHelper.AssertPropertyEqual(send, view, "Name", "Notes", "File", "Text", "Key", "UserId"); + Assert.Equal(expectedDecryptionString(send.Name), view.Name); + Assert.Equal(expectedDecryptionString(send.Notes), view.Notes); + Assert.Equal(Encoding.UTF8.GetBytes(expectedDecryptionString(send.Key)), view.Key); + + switch (send.Type) + { + case SendType.File: + TestHelper.AssertPropertyEqual(send.File, view.File, "FileName"); + Assert.Equal(expectedDecryptionString(send.File.FileName), view.File.FileName); + break; + case SendType.Text: + TestHelper.AssertPropertyEqual(send.Text, view?.Text, "Text"); + Assert.Equal(expectedDecryptionString(send.Text.Text), view.Text.Text); + break; + default: + throw new Exception("Untested Send type"); + } + + ServiceContainer.Reset(); + } + } +} diff --git a/test/Core.Test/Models/Request/SendRequestTests.cs b/test/Core.Test/Models/Request/SendRequestTests.cs new file mode 100644 index 000000000..7a17fdbcf --- /dev/null +++ b/test/Core.Test/Models/Request/SendRequestTests.cs @@ -0,0 +1,43 @@ +using System; +using Bit.Core.Enums; +using Bit.Core.Models.Domain; +using Bit.Core.Models.Request; +using Bit.Core.Test.AutoFixture; +using Bit.Core.Utilities; +using Bit.Test.Common; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.Models.Request +{ + public class SendRequestTests + { + [Theory] + [InlineCustomAutoData(new[] { typeof(TextSendCustomization) })] + [InlineCustomAutoData(new[] { typeof(FileSendCustomization) })] + public void SendRequest_FromSend_Success(Send send) + { + var request = new SendRequest(send); + + TestHelper.AssertPropertyEqual(send, request, "Id", "AccessId", "UserId", "Name", "Notes", "File", "Text", "Key", "AccessCount", "RevisionDate"); + Assert.Equal(send.Name?.EncryptedString, request.Name); + Assert.Equal(send.Notes?.EncryptedString, request.Notes); + + switch (send.Type) + { + case SendType.File: + // Only sets filename + Assert.Equal(send.File.FileName?.EncryptedString, request.File.FileName); + break; + case SendType.Text: + TestHelper.AssertPropertyEqual(send.Text, request?.Text, "Text"); + Assert.Equal(send.Text.Text?.EncryptedString, request.Text.Text); + break; + default: + throw new Exception("Untested Send type"); + } + + ServiceContainer.Reset(); + } + } +} diff --git a/test/Core.Test/Services/CryptoFunctionServiceTests.cs b/test/Core.Test/Services/CryptoFunctionServiceTests.cs new file mode 100644 index 000000000..b95335e17 --- /dev/null +++ b/test/Core.Test/Services/CryptoFunctionServiceTests.cs @@ -0,0 +1,87 @@ + +using System; +using System.Threading.Tasks; +using Xunit; +using Bit.Core.Services; +using Bit.Core.Enums; +using Bit.Test.Common.AutoFixture.Attributes; +using System.Text; + +namespace Bit.Core.Test.Services +{ + public class CryptoFunctionServiceTests + { + const string regular256Key = "qBUmEYtwTwwGPuw/z6bs/qYXXYNUlocFlyAuuANI8Pw="; + const string utf8256Key = "6DfJwW1R3txgiZKkIFTvVAb7qVlG7lKcmJGJoxR2GBU="; + const string unicode256Key = "gejGI82xthA+nKtKmIh82kjw+ttHr+ODsUoGdu5sf0A="; + const string regular512Key = "xe5cIG6ZfwGmb1FvsOedM0XKOm21myZkjL/eDeKIqqM="; + const string utf8512Key = "XQMVBnxVEhlvjSFDQc77j5GDE9aorvbS0vKnjhRg0LY="; + const string unicode512Key = "148GImrTbrjaGAe/iWEpclINM8Ehhko+9lB14+52lqc="; + const string regularSalt = "salt"; + const string utf8Salt = "üser_salt"; + const string unicodeSalt = "😀salt🙏"; + const string regularInfo = "info"; + const string utf8Info = "üser_info"; + const string unicodeInfo = "😀info🙏"; + + const string prk16Byte = "criAmKtfzxanbgea5/kelQ=="; + const string prk32Byte = "F5h4KdYQnIVH4rKH0P9CZb1GrR4n16/sJrS0PsQEn0Y="; + const string prk64Byte = "ssBK0mRG17VHdtsgt8yo4v25CRNpauH+0r2fwY/E9rLyaFBAOMbIeTry+" + + "gUJ28p8y+hFh3EI9pcrEWaNvFYonQ=="; + + + [Theory, AutoSubstitutionData] + async public Task HkdfExpand_PrkTooSmall_Throws(PclCryptoFunctionService sut) + { + var exception = await Assert.ThrowsAsync( + () => sut.HkdfExpandAsync(Convert.FromBase64String(prk16Byte), "info", 32, HkdfAlgorithm.Sha256)); + Assert.Contains("too small", exception.Message); + } + + [Theory, AutoSubstitutionData] + async public Task HkdfoExpand_OutputTooBig_Throws(PclCryptoFunctionService sut) + { + var exception = await Assert.ThrowsAsync( + () => sut.HkdfExpandAsync(Convert.FromBase64String(prk32Byte), "info", 8161, HkdfAlgorithm.Sha256)); + Assert.Contains("too large", exception.Message); + } + + [Theory] + [InlineAutoSubstitutionData(regular256Key, HkdfAlgorithm.Sha256, prk16Byte, regularSalt, regularInfo)] + [InlineAutoSubstitutionData(utf8256Key, HkdfAlgorithm.Sha256, prk16Byte, utf8Salt, utf8Info)] + [InlineAutoSubstitutionData(unicode256Key, HkdfAlgorithm.Sha256, prk16Byte, unicodeSalt, unicodeInfo)] + [InlineAutoSubstitutionData(regular512Key, HkdfAlgorithm.Sha512, prk16Byte, regularSalt, regularInfo)] + [InlineAutoSubstitutionData(utf8512Key, HkdfAlgorithm.Sha512, prk16Byte, utf8Salt, utf8Info)] + [InlineAutoSubstitutionData(unicode512Key, HkdfAlgorithm.Sha512, prk16Byte, unicodeSalt, unicodeInfo)] + async public Task Hkdf_Success(string expectedKey, HkdfAlgorithm algorithm, string ikmString, string salt, string info, PclCryptoFunctionService sut) + { + byte[] ikm = Convert.FromBase64String(ikmString); + + var key = await sut.HkdfAsync(ikm, salt, info, 32, algorithm); + Assert.Equal(expectedKey, Convert.ToBase64String(key)); + + var keyFromByteArray = await sut.HkdfAsync(ikm, Encoding.UTF8.GetBytes(salt), Encoding.UTF8.GetBytes(info), 32, algorithm); + Assert.Equal(key, keyFromByteArray); + } + + [Theory] + [InlineAutoSubstitutionData("BnIqJlfnHm0e/2iB/15cbHyR19ARPIcWRp4oNS22CD8=", + HkdfAlgorithm.Sha256, prk32Byte, 32, regularInfo)] + [InlineAutoSubstitutionData("BnIqJlfnHm0e/2iB/15cbHyR19ARPIcWRp4oNS22CD9BV+/queOZenPNkDhmlVyL2WZ3OSU5+7ISNF5NhNfvZA==", + HkdfAlgorithm.Sha256, prk32Byte, 64, regularInfo)] + [InlineAutoSubstitutionData("uLWbMWodSBms5uGJ5WTRTesyW+MD7nlpCZvagvIRXlk=", + HkdfAlgorithm.Sha512, prk64Byte, 32, regularInfo)] + [InlineAutoSubstitutionData("uLWbMWodSBms5uGJ5WTRTesyW+MD7nlpCZvagvIRXlkY5Pv0sB+MqvaopmkC6sD/j89zDwTV9Ib2fpucUydO8w==", + HkdfAlgorithm.Sha512, prk64Byte, 64, regularInfo)] + async public Task HkdfExpand_Success(string expectedKey, HkdfAlgorithm algorithm, string prkString, int outputByteSize, string info, PclCryptoFunctionService sut) + { + var prk = Convert.FromBase64String(prkString); + + var key = await sut.HkdfExpandAsync(prk, info, outputByteSize, algorithm); + Assert.Equal(expectedKey, Convert.ToBase64String(key)); + + var keyFromByteArray = await sut.HkdfExpandAsync(prk, Encoding.UTF8.GetBytes(info), outputByteSize, algorithm); + Assert.Equal(key, keyFromByteArray); + } + } +} diff --git a/test/Core.Test/Services/SendServiceTests.cs b/test/Core.Test/Services/SendServiceTests.cs new file mode 100644 index 000000000..b617cd0b4 --- /dev/null +++ b/test/Core.Test/Services/SendServiceTests.cs @@ -0,0 +1,376 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Bit.Core.Abstractions; +using Bit.Core.Models.Data; +using Bit.Core.Models.Domain; +using Bit.Core.Models.Response; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Bit.Core.Enums; +using Bit.Test.Common; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Newtonsoft.Json; +using NSubstitute; +using Xunit; +using System.Text; +using System.Net.Http; +using Bit.Core.Models.Request; +using Bit.Core.Test.AutoFixture; +using System.Linq.Expressions; +using Bit.Core.Models.View; + +namespace Bit.Core.Test.Services +{ + public class SendServiceTests + { + private string GetSendKey(string userId) => SendService.GetSendKey(userId); + + [Theory] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(TextSendCustomization) })] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(FileSendCustomization) })] + public async Task ReplaceAsync_Success(SutProvider sutProvider, string userId, IEnumerable sendDatas) + { + var actualSendDataDict = sendDatas.ToDictionary(d => d.Id, d => d); + sutProvider.GetDependency().GetUserIdAsync().Returns(userId); + + await sutProvider.Sut.ReplaceAsync(actualSendDataDict); + + await sutProvider.GetDependency() + .Received(1).SaveAsync(GetSendKey(userId), actualSendDataDict); + } + + [Theory] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(TextSendCustomization) }, 0)] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(TextSendCustomization) }, 1)] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(TextSendCustomization) }, 2)] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(TextSendCustomization) }, 3)] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(TextSendCustomization) }, 4)] + public async Task DeleteAsync_Success(int numberToDelete, SutProvider sutProvider, string userId, IEnumerable sendDatas) + { + var actualSendDataDict = sendDatas.ToDictionary(d => d.Id, d => d); + sutProvider.GetDependency().GetUserIdAsync().Returns(userId); + sutProvider.GetDependency() + .GetAsync>(GetSendKey(userId)).Returns(actualSendDataDict); + + var idsToDelete = actualSendDataDict.Take(numberToDelete).Select(kvp => kvp.Key).ToArray(); + var expectedSends = actualSendDataDict.Skip(numberToDelete).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + await sutProvider.Sut.DeleteAsync(idsToDelete); + + + await sutProvider.GetDependency().Received(1) + .SaveAsync(GetSendKey(userId), + Arg.Is>(s => TestHelper.AssertEqualExpectedPredicate(expectedSends)(s))); + } + + [Theory, SutAutoData] + public async Task ClearAsync_Success(SutProvider sutProvider, string userId) + { + await sutProvider.Sut.ClearAsync(userId); + + await sutProvider.GetDependency().Received(1).RemoveAsync(GetSendKey(userId)); + } + + [Theory] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(TextSendCustomization) })] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(FileSendCustomization) })] + public async Task DeleteWithServerAsync_Success(SutProvider sutProvider, string userId, IEnumerable sendDatas) + { + var initialSendDatas = sendDatas.ToDictionary(d => d.Id, d => d); + var idToDelete = initialSendDatas.First().Key; + var expectedSends = initialSendDatas.Skip(1).ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + sutProvider.GetDependency().GetUserIdAsync().Returns(userId); + sutProvider.GetDependency() + .GetAsync>(Arg.Any()).Returns(initialSendDatas); + + await sutProvider.Sut.DeleteWithServerAsync(idToDelete); + + await sutProvider.GetDependency().Received(1).DeleteSendAsync(idToDelete); + await sutProvider.GetDependency().Received(1) + .SaveAsync(GetSendKey(userId), + Arg.Is>(s => TestHelper.AssertEqualExpectedPredicate(expectedSends)(s))); + } + + [Theory] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(TextSendCustomization) })] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(FileSendCustomization) })] + public async Task GetAsync_Success(SutProvider sutProvider, string userId, IEnumerable sendDatas) + { + var sendDataDict = sendDatas.ToDictionary(d => d.Id, d => d); + sutProvider.GetDependency().GetUserIdAsync().Returns(userId); + sutProvider.GetDependency().GetAsync>(GetSendKey(userId)).Returns(sendDataDict); + + foreach (var dataKvp in sendDataDict) + { + var expected = new Send(dataKvp.Value); + var actual = await sutProvider.Sut.GetAsync(dataKvp.Key); + TestHelper.AssertPropertyEqual(expected, actual); + } + } + + [Theory, SutAutoData] + public async Task GetAsync_NonExistringId_ReturnsNull(SutProvider sutProvider, string userId, IEnumerable sendDatas) + { + var sendDataDict = sendDatas.ToDictionary(d => d.Id, d => d); + sutProvider.GetDependency().GetUserIdAsync().Returns(userId); + sutProvider.GetDependency().GetAsync>(GetSendKey(userId)).Returns(sendDataDict); + + var actual = await sutProvider.Sut.GetAsync(Guid.NewGuid().ToString()); + + Assert.Null(actual); + } + + [Theory] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(TextSendCustomization) })] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(FileSendCustomization) })] + public async Task GetAllAsync_Success(SutProvider sutProvider, string userId, IEnumerable sendDatas) + { + var sendDataDict = sendDatas.ToDictionary(d => d.Id, d => d); + sutProvider.GetDependency().GetUserIdAsync().Returns(userId); + sutProvider.GetDependency().GetAsync>(GetSendKey(userId)).Returns(sendDataDict); + + var allExpected = sendDataDict.Select(kvp => new Send(kvp.Value)); + var allActual = await sutProvider.Sut.GetAllAsync(); + foreach (var (actual, expected) in allActual.Zip(allExpected)) + { + TestHelper.AssertPropertyEqual(expected, actual); + } + } + + [Theory, SutAutoData] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(TextSendCustomization) })] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(FileSendCustomization) })] + public async Task GetAllDecryptedAsync_Success(SutProvider sutProvider, string userId, IEnumerable sendDatas) + { + var sendDataDict = sendDatas.ToDictionary(d => d.Id, d => d); + sutProvider.GetDependency().HasKeyAsync().Returns(true); + ServiceContainer.Register("cryptoService", sutProvider.GetDependency()); + sutProvider.GetDependency().StringComparer.Returns(StringComparer.CurrentCulture); + sutProvider.GetDependency().GetUserIdAsync().Returns(userId); + sutProvider.GetDependency().GetAsync>(GetSendKey(userId)).Returns(sendDataDict); + + var actual = await sutProvider.Sut.GetAllDecryptedAsync(); + + Assert.Equal(sendDataDict.Count, actual.Count); + foreach (var (actualView, expectedId) in actual.Zip(sendDataDict.Select(s => s.Key))) + { + // Note Send -> SendView is tested in SendTests + Assert.Equal(expectedId, actualView.Id); + } + + ServiceContainer.Reset(); + } + + // SaveWithServer() + [Theory] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(TextSendCustomization) })] + public async Task SaveWithServerAsync_NewTextSend_Success(SutProvider sutProvider, string userId, SendResponse response, Send send) + { + send.Id = null; + sutProvider.GetDependency().GetUserIdAsync().Returns(userId); + sutProvider.GetDependency().PostSendAsync(Arg.Any()).Returns(response); + sutProvider.GetDependency().PostSendFileAsync(Arg.Any()).Returns(response); + + var fileContentBytes = Encoding.UTF8.GetBytes("This is the file content"); + + await sutProvider.Sut.SaveWithServerAsync(send, fileContentBytes); + + Predicate sendRequestPredicate = r => + { + // Note Send -> SendRequest tested in SendRequestTests + TestHelper.AssertPropertyEqual(new SendRequest(send), r); + return true; + }; + + switch (send.Type) + { + case SendType.Text: + await sutProvider.GetDependency().Received(1) + .PostSendAsync(Arg.Is(r => sendRequestPredicate(r))); + break; + case SendType.File: + default: + throw new Exception("Untested send type"); + } + } + + + [Theory] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(FileSendCustomization) })] + public async Task SaveWithServerAsync_NewFileSend_Success(SutProvider sutProvider, string userId, SendResponse response, Send send) + { + send.Id = null; + sutProvider.GetDependency().GetUserIdAsync().Returns(userId); + sutProvider.GetDependency().PostSendAsync(Arg.Any()).Returns(response); + sutProvider.GetDependency().PostSendFileAsync(Arg.Any()).Returns(response); + + var fileContentBytes = Encoding.UTF8.GetBytes("This is the file content"); + + await sutProvider.Sut.SaveWithServerAsync(send, fileContentBytes); + + Predicate formDataPredicate = fd => + { + Assert.Equal(2, fd.Count()); // expect a request and file content + + var expectedRequest = JsonConvert.SerializeObject(new SendRequest(send)); + var actualRequest = fd.First().ReadAsStringAsync().GetAwaiter().GetResult(); + Assert.Equal(expectedRequest, actualRequest); + + var actualFileContent = fd.Skip(1).First().ReadAsByteArrayAsync().GetAwaiter().GetResult(); + Assert.Equal(fileContentBytes, actualFileContent); + return true; + }; + + switch (send.Type) + { + case SendType.File: + await sutProvider.GetDependency().Received(1) + .PostSendFileAsync(Arg.Is(f => formDataPredicate(f))); + break; + case SendType.Text: + default: + throw new Exception("Untested send type"); + } + } + + [Theory] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(TextSendCustomization) })] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(FileSendCustomization) })] + public async Task SaveWithServerAsync_PutSend_Success(SutProvider sutProvider, string userId, SendResponse response, Send send) + { + sutProvider.GetDependency().GetUserIdAsync().Returns(userId); + sutProvider.GetDependency().PutSendAsync(send.Id, Arg.Any()).Returns(response); + + await sutProvider.Sut.SaveWithServerAsync(send, null); + + Predicate sendRequestPredicate = r => + { + // Note Send -> SendRequest tested in SendRequestTests + TestHelper.AssertPropertyEqual(new SendRequest(send), r); + return true; + }; + + await sutProvider.GetDependency().Received(1) + .PutSendAsync(send.Id, Arg.Is(r => sendRequestPredicate(r))); + } + + [Theory, SutAutoData] + public async Task RemovePasswordWithServerAsync_Success(SutProvider sutProvider, SendResponse response, string sendId) + { + sutProvider.GetDependency().PutSendRemovePasswordAsync(sendId).Returns(response); + + await sutProvider.Sut.RemovePasswordWithServerAsync(sendId); + + await sutProvider.GetDependency().Received(1).PutSendRemovePasswordAsync(sendId); + await sutProvider.GetDependency().ReceivedWithAnyArgs(1).SaveAsync>(default, default); + } + + [Theory] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(TextSendCustomization) })] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(FileSendCustomization) })] + public async Task UpsertAsync_Update_Success(SutProvider sutProvider, string userId, IEnumerable initialSends) + { + var initialSendDict = initialSends.ToDictionary(s => s.Id, s => s); + sutProvider.GetDependency().GetUserIdAsync().Returns(userId); + sutProvider.GetDependency().GetAsync>(GetSendKey(userId)).Returns(initialSendDict); + + var updatedSends = CoreHelpers.Clone(initialSendDict); + foreach (var kvp in updatedSends) + { + kvp.Value.Disabled = !kvp.Value.Disabled; + } + + await sutProvider.Sut.UpsertAsync(updatedSends.Values.ToArray()); + + Predicate> matchSendsPredicate = actual => + { + Assert.Equal(updatedSends.Count, actual.Count); + foreach (var (expectedKvp, actualKvp) in updatedSends.Zip(actual)) + { + Assert.Equal(expectedKvp.Key, actualKvp.Key); + TestHelper.AssertPropertyEqual(expectedKvp.Value, actualKvp.Value); + } + return true; + }; + await sutProvider.GetDependency().Received(1).SaveAsync(GetSendKey(userId), Arg.Is>(d => matchSendsPredicate(d))); + } + + [Theory] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(TextSendCustomization) })] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(FileSendCustomization) })] + public async Task UpsertAsync_NewSends_Success(SutProvider sutProvider, string userId, IEnumerable initialSends, IEnumerable newSends) + { + var initialSendDict = initialSends.ToDictionary(s => s.Id, s => s); + sutProvider.GetDependency().GetUserIdAsync().Returns(userId); + sutProvider.GetDependency().GetAsync>(GetSendKey(userId)).Returns(initialSendDict); + + var expectedDict = CoreHelpers.Clone(initialSendDict).Concat(newSends.Select(s => new KeyValuePair(s.Id, s))); + + await sutProvider.Sut.UpsertAsync(newSends.ToArray()); + + Predicate> matchSendsPredicate = actual => + { + Assert.Equal(expectedDict.Count(), actual.Count); + foreach (var (expectedKvp, actualKvp) in expectedDict.Zip(actual)) + { + Assert.Equal(expectedKvp.Key, actualKvp.Key); + TestHelper.AssertPropertyEqual(expectedKvp.Value, actualKvp.Value); + } + return true; + }; + await sutProvider.GetDependency().Received(1).SaveAsync(GetSendKey(userId), Arg.Is>(d => matchSendsPredicate(d))); + } + + [Theory] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(SymmetricCryptoKeyCustomization), typeof(TextSendCustomization) })] + [InlineCustomAutoData(new[] { typeof(SutProviderCustomization), typeof(SymmetricCryptoKeyCustomization), typeof(FileSendCustomization) })] + public async Task EncryptAsync_Success(SutProvider sutProvider, SendView view, byte[] fileData, SymmetricCryptoKey privateKey) + { + var prefix = "encrypted_"; + var prefixBytes = Encoding.UTF8.GetBytes(prefix); + + + byte[] getPbkdf(string password, byte[] key) => + prefixBytes.Concat(Encoding.UTF8.GetBytes(password)).Concat(key).ToArray(); + CipherString encryptBytes(byte[] secret, SymmetricCryptoKey key) => + new CipherString($"{prefix}{Convert.ToBase64String(secret)}{Convert.ToBase64String(key.Key)}"); + CipherString encrypt(string secret, SymmetricCryptoKey key) => + new CipherString($"{prefix}{secret}{Convert.ToBase64String(key.Key)}"); + + sutProvider.GetDependency().Pbkdf2Async(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(info => getPbkdf((string)info[0], (byte[])info[1])); + sutProvider.GetDependency().EncryptAsync(Arg.Any(), Arg.Any()) + .Returns(info => encryptBytes((byte[])info[0], (SymmetricCryptoKey)info[1])); + sutProvider.GetDependency().EncryptAsync(Arg.Any(), Arg.Any()) + .Returns(info => encrypt((string)info[0], (SymmetricCryptoKey)info[1])); + + var (send, encryptedFileData) = await sutProvider.Sut.EncryptAsync(view, fileData, view.Password, privateKey); + + TestHelper.AssertPropertyEqual(view, send, "Password", "Key", "Name", "Notes", "Text", "File", + "AccessCount", "AccessId", "CryptoKey", "RevisionDate", "DeletionDate", "ExpirationDate", "UrlB64Key", + "MaxAccessCountReached", "Expired", "PendingDelete"); + Assert.Equal(Convert.ToBase64String(getPbkdf(view.Password, view.Key)), send.Password); + TestHelper.AssertPropertyEqual(encryptBytes(view.Key, privateKey), send.Key); + TestHelper.AssertPropertyEqual(encrypt(view.Name, view.CryptoKey), send.Name); + TestHelper.AssertPropertyEqual(encrypt(view.Notes, view.CryptoKey), send.Notes); + + switch (view.Type) + { + case SendType.Text: + TestHelper.AssertPropertyEqual(view.Text, send.Text, "Text", "MaskedText"); + TestHelper.AssertPropertyEqual(encrypt(view.Text.Text, view.CryptoKey), send.Text.Text); + break; + case SendType.File: + // Only set filename + TestHelper.AssertPropertyEqual(encrypt(view.File.FileName, view.CryptoKey), send.File.FileName); + TestHelper.AssertPropertyEqual(encryptBytes(fileData, view.CryptoKey), encryptedFileData); + break; + default: + throw new Exception("Untested send type"); + } + } + } +}