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