diff --git a/docs/en/docs-nav.json b/docs/en/docs-nav.json index 32c47a832e..2125256c55 100644 --- a/docs/en/docs-nav.json +++ b/docs/en/docs-nav.json @@ -716,6 +716,10 @@ "path": "framework/infrastructure/blob-storing/custom-provider.md" } ] + }, + { + "text": "Encryption & Pipeline", + "path": "framework/infrastructure/blob-storing/encryption.md" } ] }, diff --git a/docs/en/framework/infrastructure/blob-storing/encryption.md b/docs/en/framework/infrastructure/blob-storing/encryption.md new file mode 100644 index 0000000000..38de8afff4 --- /dev/null +++ b/docs/en/framework/infrastructure/blob-storing/encryption.md @@ -0,0 +1,148 @@ +```json +//[doc-seo] +{ + "Description": "Learn how to encrypt BLOBs at rest with the BLOB pipeline in ABP Framework, using tenant-specific or global passphrases, and how to build custom pipeline contributors." +} +``` + +# BLOB Encryption & Pipeline + +The BLOB Storing system provides a **pipeline** between the `IBlobContainer` and the storage provider. Pipeline contributors can transform the BLOB stream on save and on read, regardless of which [storage provider](../blob-storing) is configured. The most common use case is **encrypting BLOBs at rest**, which is provided out of the box. Compression, content validation and similar cross-cutting concerns can be implemented the same way. + +> Read the [BLOB Storing document](../blob-storing) to understand how to use the BLOB storing system. This document covers the pipeline and the built-in encryption, which are part of the [Volo.Abp.BlobStoring](https://www.nuget.org/packages/Volo.Abp.BlobStoring) package; no additional package is needed. + +## Enabling Encryption + +Encryption is enabled **per container**, with the `UseEncryption` extension method: + +**Example: Encrypt the BLOBs of a specific container** + +````csharp +Configure(options => +{ + options.Containers.Configure(container => + { + container.UseEncryption(); + }); +}); +```` + +**Example: Encrypt all containers by default** + +````csharp +Configure(options => +{ + options.Containers.ConfigureDefault(container => + { + container.UseEncryption(); + }); +}); +```` + +Containers that don't enable encryption are not affected at all; there is no performance overhead for them. + +## Resolving the Passphrase + +When encryption is enabled, the passphrase is resolved in the following order: + +1. **Container-specific passphrase**: If a passphrase is passed to the `UseEncryption` method, it is always used for that container: + +````csharp +options.Containers.Configure(container => +{ + container.UseEncryption("my-container-passphrase"); +}); +```` + +2. **Tenant-specific passphrase**: If the current tenant is available, the `Abp.BlobStoring.Encryption.TenantPassPhrase` setting is checked. This encrypted setting is restricted to the tenant setting provider, preventing a user-level value from changing the key for other users in the same tenant. You can set it per tenant, for example using the `ISettingManager`: + +````csharp +await _settingManager.SetForTenantAsync( + tenantId, + "Abp.BlobStoring.Encryption.TenantPassPhrase", + "tenant-secret-passphrase" +); +```` + +3. **Global passphrase**: The `AbpBlobStoringEncryptionOptions.DefaultPassPhrase` is used as the fallback (when there is no current tenant, or the tenant has no passphrase defined): + +````csharp +Configure(options => +{ + options.DefaultPassPhrase = "my-global-passphrase"; +}); +```` + +If encryption is enabled but no passphrase can be resolved, an `AbpException` is thrown on save/read. + +> Since the passphrase resolution is performed in the current tenant context, a multi-tenant container automatically encrypts each tenant's BLOBs with the tenant's own key (when defined), and falls back to the global key otherwise. If multi-tenancy is disabled for a container (`IsMultiTenant = false`), the global passphrase is used. + +### Customizing the Passphrase Resolution + +The passphrase resolution is implemented by the `IBlobEncryptionKeyProvider` service. The default implementation (`DefaultBlobEncryptionKeyProvider`) applies the rules above. You can [replace](../../fundamentals/dependency-injection.md) it with your own implementation to read the keys from another source, like a vault or a key management service (KMS): + +````csharp +[Dependency(ReplaceServices = true)] +public class MyEncryptionKeyProvider : IBlobEncryptionKeyProvider +{ + public Task GetPassPhraseOrNullAsync( + BlobContainerConfiguration configuration, + CancellationToken cancellationToken = default) + { + // TODO: Resolve the passphrase from your own key store + } +} +```` + +## The Encryption Format & Compatibility + +* Encryption is performed with authenticated encryption (AEAD) by the `IByteArrayEncryptionService` (AES-256-GCM where available, AES-256-CBC + HMAC-SHA256 on .NET Standard 2.0), so tampered, corrupted, or truncated BLOBs are detected while reading. An authenticated terminal record protects the end of the ciphertext. +* The data is processed in chunks with **constant memory usage**, independent from the BLOB size. +* When the source stream exposes its length, the encrypted stream exposes its exact resulting length for providers that require the object size before uploading. +* Every encrypted BLOB starts with an `ABPE` magic header and a format version byte. BLOBs **without** this header (stored before the encryption was enabled) are returned as-is, so you can enable encryption on a container that already has BLOBs. New BLOBs are encrypted from that point on. + +> Keep your passphrases safe. If the passphrase of a container is lost or changed, the BLOBs encrypted with it can not be decrypted anymore. + +## Creating Custom Pipeline Contributors + +The encryption itself is implemented as a pipeline contributor. You can write your own contributors by implementing the `IBlobPipelineContributor` interface: + +**Example: A contributor that compresses BLOBs on save and decompresses on read** + +````csharp +public class CompressionPipelineContributor : IBlobPipelineContributor, ITransientDependency +{ + public Task OnSaveAsync(BlobPipelineSaveArgs args) + { + // Return a stream that compresses args.BlobStream while being read + } + + public Task OnGetAsync(BlobPipelineGetArgs args) + { + // Return a stream that decompresses args.BlobStream while being read + } +} +```` + +Then, add it to the container configuration: + +````csharp +options.Containers.Configure(container => +{ + container.PipelineContributors.Add(typeof(CompressionPipelineContributor)); + container.UseEncryption(); // Multiple contributors can be combined +}); +```` + +Contributors are resolved from the [dependency injection](../../fundamentals/dependency-injection.md) and executed in the order they are added on save, and in the **reverse order** on read. So, the contributor added last is the first one to transform the stream back on read (in the example above, the BLOB is compressed first, then encrypted while saving; decrypted first, then decompressed while reading). + +A few notes for implementers: + +* Return the input stream as-is if your contributor has nothing to do for the given BLOB. +* Prefer **stream wrappers** over buffering the whole content in memory, so large BLOBs can be processed with constant memory usage. +* The contributors run inside the current tenant context and get the normalized container/BLOB names over the `args` parameter. + +## See Also + +* [BLOB Storing](../blob-storing) +* [Creating a custom BLOB storage provider](./custom-provider.md) diff --git a/docs/en/framework/infrastructure/blob-storing/index.md b/docs/en/framework/infrastructure/blob-storing/index.md index a00ad62ec8..8e2f6ddd48 100644 --- a/docs/en/framework/infrastructure/blob-storing/index.md +++ b/docs/en/framework/infrastructure/blob-storing/index.md @@ -313,6 +313,22 @@ Configure(options => > If your application is not multi-tenant, no worry, it works as expected. You don't need to configure the `IsMultiTenant` option. +## Encrypting BLOBs + +The BLOB Storing system has a **pipeline** that can transform the BLOB stream between the container and the storage provider. The built-in encryption contributor uses this pipeline to **encrypt BLOBs at rest**, transparently, with any storage provider: + +````csharp +Configure(options => +{ + options.Containers.Configure(container => + { + container.UseEncryption(); + }); +}); +```` + +The encryption key can be container-specific, **tenant-specific** (each tenant gets its own key) or **global**. BLOBs stored before enabling the encryption stay readable. See the [BLOB Encryption & Pipeline document](./encryption.md) for details and for creating custom pipeline contributors (like compression). + ## Extending the BLOB Storing System Most of the times, you won't need to customize the BLOB storage system except [creating a custom BLOB storage provider](./custom-provider.md). However, you can replace any service (injected via [dependency injection](../../fundamentals/dependency-injection.md)), if you need. Here, some other services not mentioned above, but you may want to know: @@ -328,4 +344,5 @@ If you want to create folders and move files between folders, assign permissions ## See Also +* [BLOB Encryption & Pipeline](./encryption.md) * [Creating a custom BLOB storage provider](./custom-provider.md) diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/AbpBlobStoringEncryptionOptions.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/AbpBlobStoringEncryptionOptions.cs new file mode 100644 index 0000000000..c2d0eb481d --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/AbpBlobStoringEncryptionOptions.cs @@ -0,0 +1,11 @@ +namespace Volo.Abp.BlobStoring; + +public class AbpBlobStoringEncryptionOptions +{ + /// + /// The global passphrase, used when no container-specific or + /// tenant-specific passphrase is available. + /// Default: null (encryption must be explicitly keyed). + /// + public string? DefaultPassPhrase { get; set; } +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainer.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainer.cs index be1846f8c0..7ecd8aa1fe 100644 --- a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainer.cs +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainer.cs @@ -1,7 +1,10 @@ -using System; +using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Volo.Abp.MultiTenancy; using Volo.Abp.Threading; @@ -116,16 +119,38 @@ public class BlobContainer : IBlobContainer { var blobNormalizeNaming = BlobNormalizeNamingService.NormalizeNaming(Configuration, ContainerName, name); - await Provider.SaveAsync( - new BlobProviderSaveArgs( - blobNormalizeNaming.ContainerName!, - Configuration, - blobNormalizeNaming.BlobName!, - stream, - overrideExisting, - CancellationTokenProvider.FallbackToProvider(cancellationToken) - ) - ); + var fallbackCancellationToken = CancellationTokenProvider.FallbackToProvider(cancellationToken); + var contributorTypes = Configuration.GetEffectivePipelineContributors().ToList(); + IServiceScope? contributorScope = null; + try + { + if (contributorTypes.Count > 0) + { + contributorScope = ServiceProvider.CreateScope(); + stream = await ApplyPipelineOnSaveAsync( + blobNormalizeNaming, + stream, + fallbackCancellationToken, + contributorTypes, + contributorScope.ServiceProvider + ); + } + + await Provider.SaveAsync( + new BlobProviderSaveArgs( + blobNormalizeNaming.ContainerName!, + Configuration, + blobNormalizeNaming.BlobName!, + stream, + overrideExisting, + fallbackCancellationToken + ) + ); + } + finally + { + contributorScope?.Dispose(); + } } } @@ -194,15 +219,104 @@ public class BlobContainer : IBlobContainer var blobNormalizeNaming = BlobNormalizeNamingService.NormalizeNaming(Configuration, ContainerName, name); - return await Provider.GetOrNullAsync( + var fallbackCancellationToken = CancellationTokenProvider.FallbackToProvider(cancellationToken); + + var stream = await Provider.GetOrNullAsync( new BlobProviderGetArgs( blobNormalizeNaming.ContainerName!, Configuration, blobNormalizeNaming.BlobName!, - CancellationTokenProvider.FallbackToProvider(cancellationToken) + fallbackCancellationToken + ) + ); + + if (stream == null) + { + return null; + } + + var contributorTypes = Configuration.GetEffectivePipelineContributors().ToList(); + if (contributorTypes.Count == 0) + { + return stream; + } + + var contributorScope = ServiceProvider.CreateScope(); + try + { + var transformedStream = await ApplyPipelineOnGetAsync( + blobNormalizeNaming, + stream, + fallbackCancellationToken, + contributorTypes, + contributorScope.ServiceProvider + ); + + return new ScopeDisposingStream(transformedStream, contributorScope); + } + catch + { + contributorScope.Dispose(); + stream.Dispose(); + throw; + } + } + } + + protected virtual async Task ApplyPipelineOnSaveAsync( + BlobNormalizeNaming blobNormalizeNaming, + Stream stream, + CancellationToken cancellationToken, + IReadOnlyList contributorTypes, + IServiceProvider contributorServiceProvider) + { + foreach (var contributorType in contributorTypes) + { + var contributor = contributorServiceProvider + .GetRequiredService(contributorType) + .As(); + + stream = await contributor.OnSaveAsync( + new BlobPipelineSaveArgs( + blobNormalizeNaming.ContainerName!, + Configuration, + blobNormalizeNaming.BlobName!, + stream, + cancellationToken + ) + ); + } + + return stream; + } + + protected virtual async Task ApplyPipelineOnGetAsync( + BlobNormalizeNaming blobNormalizeNaming, + Stream stream, + CancellationToken cancellationToken, + IReadOnlyList contributorTypes, + IServiceProvider contributorServiceProvider) + { + // Execute in reverse order, so the contributor that transformed the stream + // last on save is the first to transform it back on read. + foreach (var contributorType in contributorTypes.Reverse()) + { + var contributor = contributorServiceProvider + .GetRequiredService(contributorType) + .As(); + + stream = await contributor.OnGetAsync( + new BlobPipelineGetArgs( + blobNormalizeNaming.ContainerName!, + Configuration, + blobNormalizeNaming.BlobName!, + stream, + cancellationToken ) ); } + + return stream; } protected virtual Guid? GetTenantIdOrNull() @@ -214,4 +328,71 @@ public class BlobContainer : IBlobContainer return CurrentTenant.Id; } + + private sealed class ScopeDisposingStream : Stream + { + private readonly Stream _stream; + private readonly IServiceScope _scope; + private bool _disposed; + + public ScopeDisposingStream(Stream stream, IServiceScope scope) + { + _stream = stream; + _scope = scope; + } + + public override bool CanRead => _stream.CanRead; + public override bool CanSeek => _stream.CanSeek; + public override bool CanWrite => _stream.CanWrite; + public override long Length => _stream.Length; + + public override long Position + { + get => _stream.Position; + set => _stream.Position = value; + } + + public override void Flush() + { + _stream.Flush(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + return _stream.Read(buffer, offset, count); + } + + public override long Seek(long offset, SeekOrigin origin) + { + return _stream.Seek(offset, origin); + } + + public override void SetLength(long value) + { + _stream.SetLength(value); + } + + public override void Write(byte[] buffer, int offset, int count) + { + _stream.Write(buffer, offset, count); + } + + protected override void Dispose(bool disposing) + { + if (disposing && !_disposed) + { + _disposed = true; + try + { + _stream.Dispose(); + } + finally + { + _scope.Dispose(); + } + } + + base.Dispose(disposing); + } + } } diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfiguration.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfiguration.cs index 3d6cd86941..ae98544275 100644 --- a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfiguration.cs +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfiguration.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using JetBrains.Annotations; using Volo.Abp.Collections; @@ -32,6 +33,12 @@ public class BlobContainerConfiguration public ITypeList NamingNormalizers { get; } + /// + /// The pipeline contributors to be executed for this container, + /// before/after the provider calls. + /// + public ITypeList PipelineContributors { get; } + [NotNull] private readonly Dictionary _properties; private readonly BlobContainerConfiguration? _fallbackConfiguration; @@ -39,6 +46,7 @@ public class BlobContainerConfiguration public BlobContainerConfiguration(BlobContainerConfiguration? fallbackConfiguration = null) { NamingNormalizers = new TypeList(); + PipelineContributors = new TypeList(); _fallbackConfiguration = fallbackConfiguration; _properties = new Dictionary(); } @@ -57,6 +65,23 @@ public class BlobContainerConfiguration return NamingNormalizers; } + /// + /// Returns the pipeline contributors in effect for this container. Contributors from the fallback + /// configuration are composed before local contributors, independently from provider inheritance. + /// + public IEnumerable GetEffectivePipelineContributors() + { + if (_fallbackConfiguration == null) + { + return PipelineContributors; + } + + return _fallbackConfiguration + .GetEffectivePipelineContributors() + .Concat(PipelineContributors) + .Distinct(); + } + public T? GetConfigurationOrDefault(string name, T? defaultValue = default) { return (T?)GetConfigurationOrNull(name, defaultValue); diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfigurationEncryptionExtensions.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfigurationEncryptionExtensions.cs new file mode 100644 index 0000000000..b15ba38cad --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfigurationEncryptionExtensions.cs @@ -0,0 +1,34 @@ +using JetBrains.Annotations; + +namespace Volo.Abp.BlobStoring; + +public static class BlobContainerConfigurationEncryptionExtensions +{ + /// + /// Enables encryption for the BLOBs of this container. + /// + /// The container configuration. + /// + /// Optional container-specific passphrase. When not given, the passphrase is resolved + /// by the (tenant-specific setting first, + /// then ). + /// + public static BlobContainerConfiguration UseEncryption( + [NotNull] this BlobContainerConfiguration configuration, + string? passPhrase = null) + { + Check.NotNull(configuration, nameof(configuration)); + + if (!configuration.PipelineContributors.Contains(typeof(BlobEncryptionContributor))) + { + configuration.PipelineContributors.Add(typeof(BlobEncryptionContributor)); + } + + if (passPhrase != null) + { + configuration.SetConfiguration(BlobStoringEncryptionConfigurationNames.PassPhrase, passPhrase); + } + + return configuration; + } +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionContributor.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionContributor.cs new file mode 100644 index 0000000000..e01070a055 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionContributor.cs @@ -0,0 +1,59 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring; + +/// +/// A pipeline contributor that encrypts BLOBs on save and decrypts them on read. +/// +/// The passphrase is resolved in the following order: +/// 1. Container-specific passphrase (see UseEncryption extension method). +/// 2. (tenant-specific setting, then the global passphrase). +/// +/// +public class BlobEncryptionContributor : IBlobPipelineContributor, ITransientDependency +{ + protected IBlobEncryptionService EncryptionService { get; } + + protected IBlobEncryptionKeyProvider EncryptionKeyProvider { get; } + + public BlobEncryptionContributor( + IBlobEncryptionService encryptionService, + IBlobEncryptionKeyProvider encryptionKeyProvider) + { + EncryptionService = encryptionService; + EncryptionKeyProvider = encryptionKeyProvider; + } + + public virtual async Task OnSaveAsync(BlobPipelineSaveArgs args) + { + var passPhrase = await GetPassPhraseAsync(args); + return EncryptionService.Encrypt(args.BlobStream, passPhrase); + } + + public virtual async Task OnGetAsync(BlobPipelineGetArgs args) + { + var passPhrase = await GetPassPhraseAsync(args); + return EncryptionService.Decrypt(args.BlobStream, passPhrase); + } + + protected virtual async Task GetPassPhraseAsync(BlobProviderArgs args) + { + var passPhrase = + args.Configuration.GetConfigurationOrDefault(BlobStoringEncryptionConfigurationNames.PassPhrase) ?? + await EncryptionKeyProvider.GetPassPhraseOrNullAsync(args.Configuration, args.CancellationToken); + + if (passPhrase.IsNullOrEmpty()) + { + throw new AbpException( + $"BLOB encryption is enabled for the container '{args.ContainerName}', but no passphrase could be resolved. " + + $"Pass a passphrase to the UseEncryption extension method, set the '{BlobStoringEncryptionSettings.TenantPassPhrase}' " + + $"setting for the current tenant or configure {nameof(AbpBlobStoringEncryptionOptions)}.{nameof(AbpBlobStoringEncryptionOptions.DefaultPassPhrase)}." + ); + } + + return passPhrase!; + } +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionService.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionService.cs new file mode 100644 index 0000000000..ff0bfb1758 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionService.cs @@ -0,0 +1,559 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using Microsoft.Extensions.Options; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Security.Encryption; + +namespace Volo.Abp.BlobStoring; + +/// +/// Default implementation of . +/// +/// Inherits the authenticated (AEAD) chunked encryption from +/// and exposes it as pull-style +/// read streams, as required by the BLOB pipeline. Memory usage is constant, +/// independent from the BLOB size. +/// +/// +/// Format of an encrypted BLOB: 4 bytes magic ("ABPE") + 1 byte BLOB format version, +/// followed by the output +/// (its own header + authenticated chunks). BLOBs without the magic header +/// are returned as-is on decryption, so BLOBs stored before encryption was +/// enabled stay readable. +/// +/// +public class BlobEncryptionService : ByteArrayEncryptionService, IBlobEncryptionService +{ + protected static readonly byte[] MagicHeader = { (byte)'A', (byte)'B', (byte)'P', (byte)'E' }; + + protected const byte BlobFormatVersion = 1; + + public BlobEncryptionService(IOptions options) + : base(options) + { + } + + public virtual Stream Encrypt(Stream plainStream, string passPhrase) + { + Check.NotNull(plainStream, nameof(plainStream)); + Check.NotNullOrWhiteSpace(passPhrase, nameof(passPhrase)); + + if (Options.ChunkSize <= 0 || Options.ChunkSize > MaximumChunkSize) + { + throw new AbpException($"{nameof(Options.ChunkSize)} must be between 1 and {MaximumChunkSize} bytes!"); + } + + var algorithm = GetEncryptionAlgorithm(); + var keyBytes = DeriveKeyBytes(passPhrase, Options.DefaultSalt, algorithm); + + var baseNonce = new byte[BaseNonceSize]; + using (var random = RandomNumberGenerator.Create()) + { + random.GetBytes(baseNonce); + } + + var header = BuildHeader(algorithm, Options.ChunkSize, baseNonce); + + return new ChunkedEncryptingReadStream( + this, + plainStream, + header, + keyBytes, + baseNonce, + algorithm, + Options.ChunkSize, + TryCalculateEncryptedLength(plainStream, algorithm, Options.ChunkSize) + ); + } + + public virtual Stream Decrypt(Stream cipherStream, string passPhrase) + { + Check.NotNull(cipherStream, nameof(cipherStream)); + Check.NotNullOrWhiteSpace(passPhrase, nameof(passPhrase)); + + var prefix = ReadUpTo(cipherStream, MagicHeader.Length + 1); + if (prefix.Length < MagicHeader.Length + 1 || !HasMagicHeader(prefix)) + { + // Not an encrypted BLOB, return as-is for backward compatibility + return new PrefixingReadStream(prefix, cipherStream); + } + + if (prefix[MagicHeader.Length] != BlobFormatVersion) + { + throw new AbpException($"Unsupported BLOB encryption format version: {prefix[MagicHeader.Length]}!"); + } + + var header = ReadExactly(cipherStream, HeaderSize); + if (header == null) + { + throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: missing header!"); + } + + if (header[0] != FormatVersion) + { + throw new AbpException($"Unsupported encryption format version: {header[0]}!"); + } + + var algorithm = header[1]; + if (algorithm != AlgorithmAesGcm && algorithm != AlgorithmAesCbcHmacSha256) + { + throw new AbpException($"Unsupported encryption algorithm: {algorithm}!"); + } + + var chunkSize = ReadInt32BigEndian(header, 2); + if (chunkSize <= 0 || chunkSize > MaximumChunkSize) + { + throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid chunk size!"); + } + + var baseNonce = new byte[BaseNonceSize]; + Array.Copy(header, 6, baseNonce, 0, BaseNonceSize); + + var keyBytes = DeriveKeyBytes(passPhrase, Options.DefaultSalt, algorithm); + + return new ChunkedDecryptingReadStream( + this, + cipherStream, + header, + keyBytes, + baseNonce, + algorithm, + chunkSize, + algorithm == AlgorithmAesGcm ? GcmTagSize : HmacSize, + algorithm == AlgorithmAesGcm ? chunkSize : chunkSize + AesBlockSize + ); + } + + internal byte[] EncryptChunkToBytes(byte algorithm, byte[] keyBytes, byte[] header, byte[] baseNonce, int chunkIndex, byte[] plainChunk, int plainChunkLength) + { + using (var chunkStream = new MemoryStream()) + { + EncryptChunk(algorithm, keyBytes, header, baseNonce, chunkIndex, plainChunk, plainChunkLength, chunkStream); + return chunkStream.ToArray(); + } + } + + internal byte[]? ReadExactlyCore(Stream stream, int count) + { + return ReadExactly(stream, count); + } + + internal byte[] ReadUpToCore(Stream stream, int count) + { + return ReadUpTo(stream, count); + } + + internal byte[] DecryptChunkCore(byte algorithm, byte[] keyBytes, byte[] header, byte[] baseNonce, int chunkIndex, byte[] cipherChunk, byte[] tag) + { + return DecryptChunk(algorithm, keyBytes, header, baseNonce, chunkIndex, cipherChunk, tag); + } + + internal byte[] WriteTerminalRecordToBytes(byte algorithm, byte[] keyBytes, byte[] header, byte[] baseNonce, int chunkIndex) + { + using (var stream = new MemoryStream()) + { + WriteTerminalRecord(algorithm, keyBytes, header, baseNonce, chunkIndex, stream); + return stream.ToArray(); + } + } + + internal void VerifyTerminalRecordCore(byte algorithm, byte[] keyBytes, byte[] header, byte[] baseNonce, int chunkIndex, byte[] tag) + { + VerifyTerminalRecord(algorithm, keyBytes, header, baseNonce, chunkIndex, tag); + } + + private static long? TryCalculateEncryptedLength(Stream plainStream, byte algorithm, int chunkSize) + { + if (!plainStream.CanSeek) + { + return null; + } + + try + { + var plainLength = plainStream.Length - plainStream.Position; + if (plainLength < 0) + { + return null; + } + + var tagSize = algorithm == AlgorithmAesGcm ? GcmTagSize : HmacSize; + var fullChunkCount = plainLength / chunkSize; + var remainder = plainLength % chunkSize; + + checked + { + var length = MagicHeader.Length + 1L + HeaderSize + ChunkLengthPrefixSize + tagSize; + length += fullChunkCount * (ChunkLengthPrefixSize + tagSize + GetCipherChunkLength(algorithm, chunkSize)); + if (remainder > 0) + { + length += ChunkLengthPrefixSize + tagSize + GetCipherChunkLength(algorithm, remainder); + } + + return length; + } + } + catch (NotSupportedException) + { + return null; + } + catch (OverflowException) + { + return null; + } + } + + private static long GetCipherChunkLength(byte algorithm, long plainChunkLength) + { + return algorithm == AlgorithmAesGcm + ? plainChunkLength + : ((plainChunkLength / AesBlockSize) + 1) * AesBlockSize; + } + + private static bool HasMagicHeader(byte[] prefix) + { + for (var i = 0; i < MagicHeader.Length; i++) + { + if (prefix[i] != MagicHeader[i]) + { + return false; + } + } + + return true; + } + + private static int ReadInt32BigEndian(byte[] buffer, int offset) + { + return (buffer[offset] << 24) | (buffer[offset + 1] << 16) | (buffer[offset + 2] << 8) | buffer[offset + 3]; + } + + /// + /// A read-only, non-seekable stream that serves output produced chunk by chunk, + /// so memory usage stays constant regardless of the total data size. + /// + private abstract class ChunkedCryptoReadStream : Stream + { + private readonly long? _length; + private byte[]? _outputBuffer; + private int _outputBufferPosition; + private bool _finished; + + protected ChunkedCryptoReadStream(long? length = null) + { + _length = length; + } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => _length ?? throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + while (true) + { + if (_outputBuffer != null && _outputBufferPosition < _outputBuffer.Length) + { + var toCopy = Math.Min(count, _outputBuffer.Length - _outputBufferPosition); + Array.Copy(_outputBuffer, _outputBufferPosition, buffer, offset, toCopy); + _outputBufferPosition += toCopy; + return toCopy; + } + + if (_finished) + { + return 0; + } + + _outputBuffer = ProduceNext(); + _outputBufferPosition = 0; + + if (_outputBuffer == null) + { + _finished = true; + return 0; + } + } + } + + /// + /// Produces the next output bytes, or null when there is no more output. + /// + protected abstract byte[]? ProduceNext(); + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + } + + private class ChunkedEncryptingReadStream : ChunkedCryptoReadStream + { + private readonly BlobEncryptionService _owner; + private readonly Stream _plainStream; + private readonly byte[] _header; + private readonly byte[] _keyBytes; + private readonly byte[] _baseNonce; + private readonly byte _algorithm; + private readonly int _chunkSize; + private bool _terminalEmitted; + private bool _headerEmitted; + private int _chunkIndex; + + public ChunkedEncryptingReadStream( + BlobEncryptionService owner, + Stream plainStream, + byte[] header, + byte[] keyBytes, + byte[] baseNonce, + byte algorithm, + int chunkSize, + long? encryptedLength) + : base(encryptedLength) + { + _owner = owner; + _plainStream = plainStream; + _header = header; + _keyBytes = keyBytes; + _baseNonce = baseNonce; + _algorithm = algorithm; + _chunkSize = chunkSize; + } + + protected override byte[]? ProduceNext() + { + if (!_headerEmitted) + { + _headerEmitted = true; + + var prefix = new byte[MagicHeader.Length + 1 + _header.Length]; + MagicHeader.CopyTo(prefix, 0); + prefix[MagicHeader.Length] = BlobFormatVersion; + Array.Copy(_header, 0, prefix, MagicHeader.Length + 1, _header.Length); + return prefix; + } + + var plainChunk = _owner.ReadUpToCore(_plainStream, _chunkSize); + if (plainChunk.Length == 0) + { + if (_terminalEmitted) + { + return null; + } + + _terminalEmitted = true; + return _owner.WriteTerminalRecordToBytes( + _algorithm, + _keyBytes, + _header, + _baseNonce, + _chunkIndex + ); + } + + return _owner.EncryptChunkToBytes( + _algorithm, + _keyBytes, + _header, + _baseNonce, + _chunkIndex++, + plainChunk, + plainChunk.Length + ); + } + + protected override void Dispose(bool disposing) + { + // Do not dispose the plain stream; it is owned by the caller. + } + } + + private class ChunkedDecryptingReadStream : ChunkedCryptoReadStream + { + private readonly BlobEncryptionService _owner; + private readonly Stream _cipherStream; + private readonly byte[] _header; + private readonly byte[] _keyBytes; + private readonly byte[] _baseNonce; + private readonly byte _algorithm; + private readonly int _tagSize; + private readonly int _maxCipherChunkSize; + private int _chunkIndex; + + public ChunkedDecryptingReadStream( + BlobEncryptionService owner, + Stream cipherStream, + byte[] header, + byte[] keyBytes, + byte[] baseNonce, + byte algorithm, + int chunkSize, + int tagSize, + int maxCipherChunkSize) + { + _owner = owner; + _cipherStream = cipherStream; + _header = header; + _keyBytes = keyBytes; + _baseNonce = baseNonce; + _algorithm = algorithm; + _tagSize = tagSize; + _maxCipherChunkSize = maxCipherChunkSize; + } + + protected override byte[]? ProduceNext() + { + var lengthPrefix = _owner.ReadUpToCore(_cipherStream, 4); + if (lengthPrefix.Length == 0) + { + throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: missing terminal record!"); + } + + if (lengthPrefix.Length < 4) + { + throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: truncated chunk!"); + } + + var cipherChunkSize = ReadInt32BigEndian(lengthPrefix, 0); + if (cipherChunkSize == 0) + { + var terminalTag = _owner.ReadExactlyCore(_cipherStream, _tagSize); + if (terminalTag == null || _owner.ReadUpToCore(_cipherStream, 1).Length != 0) + { + throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid terminal record!"); + } + + _owner.VerifyTerminalRecordCore( + _algorithm, + _keyBytes, + _header, + _baseNonce, + _chunkIndex, + terminalTag + ); + return null; + } + + if (cipherChunkSize < 0 || cipherChunkSize > _maxCipherChunkSize) + { + throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid chunk length!"); + } + + var cipherChunk = _owner.ReadExactlyCore(_cipherStream, cipherChunkSize); + var tag = _owner.ReadExactlyCore(_cipherStream, _tagSize); + if (cipherChunk == null || tag == null) + { + throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: truncated chunk!"); + } + + return _owner.DecryptChunkCore( + _algorithm, + _keyBytes, + _header, + _baseNonce, + _chunkIndex++, + cipherChunk, + tag + ); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _cipherStream.Dispose(); + } + } + } + + private sealed class PrefixingReadStream : Stream + { + private readonly byte[] _prefix; + private readonly Stream _stream; + private int _prefixPosition; + + public PrefixingReadStream(byte[] prefix, Stream stream) + { + _prefix = prefix; + _stream = stream; + } + + public override bool CanRead => _stream.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + if (_prefixPosition < _prefix.Length) + { + var readCount = Math.Min(count, _prefix.Length - _prefixPosition); + Array.Copy(_prefix, _prefixPosition, buffer, offset, readCount); + _prefixPosition += readCount; + return readCount; + } + + return _stream.Read(buffer, offset, count); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _stream.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineGetArgs.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineGetArgs.cs new file mode 100644 index 0000000000..2ef95840ee --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineGetArgs.cs @@ -0,0 +1,26 @@ +using System.IO; +using System.Threading; +using JetBrains.Annotations; + +namespace Volo.Abp.BlobStoring; + +public class BlobPipelineGetArgs : BlobProviderArgs +{ + [NotNull] + public Stream BlobStream { get; } + + public BlobPipelineGetArgs( + [NotNull] string containerName, + [NotNull] BlobContainerConfiguration configuration, + [NotNull] string blobName, + [NotNull] Stream blobStream, + CancellationToken cancellationToken = default) + : base( + containerName, + configuration, + blobName, + cancellationToken) + { + BlobStream = Check.NotNull(blobStream, nameof(blobStream)); + } +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineSaveArgs.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineSaveArgs.cs new file mode 100644 index 0000000000..e3cf9dd8ae --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineSaveArgs.cs @@ -0,0 +1,26 @@ +using System.IO; +using System.Threading; +using JetBrains.Annotations; + +namespace Volo.Abp.BlobStoring; + +public class BlobPipelineSaveArgs : BlobProviderArgs +{ + [NotNull] + public Stream BlobStream { get; } + + public BlobPipelineSaveArgs( + [NotNull] string containerName, + [NotNull] BlobContainerConfiguration configuration, + [NotNull] string blobName, + [NotNull] Stream blobStream, + CancellationToken cancellationToken = default) + : base( + containerName, + configuration, + blobName, + cancellationToken) + { + BlobStream = Check.NotNull(blobStream, nameof(blobStream)); + } +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobStoringEncryptionConfigurationNames.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobStoringEncryptionConfigurationNames.cs new file mode 100644 index 0000000000..65f6bfdaf8 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobStoringEncryptionConfigurationNames.cs @@ -0,0 +1,10 @@ +namespace Volo.Abp.BlobStoring; + +public static class BlobStoringEncryptionConfigurationNames +{ + /// + /// Configuration name used to store a container-specific encryption passphrase + /// on . + /// + public const string PassPhrase = "Abp.BlobStoring.Encryption.PassPhrase"; +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobStoringEncryptionSettingDefinitionProvider.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobStoringEncryptionSettingDefinitionProvider.cs new file mode 100644 index 0000000000..63aa173df4 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobStoringEncryptionSettingDefinitionProvider.cs @@ -0,0 +1,16 @@ +using Volo.Abp.Settings; + +namespace Volo.Abp.BlobStoring; + +public class BlobStoringEncryptionSettingDefinitionProvider : SettingDefinitionProvider +{ + public override void Define(ISettingDefinitionContext context) + { + context.Add( + new SettingDefinition( + BlobStoringEncryptionSettings.TenantPassPhrase, + isEncrypted: true + ).WithProviders(TenantSettingValueProvider.ProviderName) + ); + } +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobStoringEncryptionSettings.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobStoringEncryptionSettings.cs new file mode 100644 index 0000000000..99f244ca07 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobStoringEncryptionSettings.cs @@ -0,0 +1,9 @@ +namespace Volo.Abp.BlobStoring; + +public static class BlobStoringEncryptionSettings +{ + /// + /// Setting name for the tenant-specific encryption passphrase. + /// + public const string TenantPassPhrase = "Abp.BlobStoring.Encryption.TenantPassPhrase"; +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/DefaultBlobEncryptionKeyProvider.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/DefaultBlobEncryptionKeyProvider.cs new file mode 100644 index 0000000000..53d9aef766 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/DefaultBlobEncryptionKeyProvider.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Settings; + +namespace Volo.Abp.BlobStoring; + +/// +/// Default implementation of . +/// Resolves a tenant-specific passphrase from the setting system +/// () when a tenant is available, +/// otherwise falls back to the global passphrase +/// (). +/// +public class DefaultBlobEncryptionKeyProvider : IBlobEncryptionKeyProvider, ITransientDependency +{ + protected ICurrentTenant CurrentTenant { get; } + + protected ISettingProvider SettingProvider { get; } + + protected AbpBlobStoringEncryptionOptions Options { get; } + + public DefaultBlobEncryptionKeyProvider( + ICurrentTenant currentTenant, + ISettingProvider settingProvider, + IOptions options) + { + CurrentTenant = currentTenant; + SettingProvider = settingProvider; + Options = options.Value; + } + + public virtual async Task GetPassPhraseOrNullAsync( + BlobContainerConfiguration configuration, + CancellationToken cancellationToken = default) + { + if (CurrentTenant.Id.HasValue) + { + var tenantPassPhrase = await SettingProvider.GetOrNullAsync( + BlobStoringEncryptionSettings.TenantPassPhrase + ); + + if (!tenantPassPhrase.IsNullOrEmpty()) + { + return tenantPassPhrase; + } + } + + return Options.DefaultPassPhrase; + } +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionKeyProvider.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionKeyProvider.cs new file mode 100644 index 0000000000..d206e57482 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionKeyProvider.cs @@ -0,0 +1,17 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Volo.Abp.BlobStoring; + +/// +/// Resolves the passphrase used to encrypt/decrypt the BLOBs of a container. +/// +public interface IBlobEncryptionKeyProvider +{ + /// + /// Returns the passphrase to be used, or null if no passphrase is available. + /// + Task GetPassPhraseOrNullAsync( + BlobContainerConfiguration configuration, + CancellationToken cancellationToken = default); +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionService.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionService.cs new file mode 100644 index 0000000000..6739ae58d5 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionService.cs @@ -0,0 +1,27 @@ +using System.IO; + +namespace Volo.Abp.BlobStoring; + +/// +/// Encrypts and decrypts BLOB streams with authenticated encryption +/// (see ). +/// Memory usage is constant, independent from the BLOB size. +/// +public interface IBlobEncryptionService +{ + /// + /// Wraps the given stream so that the content read from it is encrypted. + /// The returned stream starts with a small header (magic bytes and format version, + /// followed by the encryption format header), the authenticated cipher chunks, + /// and an authenticated terminal record. When the input length is known, the returned + /// stream exposes the exact encrypted . + /// + Stream Encrypt(Stream plainStream, string passPhrase); + + /// + /// Wraps the given stream so that the content read from it is decrypted. + /// If the stream does not carry the encryption header, its content is returned unchanged + /// (assumed to be stored before encryption was enabled). + /// + Stream Decrypt(Stream cipherStream, string passPhrase); +} diff --git a/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobPipelineContributor.cs b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobPipelineContributor.cs new file mode 100644 index 0000000000..ff5d7a98b5 --- /dev/null +++ b/framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobPipelineContributor.cs @@ -0,0 +1,24 @@ +using System.IO; +using System.Threading.Tasks; + +namespace Volo.Abp.BlobStoring; + +/// +/// A contributor to the BLOB pipeline. Contributors are executed inside the +/// , before/after the actual call, +/// and can transform the BLOB stream (e.g. encryption, compression). +/// +public interface IBlobPipelineContributor +{ + /// + /// Called before a BLOB is saved by the provider. + /// Return the (possibly transformed) stream to be stored. + /// + Task OnSaveAsync(BlobPipelineSaveArgs args); + + /// + /// Called after a BLOB is read from the provider. + /// Return the (possibly transformed) stream to be returned to the caller. + /// + Task OnGetAsync(BlobPipelineGetArgs args); +} diff --git a/framework/src/Volo.Abp.Security/Volo/Abp/Security/Encryption/AbpByteArrayEncryptionOptions.cs b/framework/src/Volo.Abp.Security/Volo/Abp/Security/Encryption/AbpByteArrayEncryptionOptions.cs new file mode 100644 index 0000000000..869b4699ce --- /dev/null +++ b/framework/src/Volo.Abp.Security/Volo/Abp/Security/Encryption/AbpByteArrayEncryptionOptions.cs @@ -0,0 +1,49 @@ +using System.Text; + +namespace Volo.Abp.Security.Encryption; + +/// +/// Options used by . +/// These options are independent from ; +/// changing them does not affect . +/// +public class AbpByteArrayEncryptionOptions +{ + /// + /// Default password to encrypt/decrypt data. + /// It's recommended to set to another value for security. + /// Default value: "x9V4qL2mZ8sT1pRe" + /// + public string DefaultPassPhrase { get; set; } + + /// + /// This constant string is used as a "salt" value for the key derivation function calls. + /// Default value: Encoding.ASCII.GetBytes("kT8!qW2e") + /// + public byte[] DefaultSalt { get; set; } + + /// + /// Iteration count of the PBKDF2 key derivation function. + /// Default value: 100000. + /// WARNING: Changing this value makes previously encrypted data undecryptable, + /// since the key is derived again with the current value during decryption. + /// + public int DeriveBytesIterations { get; set; } + + /// + /// Size (in bytes) of the plaintext chunks that are encrypted and authenticated + /// one by one while processing streams. Larger data is processed in constant memory, + /// independent from the total data size. + /// Default value: 65536 (64 KB). + /// Maximum value: 16777216 (16 MB). + /// + public int ChunkSize { get; set; } + + public AbpByteArrayEncryptionOptions() + { + DefaultPassPhrase = "x9V4qL2mZ8sT1pRe"; + DefaultSalt = Encoding.ASCII.GetBytes("kT8!qW2e"); + DeriveBytesIterations = 100000; + ChunkSize = 64 * 1024; + } +} diff --git a/framework/src/Volo.Abp.Security/Volo/Abp/Security/Encryption/ByteArrayEncryptionService.cs b/framework/src/Volo.Abp.Security/Volo/Abp/Security/Encryption/ByteArrayEncryptionService.cs new file mode 100644 index 0000000000..8effa3e5a0 --- /dev/null +++ b/framework/src/Volo.Abp.Security/Volo/Abp/Security/Encryption/ByteArrayEncryptionService.cs @@ -0,0 +1,530 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using Microsoft.Extensions.Options; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.Security.Encryption; + +/// +/// Implements using authenticated encryption. +/// Uses AES-256-GCM on platforms that support it, and falls back to +/// AES-256-CBC + HMAC-SHA256 (encrypt-then-MAC) on .NET Standard 2.0. +/// +/// Output format: a 14-byte header (version, algorithm, chunk size, base nonce), +/// followed by authenticated chunks: 4-byte big-endian cipher length, cipher chunk, authentication tag, +/// and an authenticated zero-length terminal record. +/// The header and the chunk index are bound to every chunk as associated data, +/// so chunks can not be re-ordered, truncated or moved between files. +/// +/// +public class ByteArrayEncryptionService : IByteArrayEncryptionService, ITransientDependency +{ + protected AbpByteArrayEncryptionOptions Options { get; } + + protected const byte FormatVersion = 1; + protected const byte AlgorithmAesGcm = 1; + protected const byte AlgorithmAesCbcHmacSha256 = 2; + protected const int BaseNonceSize = 8; + protected const int HeaderSize = 14; // version(1) + algorithm(1) + chunkSize(4) + baseNonce(8) + protected const int ChunkLengthPrefixSize = 4; + protected const int GcmNonceSize = 12; + protected const int GcmTagSize = 16; + protected const int HmacSize = 32; + protected const int AesBlockSize = 16; + protected const int MaximumChunkSize = 16 * 1024 * 1024; + + public ByteArrayEncryptionService(IOptions options) + { + Options = options.Value; + } + + public virtual byte[]? Encrypt(byte[]? plainBytes, string? passPhrase = null, byte[]? salt = null) + { + if (plainBytes == null) + { + return null; + } + + using var plainStream = new MemoryStream(plainBytes, writable: false); + using var cipherStream = new MemoryStream(); + Encrypt(plainStream, cipherStream, passPhrase, salt); + return cipherStream.ToArray(); + } + + public virtual byte[]? Decrypt(byte[]? cipherBytes, string? passPhrase = null, byte[]? salt = null) + { + if (cipherBytes == null || cipherBytes.Length == 0) + { + return null; + } + + using var cipherStream = new MemoryStream(cipherBytes, writable: false); + using var plainStream = new MemoryStream(); + Decrypt(cipherStream, plainStream, passPhrase, salt); + return plainStream.ToArray(); + } + + public virtual void Encrypt(Stream plainStream, Stream cipherStream, string? passPhrase = null, byte[]? salt = null) + { + Check.NotNull(plainStream, nameof(plainStream)); + Check.NotNull(cipherStream, nameof(cipherStream)); + + if (Options.ChunkSize <= 0 || Options.ChunkSize > MaximumChunkSize) + { + throw new AbpException($"{nameof(Options.ChunkSize)} must be between 1 and {MaximumChunkSize} bytes!"); + } + + var algorithm = GetEncryptionAlgorithm(); + var keyBytes = DeriveKeyBytes(passPhrase ?? Options.DefaultPassPhrase, salt ?? Options.DefaultSalt, algorithm); + + var baseNonce = new byte[BaseNonceSize]; + using (var random = RandomNumberGenerator.Create()) + { + random.GetBytes(baseNonce); + } + + var header = BuildHeader(algorithm, Options.ChunkSize, baseNonce); + cipherStream.Write(header, 0, header.Length); + + var buffer = new byte[Options.ChunkSize]; + var chunkIndex = 0; + int readCount; + while ((readCount = plainStream.Read(buffer, 0, buffer.Length)) > 0) + { + EncryptChunk(algorithm, keyBytes, header, baseNonce, chunkIndex, buffer, readCount, cipherStream); + chunkIndex++; + } + + WriteTerminalRecord(algorithm, keyBytes, header, baseNonce, chunkIndex, cipherStream); + } + + public virtual void Decrypt(Stream cipherStream, Stream plainStream, string? passPhrase = null, byte[]? salt = null) + { + Check.NotNull(cipherStream, nameof(cipherStream)); + Check.NotNull(plainStream, nameof(plainStream)); + + var header = ReadExactly(cipherStream, HeaderSize); + if (header == null) + { + throw new AbpException("The encrypted data is corrupted or has an invalid format: missing header!"); + } + + if (header[0] != FormatVersion) + { + throw new AbpException($"Unsupported encryption format version: {header[0]}!"); + } + + var algorithm = header[1]; + if (algorithm != AlgorithmAesGcm && algorithm != AlgorithmAesCbcHmacSha256) + { + throw new AbpException($"Unsupported encryption algorithm: {algorithm}!"); + } + + var chunkSize = ReadInt32BigEndian(header, 2); + if (chunkSize <= 0 || chunkSize > MaximumChunkSize) + { + throw new AbpException("The encrypted data is corrupted or has an invalid format: invalid chunk size!"); + } + + var baseNonce = new byte[BaseNonceSize]; + Array.Copy(header, 6, baseNonce, 0, BaseNonceSize); + + var keyBytes = DeriveKeyBytes(passPhrase ?? Options.DefaultPassPhrase, salt ?? Options.DefaultSalt, algorithm); + + var tagSize = algorithm == AlgorithmAesGcm ? GcmTagSize : HmacSize; + var maxCipherChunkSize = algorithm == AlgorithmAesGcm ? chunkSize : chunkSize + AesBlockSize; + + var chunkIndex = 0; + while (true) + { + var lengthPrefix = ReadUpTo(cipherStream, ChunkLengthPrefixSize); + if (lengthPrefix.Length == 0) + { + throw new AbpException("The encrypted data is corrupted or has an invalid format: missing terminal record!"); + } + + if (lengthPrefix.Length < ChunkLengthPrefixSize) + { + throw new AbpException("The encrypted data is corrupted or has an invalid format: truncated chunk!"); + } + + var cipherChunkSize = ReadInt32BigEndian(lengthPrefix, 0); + if (cipherChunkSize == 0) + { + var terminalTag = ReadExactly(cipherStream, tagSize); + if (terminalTag == null || ReadUpTo(cipherStream, 1).Length != 0) + { + throw new AbpException("The encrypted data is corrupted or has an invalid format: invalid terminal record!"); + } + + VerifyTerminalRecord(algorithm, keyBytes, header, baseNonce, chunkIndex, terminalTag); + break; + } + + if (cipherChunkSize < 0 || cipherChunkSize > maxCipherChunkSize) + { + throw new AbpException("The encrypted data is corrupted or has an invalid format: invalid chunk length!"); + } + + var cipherChunk = ReadExactly(cipherStream, cipherChunkSize); + var tag = ReadExactly(cipherStream, tagSize); + if (cipherChunk == null || tag == null) + { + throw new AbpException("The encrypted data is corrupted or has an invalid format: truncated chunk!"); + } + + var plainChunk = DecryptChunk(algorithm, keyBytes, header, baseNonce, chunkIndex, cipherChunk, tag); + plainStream.Write(plainChunk, 0, plainChunk.Length); + chunkIndex++; + } + } + + /// + /// Gets the algorithm used while encrypting. Decryption supports both algorithms + /// (except AES-GCM on .NET Standard 2.0, where it is not available). + /// + protected virtual byte GetEncryptionAlgorithm() + { +#if NETSTANDARD2_0 + return AlgorithmAesCbcHmacSha256; +#else + return AlgorithmAesGcm; +#endif + } + + /// + /// Derives the key material using PBKDF2-SHA1 (Rfc2898DeriveBytes). SHA1 is used on all + /// target frameworks on purpose, so that the derived key is deterministic across platforms. + /// Returns 32 bytes for AES-256-GCM, or 64 bytes (32 encryption + 32 MAC) for AES-256-CBC-HMAC. + /// + protected virtual byte[] DeriveKeyBytes(string passPhrase, byte[] salt, byte algorithm) + { + var keyLength = algorithm == AlgorithmAesGcm ? 32 : 64; +#if NET8_0_OR_GREATER + return Rfc2898DeriveBytes.Pbkdf2(passPhrase, salt, Options.DeriveBytesIterations, HashAlgorithmName.SHA1, keyLength); +#else + // The default hash algorithm of this constructor is SHA1. + using var password = new Rfc2898DeriveBytes(passPhrase, salt, Options.DeriveBytesIterations); + return password.GetBytes(keyLength); +#endif + } + + protected virtual void EncryptChunk(byte algorithm, byte[] keyBytes, byte[] header, byte[] baseNonce, int chunkIndex, byte[] plainChunk, int plainChunkLength, Stream cipherStream) + { + var associatedData = CreateChunkAssociatedData(header, chunkIndex); + byte[] cipherChunk; + byte[] tag; + + if (algorithm == AlgorithmAesGcm) + { +#if NETSTANDARD2_0 + throw new AbpException("AES-GCM is not supported on this platform (.NET Standard 2.0)!"); +#else + cipherChunk = new byte[plainChunkLength]; + tag = new byte[GcmTagSize]; + using (var aesGcm = CreateAesGcm(keyBytes)) + { + aesGcm.Encrypt(CreateChunkNonce(baseNonce, chunkIndex), plainChunk.AsSpan(0, plainChunkLength), cipherChunk, tag, associatedData); + } +#endif + } + else + { + cipherChunk = AesCbcEncryptChunk(keyBytes, baseNonce, chunkIndex, plainChunk, plainChunkLength); + tag = ComputeChunkMac(keyBytes, associatedData, cipherChunk); + } + + var lengthPrefix = new byte[ChunkLengthPrefixSize]; + WriteInt32BigEndian(lengthPrefix, 0, cipherChunk.Length); + cipherStream.Write(lengthPrefix, 0, lengthPrefix.Length); + cipherStream.Write(cipherChunk, 0, cipherChunk.Length); + cipherStream.Write(tag, 0, tag.Length); + } + + protected virtual void WriteTerminalRecord( + byte algorithm, + byte[] keyBytes, + byte[] header, + byte[] baseNonce, + int chunkIndex, + Stream cipherStream) + { + var lengthPrefix = new byte[ChunkLengthPrefixSize]; + cipherStream.Write(lengthPrefix, 0, lengthPrefix.Length); + + var tag = ComputeTerminalTag(algorithm, keyBytes, header, baseNonce, chunkIndex); + cipherStream.Write(tag, 0, tag.Length); + } + + protected virtual void VerifyTerminalRecord( + byte algorithm, + byte[] keyBytes, + byte[] header, + byte[] baseNonce, + int chunkIndex, + byte[] tag) + { + if (algorithm == AlgorithmAesGcm) + { +#if NETSTANDARD2_0 + throw new AbpException("AES-GCM encrypted data can not be decrypted on this platform (.NET Standard 2.0)!"); +#else + using (var aesGcm = CreateAesGcm(keyBytes)) + { + aesGcm.Decrypt( + CreateChunkNonce(baseNonce, chunkIndex), + Array.Empty(), + tag, + Array.Empty(), + CreateChunkAssociatedData(header, chunkIndex) + ); + } +#endif + } + else + { + var expectedTag = ComputeTerminalTag(algorithm, keyBytes, header, baseNonce, chunkIndex); + if (!FixedTimeEquals(expectedTag, tag)) + { + throw new CryptographicException("The encrypted data is tampered, corrupted or the passphrase/salt is wrong!"); + } + } + } + + protected virtual byte[] ComputeTerminalTag( + byte algorithm, + byte[] keyBytes, + byte[] header, + byte[] baseNonce, + int chunkIndex) + { + var associatedData = CreateChunkAssociatedData(header, chunkIndex); + if (algorithm == AlgorithmAesGcm) + { +#if NETSTANDARD2_0 + throw new AbpException("AES-GCM is not supported on this platform (.NET Standard 2.0)!"); +#else + var tag = new byte[GcmTagSize]; + using (var aesGcm = CreateAesGcm(keyBytes)) + { + aesGcm.Encrypt( + CreateChunkNonce(baseNonce, chunkIndex), + Array.Empty(), + Array.Empty(), + tag, + associatedData + ); + } + + return tag; +#endif + } + + return ComputeChunkMac(keyBytes, associatedData, Array.Empty()); + } + + protected virtual byte[] DecryptChunk(byte algorithm, byte[] keyBytes, byte[] header, byte[] baseNonce, int chunkIndex, byte[] cipherChunk, byte[] tag) + { + var associatedData = CreateChunkAssociatedData(header, chunkIndex); + + if (algorithm == AlgorithmAesGcm) + { +#if NETSTANDARD2_0 + throw new AbpException("AES-GCM encrypted data can not be decrypted on this platform (.NET Standard 2.0)!"); +#else + var plainChunk = new byte[cipherChunk.Length]; + using (var aesGcm = CreateAesGcm(keyBytes)) + { + // Throws CryptographicException if the authentication tag is invalid. + aesGcm.Decrypt(CreateChunkNonce(baseNonce, chunkIndex), cipherChunk, tag, plainChunk, associatedData); + } + + return plainChunk; +#endif + } + else + { + var expectedTag = ComputeChunkMac(keyBytes, associatedData, cipherChunk); + if (!FixedTimeEquals(expectedTag, tag)) + { + throw new CryptographicException("The encrypted data is tampered, corrupted or the passphrase/salt is wrong!"); + } + + return AesCbcDecryptChunk(keyBytes, baseNonce, chunkIndex, cipherChunk); + } + } + + /// + /// Creates the 12-byte nonce of a chunk: 8-byte random base nonce + 4-byte big-endian chunk index. + /// Since the base nonce is random per encryption operation and the index is unique per chunk, + /// a nonce never repeats for the same key. + /// + protected virtual byte[] CreateChunkNonce(byte[] baseNonce, int chunkIndex) + { + var nonce = new byte[GcmNonceSize]; + Array.Copy(baseNonce, 0, nonce, 0, BaseNonceSize); + WriteInt32BigEndian(nonce, BaseNonceSize, chunkIndex); + return nonce; + } + + /// + /// Creates the associated data of a chunk: the header + 4-byte big-endian chunk index. + /// This binds every chunk to its position and to the file it belongs to. + /// + protected virtual byte[] CreateChunkAssociatedData(byte[] header, int chunkIndex) + { + var associatedData = new byte[HeaderSize + 4]; + Array.Copy(header, 0, associatedData, 0, HeaderSize); + WriteInt32BigEndian(associatedData, HeaderSize, chunkIndex); + return associatedData; + } + + /// + /// Encrypts a chunk with AES-256-CBC. The IV of each chunk is derived from the MAC key, + /// the base nonce and the chunk index, so it is unique and unpredictable per chunk. + /// + protected virtual byte[] AesCbcEncryptChunk(byte[] keyBytes, byte[] baseNonce, int chunkIndex, byte[] plainChunk, int plainChunkLength) + { + using var aes = Aes.Create(); + aes.Mode = CipherMode.CBC; + using var encryptor = aes.CreateEncryptor(GetAesCbcEncryptionKey(keyBytes), DeriveAesCbcChunkIV(keyBytes, baseNonce, chunkIndex)); + return encryptor.TransformFinalBlock(plainChunk, 0, plainChunkLength); + } + + protected virtual byte[] AesCbcDecryptChunk(byte[] keyBytes, byte[] baseNonce, int chunkIndex, byte[] cipherChunk) + { + using var aes = Aes.Create(); + aes.Mode = CipherMode.CBC; + using var decryptor = aes.CreateDecryptor(GetAesCbcEncryptionKey(keyBytes), DeriveAesCbcChunkIV(keyBytes, baseNonce, chunkIndex)); + return decryptor.TransformFinalBlock(cipherChunk, 0, cipherChunk.Length); + } + + /// + /// Computes the HMAC-SHA256 of a chunk over its associated data and cipher bytes (encrypt-then-MAC). + /// + protected virtual byte[] ComputeChunkMac(byte[] keyBytes, byte[] associatedData, byte[] cipherChunk) + { + using var hmac = new HMACSHA256(GetAesCbcMacKey(keyBytes)); + hmac.TransformBlock(associatedData, 0, associatedData.Length, null, 0); + hmac.TransformFinalBlock(cipherChunk, 0, cipherChunk.Length); + return hmac.Hash!; + } + + /// + /// Derives the IV of a CBC chunk: first 16 bytes of HMAC-SHA256(MAC key, "IV" + chunk nonce). + /// + protected virtual byte[] DeriveAesCbcChunkIV(byte[] keyBytes, byte[] baseNonce, int chunkIndex) + { + var input = CreateChunkNonce(baseNonce, chunkIndex); + input[0] ^= 0xFF; // Domain separation from the GCM nonce, just in case. + using var hmac = new HMACSHA256(GetAesCbcMacKey(keyBytes)); + var hash = hmac.ComputeHash(input); + var iv = new byte[AesBlockSize]; + Array.Copy(hash, 0, iv, 0, AesBlockSize); + return iv; + } + + protected virtual byte[] GetAesCbcEncryptionKey(byte[] keyBytes) + { + var key = new byte[32]; + Array.Copy(keyBytes, 0, key, 0, 32); + return key; + } + + protected virtual byte[] GetAesCbcMacKey(byte[] keyBytes) + { + var key = new byte[32]; + Array.Copy(keyBytes, 32, key, 0, 32); + return key; + } + +#if !NETSTANDARD2_0 + private static AesGcm CreateAesGcm(byte[] keyBytes) + { +#if NET8_0_OR_GREATER + return new AesGcm(keyBytes, GcmTagSize); +#else + return new AesGcm(keyBytes); +#endif + } +#endif + + protected virtual byte[] BuildHeader(byte algorithm, int chunkSize, byte[] baseNonce) + { + var header = new byte[HeaderSize]; + header[0] = FormatVersion; + header[1] = algorithm; + WriteInt32BigEndian(header, 2, chunkSize); + Array.Copy(baseNonce, 0, header, 6, BaseNonceSize); + return header; + } + + /// + /// Reads exactly bytes from the stream. + /// Returns null if the stream ends before bytes could be read. + /// + protected virtual byte[]? ReadExactly(Stream stream, int count) + { + var buffer = ReadUpTo(stream, count); + return buffer.Length == count ? buffer : null; + } + + /// + /// Reads up to bytes from the stream. + /// May return fewer bytes (or an empty array) only if the stream ends. + /// + protected virtual byte[] ReadUpTo(Stream stream, int count) + { + var buffer = new byte[count]; + var totalReadCount = 0; + while (totalReadCount < count) + { + var readCount = stream.Read(buffer, totalReadCount, count - totalReadCount); + if (readCount == 0) + { + break; + } + + totalReadCount += readCount; + } + + if (totalReadCount == count) + { + return buffer; + } + + var result = new byte[totalReadCount]; + Array.Copy(buffer, 0, result, 0, totalReadCount); + return result; + } + + private static void WriteInt32BigEndian(byte[] buffer, int offset, int value) + { + buffer[offset] = (byte)(value >> 24); + buffer[offset + 1] = (byte)(value >> 16); + buffer[offset + 2] = (byte)(value >> 8); + buffer[offset + 3] = (byte)value; + } + + private static int ReadInt32BigEndian(byte[] buffer, int offset) + { + return (buffer[offset] << 24) | (buffer[offset + 1] << 16) | (buffer[offset + 2] << 8) | buffer[offset + 3]; + } + + private static bool FixedTimeEquals(byte[] a, byte[] b) + { + if (a.Length != b.Length) + { + return false; + } + + var diff = 0; + for (var i = 0; i < a.Length; i++) + { + diff |= a[i] ^ b[i]; + } + + return diff == 0; + } +} diff --git a/framework/src/Volo.Abp.Security/Volo/Abp/Security/Encryption/IByteArrayEncryptionService.cs b/framework/src/Volo.Abp.Security/Volo/Abp/Security/Encryption/IByteArrayEncryptionService.cs new file mode 100644 index 0000000000..cc9f849c4e --- /dev/null +++ b/framework/src/Volo.Abp.Security/Volo/Abp/Security/Encryption/IByteArrayEncryptionService.cs @@ -0,0 +1,59 @@ +using System.IO; + +namespace Volo.Abp.Security.Encryption; + +/// +/// Can be used to encrypt/decrypt binary data (files, images, serialized objects etc.) +/// with authenticated encryption. +/// Use to configure default values. +/// This service is independent from ; +/// data encrypted by one of them can not be decrypted by the other. +/// +public interface IByteArrayEncryptionService +{ + /// + /// Encrypts binary data. + /// + /// The data in plain format + /// A phrase to use as the encryption key (optional, uses default if not provided) + /// Salt value (optional, uses default if not provided) + /// Encrypted data, including a format header and authentication tags + byte[]? Encrypt(byte[]? plainBytes, string? passPhrase = null, byte[]? salt = null); + + /// + /// Decrypts binary data that is encrypted by the method. + /// + /// The data in encrypted format + /// A phrase to use as the encryption key (optional, uses default if not provided) + /// Salt value (optional, uses default if not provided) + /// Decrypted data + /// + /// Thrown when the data is tampered, corrupted or the passphrase/salt is wrong. + /// + byte[]? Decrypt(byte[]? cipherBytes, string? passPhrase = null, byte[]? salt = null); + + /// + /// Encrypts a stream into another stream. The data is processed in chunks, + /// so the memory usage is constant and independent from the total data size. + /// Each chunk is authenticated before the next one is written. + /// + /// The stream to read the plain data from + /// The stream to write the encrypted data to + /// A phrase to use as the encryption key (optional, uses default if not provided) + /// Salt value (optional, uses default if not provided) + void Encrypt(Stream plainStream, Stream cipherStream, string? passPhrase = null, byte[]? salt = null); + + /// + /// Decrypts a stream that is encrypted by the method. + /// Each chunk's authentication tag is verified before its plaintext is written + /// to the , so tampered data is never released. + /// + /// The stream to read the encrypted data from + /// The stream to write the decrypted data to + /// A phrase to use as the encryption key (optional, uses default if not provided) + /// Salt value (optional, uses default if not provided) + /// + /// Thrown when the data is tampered, corrupted or the passphrase/salt is wrong. + /// + void Decrypt(Stream cipherStream, Stream plainStream, string? passPhrase = null, byte[]? salt = null); +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringTestModule.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringTestModule.cs index 93523a8c88..5ca3c3948c 100644 --- a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringTestModule.cs +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringTestModule.cs @@ -1,9 +1,10 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using NSubstitute; using Volo.Abp.Autofac; using Volo.Abp.BlobStoring.Fakes; using Volo.Abp.BlobStoring.TestObjects; using Volo.Abp.Modularity; +using Volo.Abp.Settings; namespace Volo.Abp.BlobStoring; @@ -19,6 +20,22 @@ public class AbpBlobStoringTestModule : AbpModule context.Services.AddSingleton(Substitute.For()); context.Services.AddSingleton(Substitute.For()); + context.Services.AddSingleton(); + context.Services.AddSingleton( + serviceProvider => serviceProvider.GetRequiredService() + ); + + Configure(options => + { + var tenantProviderIndex = options.ValueProviders.IndexOf(typeof(TenantSettingValueProvider)); + options.ValueProviders[tenantProviderIndex] = typeof(FakeTenantPassPhraseSettingValueProvider); + }); + + Configure(options => + { + options.DefaultPassPhrase = "default-global-passphrase"; + }); + Configure(options => { options.Containers @@ -40,6 +57,26 @@ public class AbpBlobStoringTestModule : AbpModule .Configure(container => { container.IsMultiTenant = false; + }) + .Configure(container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.UseEncryption("container4-passphrase"); + }) + .Configure(container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.UseEncryption(); + }) + .Configure(container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.PipelineContributors.Add(typeof(FakeReversingPipelineContributor)); + }) + .Configure(container => + { + container.ProviderType = typeof(FakeInMemoryBlobProvider); + container.PipelineContributors.Add(typeof(FakeScopeBoundPipelineContributor)); }); }); } diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerConfiguration_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerConfiguration_Tests.cs index f47faa1f25..91e58b4472 100644 --- a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerConfiguration_Tests.cs +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerConfiguration_Tests.cs @@ -59,4 +59,21 @@ public class BlobContainerConfiguration_Tests effective.ShouldContain(typeof(AnotherFakeNamingNormalizer)); effective.ShouldNotContain(typeof(FakeNamingNormalizer)); } + + [Fact] + public void Should_Compose_Default_And_Local_Pipeline_Contributors_With_Provider_Override() + { + var defaultConfig = new BlobContainerConfiguration(); + defaultConfig.UseEncryption(); + + var namedConfig = new BlobContainerConfiguration(defaultConfig); + namedConfig.ProviderType = typeof(FakeBlobProvider2); + namedConfig.PipelineContributors.Add(); + + namedConfig.GetEffectivePipelineContributors().ShouldBe(new[] + { + typeof(BlobEncryptionContributor), + typeof(FakeReversingPipelineContributor) + }); + } } diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerEncryption_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerEncryption_Tests.cs new file mode 100644 index 0000000000..cef5805f84 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerEncryption_Tests.cs @@ -0,0 +1,292 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.BlobStoring.Fakes; +using Volo.Abp.BlobStoring.TestObjects; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Settings; +using Xunit; + +namespace Volo.Abp.BlobStoring; + +public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase +{ + private readonly IBlobContainer _container4; // UseEncryption("container4-passphrase") + private readonly IBlobContainer _container5; // UseEncryption() -> key provider (tenant setting / global options) + private readonly IBlobContainer _container6; // FakeReversingPipelineContributor + private readonly IBlobContainer _container7; // Scope-bound lazy contributor + private readonly FakeInMemoryBlobProvider _provider; + private readonly IBlobEncryptionService _encryptionService; + private readonly ICurrentTenant _currentTenant; + private readonly ISettingDefinitionManager _settingDefinitionManager; + + public BlobContainerEncryption_Tests() + { + _container4 = GetRequiredService>(); + _container5 = GetRequiredService>(); + _container6 = GetRequiredService>(); + _container7 = GetRequiredService>(); + _provider = GetRequiredService(); + _encryptionService = GetRequiredService(); + _currentTenant = GetRequiredService(); + _settingDefinitionManager = GetRequiredService(); + } + + [Fact] + public async Task Should_Save_Encrypted_And_Get_Decrypted_Blob() + { + var blobName = "test-blob-encrypted-1"; + var testContent = "test content".GetBytes(); + + await _container4.SaveAsync(blobName, testContent); + + var rawBytes = GetRawBytes(blobName); + rawBytes.ShouldNotBeNull(); + rawBytes!.SequenceEqual(testContent).ShouldBeFalse(); + Encoding.ASCII.GetString(rawBytes.Take(4).ToArray()).ShouldBe("ABPE"); + + var result = await _container4.GetAllBytesAsync(blobName); + result.SequenceEqual(testContent).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Encrypt_With_Tenant_Specific_PassPhrase() + { + var tenantId = Guid.NewGuid(); + var blobName = "test-blob-encrypted-tenant"; + var testContent = "test content".GetBytes(); + + using (_currentTenant.Change(tenantId)) + { + await _container5.SaveAsync(blobName, testContent); + } + + var rawBytes = GetRawBytes(blobName); + rawBytes.ShouldNotBeNull(); + + var decryptedBytes = Decrypt(rawBytes!, FakeTenantPassPhraseSettingValueProvider.GetPassPhrase(tenantId)); + decryptedBytes.SequenceEqual(testContent).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Produce_Different_CipherText_For_Different_Tenants() + { + var testContent = "test content".GetBytes(); + + var tenantId1 = Guid.NewGuid(); + using (_currentTenant.Change(tenantId1)) + { + await _container5.SaveAsync("test-blob-tenant-1", testContent); + } + + var tenantId2 = Guid.NewGuid(); + using (_currentTenant.Change(tenantId2)) + { + await _container5.SaveAsync("test-blob-tenant-2", testContent); + } + + var rawBytes1 = GetRawBytes("test-blob-tenant-1"); + var rawBytes2 = GetRawBytes("test-blob-tenant-2"); + + rawBytes1.ShouldNotBeNull(); + rawBytes2.ShouldNotBeNull(); + rawBytes1!.SequenceEqual(rawBytes2!).ShouldBeFalse(); + + Decrypt(rawBytes1, FakeTenantPassPhraseSettingValueProvider.GetPassPhrase(tenantId1)) + .SequenceEqual(testContent).ShouldBeTrue(); + Decrypt(rawBytes2, FakeTenantPassPhraseSettingValueProvider.GetPassPhrase(tenantId2)) + .SequenceEqual(testContent).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Fall_Back_To_Global_PassPhrase_Without_Tenant() + { + var blobName = "test-blob-encrypted-global"; + var testContent = "test content".GetBytes(); + + await _container5.SaveAsync(blobName, testContent); + + var rawBytes = GetRawBytes(blobName); + rawBytes.ShouldNotBeNull(); + + var decryptedBytes = Decrypt(rawBytes!, "default-global-passphrase"); + decryptedBytes.SequenceEqual(testContent).ShouldBeTrue(); + + (await _container5.GetAllBytesAsync(blobName)).SequenceEqual(testContent).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Apply_Custom_Pipeline_Contributor() + { + var blobName = "test-blob-reversed"; + var testContent = "test content".GetBytes(); + + await _container6.SaveAsync(blobName, testContent); + + var rawBytes = GetRawBytes(blobName); + rawBytes.ShouldNotBeNull(); + rawBytes!.SequenceEqual(testContent.Reverse().ToArray()).ShouldBeTrue(); + + var result = await _container6.GetAllBytesAsync(blobName); + result.SequenceEqual(testContent).ShouldBeTrue(); + } + + [Fact] + public async Task Should_Keep_Contributor_Scope_Alive_While_Provider_And_Caller_Read_Streams() + { + var blobName = "test-blob-scope-bound"; + var testContent = "scope-bound content".GetBytes(); + + await _container7.SaveAsync(blobName, testContent); + using var result = await _container7.GetAsync(blobName); + using var output = new MemoryStream(); + await result.CopyToAsync(output); + + output.ToArray().ShouldBe(testContent); + } + + [Fact] + public async Task Should_Define_Tenant_PassPhrase_As_Encrypted_And_Tenant_Only() + { + var definition = await _settingDefinitionManager.GetAsync(BlobStoringEncryptionSettings.TenantPassPhrase); + + definition.IsEncrypted.ShouldBeTrue(); + definition.Providers.ShouldBe(new[] { TenantSettingValueProvider.ProviderName }); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(65536)] + [InlineData(65537)] + public void Should_Expose_Exact_Encrypted_Length_For_Seekable_Input(int length) + { + using var encryptedStream = _encryptionService.Encrypt(new MemoryStream(new byte[length]), "length-passphrase"); + var reportedLength = encryptedStream.Length; + using var output = new MemoryStream(); + + encryptedStream.CopyTo(output); + + reportedLength.ShouldBe(output.Length); + } + + [Fact] + public void Should_Stream_NonSeekable_Unencrypted_Response_Without_Materializing_It() + { + var content = new byte[1024 * 1024]; + new Random(42).NextBytes(content); + var source = new TrackingNonSeekableStream(content); + + using var result = _encryptionService.Decrypt(source, "unused-passphrase"); + + source.BytesRead.ShouldBe(5); + using var output = new MemoryStream(); + result.CopyTo(output); + output.ToArray().ShouldBe(content); + result.Dispose(); + source.IsDisposed.ShouldBeTrue(); + } + + [Fact] + public async Task Should_Reject_Encrypted_Blob_Without_Terminal_Record() + { + var blobName = "test-blob-truncated-terminal"; + await _container4.SaveAsync(blobName, new byte[128 * 1024]); + var encryptedBytes = GetRawBytes(blobName)!; + Array.Resize(ref encryptedBytes, encryptedBytes.Length - 20); + + using var decryptedStream = _encryptionService.Decrypt(new MemoryStream(encryptedBytes), "container4-passphrase"); + using var output = new MemoryStream(); + + Should.Throw(() => decryptedStream.CopyTo(output)); + } + + [Fact] + public async Task Should_Reject_Oversized_Encrypted_Blob_Chunk_Size_Before_Allocating() + { + var blobName = "test-blob-oversized-chunk"; + await _container4.SaveAsync(blobName, new byte[] { 1 }); + var encryptedBytes = GetRawBytes(blobName)!; + encryptedBytes[7] = 0x7F; + encryptedBytes[8] = 0xFF; + encryptedBytes[9] = 0xFF; + encryptedBytes[10] = 0xFF; + + Should.Throw(() => + _encryptionService.Decrypt(new MemoryStream(encryptedBytes), "container4-passphrase") + ); + } + + private byte[]? GetRawBytes(string blobName) + { + return _provider.GetRawBytesOrNull( + BlobContainerNameAttribute.GetContainerName(), + blobName + ); + } + + private byte[] Decrypt(byte[] encryptedBytes, string passPhrase) + { + using (var decryptedStream = _encryptionService.Decrypt(new MemoryStream(encryptedBytes), passPhrase)) + using (var memoryStream = new MemoryStream()) + { + decryptedStream.CopyTo(memoryStream); + return memoryStream.ToArray(); + } + } + + private sealed class TrackingNonSeekableStream : Stream + { + private readonly MemoryStream _stream; + + public int BytesRead { get; private set; } + + public bool IsDisposed { get; private set; } + + public TrackingNonSeekableStream(byte[] bytes) + { + _stream = new MemoryStream(bytes); + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + var readCount = _stream.Read(buffer, offset, count); + BytesRead += readCount; + return readCount; + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + IsDisposed = true; + _stream.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeInMemoryBlobProvider.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeInMemoryBlobProvider.cs new file mode 100644 index 0000000000..e4be8b35d9 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeInMemoryBlobProvider.cs @@ -0,0 +1,60 @@ +#nullable enable +using System.Collections.Concurrent; +using System.IO; +using System.Threading.Tasks; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// A real in-memory provider (not a substitute), so tests can inspect the raw stored bytes. +/// +public class FakeInMemoryBlobProvider : BlobProviderBase +{ + private readonly ConcurrentDictionary _blobs = new ConcurrentDictionary(); + + public override async Task SaveAsync(BlobProviderSaveArgs args) + { + var key = GetKey(args.ContainerName, args.BlobName); + + if (!args.OverrideExisting && _blobs.ContainsKey(key)) + { + throw new BlobAlreadyExistsException( + $"Saving BLOB '{args.BlobName}' does already exists in the container '{args.ContainerName}'!"); + } + + using (var memoryStream = new MemoryStream()) + { + await args.BlobStream.CopyToAsync(memoryStream); + _blobs[key] = memoryStream.ToArray(); + } + } + + public override Task DeleteAsync(BlobProviderDeleteArgs args) + { + return Task.FromResult(_blobs.TryRemove(GetKey(args.ContainerName, args.BlobName), out _)); + } + + public override Task ExistsAsync(BlobProviderExistsArgs args) + { + return Task.FromResult(_blobs.ContainsKey(GetKey(args.ContainerName, args.BlobName))); + } + + public override Task GetOrNullAsync(BlobProviderGetArgs args) + { + return Task.FromResult( + _blobs.TryGetValue(GetKey(args.ContainerName, args.BlobName), out var bytes) + ? new MemoryStream(bytes) + : null + ); + } + + public byte[]? GetRawBytesOrNull(string containerName, string blobName) + { + return _blobs.TryGetValue(GetKey(containerName, blobName), out var bytes) ? bytes : null; + } + + private static string GetKey(string containerName, string blobName) + { + return containerName + "/" + blobName; + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeReversingPipelineContributor.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeReversingPipelineContributor.cs new file mode 100644 index 0000000000..55c63ab8ac --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeReversingPipelineContributor.cs @@ -0,0 +1,33 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// A test contributor that reverses the BLOB bytes on both save and get. +/// +public class FakeReversingPipelineContributor : IBlobPipelineContributor, ITransientDependency +{ + public Task OnSaveAsync(BlobPipelineSaveArgs args) + { + return Task.FromResult(Reverse(args.BlobStream)); + } + + public Task OnGetAsync(BlobPipelineGetArgs args) + { + return Task.FromResult(Reverse(args.BlobStream)); + } + + private static Stream Reverse(Stream stream) + { + using (var memoryStream = new MemoryStream()) + { + stream.CopyTo(memoryStream); + var bytes = memoryStream.ToArray(); + Array.Reverse(bytes); + return new MemoryStream(bytes); + } + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeScopeBoundPipelineContributor.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeScopeBoundPipelineContributor.cs new file mode 100644 index 0000000000..1921d16d51 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeScopeBoundPipelineContributor.cs @@ -0,0 +1,77 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.BlobStoring.Fakes; + +public class FakeScopeBoundPipelineContributor : IBlobPipelineContributor, IScopedDependency, IDisposable +{ + private bool _isDisposed; + + public Task OnSaveAsync(BlobPipelineSaveArgs args) + { + return Task.FromResult(new ScopeBoundStream(args.BlobStream, this)); + } + + public Task OnGetAsync(BlobPipelineGetArgs args) + { + return Task.FromResult(new ScopeBoundStream(args.BlobStream, this)); + } + + public void Dispose() + { + _isDisposed = true; + } + + private sealed class ScopeBoundStream : Stream + { + private readonly Stream _stream; + private readonly FakeScopeBoundPipelineContributor _owner; + + public ScopeBoundStream(Stream stream, FakeScopeBoundPipelineContributor owner) + { + _stream = stream; + _owner = owner; + } + + public override bool CanRead => _stream.CanRead; + public override bool CanSeek => _stream.CanSeek; + public override bool CanWrite => false; + public override long Length => _stream.Length; + + public override long Position + { + get => _stream.Position; + set => _stream.Position = value; + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + if (_owner._isDisposed) + { + throw new ObjectDisposedException(nameof(FakeScopeBoundPipelineContributor)); + } + + return _stream.Read(buffer, offset, count); + } + + public override long Seek(long offset, SeekOrigin origin) => _stream.Seek(offset, origin); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _stream.Dispose(); + } + + base.Dispose(disposing); + } + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantPassPhraseSettingValueProvider.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantPassPhraseSettingValueProvider.cs new file mode 100644 index 0000000000..c09acd896d --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantPassPhraseSettingValueProvider.cs @@ -0,0 +1,59 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Settings; + +namespace Volo.Abp.BlobStoring.Fakes; + +/// +/// Simulates a tenant-level setting value provider that gives each tenant +/// its own encryption passphrase. +/// +public class FakeTenantPassPhraseSettingValueProvider : SettingValueProvider +{ + public const string PassPhrasePrefix = "tenant-passphrase-"; + + protected ICurrentTenant CurrentTenant { get; } + + protected ISettingEncryptionService SettingEncryptionService { get; } + + public FakeTenantPassPhraseSettingValueProvider( + ISettingStore settingStore, + ICurrentTenant currentTenant, + ISettingEncryptionService settingEncryptionService) + : base(settingStore) + { + CurrentTenant = currentTenant; + SettingEncryptionService = settingEncryptionService; + } + + public override string Name => TenantSettingValueProvider.ProviderName; + + public override Task GetOrNullAsync(SettingDefinition setting) + { + if (setting.Name == BlobStoringEncryptionSettings.TenantPassPhrase && CurrentTenant.Id.HasValue) + { + return Task.FromResult( + SettingEncryptionService.Encrypt(setting, GetPassPhrase(CurrentTenant.Id.Value)) + ); + } + + return Task.FromResult(null); + } + + public override Task> GetAllAsync(SettingDefinition[] settings) + { + return Task.FromResult( + settings + .Select(s => new SettingValue(s.Name, null)) + .ToList() + ); + } + + public static string GetPassPhrase(System.Guid tenantId) + { + return PassPhrasePrefix + tenantId.ToString("N"); + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer4.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer4.cs new file mode 100644 index 0000000000..c98e55a967 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer4.cs @@ -0,0 +1,6 @@ +namespace Volo.Abp.BlobStoring.TestObjects; + +public class TestContainer4 +{ + +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer5.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer5.cs new file mode 100644 index 0000000000..0a5119be3b --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer5.cs @@ -0,0 +1,6 @@ +namespace Volo.Abp.BlobStoring.TestObjects; + +public class TestContainer5 +{ + +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer6.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer6.cs new file mode 100644 index 0000000000..0b68d27f81 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer6.cs @@ -0,0 +1,6 @@ +namespace Volo.Abp.BlobStoring.TestObjects; + +public class TestContainer6 +{ + +} diff --git a/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer7.cs b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer7.cs new file mode 100644 index 0000000000..ac1d088f16 --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer7.cs @@ -0,0 +1,5 @@ +namespace Volo.Abp.BlobStoring.TestObjects; + +public class TestContainer7 +{ +} diff --git a/framework/test/Volo.Abp.Security.Tests/Volo/Abp/Security/Encryption/ByteArrayEncryptionService_Tests.cs b/framework/test/Volo.Abp.Security.Tests/Volo/Abp/Security/Encryption/ByteArrayEncryptionService_Tests.cs new file mode 100644 index 0000000000..c3ec2254c4 --- /dev/null +++ b/framework/test/Volo.Abp.Security.Tests/Volo/Abp/Security/Encryption/ByteArrayEncryptionService_Tests.cs @@ -0,0 +1,140 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using Shouldly; +using Volo.Abp.Testing; +using Xunit; + +namespace Volo.Abp.Security.Encryption; + +public class ByteArrayEncryptionService_Tests : AbpIntegratedTest +{ + private readonly IByteArrayEncryptionService _byteArrayEncryptionService; + + public ByteArrayEncryptionService_Tests() + { + _byteArrayEncryptionService = GetRequiredService(); + } + + [Theory] + [InlineData(null)] + [InlineData(new byte[0])] + [InlineData(new byte[] { 1, 2, 3, 42, 255 })] + public void Should_Encrypt_And_Decrypt_With_Default_Options(byte[] plainBytes) + { + _byteArrayEncryptionService + .Decrypt(_byteArrayEncryptionService.Encrypt(plainBytes)) + .ShouldBe(plainBytes); + } + + [Fact] + public void Should_Encrypt_And_Decrypt_Large_Data() + { + var plainBytes = new byte[2 * 1024 * 1024]; // 2 MB + new Random(42).NextBytes(plainBytes); + + var cipherBytes = _byteArrayEncryptionService.Encrypt(plainBytes); + + cipherBytes.ShouldNotBeNull(); + cipherBytes.ShouldNotBe(plainBytes); + + _byteArrayEncryptionService.Decrypt(cipherBytes).ShouldBe(plainBytes); + } + + [Fact] + public void Should_Encrypt_And_Decrypt_Streams() + { + var plainBytes = new byte[2 * 1024 * 1024]; // 2 MB, spans multiple chunks + new Random(42).NextBytes(plainBytes); + + using var plainInput = new MemoryStream(plainBytes); + using var cipherOutput = new MemoryStream(); + _byteArrayEncryptionService.Encrypt(plainInput, cipherOutput); + + cipherOutput.Position = 0; + using var plainOutput = new MemoryStream(); + _byteArrayEncryptionService.Decrypt(cipherOutput, plainOutput); + + plainOutput.ToArray().ShouldBe(plainBytes); + } + + [Fact] + public void Should_Produce_Different_Output_For_The_Same_Input() + { + var plainBytes = new byte[] { 1, 2, 3, 42, 255 }; + + var cipherBytes1 = _byteArrayEncryptionService.Encrypt(plainBytes); + var cipherBytes2 = _byteArrayEncryptionService.Encrypt(plainBytes); + + cipherBytes1.ShouldNotBe(cipherBytes2); + } + + [Fact] + public void Should_Write_Format_Header() + { + var cipherBytes = _byteArrayEncryptionService.Encrypt(new byte[] { 1, 2, 3 }); + + cipherBytes.ShouldNotBeNull(); + cipherBytes.Length.ShouldBeGreaterThan(14); + cipherBytes[0].ShouldBe((byte)1); // Format version + } + + [Fact] + public void Should_Fail_To_Decrypt_Tampered_Data() + { + var cipherBytes = _byteArrayEncryptionService.Encrypt(new byte[] { 1, 2, 3, 42, 255 }); + + cipherBytes![cipherBytes.Length - 1] ^= 0xFF; // Flip the last byte (inside the auth tag) + + Assert.ThrowsAny(() => _byteArrayEncryptionService.Decrypt(cipherBytes)); + } + + [Fact] + public void Should_Fail_To_Decrypt_With_Wrong_PassPhrase() + { + var cipherBytes = _byteArrayEncryptionService.Encrypt(new byte[] { 1, 2, 3 }, "passphrase-1"); + + Assert.ThrowsAny(() => _byteArrayEncryptionService.Decrypt(cipherBytes, "passphrase-2")); + } + + [Fact] + public void Should_Encrypt_And_Decrypt_With_Custom_PassPhrase_And_Salt() + { + var plainBytes = new byte[] { 1, 2, 3, 42, 255 }; + var salt = new byte[] { 9, 8, 7, 6, 5, 4, 3, 2 }; + + _byteArrayEncryptionService + .Decrypt(_byteArrayEncryptionService.Encrypt(plainBytes, "my-passphrase", salt), "my-passphrase", salt) + .ShouldBe(plainBytes); + } + + [Fact] + public void Should_Return_Null_For_Null_Or_Empty_CipherBytes() + { + _byteArrayEncryptionService.Decrypt(null).ShouldBeNull(); + _byteArrayEncryptionService.Decrypt(new byte[0]).ShouldBeNull(); + } + + [Fact] + public void Should_Fail_When_Authenticated_Terminal_Record_Is_Missing() + { + var plainBytes = new byte[128 * 1024]; + var cipherBytes = _byteArrayEncryptionService.Encrypt(plainBytes)!; + + Array.Resize(ref cipherBytes, cipherBytes.Length - 20); // 4-byte terminal marker + 16-byte GCM tag + + Should.Throw(() => _byteArrayEncryptionService.Decrypt(cipherBytes)); + } + + [Fact] + public void Should_Reject_Oversized_Encoded_Chunk_Size_Before_Allocating() + { + var cipherBytes = _byteArrayEncryptionService.Encrypt(new byte[] { 1 })!; + cipherBytes[2] = 0x7F; + cipherBytes[3] = 0xFF; + cipherBytes[4] = 0xFF; + cipherBytes[5] = 0xFF; + + Should.Throw(() => _byteArrayEncryptionService.Decrypt(cipherBytes)); + } +}