Browse Source

feat: Add IByteArrayEncryptionService for binary data encryption and decryption

- Introduced IByteArrayEncryptionService interface for encrypting and decrypting binary data with authenticated encryption.
- Implemented methods for encrypting and decrypting byte arrays and streams, including options for passphrase and salt.
- Enhanced blob storing tests to cover encryption scenarios, including tenant-specific passphrases and custom pipeline contributors.
- Added FakeInMemoryBlobProvider and various test containers to facilitate testing of blob storage with encryption.
- Created unit tests for ByteArrayEncryptionService to validate encryption and decryption functionality, including edge cases for tampered data and oversized chunks.
pull/25836/head
ugurozturk 2 months ago
parent
commit
083b04b624
  1. 4
      docs/en/docs-nav.json
  2. 148
      docs/en/framework/infrastructure/blob-storing/encryption.md
  3. 17
      docs/en/framework/infrastructure/blob-storing/index.md
  4. 11
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/AbpBlobStoringEncryptionOptions.cs
  5. 207
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainer.cs
  6. 25
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfiguration.cs
  7. 34
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfigurationEncryptionExtensions.cs
  8. 59
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionContributor.cs
  9. 559
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionService.cs
  10. 26
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineGetArgs.cs
  11. 26
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineSaveArgs.cs
  12. 10
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobStoringEncryptionConfigurationNames.cs
  13. 16
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobStoringEncryptionSettingDefinitionProvider.cs
  14. 9
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobStoringEncryptionSettings.cs
  15. 54
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/DefaultBlobEncryptionKeyProvider.cs
  16. 17
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionKeyProvider.cs
  17. 27
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionService.cs
  18. 24
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobPipelineContributor.cs
  19. 49
      framework/src/Volo.Abp.Security/Volo/Abp/Security/Encryption/AbpByteArrayEncryptionOptions.cs
  20. 530
      framework/src/Volo.Abp.Security/Volo/Abp/Security/Encryption/ByteArrayEncryptionService.cs
  21. 59
      framework/src/Volo.Abp.Security/Volo/Abp/Security/Encryption/IByteArrayEncryptionService.cs
  22. 39
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringTestModule.cs
  23. 17
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerConfiguration_Tests.cs
  24. 292
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerEncryption_Tests.cs
  25. 60
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeInMemoryBlobProvider.cs
  26. 33
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeReversingPipelineContributor.cs
  27. 77
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeScopeBoundPipelineContributor.cs
  28. 59
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantPassPhraseSettingValueProvider.cs
  29. 6
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer4.cs
  30. 6
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer5.cs
  31. 6
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer6.cs
  32. 5
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer7.cs
  33. 140
      framework/test/Volo.Abp.Security.Tests/Volo/Abp/Security/Encryption/ByteArrayEncryptionService_Tests.cs

4
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"
}
]
},

