From 8d5614cd7bf915b36fe48649cf9f272a5809c2da Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Mon, 25 Jan 2021 14:27:38 -0600 Subject: [PATCH] Port send jslib to mobile (#1219) * Expand Hkdf crypto functions * Add tests for hkdf crypto functions Took the testing infrastructure from bitwarden/server * Move Hkdf to cryptoFunctionService * Port changes from bitwarden/jslib#192 * Port changes from bitwarden/jslib#205 * Make Send Expiration Optional implement changes from bitwarden/jslib#242 * Bug fixes found by testing * Test helpers * Test conversion between model types * Test SendService These are mostly happy-path tests to ensure a reasonably correct implementation * Add run tests step to GitHub Actions * Test send decryption * Test Request generation from Send * Constructor dependencies on separate lines * Remove unused testing infrastructure * Rename to match class name * Move fat arrows to previous lines * Handle exceptions in App layer * PR review cleanups * Throw when attempting to save an unkown Send Type I think it's best to only throw on unknown send types here. I don't think we want to throw whenever we encounter one since that would do bad things like lock up Sync if clients get out of date relative to servers. Instead, keep the client from ruining saved data by complaining last minute that it doesn't know what it's doing. --- .github/workflows/build.yml | 3 + bitwarden-mobile.sln | 66 +++ src/Android/Android.csproj | 2 +- src/Core/Abstractions/IApiService.cs | 7 + .../Abstractions/ICryptoFunctionService.cs | 6 + src/Core/Abstractions/ICryptoService.cs | 1 + src/Core/Abstractions/ISendService.cs | 25 ++ src/Core/Enums/HdkfAlgorithm.cs | 8 + src/Core/Enums/SendType.cs | 8 + src/Core/Models/Api/SendFileApi.cs | 12 + src/Core/Models/Api/SendTextApi.cs | 8 + src/Core/Models/Data/SendData.cs | 58 +++ src/Core/Models/Data/SendFileData.cs | 27 ++ src/Core/Models/Data/SendTextData.cs | 19 + src/Core/Models/Domain/CipherString.cs | 9 +- src/Core/Models/Domain/Domain.cs | 4 +- src/Core/Models/Domain/Send.cs | 91 +++++ src/Core/Models/Domain/SendFile.cs | 27 ++ src/Core/Models/Domain/SendText.cs | 25 ++ src/Core/Models/Request/SendRequest.cs | 54 +++ src/Core/Models/Response/SendResponse.cs | 25 ++ src/Core/Models/Response/SyncResponse.cs | 1 + src/Core/Models/View/SendFileView.cs | 25 ++ src/Core/Models/View/SendTextView.cs | 17 + src/Core/Models/View/SendView.cs | 45 +++ src/Core/Services/ApiService.cs | 36 +- src/Core/Services/CryptoService.cs | 31 +- src/Core/Services/PclCryptoFunctionService.cs | 69 ++++ src/Core/Services/SendService.cs | 279 +++++++++++++ src/Core/Services/SyncService.cs | 11 +- src/Core/Utilities/CoreHelpers.cs | 5 + src/Core/Utilities/ServiceContainer.cs | 4 +- .../Attributes/AutoSubDataAttribute.cs | 10 + .../Attributes/CustomAutoDataAttribute.cs | 25 ++ .../Attributes/InlineAutoSubDataAttribute.cs | 10 + .../InlineCustomAutoDataAttribute.cs | 23 ++ .../Attributes/InlineSutAutoDataAttribute.cs | 20 + .../Attributes/SutAutoDataAttribute.cs | 12 + test/Common/AutoFixture/FixtureExtensions.cs | 11 + test/Common/AutoFixture/ISutProvider.cs | 10 + test/Common/AutoFixture/SutProvider.cs | 130 ++++++ .../AutoFixture/SutProviderCustomization.cs | 32 ++ test/Common/Common.csproj | 21 + test/Common/TestHelper.cs | 58 +++ .../Domain/SymmetricCryptoKeyCustomization.cs | 16 + .../AutoFixture/Send/SendCustomizations.cs | 65 +++ test/Core.Test/Core.Test.csproj | 26 ++ test/Core.Test/Models/Data/SendDataTests.cs | 23 ++ test/Core.Test/Models/Domain/SendTests.cs | 78 ++++ .../Models/Request/SendRequestTests.cs | 43 ++ .../Services/CryptoFunctionServiceTests.cs | 87 ++++ test/Core.Test/Services/SendServiceTests.cs | 376 ++++++++++++++++++ 52 files changed, 2046 insertions(+), 38 deletions(-) create mode 100644 src/Core/Abstractions/ISendService.cs create mode 100644 src/Core/Enums/HdkfAlgorithm.cs create mode 100644 src/Core/Enums/SendType.cs create mode 100644 src/Core/Models/Api/SendFileApi.cs create mode 100644 src/Core/Models/Api/SendTextApi.cs create mode 100644 src/Core/Models/Data/SendData.cs create mode 100644 src/Core/Models/Data/SendFileData.cs create mode 100644 src/Core/Models/Data/SendTextData.cs create mode 100644 src/Core/Models/Domain/Send.cs create mode 100644 src/Core/Models/Domain/SendFile.cs create mode 100644 src/Core/Models/Domain/SendText.cs create mode 100644 src/Core/Models/Request/SendRequest.cs create mode 100644 src/Core/Models/Response/SendResponse.cs create mode 100644 src/Core/Models/View/SendFileView.cs create mode 100644 src/Core/Models/View/SendTextView.cs create mode 100644 src/Core/Models/View/SendView.cs create mode 100644 src/Core/Services/SendService.cs create mode 100644 test/Common/AutoFixture/Attributes/AutoSubDataAttribute.cs create mode 100644 test/Common/AutoFixture/Attributes/CustomAutoDataAttribute.cs create mode 100644 test/Common/AutoFixture/Attributes/InlineAutoSubDataAttribute.cs create mode 100644 test/Common/AutoFixture/Attributes/InlineCustomAutoDataAttribute.cs create mode 100644 test/Common/AutoFixture/Attributes/InlineSutAutoDataAttribute.cs create mode 100644 test/Common/AutoFixture/Attributes/SutAutoDataAttribute.cs create mode 100644 test/Common/AutoFixture/FixtureExtensions.cs create mode 100644 test/Common/AutoFixture/ISutProvider.cs create mode 100644 test/Common/AutoFixture/SutProvider.cs create mode 100644 test/Common/AutoFixture/SutProviderCustomization.cs create mode 100644 test/Common/Common.csproj create mode 100644 test/Common/TestHelper.cs create mode 100644 test/Core.Test/AutoFixture/Domain/SymmetricCryptoKeyCustomization.cs create mode 100644 test/Core.Test/AutoFixture/Send/SendCustomizations.cs create mode 100644 test/Core.Test/Core.Test.csproj create mode 100644 test/Core.Test/Models/Data/SendDataTests.cs create mode 100644 test/Core.Test/Models/Domain/SendTests.cs create mode 100644 test/Core.Test/Models/Request/SendRequestTests.cs create mode 100644 test/Core.Test/Services/CryptoFunctionServiceTests.cs create mode 100644 test/Core.Test/Services/SendServiceTests.cs 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"); + } + } + } +}