mirror of https://github.com/abpframework/abp.git
Browse Source
- 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
33 changed files with 2637 additions and 14 deletions
@ -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) |
||||
@ -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; } |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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!; |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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"; |
||||
|
} |
||||
@ -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) |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -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"; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,6 @@ |
|||||
|
namespace Volo.Abp.BlobStoring.TestObjects; |
||||
|
|
||||
|
public class TestContainer4 |
||||
|
{ |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,6 @@ |
|||||
|
namespace Volo.Abp.BlobStoring.TestObjects; |
||||
|
|
||||
|
public class TestContainer5 |
||||
|
{ |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,6 @@ |
|||||
|
namespace Volo.Abp.BlobStoring.TestObjects; |
||||
|
|
||||
|
public class TestContainer6 |
||||
|
{ |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,5 @@ |
|||||
|
namespace Volo.Abp.BlobStoring.TestObjects; |
||||
|
|
||||
|
public class TestContainer7 |
||||
|
{ |
||||
|
} |
||||
@ -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…
Reference in new issue