148
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<AbpBlobStoringOptions>(options =>
{
options.Containers.Configure<ProfilePictureContainer>(container =>
{
container.UseEncryption();
});
});
````
**Example: Encrypt all containers by default**
````csharp
Configure<AbpBlobStoringOptions>(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<ProfilePictureContainer>(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<AbpBlobStoringEncryptionOptions>(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<string?> 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<Stream> OnSaveAsync(BlobPipelineSaveArgs args)
{
// Return a stream that compresses args.BlobStream while being read
}
public Task<Stream> OnGetAsync(BlobPipelineGetArgs args)
{
// Return a stream that decompresses args.BlobStream while being read
}
}
````
Then, add it to the container configuration:
````csharp
options.Containers.Configure<ProfilePictureContainer>(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)

17
docs/en/framework/infrastructure/blob-storing/index.md

@ -313,6 +313,22 @@ Configure<AbpBlobStoringOptions>(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<AbpBlobStoringOptions>(options =>
{
options.Containers.Configure<ProfilePictureContainer>(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)

11
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/AbpBlobStoringEncryptionOptions.cs

@ -0,0 +1,11 @@
namespace Volo.Abp.BlobStoring;
public class AbpBlobStoringEncryptionOptions
{
/// <summary>
/// The global passphrase, used when no container-specific or
/// tenant-specific passphrase is available.
/// Default: null (encryption must be explicitly keyed).
/// </summary>
public string? DefaultPassPhrase { get; set; }
}

207
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<Stream> ApplyPipelineOnSaveAsync(
BlobNormalizeNaming blobNormalizeNaming,
Stream stream,
CancellationToken cancellationToken,
IReadOnlyList<Type> contributorTypes,
IServiceProvider contributorServiceProvider)
{
foreach (var contributorType in contributorTypes)
{
var contributor = contributorServiceProvider
.GetRequiredService(contributorType)
.As<IBlobPipelineContributor>();
stream = await contributor.OnSaveAsync(
new BlobPipelineSaveArgs(
blobNormalizeNaming.ContainerName!,
Configuration,
blobNormalizeNaming.BlobName!,
stream,
cancellationToken
)
);
}
return stream;
}
protected virtual async Task<Stream> ApplyPipelineOnGetAsync(
BlobNormalizeNaming blobNormalizeNaming,
Stream stream,
CancellationToken cancellationToken,
IReadOnlyList<Type> 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<IBlobPipelineContributor>();
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);
}
}
}

25
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<IBlobNamingNormalizer> NamingNormalizers { get; }
/// <summary>
/// The pipeline contributors to be executed for this container,
/// before/after the provider calls.
/// </summary>
public ITypeList<IBlobPipelineContributor> PipelineContributors { get; }
[NotNull] private readonly Dictionary<string, object?> _properties;
private readonly BlobContainerConfiguration? _fallbackConfiguration;
@ -39,6 +46,7 @@ public class BlobContainerConfiguration
public BlobContainerConfiguration(BlobContainerConfiguration? fallbackConfiguration = null)
{
NamingNormalizers = new TypeList<IBlobNamingNormalizer>();
PipelineContributors = new TypeList<IBlobPipelineContributor>();
_fallbackConfiguration = fallbackConfiguration;
_properties = new Dictionary<string, object?>();
}
@ -57,6 +65,23 @@ public class BlobContainerConfiguration
return NamingNormalizers;
}
/// <summary>
/// Returns the pipeline contributors in effect for this container. Contributors from the fallback
/// configuration are composed before local contributors, independently from provider inheritance.
/// </summary>
public IEnumerable<Type> GetEffectivePipelineContributors()
{
if (_fallbackConfiguration == null)
{
return PipelineContributors;
}
return _fallbackConfiguration
.GetEffectivePipelineContributors()
.Concat(PipelineContributors)
.Distinct();
}
public T? GetConfigurationOrDefault<T>(string name, T? defaultValue = default)
{
return (T?)GetConfigurationOrNull(name, defaultValue);

34
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
{
/// <summary>
/// Enables encryption for the BLOBs of this container.
/// </summary>
/// <param name="configuration">The container configuration.</param>
/// <param name="passPhrase">
/// Optional container-specific passphrase. When not given, the passphrase is resolved
/// by the <see cref="IBlobEncryptionKeyProvider"/> (tenant-specific setting first,
/// then <see cref="AbpBlobStoringEncryptionOptions.DefaultPassPhrase"/>).
/// </param>
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;
}
}

59
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;
/// <summary>
/// A pipeline contributor that encrypts BLOBs on save and decrypts them on read.
/// <para>
/// The passphrase is resolved in the following order:
/// 1. Container-specific passphrase (see <c>UseEncryption</c> extension method).
/// 2. <see cref="IBlobEncryptionKeyProvider"/> (tenant-specific setting, then the global passphrase).
/// </para>
/// </summary>
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<Stream> OnSaveAsync(BlobPipelineSaveArgs args)
{
var passPhrase = await GetPassPhraseAsync(args);
return EncryptionService.Encrypt(args.BlobStream, passPhrase);
}
public virtual async Task<Stream> OnGetAsync(BlobPipelineGetArgs args)
{
var passPhrase = await GetPassPhraseAsync(args);
return EncryptionService.Decrypt(args.BlobStream, passPhrase);
}
protected virtual async Task<string> GetPassPhraseAsync(BlobProviderArgs args)
{
var passPhrase =
args.Configuration.GetConfigurationOrDefault<string>(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!;
}
}

559
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;
/// <summary>
/// Default implementation of <see cref="IBlobEncryptionService"/>.
/// <para>
/// Inherits the authenticated (AEAD) chunked encryption from
/// <see cref="ByteArrayEncryptionService"/> and exposes it as pull-style
/// read streams, as required by the BLOB pipeline. Memory usage is constant,
/// independent from the BLOB size.
/// </para>
/// <para>
/// Format of an encrypted BLOB: 4 bytes magic ("ABPE") + 1 byte BLOB format version,
/// followed by the <see cref="ByteArrayEncryptionService"/> 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.
/// </para>
/// </summary>
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<AbpByteArrayEncryptionOptions> 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];
}
/// <summary>
/// A read-only, non-seekable stream that serves output produced chunk by chunk,
/// so memory usage stays constant regardless of the total data size.
/// </summary>
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;
}
}
}
/// <summary>
/// Produces the next output bytes, or null when there is no more output.
/// </summary>
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);
}
}
}

26
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));
}
}

26
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));
}
}

10
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobStoringEncryptionConfigurationNames.cs

@ -0,0 +1,10 @@
namespace Volo.Abp.BlobStoring;
public static class BlobStoringEncryptionConfigurationNames
{
/// <summary>
/// Configuration name used to store a container-specific encryption passphrase
/// on <see cref="BlobContainerConfiguration"/>.
/// </summary>
public const string PassPhrase = "Abp.BlobStoring.Encryption.PassPhrase";
}

16
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)
);
}
}

9
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobStoringEncryptionSettings.cs

@ -0,0 +1,9 @@
namespace Volo.Abp.BlobStoring;
public static class BlobStoringEncryptionSettings
{
/// <summary>
/// Setting name for the tenant-specific encryption passphrase.
/// </summary>
public const string TenantPassPhrase = "Abp.BlobStoring.Encryption.TenantPassPhrase";
}

54
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;
/// <summary>
/// Default implementation of <see cref="IBlobEncryptionKeyProvider"/>.
/// Resolves a tenant-specific passphrase from the setting system
/// (<see cref="BlobStoringEncryptionSettings.TenantPassPhrase"/>) when a tenant is available,
/// otherwise falls back to the global passphrase
/// (<see cref="AbpBlobStoringEncryptionOptions.DefaultPassPhrase"/>).
/// </summary>
public class DefaultBlobEncryptionKeyProvider : IBlobEncryptionKeyProvider, ITransientDependency
{
protected ICurrentTenant CurrentTenant { get; }
protected ISettingProvider SettingProvider { get; }
protected AbpBlobStoringEncryptionOptions Options { get; }
public DefaultBlobEncryptionKeyProvider(
ICurrentTenant currentTenant,
ISettingProvider settingProvider,
IOptions<AbpBlobStoringEncryptionOptions> options)
{
CurrentTenant = currentTenant;
SettingProvider = settingProvider;
Options = options.Value;
}
public virtual async Task<string?> 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;
}
}

17
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;
/// <summary>
/// Resolves the passphrase used to encrypt/decrypt the BLOBs of a container.
/// </summary>
public interface IBlobEncryptionKeyProvider
{
/// <summary>
/// Returns the passphrase to be used, or <c>null</c> if no passphrase is available.
/// </summary>
Task<string?> GetPassPhraseOrNullAsync(
BlobContainerConfiguration configuration,
CancellationToken cancellationToken = default);
}

27
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionService.cs

@ -0,0 +1,27 @@
using System.IO;
namespace Volo.Abp.BlobStoring;
/// <summary>
/// Encrypts and decrypts BLOB streams with authenticated encryption
/// (see <see cref="Volo.Abp.Security.Encryption.IByteArrayEncryptionService"/>).
/// Memory usage is constant, independent from the BLOB size.
/// </summary>
public interface IBlobEncryptionService
{
/// <summary>
/// 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 <see cref="Stream.Length"/>.
/// </summary>
Stream Encrypt(Stream plainStream, string passPhrase);
/// <summary>
/// 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).
/// </summary>
Stream Decrypt(Stream cipherStream, string passPhrase);
}

24
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;
/// <summary>
/// A contributor to the BLOB pipeline. Contributors are executed inside the
/// <see cref="BlobContainer"/>, before/after the actual <see cref="IBlobProvider"/> call,
/// and can transform the BLOB stream (e.g. encryption, compression).
/// </summary>
public interface IBlobPipelineContributor
{
/// <summary>
/// Called before a BLOB is saved by the provider.
/// Return the (possibly transformed) stream to be stored.
/// </summary>
Task<Stream> OnSaveAsync(BlobPipelineSaveArgs args);
/// <summary>
/// Called after a BLOB is read from the provider.
/// Return the (possibly transformed) stream to be returned to the caller.
/// </summary>
Task<Stream> OnGetAsync(BlobPipelineGetArgs args);
}

49
framework/src/Volo.Abp.Security/Volo/Abp/Security/Encryption/AbpByteArrayEncryptionOptions.cs

@ -0,0 +1,49 @@
using System.Text;
namespace Volo.Abp.Security.Encryption;
/// <summary>
/// Options used by <see cref="IByteArrayEncryptionService"/>.
/// These options are independent from <see cref="AbpStringEncryptionOptions"/>;
/// changing them does not affect <see cref="IStringEncryptionService"/>.
/// </summary>
public class AbpByteArrayEncryptionOptions
{
/// <summary>
/// Default password to encrypt/decrypt data.
/// It's recommended to set to another value for security.
/// Default value: "x9V4qL2mZ8sT1pRe"
/// </summary>
public string DefaultPassPhrase { get; set; }
/// <summary>
/// This constant string is used as a "salt" value for the key derivation function calls.
/// Default value: Encoding.ASCII.GetBytes("kT8!qW2e")
/// </summary>
public byte[] DefaultSalt { get; set; }
/// <summary>
/// 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.
/// </summary>
public int DeriveBytesIterations { get; set; }
/// <summary>
/// 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).
/// </summary>
public int ChunkSize { get; set; }
public AbpByteArrayEncryptionOptions()
{
DefaultPassPhrase = "x9V4qL2mZ8sT1pRe";
DefaultSalt = Encoding.ASCII.GetBytes("kT8!qW2e");
DeriveBytesIterations = 100000;
ChunkSize = 64 * 1024;
}
}

530
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;
/// <summary>
/// Implements <see cref="IByteArrayEncryptionService"/> 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.
/// <para>
/// 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.
/// </para>
/// </summary>
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<AbpByteArrayEncryptionOptions> 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++;
}
}
/// <summary>
/// Gets the algorithm used while encrypting. Decryption supports both algorithms
/// (except AES-GCM on .NET Standard 2.0, where it is not available).
/// </summary>
protected virtual byte GetEncryptionAlgorithm()
{
#if NETSTANDARD2_0
return AlgorithmAesCbcHmacSha256;
#else
return AlgorithmAesGcm;
#endif
}
/// <summary>
/// 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.
/// </summary>
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<byte>(),
tag,
Array.Empty<byte>(),
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<byte>(),
Array.Empty<byte>(),
tag,
associatedData
);
}
return tag;
#endif
}
return ComputeChunkMac(keyBytes, associatedData, Array.Empty<byte>());
}
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);
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Computes the HMAC-SHA256 of a chunk over its associated data and cipher bytes (encrypt-then-MAC).
/// </summary>
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!;
}
/// <summary>
/// Derives the IV of a CBC chunk: first 16 bytes of HMAC-SHA256(MAC key, "IV" + chunk nonce).
/// </summary>
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;
}
/// <summary>
/// Reads exactly <paramref name="count"/> bytes from the stream.
/// Returns null if the stream ends before <paramref name="count"/> bytes could be read.
/// </summary>
protected virtual byte[]? ReadExactly(Stream stream, int count)
{
var buffer = ReadUpTo(stream, count);
return buffer.Length == count ? buffer : null;
}
/// <summary>
/// Reads up to <paramref name="count"/> bytes from the stream.
/// May return fewer bytes (or an empty array) only if the stream ends.
/// </summary>
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;
}
}

59
framework/src/Volo.Abp.Security/Volo/Abp/Security/Encryption/IByteArrayEncryptionService.cs

@ -0,0 +1,59 @@
using System.IO;
namespace Volo.Abp.Security.Encryption;
/// <summary>
/// Can be used to encrypt/decrypt binary data (files, images, serialized objects etc.)
/// with authenticated encryption.
/// Use <see cref="AbpByteArrayEncryptionOptions"/> to configure default values.
/// This service is independent from <see cref="IStringEncryptionService"/>;
/// data encrypted by one of them can not be decrypted by the other.
/// </summary>
public interface IByteArrayEncryptionService
{
/// <summary>
/// Encrypts binary data.
/// </summary>
/// <param name="plainBytes">The data in plain format</param>
/// <param name="passPhrase">A phrase to use as the encryption key (optional, uses default if not provided)</param>
/// <param name="salt">Salt value (optional, uses default if not provided)</param>
/// <returns>Encrypted data, including a format header and authentication tags</returns>
byte[]? Encrypt(byte[]? plainBytes, string? passPhrase = null, byte[]? salt = null);
/// <summary>
/// Decrypts binary data that is encrypted by the <see cref="Encrypt(byte[], string?, byte[])"/> method.
/// </summary>
/// <param name="cipherBytes">The data in encrypted format</param>
/// <param name="passPhrase">A phrase to use as the encryption key (optional, uses default if not provided)</param>
/// <param name="salt">Salt value (optional, uses default if not provided)</param>
/// <returns>Decrypted data</returns>
/// <exception cref="System.Security.Cryptography.CryptographicException">
/// Thrown when the data is tampered, corrupted or the passphrase/salt is wrong.
/// </exception>
byte[]? Decrypt(byte[]? cipherBytes, string? passPhrase = null, byte[]? salt = null);
/// <summary>
/// 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.
/// </summary>
/// <param name="plainStream">The stream to read the plain data from</param>
/// <param name="cipherStream">The stream to write the encrypted data to</param>
/// <param name="passPhrase">A phrase to use as the encryption key (optional, uses default if not provided)</param>
/// <param name="salt">Salt value (optional, uses default if not provided)</param>
void Encrypt(Stream plainStream, Stream cipherStream, string? passPhrase = null, byte[]? salt = null);
/// <summary>
/// Decrypts a stream that is encrypted by the <see cref="Encrypt(Stream, Stream, string?, byte[])"/> method.
/// Each chunk's authentication tag is verified before its plaintext is written
/// to the <paramref name="plainStream"/>, so tampered data is never released.
/// </summary>
/// <param name="cipherStream">The stream to read the encrypted data from</param>
/// <param name="plainStream">The stream to write the decrypted data to</param>
/// <param name="passPhrase">A phrase to use as the encryption key (optional, uses default if not provided)</param>
/// <param name="salt">Salt value (optional, uses default if not provided)</param>
/// <exception cref="System.Security.Cryptography.CryptographicException">
/// Thrown when the data is tampered, corrupted or the passphrase/salt is wrong.
/// </exception>
void Decrypt(Stream cipherStream, Stream plainStream, string? passPhrase = null, byte[]? salt = null);
}

39
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<IBlobProvider>(Substitute.For<FakeBlobProvider1>());
context.Services.AddSingleton<IBlobProvider>(Substitute.For<FakeBlobProvider2>());
context.Services.AddSingleton<FakeInMemoryBlobProvider>();
context.Services.AddSingleton<IBlobProvider>(
serviceProvider => serviceProvider.GetRequiredService<FakeInMemoryBlobProvider>()
);
Configure<AbpSettingOptions>(options =>
{
var tenantProviderIndex = options.ValueProviders.IndexOf(typeof(TenantSettingValueProvider));
options.ValueProviders[tenantProviderIndex] = typeof(FakeTenantPassPhraseSettingValueProvider);
});
Configure<AbpBlobStoringEncryptionOptions>(options =>
{
options.DefaultPassPhrase = "default-global-passphrase";
});
Configure<AbpBlobStoringOptions>(options =>
{
options.Containers
@ -40,6 +57,26 @@ public class AbpBlobStoringTestModule : AbpModule
.Configure<TestContainer3>(container =>
{
container.IsMultiTenant = false;
})
.Configure<TestContainer4>(container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.UseEncryption("container4-passphrase");
})
.Configure<TestContainer5>(container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.UseEncryption();
})
.Configure<TestContainer6>(container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.PipelineContributors.Add(typeof(FakeReversingPipelineContributor));
})
.Configure<TestContainer7>(container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.PipelineContributors.Add(typeof(FakeScopeBoundPipelineContributor));
});
});
}

17
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<FakeReversingPipelineContributor>();
namedConfig.GetEffectivePipelineContributors().ShouldBe(new[]
{
typeof(BlobEncryptionContributor),
typeof(FakeReversingPipelineContributor)
});
}
}

292
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<TestContainer4> _container4; // UseEncryption("container4-passphrase")
private readonly IBlobContainer<TestContainer5> _container5; // UseEncryption() -> key provider (tenant setting / global options)
private readonly IBlobContainer<TestContainer6> _container6; // FakeReversingPipelineContributor
private readonly IBlobContainer<TestContainer7> _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<IBlobContainer<TestContainer4>>();
_container5 = GetRequiredService<IBlobContainer<TestContainer5>>();
_container6 = GetRequiredService<IBlobContainer<TestContainer6>>();
_container7 = GetRequiredService<IBlobContainer<TestContainer7>>();
_provider = GetRequiredService<FakeInMemoryBlobProvider>();
_encryptionService = GetRequiredService<IBlobEncryptionService>();
_currentTenant = GetRequiredService<ICurrentTenant>();
_settingDefinitionManager = GetRequiredService<ISettingDefinitionManager>();
}
[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<TestContainer4>(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<TestContainer5>(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<TestContainer5>("test-blob-tenant-1");
var rawBytes2 = GetRawBytes<TestContainer5>("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<TestContainer5>(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<TestContainer6>(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<TestContainer4>(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<AbpException>(() => 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<TestContainer4>(blobName)!;
encryptedBytes[7] = 0x7F;
encryptedBytes[8] = 0xFF;
encryptedBytes[9] = 0xFF;
encryptedBytes[10] = 0xFF;
Should.Throw<AbpException>(() =>
_encryptionService.Decrypt(new MemoryStream(encryptedBytes), "container4-passphrase")
);
}
private byte[]? GetRawBytes<TContainer>(string blobName)
{
return _provider.GetRawBytesOrNull(
BlobContainerNameAttribute.GetContainerName<TContainer>(),
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);
}
}
}

60
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;
/// <summary>
/// A real in-memory provider (not a substitute), so tests can inspect the raw stored bytes.
/// </summary>
public class FakeInMemoryBlobProvider : BlobProviderBase
{
private readonly ConcurrentDictionary<string, byte[]> _blobs = new ConcurrentDictionary<string, byte[]>();
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<bool> DeleteAsync(BlobProviderDeleteArgs args)
{
return Task.FromResult(_blobs.TryRemove(GetKey(args.ContainerName, args.BlobName), out _));
}
public override Task<bool> ExistsAsync(BlobProviderExistsArgs args)
{
return Task.FromResult(_blobs.ContainsKey(GetKey(args.ContainerName, args.BlobName)));
}
public override Task<Stream?> GetOrNullAsync(BlobProviderGetArgs args)
{
return Task.FromResult<Stream?>(
_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;
}
}

33
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;
/// <summary>
/// A test contributor that reverses the BLOB bytes on both save and get.
/// </summary>
public class FakeReversingPipelineContributor : IBlobPipelineContributor, ITransientDependency
{
public Task<Stream> OnSaveAsync(BlobPipelineSaveArgs args)
{
return Task.FromResult(Reverse(args.BlobStream));
}
public Task<Stream> 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);
}
}
}

77
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<Stream> OnSaveAsync(BlobPipelineSaveArgs args)
{
return Task.FromResult<Stream>(new ScopeBoundStream(args.BlobStream, this));
}
public Task<Stream> OnGetAsync(BlobPipelineGetArgs args)
{
return Task.FromResult<Stream>(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);
}
}
}

59
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;
/// <summary>
/// Simulates a tenant-level setting value provider that gives each tenant
/// its own encryption passphrase.
/// </summary>
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<string?> GetOrNullAsync(SettingDefinition setting)
{
if (setting.Name == BlobStoringEncryptionSettings.TenantPassPhrase && CurrentTenant.Id.HasValue)
{
return Task.FromResult(
SettingEncryptionService.Encrypt(setting, GetPassPhrase(CurrentTenant.Id.Value))
);
}
return Task.FromResult<string?>(null);
}
public override Task<List<SettingValue>> 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");
}
}

6
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer4.cs

@ -0,0 +1,6 @@
namespace Volo.Abp.BlobStoring.TestObjects;
public class TestContainer4
{
}

6
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer5.cs

@ -0,0 +1,6 @@
namespace Volo.Abp.BlobStoring.TestObjects;
public class TestContainer5
{
}

6
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer6.cs

@ -0,0 +1,6 @@
namespace Volo.Abp.BlobStoring.TestObjects;
public class TestContainer6
{
}

5
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer7.cs

@ -0,0 +1,5 @@
namespace Volo.Abp.BlobStoring.TestObjects;
public class TestContainer7
{
}

140
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<AbpSecurityTestModule>
{
private readonly IByteArrayEncryptionService _byteArrayEncryptionService;
public ByteArrayEncryptionService_Tests()
{
_byteArrayEncryptionService = GetRequiredService<IByteArrayEncryptionService>();
}
[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<CryptographicException>(() => _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<CryptographicException>(() => _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<AbpException>(() => _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<AbpException>(() => _byteArrayEncryptionService.Decrypt(cipherBytes));
}
}
Loading…
Cancel
Save