Browse Source

Rework BLOB encryption with an internal AES-256-GCM codec

pull/25836/head
maliming 2 months ago
parent
commit
e787c79bb4
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 2
      docs/en/docs-nav.json
  2. 201
      docs/en/framework/infrastructure/blob-storing/encryption.md
  3. 6
      docs/en/framework/infrastructure/blob-storing/index.md
  4. 11
      framework/src/Volo.Abp.BlobStoring.FileSystem/Volo/Abp/BlobStoring/FileSystem/FileSystemBlobProvider.cs
  5. 3
      framework/src/Volo.Abp.BlobStoring/Properties/AssemblyInfo.cs
  6. 10
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/AbpBlobStoringEncryptionOptions.cs
  7. 172
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainer.cs
  8. 25
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfiguration.cs
  9. 62
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfigurationEncryptionExtensions.cs
  10. 523
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionCodec.cs
  11. 29
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionConfiguration.cs
  12. 59
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionContributor.cs
  13. 28
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionKey.cs
  14. 18
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionKeySource.cs
  15. 559
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionService.cs
  16. 26
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineGetArgs.cs
  17. 26
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineSaveArgs.cs
  18. 10
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobStoringEncryptionConfigurationNames.cs
  19. 16
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobStoringEncryptionSettingDefinitionProvider.cs
  20. 9
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobStoringEncryptionSettings.cs
  21. 103
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/ChunkedCryptoReadStream.cs
  22. 147
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/ChunkedDecryptingReadStream.cs
  23. 110
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/ChunkedEncryptingReadStream.cs
  24. 94
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/DefaultBlobEncryptionKeyProvider.cs
  25. 16
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionKeyProvider.cs
  26. 27
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionService.cs
  27. 24
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobPipelineContributor.cs
  28. 105
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/PrefixingReadStream.cs
  29. 138
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/SequentialReadStream.cs
  30. 49
      framework/src/Volo.Abp.Security/Volo/Abp/Security/Encryption/AbpByteArrayEncryptionOptions.cs
  31. 530
      framework/src/Volo.Abp.Security/Volo/Abp/Security/Encryption/ByteArrayEncryptionService.cs
  32. 59
      framework/src/Volo.Abp.Security/Volo/Abp/Security/Encryption/IByteArrayEncryptionService.cs
  33. 424
      framework/test/Volo.Abp.BlobStoring.FileSystem.Tests/Volo/Abp/BlobStoring/FileSystem/FileSystemBlobEncryption_Tests.cs
  34. 127
      framework/test/Volo.Abp.BlobStoring.Minio.Tests/Volo/Abp/BlobStoring/Minio/MinioBlobEncryption_Tests.cs
  35. 16
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringTestModule.cs
  36. 77
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerConfiguration_Tests.cs
  37. 667
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerEncryption_Tests.cs
  38. 5
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeInMemoryBlobProvider.cs
  39. 33
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeReversingPipelineContributor.cs
  40. 77
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeScopeBoundPipelineContributor.cs
  41. 64
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantBlobEncryptionKeyProvider.cs
  42. 59
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantPassPhraseSettingValueProvider.cs
  43. 5
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/TestObjects/TestContainer8.cs
  44. 140
      framework/test/Volo.Abp.Security.Tests/Volo/Abp/Security/Encryption/ByteArrayEncryptionService_Tests.cs

2
docs/en/docs-nav.json

@ -718,7 +718,7 @@
]
},
{
"text": "Encryption & Pipeline",
"text": "Encryption",
"path": "framework/infrastructure/blob-storing/encryption.md"
}
]

201
docs/en/framework/infrastructure/blob-storing/encryption.md

@ -1,15 +1,15 @@
```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."
"Description": "Learn how to encrypt BLOBs at rest in ABP Framework, using container-specific, tenant-specific or global passphrases."
}
```
# BLOB Encryption & Pipeline
# BLOB Encryption
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.
The BLOB Storing system can **encrypt BLOBs at rest**, transparently, on top of the configured [storage provider](../blob-storing): the BLOB stream is encrypted (AES-256-GCM, authenticated) before it reaches the provider and decrypted while it is read back, so the providers themselves need no changes. The combination is covered by automated tests for the File System provider; other providers consume the same standard stream contract, but validate your provider setup before relying on it in production.
> 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.
> Read the [BLOB Storing document](../blob-storing) to understand how to use the BLOB storing system. The encryption is part of the [Volo.Abp.BlobStoring](https://www.nuget.org/packages/Volo.Abp.BlobStoring) package; no additional package is needed. It requires a platform with AES-GCM support; it is not available on .NET Standard 2.0 targets (like .NET Framework).
## Enabling Encryption
@ -36,16 +36,22 @@ Configure<AbpBlobStoringOptions>(options =>
{
container.UseEncryption();
});
// A single container can still opt out:
options.Containers.Configure<PublicPictureContainer>(container =>
{
container.DisableEncryption();
});
});
````
Containers that don't enable encryption are not affected at all; there is no performance overhead for them.
Containers that don't enable encryption are not affected at all.
## Resolving the Passphrase
When encryption is enabled, the passphrase is resolved in the following order:
When encryption is enabled, the passphrase for a **new** BLOB 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:
1. **Container-specific passphrase**: If a passphrase is passed to the `UseEncryption` method, it is always used for that container. Calling `UseEncryption()` again without parameters keeps the configured values, so multiple modules can safely compose the configuration; use `ClearEncryptionPassPhrase()` to remove a configured or inherited container passphrase:
````csharp
options.Containers.Configure<ProfilePictureContainer>(container =>
@ -54,17 +60,7 @@ options.Containers.Configure<ProfilePictureContainer>(container =>
});
````
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):
2. **Global passphrase**: The `AbpBlobStoringEncryptionOptions.DefaultPassPhrase` is used as the fallback:
````csharp
Configure<AbpBlobStoringEncryptionOptions>(options =>
@ -73,74 +69,169 @@ Configure<AbpBlobStoringEncryptionOptions>(options =>
});
````
If encryption is enabled but no passphrase can be resolved, an `AbpException` is thrown on save/read.
If encryption is enabled but no passphrase can be resolved, saving and reading encrypted BLOBs fails with an `AbpException` (on .NET Standard 2.0 targets a `PlatformNotSupportedException` is thrown before that, see above).
> 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.
> Treat passphrases as production secrets: read them from your configuration/secret store instead of hard-coding them, and prefer long, machine-generated values.
The **source** of the passphrase is recorded in the encrypted BLOB, and only that source is used while decrypting it. So, for example, a BLOB written with the global passphrase stays readable after a container-specific passphrase is configured later.
> Keep your passphrases safe. If the passphrase a BLOB was encrypted with is lost or changed, that BLOB can not be decrypted anymore.
### 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):
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 passphrases from another source, like a vault or another secret store (the provider must be able to return the passphrase itself; hardware-backed non-exportable keys are not supported).
A custom provider can also supply **tenant-specific** passphrases: return `BlobEncryptionKeySource.Tenant` while encrypting and resolve the same tenant's passphrase when it is requested for decryption. The key source recorded in the BLOB header routes each BLOB back to the provider that can decrypt it. The following implementation gives every tenant its own passphrase and keeps the standard rules for the host side:
````csharp
[Dependency(ReplaceServices = true)]
public class MyEncryptionKeyProvider : IBlobEncryptionKeyProvider
public class MyTenantBlobEncryptionKeyProvider : DefaultBlobEncryptionKeyProvider
{
public Task<string?> GetPassPhraseOrNullAsync(
protected ICurrentTenant CurrentTenant { get; }
public MyTenantBlobEncryptionKeyProvider(
ICurrentTenant currentTenant,
IOptions<AbpBlobStoringEncryptionOptions> options)
: base(options)
{
CurrentTenant = currentTenant;
}
public override async Task<BlobEncryptionKey> ResolveForEncryptionAsync(
BlobContainerConfiguration configuration,
CancellationToken cancellationToken = default)
{
// TODO: Resolve the passphrase from your own key store
// Keep a container-specific passphrase as the highest-priority source
var containerPassPhrase = GetContainerPassPhraseOrNull(configuration);
if (string.IsNullOrWhiteSpace(containerPassPhrase) && CurrentTenant.Id.HasValue)
{
return new BlobEncryptionKey(
BlobEncryptionKeySource.Tenant,
await GetTenantPassPhraseAsync(CurrentTenant.Id.Value, cancellationToken)
);
}
return await base.ResolveForEncryptionAsync(configuration, cancellationToken);
}
}
````
## The Encryption Format & Compatibility
public override async Task<string> ResolveForDecryptionAsync(
BlobEncryptionKeySource keySource,
BlobContainerConfiguration configuration,
CancellationToken cancellationToken = default)
{
if (keySource == BlobEncryptionKeySource.Tenant)
{
if (!CurrentTenant.Id.HasValue)
{
throw new AbpException(
"The BLOB was encrypted with a tenant-specific passphrase, " +
"but there is no current tenant!");
}
return await GetTenantPassPhraseAsync(CurrentTenant.Id.Value, cancellationToken);
}
return await base.ResolveForDecryptionAsync(keySource, configuration, cancellationToken);
}
* 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.
private async Task<string> GetTenantPassPhraseAsync(
Guid tenantId, CancellationToken cancellationToken)
{
/* Read the tenant's passphrase from your secret store.
It must return the same value for the lifetime of the tenant's BLOBs. */
}
}
````
> Keep your passphrases safe. If the passphrase of a container is lost or changed, the BLOBs encrypted with it can not be decrypted anymore.
Notes on this pattern:
## Creating Custom Pipeline Contributors
* The multi-tenant BLOB containers already isolate tenants physically (see the [BLOB Storing document](../blob-storing)); tenant-specific passphrases add **cryptographic** isolation on top: one tenant's BLOBs can not be decrypted with another tenant's (or the host's) passphrase, and the tenant identity is part of the authenticated data.
* Decryption runs in the tenant context of the BLOB's owner (the container itself switches to the right tenant), so resolving by `CurrentTenant.Id` is correct for both saving and reading.
* Only enable such a provider on containers with `IsMultiTenant = true` (the default). On a shared (`IsMultiTenant = false`) container all tenants read the same BLOBs, so a per-tenant passphrase would make a BLOB readable only by the tenant that happened to write it.
The encryption itself is implemented as a pipeline contributor. You can write your own contributors by implementing the `IBlobPipelineContributor` interface:
## BLOBs Stored Before Enabling Encryption
**Example: A contributor that compresses BLOBs on save and decompresses on read**
By default, reading a BLOB that does not have the encrypted format fails, so a tampered or corrupted BLOB can not silently bypass the authenticity check. If a container already has plaintext BLOBs from before encryption was enabled, allow reading them explicitly:
````csharp
public class CompressionPipelineContributor : IBlobPipelineContributor, ITransientDependency
options.Containers.Configure<ProfilePictureContainer>(container =>
{
public Task<Stream> OnSaveAsync(BlobPipelineSaveArgs args)
{
// Return a stream that compresses args.BlobStream while being read
}
container.UseEncryption(allowLegacyPlaintext: true);
});
````
public Task<Stream> OnGetAsync(BlobPipelineGetArgs args)
{
// Return a stream that decompresses args.BlobStream while being read
}
}
With this option, content without the encrypted format header is returned as-is, **without any authenticity check** — including an encrypted BLOB whose format header got corrupted or tampered. Treat it as a short-term migration switch: new BLOBs are always encrypted, and the option should be disabled once the existing BLOBs are migrated (re-saved).
A typical migration of an existing container:
1. Enable encryption with `UseEncryption(allowLegacyPlaintext: true)` and deploy. New and updated BLOBs are written encrypted; the existing plaintext BLOBs stay readable.
2. Re-save the existing BLOBs (the BLOB storing system has no list operation, so iterate the BLOB names from your own application data):
````csharp
var bytes = await container.GetAllBytesAsync(blobName);
await container.SaveAsync(blobName, bytes, overrideExisting: true);
````
Then, add it to the container configuration:
3. Remove the `allowLegacyPlaintext` option, so reading fails closed again for any content that does not have the encrypted format.
> Legacy plaintext content that itself starts with the `ABPE` format magic can not be distinguished from an encrypted BLOB and fails to be read. Re-save such content once to encrypt it. Also note that legacy BLOBs are returned over a non-seekable wrapper stream while this option is enabled (the `Length` stays available when the provider stream knows it).
## Changing a Passphrase
The format does not support key rotation: a BLOB is only readable with the exact passphrase it was written with, and there is no way to keep an old and a new passphrase of the **same source** active at the same time. So changing a passphrase in place makes the BLOBs written with the old one permanently unreadable — migrate the content **before** the change:
* **From the global to a container-specific passphrase**: this direction works without downtime, because the two are different key sources. Configure the new container passphrase; BLOBs recorded with the `Global` source keep decrypting with `DefaultPassPhrase`, while new saves use the container passphrase. Re-save the existing BLOBs (as in the migration steps above) to move them to the new passphrase; the global one can be retired once no BLOB uses it anymore.
* **Any other change**: while the old passphrase is still configured, read the BLOBs and re-save them into a container using a different key source (or export them to a safe location), then apply the change and save them back. Verify the migrated BLOBs are readable before deleting anything.
## Behavioral Changes for Encrypted Containers
* The stream returned for an encrypted BLOB is read-only and non-seekable, and its `Length` is not available; read it sequentially (for example with `CopyToAsync`).
* Opening a BLOB throws an `AbpException` when the content does not have a valid encrypted format; a `CryptographicException` is thrown **while reading** when the content fails authentication (tampered data or a wrong passphrase).
* Each returned chunk is individually authenticated, but the completeness of the whole BLOB (protection against cutting whole chunks off the end) is only verified when the stream is read to its end.
* The file system provider writes an encrypted BLOB in a single attempt (the encrypting stream can not be replayed for I/O retries): a failed save throws, and any partially written content fails closed while reading instead of being returned as damaged data.
## Performance and Cost
Deriving the encryption key from the passphrase is intentionally expensive (PBKDF2-SHA256), so leaked storage can not be brute-forced cheaply. Understand the cost profile before enabling encryption on hot containers:
* One key derivation runs on **every BLOB save** and on **every encrypted BLOB open** (before the stream is returned). The cost does not depend on the BLOB size — it scales with the number of operations, so many small, frequently read BLOBs amplify it the most.
* Every BLOB uses its own random salt, so derivation results can not be cached or reused; reading the same BLOB again derives the key again.
* The default iteration count is 100,000 (tens of milliseconds of CPU per operation, hardware dependent). Measure on your target hardware and concurrency before enabling encryption on high-frequency containers — it is not a microsecond-level transparent overhead.
* Use a long, machine-generated (at least 128 bits of entropy) value from your secret store as the passphrase in production. For low-entropy, human-chosen passphrases you can raise the iteration count — this increases the offline guessing cost and the per-operation CPU cost by the same factor:
````csharp
options.Containers.Configure<ProfilePictureContainer>(container =>
Configure<AbpBlobStoringEncryptionOptions>(options =>
{
container.PipelineContributors.Add(typeof(CompressionPipelineContributor));
container.UseEncryption(); // Multiple contributors can be combined
options.KdfIterations = 600_000; // allowed range: 100,000 - 600,000
});
````
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).
Changing the iteration count only affects newly written BLOBs; existing BLOBs are decrypted with the count recorded in their own header.
## The Encryption Format
* Encryption is authenticated (AES-256-GCM): modified, re-ordered, corrupted or truncated content of a BLOB is detected while reading.
* Every encrypted BLOB is bound to its storage identity (the *normalized* container name, BLOB name and tenant). Copying or renaming an encrypted BLOB at the storage level makes it unreadable at the new location, which also makes substituting one (validly encrypted) BLOB for another detectable. Re-writing an older version of the same BLOB back to its own location is not detectable at this layer.
* Because of the identity binding, the following otherwise-legal operations make existing encrypted BLOBs permanently unreadable: changing the `IsMultiTenant` value of the container, moving BLOBs between tenants or containers, and switching to a storage provider that normalizes container/BLOB names differently (for example, providers that lowercase container names). Decrypt (re-save) the BLOBs before such a change.
* The data is processed in chunks with **constant memory usage**, independent from the BLOB size.
* Every BLOB is encrypted with its own key, derived (PBKDF2-SHA256) from the passphrase and a random per-BLOB salt.
* When the source stream exposes its length, the encrypted stream exposes its exact resulting length for providers that require the object size before uploading.
### What Is (Not) Protected
* Only the BLOB **content** is encrypted. Container names, BLOB names and any provider-level metadata stay in plaintext, so the existence and the approximate size of a BLOB remain visible in the storage.
* The size overhead is small and deterministic: a 39-byte prefix, plus 20 bytes per 64 KB chunk, plus a 20-byte end-of-stream record (about 0.03% for large BLOBs).
* Server-side encryption offered by the storage provider (like S3 or Azure Storage encryption) is complementary, not redundant: it uses provider-managed keys at the storage layer, while this feature encrypts with application-managed passphrases before the content leaves your application. They can be combined for defense in depth.
A few notes for implementers:
## Troubleshooting
* 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.
| Error | Cause and solution |
|---|---|
| `AbpException`: *...is not in the encrypted format* | The BLOB was saved before encryption was enabled (or by an application without encryption). Use `allowLegacyPlaintext: true` during the migration. |
| `AbpException`: *...no passphrase could be resolved* | Encryption is enabled, but neither a container passphrase nor `DefaultPassPhrase` is configured. |
| `AbpException`: *...the default key provider does not supply tenant keys* | The BLOB was encrypted by a custom key provider with a tenant-specific passphrase; the same provider must be registered to read it back. |
| `AbpException`: *...that passphrase is not available anymore* | The passphrase of the key source recorded in the BLOB was removed or cleared from the configuration. Restore it. |
| `CryptographicException` while reading | Wrong passphrase, tampered/corrupted content, or the BLOB was copied, renamed or moved across containers/tenants at the storage level (see the identity binding above). |
| `PlatformNotSupportedException` | The application runs on .NET Standard 2.0 (like .NET Framework) or on a platform without AES-GCM support. |
## See Also

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

@ -315,7 +315,7 @@ Configure<AbpBlobStoringOptions>(options =>
## Encrypting BLOBs
The BLOB Storing system has a **pipeline** that can transform the BLOB stream between the container and the storage provider. The built-in encryption contributor uses this pipeline to **encrypt BLOBs at rest**, transparently, with any storage provider:
The BLOB Storing system can **encrypt BLOBs at rest**, transparently, on top of the configured storage provider:
````csharp
Configure<AbpBlobStoringOptions>(options =>
@ -327,7 +327,7 @@ Configure<AbpBlobStoringOptions>(options =>
});
````
The encryption key can be container-specific, **tenant-specific** (each tenant gets its own key) or **global**. BLOBs stored before enabling the encryption stay readable. See the [BLOB Encryption & Pipeline document](./encryption.md) for details and for creating custom pipeline contributors (like compression).
The encryption key can be container-specific or **global**; per-tenant keys can be plugged in over the key provider. See the [BLOB Encryption document](./encryption.md) for details.
## Extending the BLOB Storing System
@ -344,5 +344,5 @@ If you want to create folders and move files between folders, assign permissions
## See Also
* [BLOB Encryption & Pipeline](./encryption.md)
* [BLOB Encryption](./encryption.md)
* [Creating a custom BLOB storage provider](./custom-provider.md)

11
framework/src/Volo.Abp.BlobStoring.FileSystem/Volo/Abp/BlobStoring/FileSystem/FileSystemBlobProvider.cs

@ -31,10 +31,19 @@ public class FileSystemBlobProvider : BlobProviderBase, ITransientDependency
? FileMode.Create
: FileMode.CreateNew;
await Policy.Handle<IOException>()
// Retry only replayable overwrites: a non-seekable source would continue from the
// middle (corrupted file), and a failed CreateNew leaves the file behind anyway
var sourcePosition = args.BlobStream.CanSeek && fileMode == FileMode.Create ? args.BlobStream.Position : -1;
await Policy.Handle<IOException>(_ => sourcePosition >= 0)
.WaitAndRetryAsync(2, retryCount => TimeSpan.FromSeconds(retryCount))
.ExecuteAsync(async () =>
{
if (sourcePosition >= 0)
{
args.BlobStream.Seek(sourcePosition, SeekOrigin.Begin);
}
using (var fileStream = File.Open(filePath, fileMode, FileAccess.Write))
{
await args.BlobStream.CopyToAsync(

3
framework/src/Volo.Abp.BlobStoring/Properties/AssemblyInfo.cs

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Volo.Abp.BlobStoring.Tests")]

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

@ -3,9 +3,15 @@ namespace Volo.Abp.BlobStoring;
public class AbpBlobStoringEncryptionOptions
{
/// <summary>
/// The global passphrase, used when no container-specific or
/// tenant-specific passphrase is available.
/// The global passphrase, used when no container-specific passphrase is available.
/// Default: null (encryption must be explicitly keyed).
/// </summary>
public string? DefaultPassPhrase { get; set; }
/// <summary>
/// PBKDF2 iteration count for newly encrypted BLOBs (existing BLOBs use the count
/// in their own header). Higher values raise both the offline guessing cost and
/// the CPU cost of every save/read. Allowed: 100,000 - 600,000. Default: 100,000.
/// </summary>
public int KdfIterations { get; set; } = 100_000;
}

172
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainer.cs

@ -1,7 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
@ -120,22 +118,15 @@ public class BlobContainer : IBlobContainer
var blobNormalizeNaming = BlobNormalizeNamingService.NormalizeNaming(Configuration, ContainerName, name);
var fallbackCancellationToken = CancellationTokenProvider.FallbackToProvider(cancellationToken);
var contributorTypes = Configuration.GetEffectivePipelineContributors().ToList();
IServiceScope? contributorScope = null;
try
Stream? encryptingStream = null;
if (BlobEncryptionConfiguration.IsEnabled(Configuration))
{
if (contributorTypes.Count > 0)
{
contributorScope = ServiceProvider.CreateScope();
stream = await ApplyPipelineOnSaveAsync(
blobNormalizeNaming,
stream,
fallbackCancellationToken,
contributorTypes,
contributorScope.ServiceProvider
);
}
encryptingStream = await CreateEncryptingStreamAsync(blobNormalizeNaming, stream, fallbackCancellationToken);
stream = encryptingStream;
}
try
{
await Provider.SaveAsync(
new BlobProviderSaveArgs(
blobNormalizeNaming.ContainerName!,
@ -149,7 +140,8 @@ public class BlobContainer : IBlobContainer
}
finally
{
contributorScope?.Dispose();
// Disposing the wrapper zeroes the derived key; the caller's stream is untouched
encryptingStream?.Dispose();
}
}
}
@ -235,88 +227,65 @@ public class BlobContainer : IBlobContainer
return null;
}
var contributorTypes = Configuration.GetEffectivePipelineContributors().ToList();
if (contributorTypes.Count == 0)
if (!BlobEncryptionConfiguration.IsEnabled(Configuration))
{
return stream;
}
var contributorScope = ServiceProvider.CreateScope();
try
{
var transformedStream = await ApplyPipelineOnGetAsync(
blobNormalizeNaming,
stream,
fallbackCancellationToken,
contributorTypes,
contributorScope.ServiceProvider
);
return new ScopeDisposingStream(transformedStream, contributorScope);
return await CreateDecryptingStreamAsync(blobNormalizeNaming, stream, fallbackCancellationToken);
}
catch
{
contributorScope.Dispose();
stream.Dispose();
throw;
}
}
}
protected virtual async Task<Stream> ApplyPipelineOnSaveAsync(
BlobNormalizeNaming blobNormalizeNaming,
Stream stream,
CancellationToken cancellationToken,
IReadOnlyList<Type> contributorTypes,
IServiceProvider contributorServiceProvider)
/// <summary>
/// Wraps the stream for encryption. The caller keeps the ownership of
/// <paramref name="stream"/>; the wrapper is disposed after the provider call.
/// </summary>
protected virtual async Task<Stream> CreateEncryptingStreamAsync(BlobNormalizeNaming blobNormalizeNaming, Stream stream, CancellationToken cancellationToken)
{
foreach (var contributorType in contributorTypes)
// The key is fully resolved before returning, so the scope can be released here
await using (var scope = ServiceProvider.CreateAsyncScope())
{
var contributor = contributorServiceProvider
.GetRequiredService(contributorType)
.As<IBlobPipelineContributor>();
stream = await contributor.OnSaveAsync(
new BlobPipelineSaveArgs(
blobNormalizeNaming.ContainerName!,
return await scope.ServiceProvider
.GetRequiredService<BlobEncryptionCodec>()
.CreateEncryptingStreamAsync(
Configuration,
blobNormalizeNaming.ContainerName!,
blobNormalizeNaming.BlobName!,
GetTenantIdOrNull(),
stream,
cancellationToken
)
);
);
}
return stream;
}
protected virtual async Task<Stream> ApplyPipelineOnGetAsync(
BlobNormalizeNaming blobNormalizeNaming,
Stream stream,
CancellationToken cancellationToken,
IReadOnlyList<Type> contributorTypes,
IServiceProvider contributorServiceProvider)
/// <summary>
/// Wraps the provider stream for decryption; the returned stream owns it.
/// Opening throws <see cref="AbpException"/> for format violations; reading throws
/// <see cref="System.Security.Cryptography.CryptographicException"/> on failed authentication.
/// </summary>
protected virtual async Task<Stream> CreateDecryptingStreamAsync(BlobNormalizeNaming blobNormalizeNaming, Stream stream, CancellationToken cancellationToken)
{
// Execute in reverse order, so the contributor that transformed the stream
// last on save is the first to transform it back on read.
foreach (var contributorType in contributorTypes.Reverse())
await using (var scope = ServiceProvider.CreateAsyncScope())
{
var contributor = contributorServiceProvider
.GetRequiredService(contributorType)
.As<IBlobPipelineContributor>();
stream = await contributor.OnGetAsync(
new BlobPipelineGetArgs(
blobNormalizeNaming.ContainerName!,
return await scope.ServiceProvider
.GetRequiredService<BlobEncryptionCodec>()
.CreateDecryptingStreamAsync(
Configuration,
blobNormalizeNaming.ContainerName!,
blobNormalizeNaming.BlobName!,
GetTenantIdOrNull(),
stream,
cancellationToken
)
);
);
}
return stream;
}
protected virtual Guid? GetTenantIdOrNull()
@ -328,71 +297,4 @@ public class BlobContainer : IBlobContainer
return CurrentTenant.Id;
}
private sealed class ScopeDisposingStream : Stream
{
private readonly Stream _stream;
private readonly IServiceScope _scope;
private bool _disposed;
public ScopeDisposingStream(Stream stream, IServiceScope scope)
{
_stream = stream;
_scope = scope;
}
public override bool CanRead => _stream.CanRead;
public override bool CanSeek => _stream.CanSeek;
public override bool CanWrite => _stream.CanWrite;
public override long Length => _stream.Length;
public override long Position
{
get => _stream.Position;
set => _stream.Position = value;
}
public override void Flush()
{
_stream.Flush();
}
public override int Read(byte[] buffer, int offset, int count)
{
return _stream.Read(buffer, offset, count);
}
public override long Seek(long offset, SeekOrigin origin)
{
return _stream.Seek(offset, origin);
}
public override void SetLength(long value)
{
_stream.SetLength(value);
}
public override void Write(byte[] buffer, int offset, int count)
{
_stream.Write(buffer, offset, count);
}
protected override void Dispose(bool disposing)
{
if (disposing && !_disposed)
{
_disposed = true;
try
{
_stream.Dispose();
}
finally
{
_scope.Dispose();
}
}
base.Dispose(disposing);
}
}
}

25
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfiguration.cs

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

62
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfigurationEncryptionExtensions.cs

@ -5,30 +5,76 @@ namespace Volo.Abp.BlobStoring;
public static class BlobContainerConfigurationEncryptionExtensions
{
/// <summary>
/// Enables encryption for the BLOBs of this container.
/// Enables encryption for the BLOBs of this container. Calling it again is safe:
/// omitted parameters keep the already configured (or inherited) values,
/// so multiple modules can compose the configuration.
/// </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"/>).
/// Optional container-specific passphrase. Without one, the passphrase is resolved
/// by the <see cref="IBlobEncryptionKeyProvider"/>. Use
/// <see cref="ClearEncryptionPassPhrase"/> to remove a configured passphrase.
/// </param>
/// <param name="allowLegacyPlaintext">
/// Allows reading BLOBs stored as plaintext before encryption was enabled:
/// content without the encrypted format header is then returned as-is,
/// <b>without any authenticity check</b>. Keep it disabled (default) unless
/// the container really has such BLOBs.
/// </param>
public static BlobContainerConfiguration UseEncryption(
[NotNull] this BlobContainerConfiguration configuration,
string? passPhrase = null)
string? passPhrase = null,
bool? allowLegacyPlaintext = null)
{
Check.NotNull(configuration, nameof(configuration));
if (!configuration.PipelineContributors.Contains(typeof(BlobEncryptionContributor)))
configuration.SetConfiguration(BlobEncryptionConfiguration.EnabledName, true);
if (allowLegacyPlaintext.HasValue)
{
configuration.PipelineContributors.Add(typeof(BlobEncryptionContributor));
configuration.SetConfiguration(BlobEncryptionConfiguration.AllowLegacyPlaintextName, allowLegacyPlaintext.Value);
}
if (passPhrase != null)
{
configuration.SetConfiguration(BlobStoringEncryptionConfigurationNames.PassPhrase, passPhrase);
Check.NotNullOrWhiteSpace(passPhrase, nameof(passPhrase));
configuration.SetConfiguration(BlobEncryptionConfiguration.PassPhraseName, passPhrase);
}
return configuration;
}
/// <summary>
/// Removes the container passphrase (including an inherited one), so the
/// <see cref="IBlobEncryptionKeyProvider"/> resolves the passphrase again.
/// BLOBs encrypted with the removed passphrase can not be read anymore.
/// </summary>
public static BlobContainerConfiguration ClearEncryptionPassPhrase(
[NotNull] this BlobContainerConfiguration configuration)
{
Check.NotNull(configuration, nameof(configuration));
// An explicit empty value shadows a passphrase inherited from the
// default (fallback) container configuration.
configuration.SetConfiguration(BlobEncryptionConfiguration.PassPhraseName, string.Empty);
return configuration;
}
/// <summary>
/// Disables encryption for this container (even when inherited from the default
/// configuration) and removes its passphrase/legacy options. Existing encrypted
/// BLOBs are then returned as stored (still encrypted bytes) while reading.
/// </summary>
public static BlobContainerConfiguration DisableEncryption(
[NotNull] this BlobContainerConfiguration configuration)
{
Check.NotNull(configuration, nameof(configuration));
configuration.SetConfiguration(BlobEncryptionConfiguration.EnabledName, false);
configuration.ClearConfiguration(BlobEncryptionConfiguration.PassPhraseName);
configuration.ClearConfiguration(BlobEncryptionConfiguration.AllowLegacyPlaintextName);
return configuration;
}
}

523
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionCodec.cs

@ -0,0 +1,523 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.BlobStoring;
/// <summary>
/// Implements the encrypted BLOB format (version 1) using AES-256-GCM.
/// Not available on .NET Standard 2.0 (no AES-GCM).
/// <para>
/// Format: "ABPE" magic (4) + format version (1) + header (34: algorithm 1,
/// key source 1, KDF iterations 4, random per-BLOB KDF salt 16, chunk size 4,
/// base nonce 8), followed by authenticated chunk records (4-byte big-endian
/// cipher length, cipher chunk, 16-byte tag) and an authenticated zero-length
/// terminal record. The whole prefix, the storage identity (container, BLOB
/// name, tenant) and the chunk index are bound to every chunk as associated
/// data; the per-BLOB salt gives every BLOB its own derived key.
/// </para>
/// </summary>
internal sealed class BlobEncryptionCodec : ITransientDependency
{
internal static readonly byte[] Magic = { (byte)'A', (byte)'B', (byte)'P', (byte)'E' };
internal const byte FormatVersion = 1;
internal const byte AlgorithmAesGcm = 1;
internal const int MinKdfIterations = 100_000;
internal const int MaxKdfIterations = 600_000; // reader cap: bounded headroom above the writer constant
internal const int KdfSaltSize = 16;
internal const int ChunkSize = 64 * 1024;
internal const int MaxChunkSize = 1024 * 1024; // reader cap: bounds allocations driven by the (pre-authentication) header
internal const int BaseNonceSize = 8;
internal const int HeaderSize = 34; // algorithm(1) + keySource(1) + iterations(4) + salt(16) + chunkSize(4) + baseNonce(8)
internal const int ChunkLengthPrefixSize = 4;
internal const int GcmNonceSize = 12;
internal const int GcmTagSize = 16;
private readonly IBlobEncryptionKeyProvider _keyProvider;
private readonly AbpBlobStoringEncryptionOptions _options;
public BlobEncryptionCodec(
IBlobEncryptionKeyProvider keyProvider,
Microsoft.Extensions.Options.IOptions<AbpBlobStoringEncryptionOptions> options)
{
_keyProvider = keyProvider;
_options = options.Value;
}
// The key is fully resolved before the stream is returned, so the resolution scope can be released.
public async Task<Stream> CreateEncryptingStreamAsync(
BlobContainerConfiguration configuration,
string containerName,
string blobName,
Guid? tenantId,
Stream plainStream,
CancellationToken cancellationToken = default)
{
#if NETSTANDARD2_0
// Fail before any output is produced, so no partial (corrupted) data is ever written.
throw new PlatformNotSupportedException("BLOB encryption requires AES-GCM, which is not available on .NET Standard 2.0!");
#else
#if NET8_0_OR_GREATER
if (!AesGcm.IsSupported)
{
throw new PlatformNotSupportedException("AES-GCM is not supported on this platform!");
}
#endif
var kdfIterations = _options.KdfIterations;
if (kdfIterations < MinKdfIterations || kdfIterations > MaxKdfIterations)
{
throw new AbpException(
$"{nameof(AbpBlobStoringEncryptionOptions)}.{nameof(AbpBlobStoringEncryptionOptions.KdfIterations)} " +
$"must be between {MinKdfIterations} and {MaxKdfIterations}!");
}
var key = await _keyProvider.ResolveForEncryptionAsync(configuration, cancellationToken);
var salt = new byte[KdfSaltSize];
var baseNonce = new byte[BaseNonceSize];
using (var random = RandomNumberGenerator.Create())
{
random.GetBytes(salt);
random.GetBytes(baseNonce);
}
var header = BuildHeader(key.Source, kdfIterations, salt, ChunkSize, baseNonce);
var blobPrefix = CreateBlobPrefix(header);
cancellationToken.ThrowIfCancellationRequested();
var keyBytes = DeriveKeyBytes(key.PassPhrase, salt, kdfIterations);
return new ChunkedEncryptingReadStream(
plainStream,
blobPrefix,
BuildAssociatedDataPrefix(blobPrefix, containerName, blobName, tenantId),
keyBytes,
baseNonce,
ChunkSize,
TryCalculateEncryptedLength(plainStream, ChunkSize)
);
#endif
}
public async Task<Stream> CreateDecryptingStreamAsync(
BlobContainerConfiguration configuration,
string containerName,
string blobName,
Guid? tenantId,
Stream cipherStream,
CancellationToken cancellationToken = default)
{
var prefix = await ReadUpToAsync(cipherStream, Magic.Length + 1, cancellationToken);
if (!HasMagic(prefix))
{
if (BlobEncryptionConfiguration.IsLegacyPlaintextAllowed(configuration))
{
return new PrefixingReadStream(prefix, cipherStream);
}
throw new AbpException(
"The BLOB does not have the encrypted BLOB format. If it was stored before encryption " +
"was enabled for the container, enable reading legacy plaintext BLOBs explicitly " +
"(see the UseEncryption extension method). Otherwise the BLOB is corrupted or tampered."
);
}
if (prefix[Magic.Length] != FormatVersion)
{
throw new AbpException($"Unsupported encrypted BLOB format version: {prefix[Magic.Length]}!");
}
#if NETSTANDARD2_0
throw new PlatformNotSupportedException("BLOB decryption requires AES-GCM, which is not available on .NET Standard 2.0!");
#else
#if NET8_0_OR_GREATER
if (!AesGcm.IsSupported)
{
throw new PlatformNotSupportedException("AES-GCM is not supported on this platform!");
}
#endif
var header = await ReadExactlyAsync(cipherStream, HeaderSize, cancellationToken);
if (header == null)
{
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: missing header!");
}
if (header[0] != AlgorithmAesGcm)
{
throw new AbpException($"Unsupported encrypted BLOB algorithm: {header[0]}!");
}
var keySource = header[1];
if (keySource < (byte)BlobEncryptionKeySource.Container || keySource > (byte)BlobEncryptionKeySource.Global)
{
throw new AbpException($"Unknown BLOB encryption key source: {keySource}!");
}
var iterations = ReadInt32BigEndian(header, 2);
if (iterations <= 0 || iterations > MaxKdfIterations)
{
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid KDF iteration count!");
}
var salt = new byte[KdfSaltSize];
Array.Copy(header, 6, salt, 0, KdfSaltSize);
var chunkSize = ReadInt32BigEndian(header, 22);
if (chunkSize <= 0 || chunkSize > MaxChunkSize)
{
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid chunk size!");
}
var baseNonce = new byte[BaseNonceSize];
Array.Copy(header, 26, baseNonce, 0, BaseNonceSize);
var passPhrase = await _keyProvider.ResolveForDecryptionAsync(
(BlobEncryptionKeySource)keySource,
configuration,
cancellationToken
);
cancellationToken.ThrowIfCancellationRequested();
var keyBytes = DeriveKeyBytes(passPhrase, salt, iterations);
var blobPrefix = new byte[Magic.Length + 1 + HeaderSize];
Array.Copy(prefix, 0, blobPrefix, 0, Magic.Length + 1);
Array.Copy(header, 0, blobPrefix, Magic.Length + 1, HeaderSize);
return new ChunkedDecryptingReadStream(
cipherStream,
BuildAssociatedDataPrefix(blobPrefix, containerName, blobName, tenantId),
keyBytes,
baseNonce,
chunkSize
);
#endif
}
internal static byte[] BuildHeader(BlobEncryptionKeySource keySource, int iterations, byte[] salt, int chunkSize, byte[] baseNonce)
{
var header = new byte[HeaderSize];
header[0] = AlgorithmAesGcm;
header[1] = (byte)keySource;
WriteInt32BigEndian(header, 2, iterations);
Array.Copy(salt, 0, header, 6, KdfSaltSize);
WriteInt32BigEndian(header, 22, chunkSize);
Array.Copy(baseNonce, 0, header, 26, BaseNonceSize);
return header;
}
// Length-prefixed identity fields: a validly encrypted BLOB can not be read
// from another BLOB name, container or tenant.
internal static byte[] BuildAssociatedDataPrefix(byte[] blobPrefix, string containerName, string blobName, Guid? tenantId)
{
var containerNameBytes = Encoding.UTF8.GetBytes(containerName);
var blobNameBytes = Encoding.UTF8.GetBytes(blobName);
var tenantIdBytes = tenantId?.ToByteArray() ?? Array.Empty<byte>();
var prefix = new byte[blobPrefix.Length + 4 + containerNameBytes.Length + 4 + blobNameBytes.Length + 4 + tenantIdBytes.Length];
var offset = 0;
Array.Copy(blobPrefix, 0, prefix, offset, blobPrefix.Length);
offset += blobPrefix.Length;
offset = WriteLengthPrefixed(prefix, offset, containerNameBytes);
offset = WriteLengthPrefixed(prefix, offset, blobNameBytes);
WriteLengthPrefixed(prefix, offset, tenantIdBytes);
return prefix;
}
private static int WriteLengthPrefixed(byte[] buffer, int offset, byte[] bytes)
{
WriteInt32BigEndian(buffer, offset, bytes.Length);
Array.Copy(bytes, 0, buffer, offset + 4, bytes.Length);
return offset + 4 + bytes.Length;
}
internal static byte[] CreateBlobPrefix(byte[] header)
{
var prefix = new byte[Magic.Length + 1 + header.Length];
Magic.CopyTo(prefix, 0);
prefix[Magic.Length] = FormatVersion;
Array.Copy(header, 0, prefix, Magic.Length + 1, header.Length);
return prefix;
}
internal static byte[] DeriveKeyBytes(string passPhrase, byte[] salt, int iterations)
{
#if NETSTANDARD2_0
throw new PlatformNotSupportedException("BLOB encryption requires AES-GCM, which is not available on .NET Standard 2.0!");
#elif NET8_0_OR_GREATER
return Rfc2898DeriveBytes.Pbkdf2(passPhrase, salt, iterations, HashAlgorithmName.SHA256, 32);
#else
using var password = new Rfc2898DeriveBytes(passPhrase, salt, iterations, HashAlgorithmName.SHA256);
return password.GetBytes(32);
#endif
}
internal static byte[] EncryptChunk(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex, byte[] plainChunk, int plainChunkLength)
{
#if NETSTANDARD2_0
throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!");
#else
var record = new byte[ChunkLengthPrefixSize + plainChunkLength + GcmTagSize];
WriteInt32BigEndian(record, 0, plainChunkLength);
using (var aesGcm = CreateAesGcm(keyBytes))
{
aesGcm.Encrypt(
CreateChunkNonce(baseNonce, chunkIndex),
plainChunk.AsSpan(0, plainChunkLength),
record.AsSpan(ChunkLengthPrefixSize, plainChunkLength),
record.AsSpan(ChunkLengthPrefixSize + plainChunkLength, GcmTagSize),
CreateChunkAssociatedData(associatedDataPrefix, chunkIndex)
);
}
return record;
#endif
}
internal static byte[] DecryptChunk(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex, byte[] cipherChunk, byte[] tag)
{
#if NETSTANDARD2_0
throw new PlatformNotSupportedException("AES-GCM is not available on .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, CreateChunkAssociatedData(associatedDataPrefix, chunkIndex));
}
return plainChunk;
#endif
}
// The authenticated terminal record makes truncation of complete chunks detectable
internal static byte[] CreateTerminalRecord(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex)
{
#if NETSTANDARD2_0
throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!");
#else
var record = new byte[ChunkLengthPrefixSize + GcmTagSize];
using (var aesGcm = CreateAesGcm(keyBytes))
{
aesGcm.Encrypt(
CreateChunkNonce(baseNonce, chunkIndex),
Array.Empty<byte>(),
Array.Empty<byte>(),
record.AsSpan(ChunkLengthPrefixSize, GcmTagSize),
CreateChunkAssociatedData(associatedDataPrefix, chunkIndex)
);
}
return record;
#endif
}
internal static void VerifyTerminalRecord(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex, byte[] tag)
{
#if NETSTANDARD2_0
throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!");
#else
using (var aesGcm = CreateAesGcm(keyBytes))
{
// Throws CryptographicException if the tag is invalid.
aesGcm.Decrypt(
CreateChunkNonce(baseNonce, chunkIndex),
Array.Empty<byte>(),
tag,
Array.Empty<byte>(),
CreateChunkAssociatedData(associatedDataPrefix, chunkIndex)
);
}
#endif
}
// Nonce = 8-byte random base + 4-byte chunk index; the per-BLOB key (random salt)
// makes cross-BLOB reuse harmless and the index keeps it unique within the BLOB.
internal static byte[] CreateChunkNonce(byte[] baseNonce, int chunkIndex)
{
if (chunkIndex < 0)
{
// A wrapped chunk index would repeat a nonce for the same key, which breaks AES-GCM.
throw new AbpException("The data is too large: the maximum chunk count has been exceeded!");
}
var nonce = new byte[GcmNonceSize];
Array.Copy(baseNonce, 0, nonce, 0, BaseNonceSize);
WriteInt32BigEndian(nonce, BaseNonceSize, chunkIndex);
return nonce;
}
internal static byte[] CreateChunkAssociatedData(byte[] associatedDataPrefix, int chunkIndex)
{
var associatedData = new byte[associatedDataPrefix.Length + 4];
Array.Copy(associatedDataPrefix, 0, associatedData, 0, associatedDataPrefix.Length);
WriteInt32BigEndian(associatedData, associatedDataPrefix.Length, chunkIndex);
return associatedData;
}
internal static int GetCipherChunkSize(byte[] lengthPrefix, int maxCipherChunkSize)
{
if (lengthPrefix.Length == 0)
{
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: missing terminal record!");
}
if (lengthPrefix.Length < ChunkLengthPrefixSize)
{
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: truncated chunk!");
}
var cipherChunkSize = ReadInt32BigEndian(lengthPrefix, 0);
if (cipherChunkSize < 0 || cipherChunkSize > maxCipherChunkSize)
{
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid chunk length!");
}
return cipherChunkSize;
}
internal static byte[]? ReadExactly(Stream stream, int count)
{
var buffer = ReadUpTo(stream, count);
return buffer.Length == count ? buffer : null;
}
internal static 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;
}
internal static async Task<byte[]?> ReadExactlyAsync(Stream stream, int count, CancellationToken cancellationToken = default)
{
var buffer = await ReadUpToAsync(stream, count, cancellationToken);
return buffer.Length == count ? buffer : null;
}
internal static async Task<byte[]> ReadUpToAsync(Stream stream, int count, CancellationToken cancellationToken = default)
{
var buffer = new byte[count];
var totalReadCount = 0;
while (totalReadCount < count)
{
var readCount = await stream.ReadAsync(buffer, totalReadCount, count - totalReadCount, cancellationToken);
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 bool HasMagic(byte[] prefix)
{
if (prefix.Length < Magic.Length + 1)
{
return false;
}
for (var i = 0; i < Magic.Length; i++)
{
if (prefix[i] != Magic[i])
{
return false;
}
}
return true;
}
private static long? TryCalculateEncryptedLength(Stream plainStream, int chunkSize)
{
if (!plainStream.CanSeek)
{
return null;
}
try
{
var plainLength = plainStream.Length - plainStream.Position;
if (plainLength < 0)
{
return null;
}
var fullChunkCount = plainLength / chunkSize;
var chunkRecordCount = fullChunkCount + (plainLength % chunkSize > 0 ? 1 : 0) + 1; // +1: terminal record
checked
{
return Magic.Length + 1L + HeaderSize + plainLength +
chunkRecordCount * (ChunkLengthPrefixSize + GcmTagSize);
}
}
catch (NotSupportedException)
{
return null;
}
catch (OverflowException)
{
return null;
}
}
#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
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];
}
}

29
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionConfiguration.cs

@ -0,0 +1,29 @@
namespace Volo.Abp.BlobStoring;
/// <summary>
/// Reads the encryption values of a container configuration (set by the
/// UseEncryption/DisableEncryption extension methods, inherited over the fallback chain).
/// </summary>
internal static class BlobEncryptionConfiguration
{
public const string EnabledName = "Abp.BlobStoring.Encryption.Enabled";
public const string PassPhraseName = "Abp.BlobStoring.Encryption.PassPhrase";
public const string AllowLegacyPlaintextName = "Abp.BlobStoring.Encryption.AllowLegacyPlaintext";
public static bool IsEnabled(BlobContainerConfiguration configuration)
{
return configuration.GetConfigurationOrDefault(EnabledName, false);
}
public static string? GetPassPhraseOrNull(BlobContainerConfiguration configuration)
{
// An explicit empty value shadows an inherited passphrase (see UseEncryption).
var passPhrase = configuration.GetConfigurationOrDefault<string?>(PassPhraseName);
return string.IsNullOrWhiteSpace(passPhrase) ? null : passPhrase;
}
public static bool IsLegacyPlaintextAllowed(BlobContainerConfiguration configuration)
{
return configuration.GetConfigurationOrDefault(AllowLegacyPlaintextName, false);
}
}

59
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionContributor.cs

@ -1,59 +0,0 @@
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!;
}
}

28
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionKey.cs

@ -0,0 +1,28 @@
using System;
using JetBrains.Annotations;
namespace Volo.Abp.BlobStoring;
/// <summary>
/// The passphrase resolved for encrypting a BLOB, together with its source.
/// </summary>
public sealed class BlobEncryptionKey
{
public BlobEncryptionKeySource Source { get; }
[NotNull]
public string PassPhrase { get; }
public BlobEncryptionKey(BlobEncryptionKeySource source, [NotNull] string passPhrase)
{
if (source < BlobEncryptionKeySource.Container || source > BlobEncryptionKeySource.Global)
{
// The source is stored in the BLOB header and validated while reading;
// an unknown value would make the BLOB permanently unreadable.
throw new ArgumentException($"Unknown BLOB encryption key source: {source}!", nameof(source));
}
Source = source;
PassPhrase = Check.NotNullOrWhiteSpace(passPhrase, nameof(passPhrase));
}
}

18
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionKeySource.cs

@ -0,0 +1,18 @@
namespace Volo.Abp.BlobStoring;
/// <summary>
/// Identifies where the encryption passphrase of a BLOB comes from. The value is
/// stored in the BLOB header, so decryption uses the same source again even if
/// other sources are configured later.
/// </summary>
public enum BlobEncryptionKeySource : byte
{
/// <summary>The container-specific passphrase (see the UseEncryption extension method).</summary>
Container = 1,
/// <summary>A tenant-specific passphrase, provided by a custom <see cref="IBlobEncryptionKeyProvider"/>; unused by the default provider.</summary>
Tenant = 2,
/// <summary>The global passphrase (see <see cref="AbpBlobStoringEncryptionOptions.DefaultPassPhrase"/>).</summary>
Global = 3
}

559
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionService.cs

@ -1,559 +0,0 @@
using System;
using System.IO;
using System.Security.Cryptography;
using Microsoft.Extensions.Options;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Security.Encryption;
namespace Volo.Abp.BlobStoring;
/// <summary>
/// Default implementation of <see cref="IBlobEncryptionService"/>.
/// <para>
/// Inherits the authenticated (AEAD) chunked encryption from
/// <see cref="ByteArrayEncryptionService"/> and exposes it as pull-style
/// read streams, as required by the BLOB pipeline. Memory usage is constant,
/// independent from the BLOB size.
/// </para>
/// <para>
/// Format of an encrypted BLOB: 4 bytes magic ("ABPE") + 1 byte BLOB format version,
/// followed by the <see cref="ByteArrayEncryptionService"/> output
/// (its own header + authenticated chunks). BLOBs without the magic header
/// are returned as-is on decryption, so BLOBs stored before encryption was
/// enabled stay readable.
/// </para>
/// </summary>
public class BlobEncryptionService : ByteArrayEncryptionService, IBlobEncryptionService
{
protected static readonly byte[] MagicHeader = { (byte)'A', (byte)'B', (byte)'P', (byte)'E' };
protected const byte BlobFormatVersion = 1;
public BlobEncryptionService(IOptions<AbpByteArrayEncryptionOptions> options)
: base(options)
{
}
public virtual Stream Encrypt(Stream plainStream, string passPhrase)
{
Check.NotNull(plainStream, nameof(plainStream));
Check.NotNullOrWhiteSpace(passPhrase, nameof(passPhrase));
if (Options.ChunkSize <= 0 || Options.ChunkSize > MaximumChunkSize)
{
throw new AbpException($"{nameof(Options.ChunkSize)} must be between 1 and {MaximumChunkSize} bytes!");
}
var algorithm = GetEncryptionAlgorithm();
var keyBytes = DeriveKeyBytes(passPhrase, Options.DefaultSalt, algorithm);
var baseNonce = new byte[BaseNonceSize];
using (var random = RandomNumberGenerator.Create())
{
random.GetBytes(baseNonce);
}
var header = BuildHeader(algorithm, Options.ChunkSize, baseNonce);
return new ChunkedEncryptingReadStream(
this,
plainStream,
header,
keyBytes,
baseNonce,
algorithm,
Options.ChunkSize,
TryCalculateEncryptedLength(plainStream, algorithm, Options.ChunkSize)
);
}
public virtual Stream Decrypt(Stream cipherStream, string passPhrase)
{
Check.NotNull(cipherStream, nameof(cipherStream));
Check.NotNullOrWhiteSpace(passPhrase, nameof(passPhrase));
var prefix = ReadUpTo(cipherStream, MagicHeader.Length + 1);
if (prefix.Length < MagicHeader.Length + 1 || !HasMagicHeader(prefix))
{
// Not an encrypted BLOB, return as-is for backward compatibility
return new PrefixingReadStream(prefix, cipherStream);
}
if (prefix[MagicHeader.Length] != BlobFormatVersion)
{
throw new AbpException($"Unsupported BLOB encryption format version: {prefix[MagicHeader.Length]}!");
}
var header = ReadExactly(cipherStream, HeaderSize);
if (header == null)
{
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: missing header!");
}
if (header[0] != FormatVersion)
{
throw new AbpException($"Unsupported encryption format version: {header[0]}!");
}
var algorithm = header[1];
if (algorithm != AlgorithmAesGcm && algorithm != AlgorithmAesCbcHmacSha256)
{
throw new AbpException($"Unsupported encryption algorithm: {algorithm}!");
}
var chunkSize = ReadInt32BigEndian(header, 2);
if (chunkSize <= 0 || chunkSize > MaximumChunkSize)
{
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid chunk size!");
}
var baseNonce = new byte[BaseNonceSize];
Array.Copy(header, 6, baseNonce, 0, BaseNonceSize);
var keyBytes = DeriveKeyBytes(passPhrase, Options.DefaultSalt, algorithm);
return new ChunkedDecryptingReadStream(
this,
cipherStream,
header,
keyBytes,
baseNonce,
algorithm,
chunkSize,
algorithm == AlgorithmAesGcm ? GcmTagSize : HmacSize,
algorithm == AlgorithmAesGcm ? chunkSize : chunkSize + AesBlockSize
);
}
internal byte[] EncryptChunkToBytes(byte algorithm, byte[] keyBytes, byte[] header, byte[] baseNonce, int chunkIndex, byte[] plainChunk, int plainChunkLength)
{
using (var chunkStream = new MemoryStream())
{
EncryptChunk(algorithm, keyBytes, header, baseNonce, chunkIndex, plainChunk, plainChunkLength, chunkStream);
return chunkStream.ToArray();
}
}
internal byte[]? ReadExactlyCore(Stream stream, int count)
{
return ReadExactly(stream, count);
}
internal byte[] ReadUpToCore(Stream stream, int count)
{
return ReadUpTo(stream, count);
}
internal byte[] DecryptChunkCore(byte algorithm, byte[] keyBytes, byte[] header, byte[] baseNonce, int chunkIndex, byte[] cipherChunk, byte[] tag)
{
return DecryptChunk(algorithm, keyBytes, header, baseNonce, chunkIndex, cipherChunk, tag);
}
internal byte[] WriteTerminalRecordToBytes(byte algorithm, byte[] keyBytes, byte[] header, byte[] baseNonce, int chunkIndex)
{
using (var stream = new MemoryStream())
{
WriteTerminalRecord(algorithm, keyBytes, header, baseNonce, chunkIndex, stream);
return stream.ToArray();
}
}
internal void VerifyTerminalRecordCore(byte algorithm, byte[] keyBytes, byte[] header, byte[] baseNonce, int chunkIndex, byte[] tag)
{
VerifyTerminalRecord(algorithm, keyBytes, header, baseNonce, chunkIndex, tag);
}
private static long? TryCalculateEncryptedLength(Stream plainStream, byte algorithm, int chunkSize)
{
if (!plainStream.CanSeek)
{
return null;
}
try
{
var plainLength = plainStream.Length - plainStream.Position;
if (plainLength < 0)
{
return null;
}
var tagSize = algorithm == AlgorithmAesGcm ? GcmTagSize : HmacSize;
var fullChunkCount = plainLength / chunkSize;
var remainder = plainLength % chunkSize;
checked
{
var length = MagicHeader.Length + 1L + HeaderSize + ChunkLengthPrefixSize + tagSize;
length += fullChunkCount * (ChunkLengthPrefixSize + tagSize + GetCipherChunkLength(algorithm, chunkSize));
if (remainder > 0)
{
length += ChunkLengthPrefixSize + tagSize + GetCipherChunkLength(algorithm, remainder);
}
return length;
}
}
catch (NotSupportedException)
{
return null;
}
catch (OverflowException)
{
return null;
}
}
private static long GetCipherChunkLength(byte algorithm, long plainChunkLength)
{
return algorithm == AlgorithmAesGcm
? plainChunkLength
: ((plainChunkLength / AesBlockSize) + 1) * AesBlockSize;
}
private static bool HasMagicHeader(byte[] prefix)
{
for (var i = 0; i < MagicHeader.Length; i++)
{
if (prefix[i] != MagicHeader[i])
{
return false;
}
}
return true;
}
private static int ReadInt32BigEndian(byte[] buffer, int offset)
{
return (buffer[offset] << 24) | (buffer[offset + 1] << 16) | (buffer[offset + 2] << 8) | buffer[offset + 3];
}
/// <summary>
/// A read-only, non-seekable stream that serves output produced chunk by chunk,
/// so memory usage stays constant regardless of the total data size.
/// </summary>
private abstract class ChunkedCryptoReadStream : Stream
{
private readonly long? _length;
private byte[]? _outputBuffer;
private int _outputBufferPosition;
private bool _finished;
protected ChunkedCryptoReadStream(long? length = null)
{
_length = length;
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => _length ?? throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override void Flush()
{
}
public override int Read(byte[] buffer, int offset, int count)
{
while (true)
{
if (_outputBuffer != null && _outputBufferPosition < _outputBuffer.Length)
{
var toCopy = Math.Min(count, _outputBuffer.Length - _outputBufferPosition);
Array.Copy(_outputBuffer, _outputBufferPosition, buffer, offset, toCopy);
_outputBufferPosition += toCopy;
return toCopy;
}
if (_finished)
{
return 0;
}
_outputBuffer = ProduceNext();
_outputBufferPosition = 0;
if (_outputBuffer == null)
{
_finished = true;
return 0;
}
}
}
/// <summary>
/// Produces the next output bytes, or null when there is no more output.
/// </summary>
protected abstract byte[]? ProduceNext();
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException();
}
public override void SetLength(long value)
{
throw new NotSupportedException();
}
public override void Write(byte[] buffer, int offset, int count)
{
throw new NotSupportedException();
}
}
private class ChunkedEncryptingReadStream : ChunkedCryptoReadStream
{
private readonly BlobEncryptionService _owner;
private readonly Stream _plainStream;
private readonly byte[] _header;
private readonly byte[] _keyBytes;
private readonly byte[] _baseNonce;
private readonly byte _algorithm;
private readonly int _chunkSize;
private bool _terminalEmitted;
private bool _headerEmitted;
private int _chunkIndex;
public ChunkedEncryptingReadStream(
BlobEncryptionService owner,
Stream plainStream,
byte[] header,
byte[] keyBytes,
byte[] baseNonce,
byte algorithm,
int chunkSize,
long? encryptedLength)
: base(encryptedLength)
{
_owner = owner;
_plainStream = plainStream;
_header = header;
_keyBytes = keyBytes;
_baseNonce = baseNonce;
_algorithm = algorithm;
_chunkSize = chunkSize;
}
protected override byte[]? ProduceNext()
{
if (!_headerEmitted)
{
_headerEmitted = true;
var prefix = new byte[MagicHeader.Length + 1 + _header.Length];
MagicHeader.CopyTo(prefix, 0);
prefix[MagicHeader.Length] = BlobFormatVersion;
Array.Copy(_header, 0, prefix, MagicHeader.Length + 1, _header.Length);
return prefix;
}
var plainChunk = _owner.ReadUpToCore(_plainStream, _chunkSize);
if (plainChunk.Length == 0)
{
if (_terminalEmitted)
{
return null;
}
_terminalEmitted = true;
return _owner.WriteTerminalRecordToBytes(
_algorithm,
_keyBytes,
_header,
_baseNonce,
_chunkIndex
);
}
return _owner.EncryptChunkToBytes(
_algorithm,
_keyBytes,
_header,
_baseNonce,
_chunkIndex++,
plainChunk,
plainChunk.Length
);
}
protected override void Dispose(bool disposing)
{
// Do not dispose the plain stream; it is owned by the caller.
}
}
private class ChunkedDecryptingReadStream : ChunkedCryptoReadStream
{
private readonly BlobEncryptionService _owner;
private readonly Stream _cipherStream;
private readonly byte[] _header;
private readonly byte[] _keyBytes;
private readonly byte[] _baseNonce;
private readonly byte _algorithm;
private readonly int _tagSize;
private readonly int _maxCipherChunkSize;
private int _chunkIndex;
public ChunkedDecryptingReadStream(
BlobEncryptionService owner,
Stream cipherStream,
byte[] header,
byte[] keyBytes,
byte[] baseNonce,
byte algorithm,
int chunkSize,
int tagSize,
int maxCipherChunkSize)
{
_owner = owner;
_cipherStream = cipherStream;
_header = header;
_keyBytes = keyBytes;
_baseNonce = baseNonce;
_algorithm = algorithm;
_tagSize = tagSize;
_maxCipherChunkSize = maxCipherChunkSize;
}
protected override byte[]? ProduceNext()
{
var lengthPrefix = _owner.ReadUpToCore(_cipherStream, 4);
if (lengthPrefix.Length == 0)
{
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: missing terminal record!");
}
if (lengthPrefix.Length < 4)
{
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: truncated chunk!");
}
var cipherChunkSize = ReadInt32BigEndian(lengthPrefix, 0);
if (cipherChunkSize == 0)
{
var terminalTag = _owner.ReadExactlyCore(_cipherStream, _tagSize);
if (terminalTag == null || _owner.ReadUpToCore(_cipherStream, 1).Length != 0)
{
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid terminal record!");
}
_owner.VerifyTerminalRecordCore(
_algorithm,
_keyBytes,
_header,
_baseNonce,
_chunkIndex,
terminalTag
);
return null;
}
if (cipherChunkSize < 0 || cipherChunkSize > _maxCipherChunkSize)
{
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid chunk length!");
}
var cipherChunk = _owner.ReadExactlyCore(_cipherStream, cipherChunkSize);
var tag = _owner.ReadExactlyCore(_cipherStream, _tagSize);
if (cipherChunk == null || tag == null)
{
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: truncated chunk!");
}
return _owner.DecryptChunkCore(
_algorithm,
_keyBytes,
_header,
_baseNonce,
_chunkIndex++,
cipherChunk,
tag
);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_cipherStream.Dispose();
}
}
}
private sealed class PrefixingReadStream : Stream
{
private readonly byte[] _prefix;
private readonly Stream _stream;
private int _prefixPosition;
public PrefixingReadStream(byte[] prefix, Stream stream)
{
_prefix = prefix;
_stream = stream;
}
public override bool CanRead => _stream.CanRead;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override void Flush()
{
}
public override int Read(byte[] buffer, int offset, int count)
{
if (_prefixPosition < _prefix.Length)
{
var readCount = Math.Min(count, _prefix.Length - _prefixPosition);
Array.Copy(_prefix, _prefixPosition, buffer, offset, readCount);
_prefixPosition += readCount;
return readCount;
}
return _stream.Read(buffer, offset, count);
}
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException();
}
public override void SetLength(long value)
{
throw new NotSupportedException();
}
public override void Write(byte[] buffer, int offset, int count)
{
throw new NotSupportedException();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_stream.Dispose();
}
base.Dispose(disposing);
}
}
}

26
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineGetArgs.cs

@ -1,26 +0,0 @@
using System.IO;
using System.Threading;
using JetBrains.Annotations;
namespace Volo.Abp.BlobStoring;
public class BlobPipelineGetArgs : BlobProviderArgs
{
[NotNull]
public Stream BlobStream { get; }
public BlobPipelineGetArgs(
[NotNull] string containerName,
[NotNull] BlobContainerConfiguration configuration,
[NotNull] string blobName,
[NotNull] Stream blobStream,
CancellationToken cancellationToken = default)
: base(
containerName,
configuration,
blobName,
cancellationToken)
{
BlobStream = Check.NotNull(blobStream, nameof(blobStream));
}
}

26
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineSaveArgs.cs

@ -1,26 +0,0 @@
using System.IO;
using System.Threading;
using JetBrains.Annotations;
namespace Volo.Abp.BlobStoring;
public class BlobPipelineSaveArgs : BlobProviderArgs
{
[NotNull]
public Stream BlobStream { get; }
public BlobPipelineSaveArgs(
[NotNull] string containerName,
[NotNull] BlobContainerConfiguration configuration,
[NotNull] string blobName,
[NotNull] Stream blobStream,
CancellationToken cancellationToken = default)
: base(
containerName,
configuration,
blobName,
cancellationToken)
{
BlobStream = Check.NotNull(blobStream, nameof(blobStream));
}
}

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

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

16
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobStoringEncryptionSettingDefinitionProvider.cs

@ -1,16 +0,0 @@
using Volo.Abp.Settings;
namespace Volo.Abp.BlobStoring;
public class BlobStoringEncryptionSettingDefinitionProvider : SettingDefinitionProvider
{
public override void Define(ISettingDefinitionContext context)
{
context.Add(
new SettingDefinition(
BlobStoringEncryptionSettings.TenantPassPhrase,
isEncrypted: true
).WithProviders(TenantSettingValueProvider.ProviderName)
);
}
}

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

@ -1,9 +0,0 @@
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";
}

103
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/ChunkedCryptoReadStream.cs

@ -0,0 +1,103 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Volo.Abp.BlobStoring;
/// <summary>
/// A read-only, non-seekable stream that serves output produced chunk by chunk.
/// </summary>
internal abstract class ChunkedCryptoReadStream : SequentialReadStream
{
private readonly long? _length;
private byte[]? _outputBuffer;
private int _outputBufferPosition;
private long _position;
private bool _finished;
protected ChunkedCryptoReadStream(long? length = null)
{
_length = length;
}
public override long Length => _length ?? throw new NotSupportedException();
// Some storage SDKs (like AWS S3) compute the upload size as Length - Position,
// so the getter reports the number of bytes served so far instead of throwing.
public override long Position
{
get => _position;
set => throw new NotSupportedException();
}
protected sealed override int ReadCore(byte[] buffer, int offset, int count)
{
while (true)
{
var copiedCount = TryCopyFromOutputBuffer(buffer, offset, count);
if (copiedCount > 0 || _finished)
{
return copiedCount;
}
SetOutputBuffer(ProduceNext());
}
}
protected sealed override async Task<int> ReadCoreAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
while (true)
{
var copiedCount = TryCopyFromOutputBuffer(buffer, offset, count);
if (copiedCount > 0 || _finished)
{
return copiedCount;
}
SetOutputBuffer(await ProduceNextAsync(cancellationToken));
}
}
/// <summary>
/// Produces the next output bytes, or null when there is no more output.
/// </summary>
protected abstract byte[]? ProduceNext();
protected abstract Task<byte[]?> ProduceNextAsync(CancellationToken cancellationToken);
protected override void Dispose(bool disposing)
{
if (disposing && _outputBuffer != null)
{
Array.Clear(_outputBuffer, 0, _outputBuffer.Length);
_outputBuffer = null;
}
base.Dispose(disposing);
}
private int TryCopyFromOutputBuffer(byte[] buffer, int offset, int count)
{
if (_outputBuffer == null || _outputBufferPosition >= _outputBuffer.Length)
{
return 0;
}
var toCopy = Math.Min(count, _outputBuffer.Length - _outputBufferPosition);
Array.Copy(_outputBuffer, _outputBufferPosition, buffer, offset, toCopy);
_outputBufferPosition += toCopy;
_position += toCopy;
return toCopy;
}
private void SetOutputBuffer(byte[]? outputBuffer)
{
_outputBuffer = outputBuffer;
_outputBufferPosition = 0;
if (outputBuffer == null)
{
_finished = true;
}
}
}

147
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/ChunkedDecryptingReadStream.cs

@ -0,0 +1,147 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Volo.Abp.BlobStoring;
/// <summary>
/// Decrypts the cipher stream chunk by chunk while being read.
/// </summary>
internal class ChunkedDecryptingReadStream : ChunkedCryptoReadStream
{
private readonly Stream _cipherStream;
private readonly byte[] _associatedDataPrefix;
private readonly byte[] _keyBytes;
private readonly byte[] _baseNonce;
private readonly int _chunkSize;
private int _chunkIndex;
private bool _disposed;
public ChunkedDecryptingReadStream(
Stream cipherStream,
byte[] associatedDataPrefix,
byte[] keyBytes,
byte[] baseNonce,
int chunkSize)
{
_cipherStream = cipherStream;
_associatedDataPrefix = associatedDataPrefix;
_keyBytes = keyBytes;
_baseNonce = baseNonce;
_chunkSize = chunkSize;
}
protected override byte[]? ProduceNext()
{
var cipherChunkSize = BlobEncryptionCodec.GetCipherChunkSize(
BlobEncryptionCodec.ReadUpTo(_cipherStream, BlobEncryptionCodec.ChunkLengthPrefixSize),
_chunkSize
);
if (cipherChunkSize == 0)
{
var terminalTag = BlobEncryptionCodec.ReadExactly(_cipherStream, BlobEncryptionCodec.GcmTagSize);
if (terminalTag == null || BlobEncryptionCodec.ReadUpTo(_cipherStream, 1).Length != 0)
{
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid terminal record!");
}
BlobEncryptionCodec.VerifyTerminalRecord(_keyBytes, _associatedDataPrefix, _baseNonce, _chunkIndex, terminalTag);
return null;
}
return DecryptPayload(
BlobEncryptionCodec.ReadExactly(_cipherStream, cipherChunkSize),
BlobEncryptionCodec.ReadExactly(_cipherStream, BlobEncryptionCodec.GcmTagSize)
);
}
protected override async Task<byte[]?> ProduceNextAsync(CancellationToken cancellationToken)
{
var cipherChunkSize = BlobEncryptionCodec.GetCipherChunkSize(
await BlobEncryptionCodec.ReadUpToAsync(_cipherStream, BlobEncryptionCodec.ChunkLengthPrefixSize, cancellationToken),
_chunkSize
);
if (cipherChunkSize == 0)
{
var terminalTag = await BlobEncryptionCodec.ReadExactlyAsync(_cipherStream, BlobEncryptionCodec.GcmTagSize, cancellationToken);
if (terminalTag == null || (await BlobEncryptionCodec.ReadUpToAsync(_cipherStream, 1, cancellationToken)).Length != 0)
{
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid terminal record!");
}
BlobEncryptionCodec.VerifyTerminalRecord(_keyBytes, _associatedDataPrefix, _baseNonce, _chunkIndex, terminalTag);
return null;
}
return DecryptPayload(
await BlobEncryptionCodec.ReadExactlyAsync(_cipherStream, cipherChunkSize, cancellationToken),
await BlobEncryptionCodec.ReadExactlyAsync(_cipherStream, BlobEncryptionCodec.GcmTagSize, cancellationToken)
);
}
private byte[] DecryptPayload(byte[]? cipherChunk, byte[]? tag)
{
if (cipherChunk == null || tag == null)
{
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: truncated chunk!");
}
var plainChunk = BlobEncryptionCodec.DecryptChunk(_keyBytes, _associatedDataPrefix, _baseNonce, _chunkIndex, cipherChunk, tag);
_chunkIndex++;
return plainChunk;
}
protected override void Dispose(bool disposing)
{
if (disposing && !_disposed)
{
_disposed = true;
ClearKeyBytes();
try
{
_cipherStream.Dispose();
}
finally
{
base.Dispose(disposing);
}
return;
}
base.Dispose(disposing);
}
#if !NETSTANDARD2_0
public override async ValueTask DisposeAsync()
{
if (!_disposed)
{
_disposed = true;
ClearKeyBytes();
try
{
await _cipherStream.DisposeAsync();
}
finally
{
await base.DisposeAsync();
}
return;
}
await base.DisposeAsync();
}
#endif
private void ClearKeyBytes()
{
#if NETSTANDARD2_0
Array.Clear(_keyBytes, 0, _keyBytes.Length);
#else
System.Security.Cryptography.CryptographicOperations.ZeroMemory(_keyBytes);
#endif
}
}

110
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/ChunkedEncryptingReadStream.cs

@ -0,0 +1,110 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Volo.Abp.BlobStoring;
/// <summary>
/// Encrypts the source stream chunk by chunk while being read.
/// </summary>
internal class ChunkedEncryptingReadStream : ChunkedCryptoReadStream
{
private readonly Stream _plainStream;
private readonly byte[] _prefix;
private readonly byte[] _associatedDataPrefix;
private readonly byte[] _keyBytes;
private readonly byte[] _baseNonce;
private readonly int _chunkSize;
private bool _prefixEmitted;
private bool _terminalEmitted;
private int _chunkIndex;
public ChunkedEncryptingReadStream(
Stream plainStream,
byte[] prefix,
byte[] associatedDataPrefix,
byte[] keyBytes,
byte[] baseNonce,
int chunkSize,
long? encryptedLength)
: base(encryptedLength)
{
_plainStream = plainStream;
_prefix = prefix;
_associatedDataPrefix = associatedDataPrefix;
_keyBytes = keyBytes;
_baseNonce = baseNonce;
_chunkSize = chunkSize;
}
protected override byte[]? ProduceNext()
{
var prefix = TryProducePrefix();
if (prefix != null)
{
return prefix;
}
return ProducePayload(BlobEncryptionCodec.ReadUpTo(_plainStream, _chunkSize));
}
protected override async Task<byte[]?> ProduceNextAsync(CancellationToken cancellationToken)
{
var prefix = TryProducePrefix();
if (prefix != null)
{
return prefix;
}
return ProducePayload(await BlobEncryptionCodec.ReadUpToAsync(_plainStream, _chunkSize, cancellationToken));
}
private byte[]? TryProducePrefix()
{
if (_prefixEmitted)
{
return null;
}
_prefixEmitted = true;
return _prefix;
}
private byte[]? ProducePayload(byte[] plainChunk)
{
if (plainChunk.Length == 0)
{
if (_terminalEmitted)
{
return null;
}
_terminalEmitted = true;
return BlobEncryptionCodec.CreateTerminalRecord(_keyBytes, _associatedDataPrefix, _baseNonce, _chunkIndex);
}
var chunkBytes = BlobEncryptionCodec.EncryptChunk(_keyBytes, _associatedDataPrefix, _baseNonce, _chunkIndex, plainChunk, plainChunk.Length);
_chunkIndex++;
return chunkBytes;
}
protected override void Dispose(bool disposing)
{
// Do not dispose the plain stream; it is owned by the caller.
if (disposing)
{
ClearKeyBytes();
}
base.Dispose(disposing);
}
private void ClearKeyBytes()
{
#if NETSTANDARD2_0
System.Array.Clear(_keyBytes, 0, _keyBytes.Length);
#else
System.Security.Cryptography.CryptographicOperations.ZeroMemory(_keyBytes);
#endif
}
}

94
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/DefaultBlobEncryptionKeyProvider.cs

@ -1,54 +1,92 @@
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"/>).
/// Resolves the container passphrase first, then the global
/// <see cref="AbpBlobStoringEncryptionOptions.DefaultPassPhrase"/>; decryption uses
/// only the source recorded in the BLOB header. Replace this service for
/// tenant-specific or externally stored passphrases.
/// </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)
public DefaultBlobEncryptionKeyProvider(IOptions<AbpBlobStoringEncryptionOptions> options)
{
CurrentTenant = currentTenant;
SettingProvider = settingProvider;
Options = options.Value;
}
public virtual async Task<string?> GetPassPhraseOrNullAsync(
public virtual Task<BlobEncryptionKey> ResolveForEncryptionAsync(
BlobContainerConfiguration configuration,
CancellationToken cancellationToken = default)
{
if (CurrentTenant.Id.HasValue)
cancellationToken.ThrowIfCancellationRequested();
var containerPassPhrase = GetContainerPassPhraseOrNull(configuration);
if (!string.IsNullOrWhiteSpace(containerPassPhrase))
{
var tenantPassPhrase = await SettingProvider.GetOrNullAsync(
BlobStoringEncryptionSettings.TenantPassPhrase
);
return Task.FromResult(new BlobEncryptionKey(BlobEncryptionKeySource.Container, containerPassPhrase!));
}
if (!string.IsNullOrWhiteSpace(Options.DefaultPassPhrase))
{
return Task.FromResult(new BlobEncryptionKey(BlobEncryptionKeySource.Global, Options.DefaultPassPhrase!));
}
throw new AbpException(
"BLOB encryption is enabled, but no passphrase could be resolved. " +
"Pass a passphrase to the UseEncryption extension method or configure " +
$"{nameof(AbpBlobStoringEncryptionOptions)}.{nameof(AbpBlobStoringEncryptionOptions.DefaultPassPhrase)}."
);
}
public virtual Task<string> ResolveForDecryptionAsync(
BlobEncryptionKeySource keySource,
BlobContainerConfiguration configuration,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (!tenantPassPhrase.IsNullOrEmpty())
{
return tenantPassPhrase;
}
string? passPhrase;
switch (keySource)
{
case BlobEncryptionKeySource.Container:
passPhrase = GetContainerPassPhraseOrNull(configuration);
break;
case BlobEncryptionKeySource.Tenant:
throw new AbpException(
"The BLOB was encrypted with a tenant-specific passphrase, but the default " +
$"key provider does not supply tenant keys. Replace the {nameof(IBlobEncryptionKeyProvider)} " +
"service with the implementation that was used to encrypt the BLOB."
);
case BlobEncryptionKeySource.Global:
passPhrase = Options.DefaultPassPhrase;
break;
default:
throw new AbpException($"Unknown BLOB encryption key source: {keySource}!");
}
return Options.DefaultPassPhrase;
if (string.IsNullOrWhiteSpace(passPhrase))
{
throw new AbpException(
$"The BLOB was encrypted with the '{keySource}' passphrase, " +
"but that passphrase is not available anymore, so the BLOB can not be decrypted."
);
}
return Task.FromResult(passPhrase!);
}
/// <summary>
/// Returns the container-specific passphrase, so derived providers can keep it
/// as the highest-priority source.
/// </summary>
protected virtual string? GetContainerPassPhraseOrNull(BlobContainerConfiguration configuration)
{
return BlobEncryptionConfiguration.GetPassPhraseOrNull(configuration);
}
}

16
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionKeyProvider.cs

@ -5,13 +5,25 @@ namespace Volo.Abp.BlobStoring;
/// <summary>
/// Resolves the passphrase used to encrypt/decrypt the BLOBs of a container.
/// Replace this service to read the passphrases from another source, like a vault
/// or another secret store (the provider must be able to return the passphrase
/// itself; hardware-backed non-exportable keys are not supported).
/// </summary>
public interface IBlobEncryptionKeyProvider
{
/// <summary>
/// Returns the passphrase to be used, or <c>null</c> if no passphrase is available.
/// Resolves the passphrase (and its source) to encrypt a new BLOB; throws if none is available.
/// </summary>
Task<string?> GetPassPhraseOrNullAsync(
Task<BlobEncryptionKey> ResolveForEncryptionAsync(
BlobContainerConfiguration configuration,
CancellationToken cancellationToken = default);
/// <summary>
/// Resolves the passphrase for the key source recorded in the BLOB header;
/// throws if it is not available anymore.
/// </summary>
Task<string> ResolveForDecryptionAsync(
BlobEncryptionKeySource keySource,
BlobContainerConfiguration configuration,
CancellationToken cancellationToken = default);
}

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

@ -1,27 +0,0 @@
using System.IO;
namespace Volo.Abp.BlobStoring;
/// <summary>
/// Encrypts and decrypts BLOB streams with authenticated encryption
/// (see <see cref="Volo.Abp.Security.Encryption.IByteArrayEncryptionService"/>).
/// Memory usage is constant, independent from the BLOB size.
/// </summary>
public interface IBlobEncryptionService
{
/// <summary>
/// Wraps the given stream so that the content read from it is encrypted.
/// The returned stream starts with a small header (magic bytes and format version,
/// followed by the encryption format header), the authenticated cipher chunks,
/// and an authenticated terminal record. When the input length is known, the returned
/// stream exposes the exact encrypted <see cref="Stream.Length"/>.
/// </summary>
Stream Encrypt(Stream plainStream, string passPhrase);
/// <summary>
/// Wraps the given stream so that the content read from it is decrypted.
/// If the stream does not carry the encryption header, its content is returned unchanged
/// (assumed to be stored before encryption was enabled).
/// </summary>
Stream Decrypt(Stream cipherStream, string passPhrase);
}

24
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobPipelineContributor.cs

@ -1,24 +0,0 @@
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);
}

105
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/PrefixingReadStream.cs

@ -0,0 +1,105 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Volo.Abp.BlobStoring;
/// <summary>
/// Serves the already-consumed prefix bytes first, then the rest of the underlying stream.
/// </summary>
internal sealed class PrefixingReadStream : SequentialReadStream
{
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;
// Legacy plaintext BLOBs had a usable Length before encryption was enabled;
// keep it available when the underlying stream knows it.
public override long Length => _stream.CanSeek ? _stream.Length : throw new NotSupportedException();
protected override int ReadCore(byte[] buffer, int offset, int count)
{
var prefixReadCount = TryCopyFromPrefix(buffer, offset, count);
if (prefixReadCount > 0)
{
return prefixReadCount;
}
return _stream.Read(buffer, offset, count);
}
protected override async Task<int> ReadCoreAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
var prefixReadCount = TryCopyFromPrefix(buffer, offset, count);
if (prefixReadCount > 0)
{
return prefixReadCount;
}
return await _stream.ReadAsync(buffer, offset, count, cancellationToken);
}
private int TryCopyFromPrefix(byte[] buffer, int offset, int count)
{
if (_prefixPosition >= _prefix.Length)
{
return 0;
}
var readCount = Math.Min(count, _prefix.Length - _prefixPosition);
Array.Copy(_prefix, _prefixPosition, buffer, offset, readCount);
_prefixPosition += readCount;
return readCount;
}
protected override void Dispose(bool disposing)
{
if (disposing && !IsDisposed)
{
IsDisposed = true;
try
{
_stream.Dispose();
}
finally
{
base.Dispose(disposing);
}
return;
}
base.Dispose(disposing);
}
#if !NETSTANDARD2_0
public override async ValueTask DisposeAsync()
{
if (!IsDisposed)
{
IsDisposed = true;
try
{
await _stream.DisposeAsync();
}
finally
{
await base.DisposeAsync();
}
return;
}
await base.DisposeAsync();
}
#endif
}

138
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/SequentialReadStream.cs

@ -0,0 +1,138 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Volo.Abp.BlobStoring;
/// <summary>
/// A read-only, non-seekable, forward-only stream; a failed read faults it permanently.
/// </summary>
internal abstract class SequentialReadStream : Stream
{
private bool _faulted;
protected bool IsDisposed { get; set; }
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)
{
ValidateReadArguments(buffer, offset, count);
EnsureCanServe();
if (count == 0)
{
return 0;
}
try
{
return ReadCore(buffer, offset, count);
}
catch
{
_faulted = true;
throw;
}
}
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
ValidateReadArguments(buffer, offset, count);
EnsureCanServe();
cancellationToken.ThrowIfCancellationRequested();
if (count == 0)
{
return 0;
}
try
{
return await ReadCoreAsync(buffer, offset, count, cancellationToken);
}
catch
{
_faulted = true;
throw;
}
}
protected abstract int ReadCore(byte[] buffer, int offset, int count);
protected abstract Task<int> ReadCoreAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken);
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)
{
IsDisposed = true;
base.Dispose(disposing);
}
private void EnsureCanServe()
{
if (IsDisposed)
{
throw new ObjectDisposedException(GetType().FullName);
}
if (_faulted)
{
throw new AbpException("The stream can not be read anymore, because a previous read operation has failed!");
}
}
private static void ValidateReadArguments(byte[] buffer, int offset, int count)
{
if (buffer == null)
{
throw new ArgumentNullException(nameof(buffer));
}
if (offset < 0)
{
throw new ArgumentOutOfRangeException(nameof(offset));
}
if (count < 0)
{
throw new ArgumentOutOfRangeException(nameof(count));
}
if (buffer.Length - offset < count)
{
throw new ArgumentException("The sum of offset and count is larger than the buffer length!");
}
}
}

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

@ -1,49 +0,0 @@
using System.Text;
namespace Volo.Abp.Security.Encryption;
/// <summary>
/// Options used by <see cref="IByteArrayEncryptionService"/>.
/// These options are independent from <see cref="AbpStringEncryptionOptions"/>;
/// changing them does not affect <see cref="IStringEncryptionService"/>.
/// </summary>
public class AbpByteArrayEncryptionOptions
{
/// <summary>
/// Default password to encrypt/decrypt data.
/// It's recommended to set to another value for security.
/// Default value: "x9V4qL2mZ8sT1pRe"
/// </summary>
public string DefaultPassPhrase { get; set; }
/// <summary>
/// This constant string is used as a "salt" value for the key derivation function calls.
/// Default value: Encoding.ASCII.GetBytes("kT8!qW2e")
/// </summary>
public byte[] DefaultSalt { get; set; }
/// <summary>
/// Iteration count of the PBKDF2 key derivation function.
/// Default value: 100000.
/// WARNING: Changing this value makes previously encrypted data undecryptable,
/// since the key is derived again with the current value during decryption.
/// </summary>
public int DeriveBytesIterations { get; set; }
/// <summary>
/// Size (in bytes) of the plaintext chunks that are encrypted and authenticated
/// one by one while processing streams. Larger data is processed in constant memory,
/// independent from the total data size.
/// Default value: 65536 (64 KB).
/// Maximum value: 16777216 (16 MB).
/// </summary>
public int ChunkSize { get; set; }
public AbpByteArrayEncryptionOptions()
{
DefaultPassPhrase = "x9V4qL2mZ8sT1pRe";
DefaultSalt = Encoding.ASCII.GetBytes("kT8!qW2e");
DeriveBytesIterations = 100000;
ChunkSize = 64 * 1024;
}
}

530
framework/src/Volo.Abp.Security/Volo/Abp/Security/Encryption/ByteArrayEncryptionService.cs

@ -1,530 +0,0 @@
using System;
using System.IO;
using System.Security.Cryptography;
using Microsoft.Extensions.Options;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.Security.Encryption;
/// <summary>
/// Implements <see cref="IByteArrayEncryptionService"/> using authenticated encryption.
/// Uses AES-256-GCM on platforms that support it, and falls back to
/// AES-256-CBC + HMAC-SHA256 (encrypt-then-MAC) on .NET Standard 2.0.
/// <para>
/// Output format: a 14-byte header (version, algorithm, chunk size, base nonce),
/// followed by authenticated chunks: 4-byte big-endian cipher length, cipher chunk, authentication tag,
/// and an authenticated zero-length terminal record.
/// The header and the chunk index are bound to every chunk as associated data,
/// so chunks can not be re-ordered, truncated or moved between files.
/// </para>
/// </summary>
public class ByteArrayEncryptionService : IByteArrayEncryptionService, ITransientDependency
{
protected AbpByteArrayEncryptionOptions Options { get; }
protected const byte FormatVersion = 1;
protected const byte AlgorithmAesGcm = 1;
protected const byte AlgorithmAesCbcHmacSha256 = 2;
protected const int BaseNonceSize = 8;
protected const int HeaderSize = 14; // version(1) + algorithm(1) + chunkSize(4) + baseNonce(8)
protected const int ChunkLengthPrefixSize = 4;
protected const int GcmNonceSize = 12;
protected const int GcmTagSize = 16;
protected const int HmacSize = 32;
protected const int AesBlockSize = 16;
protected const int MaximumChunkSize = 16 * 1024 * 1024;
public ByteArrayEncryptionService(IOptions<AbpByteArrayEncryptionOptions> options)
{
Options = options.Value;
}
public virtual byte[]? Encrypt(byte[]? plainBytes, string? passPhrase = null, byte[]? salt = null)
{
if (plainBytes == null)
{
return null;
}
using var plainStream = new MemoryStream(plainBytes, writable: false);
using var cipherStream = new MemoryStream();
Encrypt(plainStream, cipherStream, passPhrase, salt);
return cipherStream.ToArray();
}
public virtual byte[]? Decrypt(byte[]? cipherBytes, string? passPhrase = null, byte[]? salt = null)
{
if (cipherBytes == null || cipherBytes.Length == 0)
{
return null;
}
using var cipherStream = new MemoryStream(cipherBytes, writable: false);
using var plainStream = new MemoryStream();
Decrypt(cipherStream, plainStream, passPhrase, salt);
return plainStream.ToArray();
}
public virtual void Encrypt(Stream plainStream, Stream cipherStream, string? passPhrase = null, byte[]? salt = null)
{
Check.NotNull(plainStream, nameof(plainStream));
Check.NotNull(cipherStream, nameof(cipherStream));
if (Options.ChunkSize <= 0 || Options.ChunkSize > MaximumChunkSize)
{
throw new AbpException($"{nameof(Options.ChunkSize)} must be between 1 and {MaximumChunkSize} bytes!");
}
var algorithm = GetEncryptionAlgorithm();
var keyBytes = DeriveKeyBytes(passPhrase ?? Options.DefaultPassPhrase, salt ?? Options.DefaultSalt, algorithm);
var baseNonce = new byte[BaseNonceSize];
using (var random = RandomNumberGenerator.Create())
{
random.GetBytes(baseNonce);
}
var header = BuildHeader(algorithm, Options.ChunkSize, baseNonce);
cipherStream.Write(header, 0, header.Length);
var buffer = new byte[Options.ChunkSize];
var chunkIndex = 0;
int readCount;
while ((readCount = plainStream.Read(buffer, 0, buffer.Length)) > 0)
{
EncryptChunk(algorithm, keyBytes, header, baseNonce, chunkIndex, buffer, readCount, cipherStream);
chunkIndex++;
}
WriteTerminalRecord(algorithm, keyBytes, header, baseNonce, chunkIndex, cipherStream);
}
public virtual void Decrypt(Stream cipherStream, Stream plainStream, string? passPhrase = null, byte[]? salt = null)
{
Check.NotNull(cipherStream, nameof(cipherStream));
Check.NotNull(plainStream, nameof(plainStream));
var header = ReadExactly(cipherStream, HeaderSize);
if (header == null)
{
throw new AbpException("The encrypted data is corrupted or has an invalid format: missing header!");
}
if (header[0] != FormatVersion)
{
throw new AbpException($"Unsupported encryption format version: {header[0]}!");
}
var algorithm = header[1];
if (algorithm != AlgorithmAesGcm && algorithm != AlgorithmAesCbcHmacSha256)
{
throw new AbpException($"Unsupported encryption algorithm: {algorithm}!");
}
var chunkSize = ReadInt32BigEndian(header, 2);
if (chunkSize <= 0 || chunkSize > MaximumChunkSize)
{
throw new AbpException("The encrypted data is corrupted or has an invalid format: invalid chunk size!");
}
var baseNonce = new byte[BaseNonceSize];
Array.Copy(header, 6, baseNonce, 0, BaseNonceSize);
var keyBytes = DeriveKeyBytes(passPhrase ?? Options.DefaultPassPhrase, salt ?? Options.DefaultSalt, algorithm);
var tagSize = algorithm == AlgorithmAesGcm ? GcmTagSize : HmacSize;
var maxCipherChunkSize = algorithm == AlgorithmAesGcm ? chunkSize : chunkSize + AesBlockSize;
var chunkIndex = 0;
while (true)
{
var lengthPrefix = ReadUpTo(cipherStream, ChunkLengthPrefixSize);
if (lengthPrefix.Length == 0)
{
throw new AbpException("The encrypted data is corrupted or has an invalid format: missing terminal record!");
}
if (lengthPrefix.Length < ChunkLengthPrefixSize)
{
throw new AbpException("The encrypted data is corrupted or has an invalid format: truncated chunk!");
}
var cipherChunkSize = ReadInt32BigEndian(lengthPrefix, 0);
if (cipherChunkSize == 0)
{
var terminalTag = ReadExactly(cipherStream, tagSize);
if (terminalTag == null || ReadUpTo(cipherStream, 1).Length != 0)
{
throw new AbpException("The encrypted data is corrupted or has an invalid format: invalid terminal record!");
}
VerifyTerminalRecord(algorithm, keyBytes, header, baseNonce, chunkIndex, terminalTag);
break;
}
if (cipherChunkSize < 0 || cipherChunkSize > maxCipherChunkSize)
{
throw new AbpException("The encrypted data is corrupted or has an invalid format: invalid chunk length!");
}
var cipherChunk = ReadExactly(cipherStream, cipherChunkSize);
var tag = ReadExactly(cipherStream, tagSize);
if (cipherChunk == null || tag == null)
{
throw new AbpException("The encrypted data is corrupted or has an invalid format: truncated chunk!");
}
var plainChunk = DecryptChunk(algorithm, keyBytes, header, baseNonce, chunkIndex, cipherChunk, tag);
plainStream.Write(plainChunk, 0, plainChunk.Length);
chunkIndex++;
}
}
/// <summary>
/// Gets the algorithm used while encrypting. Decryption supports both algorithms
/// (except AES-GCM on .NET Standard 2.0, where it is not available).
/// </summary>
protected virtual byte GetEncryptionAlgorithm()
{
#if NETSTANDARD2_0
return AlgorithmAesCbcHmacSha256;
#else
return AlgorithmAesGcm;
#endif
}
/// <summary>
/// Derives the key material using PBKDF2-SHA1 (Rfc2898DeriveBytes). SHA1 is used on all
/// target frameworks on purpose, so that the derived key is deterministic across platforms.
/// Returns 32 bytes for AES-256-GCM, or 64 bytes (32 encryption + 32 MAC) for AES-256-CBC-HMAC.
/// </summary>
protected virtual byte[] DeriveKeyBytes(string passPhrase, byte[] salt, byte algorithm)
{
var keyLength = algorithm == AlgorithmAesGcm ? 32 : 64;
#if NET8_0_OR_GREATER
return Rfc2898DeriveBytes.Pbkdf2(passPhrase, salt, Options.DeriveBytesIterations, HashAlgorithmName.SHA1, keyLength);
#else
// The default hash algorithm of this constructor is SHA1.
using var password = new Rfc2898DeriveBytes(passPhrase, salt, Options.DeriveBytesIterations);
return password.GetBytes(keyLength);
#endif
}
protected virtual void EncryptChunk(byte algorithm, byte[] keyBytes, byte[] header, byte[] baseNonce, int chunkIndex, byte[] plainChunk, int plainChunkLength, Stream cipherStream)
{
var associatedData = CreateChunkAssociatedData(header, chunkIndex);
byte[] cipherChunk;
byte[] tag;
if (algorithm == AlgorithmAesGcm)
{
#if NETSTANDARD2_0
throw new AbpException("AES-GCM is not supported on this platform (.NET Standard 2.0)!");
#else
cipherChunk = new byte[plainChunkLength];
tag = new byte[GcmTagSize];
using (var aesGcm = CreateAesGcm(keyBytes))
{
aesGcm.Encrypt(CreateChunkNonce(baseNonce, chunkIndex), plainChunk.AsSpan(0, plainChunkLength), cipherChunk, tag, associatedData);
}
#endif
}
else
{
cipherChunk = AesCbcEncryptChunk(keyBytes, baseNonce, chunkIndex, plainChunk, plainChunkLength);
tag = ComputeChunkMac(keyBytes, associatedData, cipherChunk);
}
var lengthPrefix = new byte[ChunkLengthPrefixSize];
WriteInt32BigEndian(lengthPrefix, 0, cipherChunk.Length);
cipherStream.Write(lengthPrefix, 0, lengthPrefix.Length);
cipherStream.Write(cipherChunk, 0, cipherChunk.Length);
cipherStream.Write(tag, 0, tag.Length);
}
protected virtual void WriteTerminalRecord(
byte algorithm,
byte[] keyBytes,
byte[] header,
byte[] baseNonce,
int chunkIndex,
Stream cipherStream)
{
var lengthPrefix = new byte[ChunkLengthPrefixSize];
cipherStream.Write(lengthPrefix, 0, lengthPrefix.Length);
var tag = ComputeTerminalTag(algorithm, keyBytes, header, baseNonce, chunkIndex);
cipherStream.Write(tag, 0, tag.Length);
}
protected virtual void VerifyTerminalRecord(
byte algorithm,
byte[] keyBytes,
byte[] header,
byte[] baseNonce,
int chunkIndex,
byte[] tag)
{
if (algorithm == AlgorithmAesGcm)
{
#if NETSTANDARD2_0
throw new AbpException("AES-GCM encrypted data can not be decrypted on this platform (.NET Standard 2.0)!");
#else
using (var aesGcm = CreateAesGcm(keyBytes))
{
aesGcm.Decrypt(
CreateChunkNonce(baseNonce, chunkIndex),
Array.Empty<byte>(),
tag,
Array.Empty<byte>(),
CreateChunkAssociatedData(header, chunkIndex)
);
}
#endif
}
else
{
var expectedTag = ComputeTerminalTag(algorithm, keyBytes, header, baseNonce, chunkIndex);
if (!FixedTimeEquals(expectedTag, tag))
{
throw new CryptographicException("The encrypted data is tampered, corrupted or the passphrase/salt is wrong!");
}
}
}
protected virtual byte[] ComputeTerminalTag(
byte algorithm,
byte[] keyBytes,
byte[] header,
byte[] baseNonce,
int chunkIndex)
{
var associatedData = CreateChunkAssociatedData(header, chunkIndex);
if (algorithm == AlgorithmAesGcm)
{
#if NETSTANDARD2_0
throw new AbpException("AES-GCM is not supported on this platform (.NET Standard 2.0)!");
#else
var tag = new byte[GcmTagSize];
using (var aesGcm = CreateAesGcm(keyBytes))
{
aesGcm.Encrypt(
CreateChunkNonce(baseNonce, chunkIndex),
Array.Empty<byte>(),
Array.Empty<byte>(),
tag,
associatedData
);
}
return tag;
#endif
}
return ComputeChunkMac(keyBytes, associatedData, Array.Empty<byte>());
}
protected virtual byte[] DecryptChunk(byte algorithm, byte[] keyBytes, byte[] header, byte[] baseNonce, int chunkIndex, byte[] cipherChunk, byte[] tag)
{
var associatedData = CreateChunkAssociatedData(header, chunkIndex);
if (algorithm == AlgorithmAesGcm)
{
#if NETSTANDARD2_0
throw new AbpException("AES-GCM encrypted data can not be decrypted on this platform (.NET Standard 2.0)!");
#else
var plainChunk = new byte[cipherChunk.Length];
using (var aesGcm = CreateAesGcm(keyBytes))
{
// Throws CryptographicException if the authentication tag is invalid.
aesGcm.Decrypt(CreateChunkNonce(baseNonce, chunkIndex), cipherChunk, tag, plainChunk, associatedData);
}
return plainChunk;
#endif
}
else
{
var expectedTag = ComputeChunkMac(keyBytes, associatedData, cipherChunk);
if (!FixedTimeEquals(expectedTag, tag))
{
throw new CryptographicException("The encrypted data is tampered, corrupted or the passphrase/salt is wrong!");
}
return AesCbcDecryptChunk(keyBytes, baseNonce, chunkIndex, cipherChunk);
}
}
/// <summary>
/// Creates the 12-byte nonce of a chunk: 8-byte random base nonce + 4-byte big-endian chunk index.
/// Since the base nonce is random per encryption operation and the index is unique per chunk,
/// a nonce never repeats for the same key.
/// </summary>
protected virtual byte[] CreateChunkNonce(byte[] baseNonce, int chunkIndex)
{
var nonce = new byte[GcmNonceSize];
Array.Copy(baseNonce, 0, nonce, 0, BaseNonceSize);
WriteInt32BigEndian(nonce, BaseNonceSize, chunkIndex);
return nonce;
}
/// <summary>
/// Creates the associated data of a chunk: the header + 4-byte big-endian chunk index.
/// This binds every chunk to its position and to the file it belongs to.
/// </summary>
protected virtual byte[] CreateChunkAssociatedData(byte[] header, int chunkIndex)
{
var associatedData = new byte[HeaderSize + 4];
Array.Copy(header, 0, associatedData, 0, HeaderSize);
WriteInt32BigEndian(associatedData, HeaderSize, chunkIndex);
return associatedData;
}
/// <summary>
/// Encrypts a chunk with AES-256-CBC. The IV of each chunk is derived from the MAC key,
/// the base nonce and the chunk index, so it is unique and unpredictable per chunk.
/// </summary>
protected virtual byte[] AesCbcEncryptChunk(byte[] keyBytes, byte[] baseNonce, int chunkIndex, byte[] plainChunk, int plainChunkLength)
{
using var aes = Aes.Create();
aes.Mode = CipherMode.CBC;
using var encryptor = aes.CreateEncryptor(GetAesCbcEncryptionKey(keyBytes), DeriveAesCbcChunkIV(keyBytes, baseNonce, chunkIndex));
return encryptor.TransformFinalBlock(plainChunk, 0, plainChunkLength);
}
protected virtual byte[] AesCbcDecryptChunk(byte[] keyBytes, byte[] baseNonce, int chunkIndex, byte[] cipherChunk)
{
using var aes = Aes.Create();
aes.Mode = CipherMode.CBC;
using var decryptor = aes.CreateDecryptor(GetAesCbcEncryptionKey(keyBytes), DeriveAesCbcChunkIV(keyBytes, baseNonce, chunkIndex));
return decryptor.TransformFinalBlock(cipherChunk, 0, cipherChunk.Length);
}
/// <summary>
/// Computes the HMAC-SHA256 of a chunk over its associated data and cipher bytes (encrypt-then-MAC).
/// </summary>
protected virtual byte[] ComputeChunkMac(byte[] keyBytes, byte[] associatedData, byte[] cipherChunk)
{
using var hmac = new HMACSHA256(GetAesCbcMacKey(keyBytes));
hmac.TransformBlock(associatedData, 0, associatedData.Length, null, 0);
hmac.TransformFinalBlock(cipherChunk, 0, cipherChunk.Length);
return hmac.Hash!;
}
/// <summary>
/// Derives the IV of a CBC chunk: first 16 bytes of HMAC-SHA256(MAC key, "IV" + chunk nonce).
/// </summary>
protected virtual byte[] DeriveAesCbcChunkIV(byte[] keyBytes, byte[] baseNonce, int chunkIndex)
{
var input = CreateChunkNonce(baseNonce, chunkIndex);
input[0] ^= 0xFF; // Domain separation from the GCM nonce, just in case.
using var hmac = new HMACSHA256(GetAesCbcMacKey(keyBytes));
var hash = hmac.ComputeHash(input);
var iv = new byte[AesBlockSize];
Array.Copy(hash, 0, iv, 0, AesBlockSize);
return iv;
}
protected virtual byte[] GetAesCbcEncryptionKey(byte[] keyBytes)
{
var key = new byte[32];
Array.Copy(keyBytes, 0, key, 0, 32);
return key;
}
protected virtual byte[] GetAesCbcMacKey(byte[] keyBytes)
{
var key = new byte[32];
Array.Copy(keyBytes, 32, key, 0, 32);
return key;
}
#if !NETSTANDARD2_0
private static AesGcm CreateAesGcm(byte[] keyBytes)
{
#if NET8_0_OR_GREATER
return new AesGcm(keyBytes, GcmTagSize);
#else
return new AesGcm(keyBytes);
#endif
}
#endif
protected virtual byte[] BuildHeader(byte algorithm, int chunkSize, byte[] baseNonce)
{
var header = new byte[HeaderSize];
header[0] = FormatVersion;
header[1] = algorithm;
WriteInt32BigEndian(header, 2, chunkSize);
Array.Copy(baseNonce, 0, header, 6, BaseNonceSize);
return header;
}
/// <summary>
/// Reads exactly <paramref name="count"/> bytes from the stream.
/// Returns null if the stream ends before <paramref name="count"/> bytes could be read.
/// </summary>
protected virtual byte[]? ReadExactly(Stream stream, int count)
{
var buffer = ReadUpTo(stream, count);
return buffer.Length == count ? buffer : null;
}
/// <summary>
/// Reads up to <paramref name="count"/> bytes from the stream.
/// May return fewer bytes (or an empty array) only if the stream ends.
/// </summary>
protected virtual byte[] ReadUpTo(Stream stream, int count)
{
var buffer = new byte[count];
var totalReadCount = 0;
while (totalReadCount < count)
{
var readCount = stream.Read(buffer, totalReadCount, count - totalReadCount);
if (readCount == 0)
{
break;
}
totalReadCount += readCount;
}
if (totalReadCount == count)
{
return buffer;
}
var result = new byte[totalReadCount];
Array.Copy(buffer, 0, result, 0, totalReadCount);
return result;
}
private static void WriteInt32BigEndian(byte[] buffer, int offset, int value)
{
buffer[offset] = (byte)(value >> 24);
buffer[offset + 1] = (byte)(value >> 16);
buffer[offset + 2] = (byte)(value >> 8);
buffer[offset + 3] = (byte)value;
}
private static int ReadInt32BigEndian(byte[] buffer, int offset)
{
return (buffer[offset] << 24) | (buffer[offset + 1] << 16) | (buffer[offset + 2] << 8) | buffer[offset + 3];
}
private static bool FixedTimeEquals(byte[] a, byte[] b)
{
if (a.Length != b.Length)
{
return false;
}
var diff = 0;
for (var i = 0; i < a.Length; i++)
{
diff |= a[i] ^ b[i];
}
return diff == 0;
}
}

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

@ -1,59 +0,0 @@
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);
}

424
framework/test/Volo.Abp.BlobStoring.FileSystem.Tests/Volo/Abp/BlobStoring/FileSystem/FileSystemBlobEncryption_Tests.cs

@ -0,0 +1,424 @@
#nullable enable
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Shouldly;
using Volo.Abp.BlobStoring.TestObjects;
using Volo.Abp.MultiTenancy;
using Xunit;
namespace Volo.Abp.BlobStoring.FileSystem;
public class FileSystemBlobEncryption_Tests : AbpBlobStoringFileSystemTestBase
{
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; // UseEncryption("container6-passphrase", allowLegacyPlaintext: true)
private readonly IBlobFilePathCalculator _filePathCalculator;
private readonly IBlobContainerConfigurationProvider _configurationProvider;
private readonly ICurrentTenant _currentTenant;
public FileSystemBlobEncryption_Tests()
{
_container4 = GetRequiredService<IBlobContainer<TestContainer4>>();
_container5 = GetRequiredService<IBlobContainer<TestContainer5>>();
_container6 = GetRequiredService<IBlobContainer<TestContainer6>>();
_filePathCalculator = GetRequiredService<IBlobFilePathCalculator>();
_configurationProvider = GetRequiredService<IBlobContainerConfigurationProvider>();
_currentTenant = GetRequiredService<ICurrentTenant>();
}
[Fact]
public async Task Should_Store_Encrypted_Bytes_On_Disk_And_Read_Them_Back()
{
var blobName = "fs-encrypted-roundtrip";
var testContent = "file system test content".GetBytes();
await _container4.SaveAsync(blobName, testContent);
var fileBytes = await File.ReadAllBytesAsync(GetFilePath<TestContainer4>(blobName));
fileBytes.SequenceEqual(testContent).ShouldBeFalse();
Encoding.ASCII.GetString(fileBytes.Take(4).ToArray()).ShouldBe("ABPE");
(await _container4.GetAllBytesAsync(blobName)).SequenceEqual(testContent).ShouldBeTrue();
}
[Fact]
public async Task Should_Save_And_Get_Empty_And_Multi_Chunk_Blobs()
{
await _container4.SaveAsync("fs-empty", Array.Empty<byte>());
(await _container4.GetAllBytesAsync("fs-empty")).ShouldBeEmpty();
var largeContent = new byte[3 * 1024 * 1024 + 123]; // Spans many 64 KB chunks
new Random(42).NextBytes(largeContent);
await _container4.SaveAsync("fs-large", largeContent);
(await _container4.GetAllBytesAsync("fs-large")).SequenceEqual(largeContent).ShouldBeTrue();
}
[Fact]
public async Task Should_Override_An_Existing_Encrypted_Blob()
{
var blobName = "fs-override";
await _container4.SaveAsync(blobName, "first content".GetBytes());
await _container4.SaveAsync(blobName, "second content".GetBytes(), overrideExisting: true);
(await _container4.GetAllBytesAsync(blobName)).ShouldBe("second content".GetBytes());
}
[Fact]
public async Task Should_Save_From_Async_Only_Source_To_Disk()
{
var blobName = "fs-async-only";
var testContent = new byte[192 * 1024];
new Random(42).NextBytes(testContent);
await _container4.SaveAsync(blobName, new AsyncOnlyStream(testContent));
using var result = await _container4.GetAsync(blobName);
using var output = new MemoryStream();
await result.CopyToAsync(output);
output.ToArray().ShouldBe(testContent);
}
[Fact]
public async Task Should_Read_Legacy_Plaintext_File_When_Allowed()
{
var blobName = "fs-legacy";
var legacyContent = "plaintext file from before encryption".GetBytes();
WriteRawFile<TestContainer6>(blobName, legacyContent);
(await _container6.GetAllBytesAsync(blobName)).SequenceEqual(legacyContent).ShouldBeTrue();
}
[Fact]
public async Task Should_Reject_Legacy_Plaintext_File_By_Default()
{
var blobName = "fs-legacy-rejected";
WriteRawFile<TestContainer4>(blobName, "plaintext file".GetBytes());
await Assert.ThrowsAsync<AbpException>(async () =>
{
using var stream = await _container4.GetAsync(blobName);
});
}
[Fact]
public async Task Should_Detect_Tampered_File_On_Disk()
{
var blobName = "fs-tampered";
var content = new byte[128 * 1024];
new Random(42).NextBytes(content);
await _container4.SaveAsync(blobName, content);
var filePath = GetFilePath<TestContainer4>(blobName);
var fileBytes = await File.ReadAllBytesAsync(filePath);
fileBytes[100] ^= 0xFF; // Inside the first cipher chunk
await File.WriteAllBytesAsync(filePath, fileBytes);
using var stream = await _container4.GetAsync(blobName);
using var output = new MemoryStream();
Assert.ThrowsAny<CryptographicException>(() => stream.CopyTo(output));
}
[Fact]
public async Task Should_Detect_Truncated_File_On_Disk()
{
var blobName = "fs-truncated";
await _container4.SaveAsync(blobName, new byte[128 * 1024]);
var filePath = GetFilePath<TestContainer4>(blobName);
var fileBytes = await File.ReadAllBytesAsync(filePath);
Array.Resize(ref fileBytes, fileBytes.Length - 20); // Cut the terminal record
await File.WriteAllBytesAsync(filePath, fileBytes);
using var stream = await _container4.GetAsync(blobName);
using var output = new MemoryStream();
Should.Throw<AbpException>(() => stream.CopyTo(output));
}
[Fact]
public async Task Should_Fail_Closed_When_File_Magic_Is_Tampered()
{
var blobName = "fs-tampered-magic";
await _container4.SaveAsync(blobName, "secret".GetBytes());
var filePath = GetFilePath<TestContainer4>(blobName);
var fileBytes = await File.ReadAllBytesAsync(filePath);
fileBytes[0] ^= 0xFF;
await File.WriteAllBytesAsync(filePath, fileBytes);
await Assert.ThrowsAsync<AbpException>(async () =>
{
using var stream = await _container4.GetAsync(blobName);
});
}
[Fact]
public async Task Should_Support_Exists_And_Delete_For_Encrypted_Blobs()
{
var blobName = "fs-exists-delete";
await _container4.SaveAsync(blobName, "content".GetBytes());
(await _container4.ExistsAsync(blobName)).ShouldBeTrue();
(await _container4.DeleteAsync(blobName)).ShouldBeTrue();
(await _container4.ExistsAsync(blobName)).ShouldBeFalse();
(await _container4.GetOrNullAsync(blobName)).ShouldBeNull();
}
[Fact]
public async Task Should_Isolate_Tenant_Blobs_In_Separate_Files()
{
var blobName = "fs-tenant-isolation";
var tenant1 = Guid.NewGuid();
var tenant2 = Guid.NewGuid();
using (_currentTenant.Change(tenant1))
{
await _container5.SaveAsync(blobName, "tenant 1 content".GetBytes());
}
using (_currentTenant.Change(tenant2))
{
await _container5.SaveAsync(blobName, "tenant 2 content".GetBytes());
}
string tenant1Path, tenant2Path;
using (_currentTenant.Change(tenant1))
{
tenant1Path = GetFilePath<TestContainer5>(blobName);
(await _container5.GetAllBytesAsync(blobName)).ShouldBe("tenant 1 content".GetBytes());
}
using (_currentTenant.Change(tenant2))
{
tenant2Path = GetFilePath<TestContainer5>(blobName);
(await _container5.GetAllBytesAsync(blobName)).ShouldBe("tenant 2 content".GetBytes());
}
tenant1Path.ShouldNotBe(tenant2Path);
File.Exists(tenant1Path).ShouldBeTrue();
File.Exists(tenant2Path).ShouldBeTrue();
}
[Fact]
public async Task Should_Reject_A_File_Moved_Between_Tenants()
{
var blobName = "fs-moved-between-tenants";
var tenant1 = Guid.NewGuid();
var tenant2 = Guid.NewGuid();
string tenant1Path, tenant2Path;
using (_currentTenant.Change(tenant1))
{
await _container4.SaveAsync(blobName, "tenant 1 secret".GetBytes());
tenant1Path = GetFilePath<TestContainer4>(blobName);
}
using (_currentTenant.Change(tenant2))
{
tenant2Path = GetFilePath<TestContainer4>(blobName);
}
// Same container passphrase for both tenants: only the identity binding
// makes the copied file unreadable at the new location.
Directory.CreateDirectory(Path.GetDirectoryName(tenant2Path)!);
File.Copy(tenant1Path, tenant2Path);
using (_currentTenant.Change(tenant2))
{
using var stream = await _container4.GetAsync(blobName);
using var output = new MemoryStream();
Assert.ThrowsAny<CryptographicException>(() => stream.CopyTo(output));
}
}
[Fact]
public async Task Should_Use_Global_PassPhrase_On_Disk_Without_Tenant()
{
var blobName = "fs-global-key";
await _container5.SaveAsync(blobName, "global content".GetBytes());
var fileBytes = await File.ReadAllBytesAsync(GetFilePath<TestContainer5>(blobName));
fileBytes[6].ShouldBe((byte)BlobEncryptionKeySource.Global);
(await _container5.GetAllBytesAsync(blobName)).ShouldBe("global content".GetBytes());
}
[Fact]
public async Task Should_Retry_And_Produce_A_Complete_File_For_A_Replayable_Source()
{
// TestContainer8 is not encrypted, so the seekable source reaches the provider directly
var container8 = GetRequiredService<IBlobContainer<TestContainer8>>();
var content = new byte[64 * 1024];
new Random(42).NextBytes(content);
var source = new FaultOnceSeekableStream(content);
await container8.SaveAsync("fs-retry-replayable", source, overrideExisting: true);
source.FaultsInjected.ShouldBe(1); // First attempt failed, the retry succeeded
(await container8.GetAllBytesAsync("fs-retry-replayable")).SequenceEqual(content).ShouldBeTrue();
}
[Fact]
public async Task Should_Not_Retry_A_Non_Replayable_Encrypted_Save()
{
// The encrypting wrapper is not seekable, so a mid-write failure must not be retried
var content = new byte[64 * 1024];
new Random(42).NextBytes(content);
var source = new FaultOnceSeekableStream(content, reportSeekable: false);
await Assert.ThrowsAsync<IOException>(async () =>
{
await _container4.SaveAsync("fs-retry-non-replayable", source, overrideExisting: true);
});
source.FaultsInjected.ShouldBe(1); // No second attempt
}
private sealed class FaultOnceSeekableStream : Stream
{
private readonly MemoryStream _stream;
private readonly bool _reportSeekable;
private bool _faulted;
public int FaultsInjected { get; private set; }
public FaultOnceSeekableStream(byte[] bytes, bool reportSeekable = true)
{
_stream = new MemoryStream(bytes);
_reportSeekable = reportSeekable;
}
public override bool CanRead => true;
public override bool CanSeek => _reportSeekable;
public override bool CanWrite => false;
public override long Length => _reportSeekable ? _stream.Length : throw new NotSupportedException();
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)
{
return ReadCore(() => _stream.Read(buffer, offset, count));
}
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken)
{
return Task.FromResult(ReadCore(() => _stream.Read(buffer, offset, count)));
}
private int ReadCore(Func<int> read)
{
// Fail once in the middle of the content
if (!_faulted && _stream.Position >= _stream.Length / 2)
{
_faulted = true;
FaultsInjected++;
throw new IOException("Injected I/O failure!");
}
return read();
}
public override long Seek(long offset, SeekOrigin origin)
{
return _reportSeekable ? _stream.Seek(offset, 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);
}
}
private string GetFilePath<TContainer>(string blobName)
{
return _filePathCalculator.Calculate(
new BlobProviderGetArgs(
BlobContainerNameAttribute.GetContainerName<TContainer>(),
_configurationProvider.Get<TContainer>(),
blobName
)
);
}
private void WriteRawFile<TContainer>(string blobName, byte[] bytes)
{
var filePath = GetFilePath<TContainer>(blobName);
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
File.WriteAllBytes(filePath, bytes);
}
private sealed class AsyncOnlyStream : Stream
{
private readonly MemoryStream _stream;
public AsyncOnlyStream(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)
{
throw new InvalidOperationException("Synchronous reads are not allowed on this stream!");
}
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken)
{
return _stream.ReadAsync(buffer, offset, count, cancellationToken);
}
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);
}
}
}

127
framework/test/Volo.Abp.BlobStoring.Minio.Tests/Volo/Abp/BlobStoring/Minio/MinioBlobEncryption_Tests.cs

@ -0,0 +1,127 @@
#nullable enable
/*
//Please set the correct connection string in secrets.json and continue the test.
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Shouldly;
using Volo.Abp.BlobStoring.TestObjects;
using Xunit;
namespace Volo.Abp.BlobStoring.Minio;
public class MinioBlobEncryption_Tests : AbpBlobStoringMinioTestBase
{
private readonly IBlobContainer<TestContainer4> _container4; // UseEncryption("container4-passphrase")
public MinioBlobEncryption_Tests()
{
_container4 = GetRequiredService<IBlobContainer<TestContainer4>>();
}
[Fact]
public async Task Should_Save_And_Get_Encrypted_Blob()
{
var blobName = "minio-encrypted-roundtrip";
var testContent = "minio test content".GetBytes();
await _container4.SaveAsync(blobName, testContent);
(await _container4.GetAllBytesAsync(blobName)).SequenceEqual(testContent).ShouldBeTrue();
}
[Fact]
public async Task Should_Save_And_Get_Empty_And_Multi_Chunk_Blobs()
{
await _container4.SaveAsync("minio-empty", Array.Empty<byte>());
(await _container4.GetAllBytesAsync("minio-empty")).ShouldBeEmpty();
// MinIO reads BlobStream.Length before uploading, so this verifies the
// exact encrypted length calculation against a real object store.
var largeContent = new byte[3 * 1024 * 1024 + 123]; // Spans many 64 KB chunks
new Random(42).NextBytes(largeContent);
await _container4.SaveAsync("minio-large", largeContent);
(await _container4.GetAllBytesAsync("minio-large")).SequenceEqual(largeContent).ShouldBeTrue();
}
[Fact]
public async Task Should_Override_An_Existing_Encrypted_Blob()
{
var blobName = "minio-override";
await _container4.SaveAsync(blobName, "first content".GetBytes());
await _container4.SaveAsync(blobName, "second content".GetBytes(), overrideExisting: true);
(await _container4.GetAllBytesAsync(blobName)).ShouldBe("second content".GetBytes());
}
[Fact]
public async Task Should_Support_Exists_And_Delete_For_Encrypted_Blobs()
{
var blobName = "minio-exists-delete";
await _container4.SaveAsync(blobName, "content".GetBytes());
(await _container4.ExistsAsync(blobName)).ShouldBeTrue();
(await _container4.DeleteAsync(blobName)).ShouldBeTrue();
(await _container4.ExistsAsync(blobName)).ShouldBeFalse();
(await _container4.GetOrNullAsync(blobName)).ShouldBeNull();
}
[Fact]
public async Task Should_Reject_Non_Seekable_Source_Because_Minio_Requires_The_Length()
{
// The MinIO provider reads BlobStream.Length; for a non-seekable source the
// encrypted length is unknown, so saving fails (same as without encryption).
await Assert.ThrowsAsync<NotSupportedException>(async () =>
{
await _container4.SaveAsync("minio-non-seekable", new NonSeekableStream("content".GetBytes()));
});
}
private sealed class NonSeekableStream : Stream
{
private readonly MemoryStream _stream;
public NonSeekableStream(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)
{
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);
}
}
}
*/

16
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringTestModule.cs

@ -4,7 +4,6 @@ using Volo.Abp.Autofac;
using Volo.Abp.BlobStoring.Fakes;
using Volo.Abp.BlobStoring.TestObjects;
using Volo.Abp.Modularity;
using Volo.Abp.Settings;
namespace Volo.Abp.BlobStoring;
@ -25,11 +24,7 @@ public class AbpBlobStoringTestModule : AbpModule
serviceProvider => serviceProvider.GetRequiredService<FakeInMemoryBlobProvider>()
);
Configure<AbpSettingOptions>(options =>
{
var tenantProviderIndex = options.ValueProviders.IndexOf(typeof(TenantSettingValueProvider));
options.ValueProviders[tenantProviderIndex] = typeof(FakeTenantPassPhraseSettingValueProvider);
});
context.Services.AddTransient<IBlobEncryptionKeyProvider, FakeTenantBlobEncryptionKeyProvider>();
Configure<AbpBlobStoringEncryptionOptions>(options =>
{
@ -71,12 +66,17 @@ public class AbpBlobStoringTestModule : AbpModule
.Configure<TestContainer6>(container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.PipelineContributors.Add(typeof(FakeReversingPipelineContributor));
container.UseEncryption("container6-passphrase", allowLegacyPlaintext: true);
})
.Configure<TestContainer7>(container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.PipelineContributors.Add(typeof(FakeScopeBoundPipelineContributor));
container.IsMultiTenant = false;
container.UseEncryption("container7-shared-passphrase");
})
.Configure<TestContainer8>(container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
});
});
}

77
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerConfiguration_Tests.cs

@ -1,3 +1,4 @@
using System;
using System.Linq;
using Shouldly;
using Volo.Abp.BlobStoring.Fakes;
@ -61,19 +62,79 @@ public class BlobContainerConfiguration_Tests
}
[Fact]
public void Should_Compose_Default_And_Local_Pipeline_Contributors_With_Provider_Override()
public void Should_Inherit_Encryption_From_Default_Container()
{
var defaultConfig = new BlobContainerConfiguration();
defaultConfig.UseEncryption();
var namedConfig = new BlobContainerConfiguration(defaultConfig);
namedConfig.ProviderType = typeof(FakeBlobProvider2);
namedConfig.PipelineContributors.Add<FakeReversingPipelineContributor>();
namedConfig.GetEffectivePipelineContributors().ShouldBe(new[]
{
typeof(BlobEncryptionContributor),
typeof(FakeReversingPipelineContributor)
});
BlobEncryptionConfiguration.IsEnabled(namedConfig).ShouldBeTrue();
BlobEncryptionConfiguration.IsEnabled(defaultConfig).ShouldBeTrue();
}
[Fact]
public void Should_Disable_Inherited_Encryption_For_A_Single_Container()
{
var defaultConfig = new BlobContainerConfiguration();
defaultConfig.UseEncryption();
var namedConfig = new BlobContainerConfiguration(defaultConfig);
namedConfig.DisableEncryption();
BlobEncryptionConfiguration.IsEnabled(namedConfig).ShouldBeFalse();
BlobEncryptionConfiguration.IsEnabled(defaultConfig).ShouldBeTrue();
}
[Fact]
public void Should_Enable_Encryption_Again_After_Disabling()
{
var defaultConfig = new BlobContainerConfiguration();
defaultConfig.UseEncryption();
var namedConfig = new BlobContainerConfiguration(defaultConfig);
namedConfig.DisableEncryption();
namedConfig.UseEncryption("named-passphrase");
BlobEncryptionConfiguration.IsEnabled(namedConfig).ShouldBeTrue();
BlobEncryptionConfiguration.GetPassPhraseOrNull(namedConfig).ShouldBe("named-passphrase");
}
[Fact]
public void Should_Keep_The_Configured_PassPhrase_When_UseEncryption_Is_Called_Again()
{
var configuration = new BlobContainerConfiguration();
configuration.UseEncryption("first-passphrase", allowLegacyPlaintext: true);
// Another module just ensuring that encryption is enabled must not
// change the configured key or the legacy option.
configuration.UseEncryption();
BlobEncryptionConfiguration.GetPassPhraseOrNull(configuration).ShouldBe("first-passphrase");
BlobEncryptionConfiguration.IsLegacyPlaintextAllowed(configuration).ShouldBeTrue();
}
[Fact]
public void Should_Shadow_The_PassPhrase_Inherited_From_The_Default_Container()
{
var defaultConfig = new BlobContainerConfiguration();
defaultConfig.UseEncryption("default-passphrase");
var namedConfig = new BlobContainerConfiguration(defaultConfig);
namedConfig.UseEncryption();
namedConfig.ClearEncryptionPassPhrase();
// The named container explicitly opted out of the inherited passphrase
BlobEncryptionConfiguration.GetPassPhraseOrNull(namedConfig).ShouldBeNull();
BlobEncryptionConfiguration.GetPassPhraseOrNull(defaultConfig).ShouldBe("default-passphrase");
}
[Fact]
public void Should_Reject_Empty_PassPhrase()
{
var configuration = new BlobContainerConfiguration();
Assert.ThrowsAny<ArgumentException>(() => configuration.UseEncryption(""));
Assert.ThrowsAny<ArgumentException>(() => configuration.UseEncryption(" "));
}
}

667
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerEncryption_Tests.cs

@ -2,13 +2,13 @@
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
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;
@ -17,23 +17,17 @@ 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 IBlobContainer<TestContainer6> _container6; // UseEncryption("container6-passphrase", allowLegacyPlaintext: true)
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]
@ -53,6 +47,28 @@ public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase
result.SequenceEqual(testContent).ShouldBeTrue();
}
[Fact]
public async Task Should_Save_And_Get_Empty_Blob()
{
var blobName = "test-blob-empty";
await _container4.SaveAsync(blobName, Array.Empty<byte>());
(await _container4.GetAllBytesAsync(blobName)).ShouldBeEmpty();
}
[Fact]
public async Task Should_Record_Key_Source_In_The_Header()
{
var blobName = "test-blob-key-source";
await _container4.SaveAsync(blobName, "content".GetBytes());
var rawBytes = GetRawBytes<TestContainer4>(blobName)!;
// magic(4) + version(1) + algorithm(1), then the key source byte
rawBytes[6].ShouldBe((byte)BlobEncryptionKeySource.Container);
}
[Fact]
public async Task Should_Encrypt_With_Tenant_Specific_PassPhrase()
{
@ -63,13 +79,13 @@ public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase
using (_currentTenant.Change(tenantId))
{
await _container5.SaveAsync(blobName, testContent);
}
var rawBytes = GetRawBytes<TestContainer5>(blobName);
rawBytes.ShouldNotBeNull();
var rawBytes = GetRawBytes<TestContainer5>(blobName);
rawBytes.ShouldNotBeNull();
rawBytes![6].ShouldBe((byte)BlobEncryptionKeySource.Tenant);
var decryptedBytes = Decrypt(rawBytes!, FakeTenantPassPhraseSettingValueProvider.GetPassPhrase(tenantId));
decryptedBytes.SequenceEqual(testContent).ShouldBeTrue();
(await _container5.GetAllBytesAsync(blobName)).SequenceEqual(testContent).ShouldBeTrue();
}
}
[Fact]
@ -89,17 +105,20 @@ public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase
await _container5.SaveAsync("test-blob-tenant-2", testContent);
}
var rawBytes1 = GetRawBytes<TestContainer5>("test-blob-tenant-1");
var rawBytes2 = GetRawBytes<TestContainer5>("test-blob-tenant-2");
var rawBytes1 = GetRawBytes<TestContainer5>("test-blob-tenant-1")!;
var rawBytes2 = GetRawBytes<TestContainer5>("test-blob-tenant-2")!;
rawBytes1.SequenceEqual(rawBytes2).ShouldBeFalse();
rawBytes1.ShouldNotBeNull();
rawBytes2.ShouldNotBeNull();
rawBytes1!.SequenceEqual(rawBytes2!).ShouldBeFalse();
// Each tenant can only read its own BLOB
using (_currentTenant.Change(tenantId1))
{
(await _container5.GetAllBytesAsync("test-blob-tenant-1")).SequenceEqual(testContent).ShouldBeTrue();
}
Decrypt(rawBytes1, FakeTenantPassPhraseSettingValueProvider.GetPassPhrase(tenantId1))
.SequenceEqual(testContent).ShouldBeTrue();
Decrypt(rawBytes2, FakeTenantPassPhraseSettingValueProvider.GetPassPhrase(tenantId2))
.SequenceEqual(testContent).ShouldBeTrue();
using (_currentTenant.Change(tenantId2))
{
(await _container5.GetAllBytesAsync("test-blob-tenant-2")).SequenceEqual(testContent).ShouldBeTrue();
}
}
[Fact]
@ -112,37 +131,144 @@ public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase
var rawBytes = GetRawBytes<TestContainer5>(blobName);
rawBytes.ShouldNotBeNull();
var decryptedBytes = Decrypt(rawBytes!, "default-global-passphrase");
decryptedBytes.SequenceEqual(testContent).ShouldBeTrue();
rawBytes![6].ShouldBe((byte)BlobEncryptionKeySource.Global);
(await _container5.GetAllBytesAsync(blobName)).SequenceEqual(testContent).ShouldBeTrue();
}
[Fact]
public async Task Should_Apply_Custom_Pipeline_Contributor()
public async Task Should_Decrypt_With_The_Recorded_Key_Source_Even_If_Other_Keys_Appear_Later()
{
var blobName = "test-blob-reversed";
var testContent = "test content".GetBytes();
var codec = GetRequiredService<BlobEncryptionCodec>();
var testContent = "written with the global key".GetBytes();
await _container6.SaveAsync(blobName, testContent);
// Encrypted while only the global passphrase was available
var globalConfiguration = new BlobContainerConfiguration().UseEncryption();
using var cipherBytes = new MemoryStream();
using (var encryptingStream = await codec.CreateEncryptingStreamAsync(globalConfiguration, "routing-container", "routing-blob", null, new MemoryStream(testContent)))
{
encryptingStream.CopyTo(cipherBytes);
}
var rawBytes = GetRawBytes<TestContainer6>(blobName);
rawBytes.ShouldNotBeNull();
rawBytes!.SequenceEqual(testContent.Reverse().ToArray()).ShouldBeTrue();
// A container passphrase is configured later: the header still routes
// this BLOB to the global key, so it stays readable.
var laterConfiguration = new BlobContainerConfiguration().UseEncryption("container-passphrase-added-later");
using var decryptingStream = await codec.CreateDecryptingStreamAsync(laterConfiguration, "routing-container", "routing-blob", null, new MemoryStream(cipherBytes.ToArray()));
using var output = new MemoryStream();
decryptingStream.CopyTo(output);
var result = await _container6.GetAllBytesAsync(blobName);
result.SequenceEqual(testContent).ShouldBeTrue();
output.ToArray().ShouldBe(testContent);
}
[Fact]
public async Task Should_Read_Legacy_Plaintext_Blob_When_Allowed()
{
var blobName = "test-blob-legacy";
var legacyContent = "legacy plain content, stored before encryption was enabled".GetBytes();
SetRawBytes<TestContainer6>(blobName, legacyContent);
(await _container6.GetAllBytesAsync(blobName)).SequenceEqual(legacyContent).ShouldBeTrue();
}
[Fact]
public async Task Should_Keep_Contributor_Scope_Alive_While_Provider_And_Caller_Read_Streams()
public async Task Should_Reject_Legacy_Plaintext_Blob_By_Default()
{
var blobName = "test-blob-scope-bound";
var testContent = "scope-bound content".GetBytes();
var blobName = "test-blob-legacy-rejected";
SetRawBytes<TestContainer4>(blobName, "legacy plain content".GetBytes());
await _container7.SaveAsync(blobName, testContent);
using var result = await _container7.GetAsync(blobName);
await Assert.ThrowsAsync<AbpException>(async () =>
{
using var stream = await _container4.GetAsync(blobName);
});
}
[Fact]
public async Task Should_Reject_Encrypted_Blob_With_Tampered_Magic()
{
var blobName = "test-blob-tampered-magic";
await _container4.SaveAsync(blobName, "secret content".GetBytes());
var rawBytes = GetRawBytes<TestContainer4>(blobName)!;
rawBytes[0] ^= 0xFF;
SetRawBytes<TestContainer4>(blobName, rawBytes);
// Without legacy plaintext enabled this must fail closed instead of
// returning the raw ciphertext bytes.
await Assert.ThrowsAsync<AbpException>(async () =>
{
using var stream = await _container4.GetAsync(blobName);
});
}
[Fact]
public async Task Should_Fault_Stream_After_Tampered_Chunk()
{
var blobName = "test-blob-tampered-chunk";
var content = new byte[128 * 1024]; // Spans multiple chunks
new Random(42).NextBytes(content);
await _container4.SaveAsync(blobName, content);
var rawBytes = GetRawBytes<TestContainer4>(blobName)!;
rawBytes[60] ^= 0xFF; // Inside the first cipher chunk
SetRawBytes<TestContainer4>(blobName, rawBytes);
using var stream = await _container4.GetAsync(blobName);
var buffer = new byte[256 * 1024];
Assert.ThrowsAny<Exception>(() =>
{
while (stream.Read(buffer, 0, buffer.Length) > 0)
{
}
});
// The stream must stay unreadable; otherwise a caller swallowing the first
// exception could read the chunks after the tampered one.
Should.Throw<AbpException>(() => stream.Read(buffer, 0, buffer.Length));
}
[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 rawBytes = GetRawBytes<TestContainer4>(blobName)!;
Array.Resize(ref rawBytes, rawBytes.Length - 20); // terminal record: 4-byte marker + 16-byte tag
SetRawBytes<TestContainer4>(blobName, rawBytes);
using var stream = await _container4.GetAsync(blobName);
using var output = new MemoryStream();
Should.Throw<AbpException>(() => stream.CopyTo(output));
}
[Fact]
public async Task Should_Reject_Blob_Encrypted_With_Another_PassPhrase()
{
var blobName = "test-blob-wrong-key";
await _container6.SaveAsync(blobName, "content".GetBytes());
// Same raw bytes, read over a container with a different passphrase
SetRawBytes<TestContainer4>(blobName, GetRawBytes<TestContainer6>(blobName)!);
using var stream = await _container4.GetAsync(blobName);
using var output = new MemoryStream();
Assert.ThrowsAny<CryptographicException>(() => stream.CopyTo(output));
}
[Fact]
public async Task Should_Save_And_Get_Encrypted_Blob_With_Async_Only_Streams()
{
var blobName = "test-blob-async-only";
var testContent = new byte[192 * 1024]; // Spans multiple encryption chunks
new Random(42).NextBytes(testContent);
// Simulates sources like the ASP.NET Core request body, where synchronous reads throw
await _container4.SaveAsync(blobName, new AsyncOnlyStream(testContent));
using var result = await _container4.GetAsync(blobName);
using var output = new MemoryStream();
await result.CopyToAsync(output);
@ -150,75 +276,384 @@ public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase
}
[Fact]
public async Task Should_Define_Tenant_PassPhrase_As_Encrypted_And_Tenant_Only()
public async Task Should_Not_Consume_Data_On_Zero_Byte_Reads()
{
var blobName = "test-blob-zero-read";
var content = new byte[100 * 1024];
new Random(42).NextBytes(content);
await _container4.SaveAsync(blobName, content);
using var stream = await _container4.GetAsync(blobName);
using var collected = new MemoryStream();
var buffer = new byte[8 * 1024];
stream.Read(buffer, 0, 0).ShouldBe(0);
int readCount;
while ((readCount = stream.Read(buffer, 0, buffer.Length)) > 0)
{
collected.Write(buffer, 0, readCount);
(await stream.ReadAsync(buffer, 0, 0)).ShouldBe(0);
}
collected.ToArray().ShouldBe(content);
}
[Fact]
public async Task Should_Not_Allow_Reading_After_Dispose()
{
var definition = await _settingDefinitionManager.GetAsync(BlobStoringEncryptionSettings.TenantPassPhrase);
var blobName = "test-blob-dispose";
await _container4.SaveAsync(blobName, "dispose then read".GetBytes());
var stream = await _container4.GetAsync(blobName);
var buffer = new byte[1024];
stream.Read(buffer, 0, buffer.Length);
stream.Dispose();
definition.IsEncrypted.ShouldBeTrue();
definition.Providers.ShouldBe(new[] { TenantSettingValueProvider.ProviderName });
Should.Throw<ObjectDisposedException>(() => stream.Read(buffer, 0, buffer.Length));
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(65536)]
[InlineData(65537)]
public void Should_Expose_Exact_Encrypted_Length_For_Seekable_Input(int length)
[Fact]
public async Task Should_Dispose_Underlying_Stream_Only_Once_On_Mixed_Dispose()
{
using var encryptedStream = _encryptionService.Encrypt(new MemoryStream(new byte[length]), "length-passphrase");
var reportedLength = encryptedStream.Length;
using var output = new MemoryStream();
var codec = GetRequiredService<BlobEncryptionCodec>();
var configuration = new BlobContainerConfiguration().UseEncryption("dispose-passphrase");
encryptedStream.CopyTo(output);
using var cipherBytes = new MemoryStream();
using (var encryptingStream = await codec.CreateEncryptingStreamAsync(configuration, "test-container", "dispose-blob", null, new MemoryStream("dispose once".GetBytes())))
{
encryptingStream.CopyTo(cipherBytes);
}
reportedLength.ShouldBe(output.Length);
var source = new TrackingNonSeekableStream(cipherBytes.ToArray());
var decryptingStream = await codec.CreateDecryptingStreamAsync(configuration, "test-container", "dispose-blob", null, source);
await decryptingStream.DisposeAsync();
decryptingStream.Dispose();
source.DisposeCount.ShouldBe(1);
}
[Fact]
public void Should_Stream_NonSeekable_Unencrypted_Response_Without_Materializing_It()
public async Task Should_Expose_Exact_Encrypted_Length_For_Seekable_Input()
{
var content = new byte[1024 * 1024];
new Random(42).NextBytes(content);
var source = new TrackingNonSeekableStream(content);
var codec = GetRequiredService<BlobEncryptionCodec>();
var configuration = new BlobContainerConfiguration().UseEncryption("length-passphrase");
foreach (var length in new[] { 0, 1, 64 * 1024, 64 * 1024 + 1 })
{
using var encryptedStream = await codec.CreateEncryptingStreamAsync(configuration, "test-container", "length-blob", null, new MemoryStream(new byte[length]));
var reportedLength = encryptedStream.Length;
using var output = new MemoryStream();
encryptedStream.CopyTo(output);
reportedLength.ShouldBe(output.Length);
}
}
using var result = _encryptionService.Decrypt(source, "unused-passphrase");
[Fact]
public async Task Should_Reject_A_Blob_Copied_To_Another_Name()
{
await _container4.SaveAsync("identity-a", "content of a".GetBytes());
await _container4.SaveAsync("identity-b", "content of b".GetBytes());
source.BytesRead.ShouldBe(5);
// Same container, same passphrase: only the AAD identity binding can catch this
SetRawBytes<TestContainer4>("identity-a", GetRawBytes<TestContainer4>("identity-b")!);
using var stream = await _container4.GetAsync("identity-a");
using var output = new MemoryStream();
result.CopyTo(output);
output.ToArray().ShouldBe(content);
result.Dispose();
source.IsDisposed.ShouldBeTrue();
Assert.ThrowsAny<CryptographicException>(() => stream.CopyTo(output));
}
[Fact]
public async Task Should_Reject_Encrypted_Blob_Without_Terminal_Record()
public async Task Should_Reject_A_Blob_Served_From_Another_Tenants_Location()
{
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);
var blobName = "identity-tenant";
using (_currentTenant.Change(Guid.NewGuid()))
{
await _container4.SaveAsync(blobName, "tenant content".GetBytes());
}
using var decryptedStream = _encryptionService.Decrypt(new MemoryStream(encryptedBytes), "container4-passphrase");
// The fake provider stores host and tenant blobs in the same slot, so this
// simulates moving the encrypted bytes to the host location. Same passphrase,
// different identity: must fail.
using var stream = await _container4.GetAsync(blobName);
using var output = new MemoryStream();
Should.Throw<AbpException>(() => decryptedStream.CopyTo(output));
Assert.ThrowsAny<CryptographicException>(() => stream.CopyTo(output));
}
[Fact]
public async Task Should_Share_Blobs_Of_A_Non_MultiTenant_Container_Between_Tenants()
{
var container7 = GetRequiredService<IBlobContainer<TestContainer7>>(); // IsMultiTenant = false
var blobName = "shared-container-blob";
var testContent = "shared content".GetBytes();
using (_currentTenant.Change(Guid.NewGuid()))
{
await container7.SaveAsync(blobName, testContent);
}
// A shared container always uses the host identity, so every tenant
// (and the host) reads the same BLOB.
using (_currentTenant.Change(Guid.NewGuid()))
{
(await container7.GetAllBytesAsync(blobName)).SequenceEqual(testContent).ShouldBeTrue();
}
(await container7.GetAllBytesAsync(blobName)).SequenceEqual(testContent).ShouldBeTrue();
}
[Fact]
public async Task Should_Reject_Oversized_Encrypted_Blob_Chunk_Size_Before_Allocating()
public async Task Should_Throw_When_Cancelled_Before_Saving()
{
var blobName = "test-blob-oversized-chunk";
await _container4.SaveAsync(blobName, new byte[] { 1 });
using var cts = new System.Threading.CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await _container4.SaveAsync("cancelled-save", new MemoryStream("content".GetBytes()), cancellationToken: cts.Token);
});
}
[Fact]
public async Task Should_Throw_When_Cancelled_Before_Reading()
{
await _container4.SaveAsync("cancelled-read", "content".GetBytes());
using var cts = new System.Threading.CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
using var stream = await _container4.GetAsync("cancelled-read", cts.Token);
});
}
[Fact]
public async Task Should_Reject_Tenant_Key_Source_With_The_Default_Key_Provider()
{
var defaultProvider = new DefaultBlobEncryptionKeyProvider(
Microsoft.Extensions.Options.Options.Create(new AbpBlobStoringEncryptionOptions())
);
await Assert.ThrowsAsync<AbpException>(async () =>
{
await defaultProvider.ResolveForDecryptionAsync(BlobEncryptionKeySource.Tenant, new BlobContainerConfiguration());
});
}
[Fact]
public async Task Should_Throw_When_No_PassPhrase_Can_Be_Resolved()
{
var defaultProvider = new DefaultBlobEncryptionKeyProvider(
Microsoft.Extensions.Options.Options.Create(new AbpBlobStoringEncryptionOptions())
);
var configuration = new BlobContainerConfiguration().UseEncryption();
await Assert.ThrowsAsync<AbpException>(async () =>
{
await defaultProvider.ResolveForEncryptionAsync(configuration);
});
}
[Fact]
public async Task Should_Return_Raw_Bytes_When_Encryption_Is_Not_Enabled_For_The_Container()
{
// Simulates reading an encrypted BLOB over a container without encryption
// (like after DisableEncryption): the stored bytes are returned as-is.
var blobName = "raw-ciphertext-readback";
await _container4.SaveAsync(blobName, "content".GetBytes());
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")
var container8 = GetRequiredService<IBlobContainer<TestContainer8>>(); // no encryption
_provider.SetRawBytes(
BlobContainerNameAttribute.GetContainerName<TestContainer8>(),
blobName,
encryptedBytes
);
(await container8.GetAllBytesAsync(blobName)).SequenceEqual(encryptedBytes).ShouldBeTrue();
}
[Fact]
public async Task Should_Reject_An_Unknown_Format_Version()
{
var blobName = "unknown-format-version";
await _container4.SaveAsync(blobName, "content".GetBytes());
var rawBytes = GetRawBytes<TestContainer4>(blobName)!;
rawBytes[4] = 2; // Format version byte
SetRawBytes<TestContainer4>(blobName, rawBytes);
await Assert.ThrowsAsync<AbpException>(async () =>
{
using var stream = await _container4.GetAsync(blobName);
});
}
[Fact]
public async Task Should_Read_A_Legacy_Blob_Shorter_Than_The_Format_Magic()
{
var blobName = "tiny-legacy-blob";
var tinyContent = new byte[] { 1, 2, 3 };
SetRawBytes<TestContainer6>(blobName, tinyContent); // allowLegacyPlaintext: true
(await _container6.GetAllBytesAsync(blobName)).SequenceEqual(tinyContent).ShouldBeTrue();
}
[Fact]
public async Task Should_Use_The_Configured_Kdf_Iterations()
{
var options = Microsoft.Extensions.Options.Options.Create(new AbpBlobStoringEncryptionOptions
{
DefaultPassPhrase = "iterations-passphrase",
KdfIterations = 150_000
});
var codec = new BlobEncryptionCodec(new DefaultBlobEncryptionKeyProvider(options), options);
var configuration = new BlobContainerConfiguration().UseEncryption();
using var cipherBytes = new MemoryStream();
using (var encryptingStream = await codec.CreateEncryptingStreamAsync(configuration, "iter-container", "iter-blob", null, new MemoryStream("content".GetBytes())))
{
encryptingStream.CopyTo(cipherBytes);
}
var rawBytes = cipherBytes.ToArray();
var recordedIterations = (rawBytes[7] << 24) | (rawBytes[8] << 16) | (rawBytes[9] << 8) | rawBytes[10];
recordedIterations.ShouldBe(150_000);
using var decryptingStream = await codec.CreateDecryptingStreamAsync(configuration, "iter-container", "iter-blob", null, new MemoryStream(rawBytes));
using var output = new MemoryStream();
decryptingStream.CopyTo(output);
output.ToArray().ShouldBe("content".GetBytes());
}
[Fact]
public async Task Should_Reject_Kdf_Iterations_Out_Of_The_Allowed_Range()
{
foreach (var iterations in new[] { 50_000, 700_000 })
{
var options = Microsoft.Extensions.Options.Options.Create(new AbpBlobStoringEncryptionOptions
{
DefaultPassPhrase = "iterations-passphrase",
KdfIterations = iterations
});
var codec = new BlobEncryptionCodec(new DefaultBlobEncryptionKeyProvider(options), options);
var configuration = new BlobContainerConfiguration().UseEncryption();
await Assert.ThrowsAsync<AbpException>(async () =>
{
await codec.CreateEncryptingStreamAsync(configuration, "iter-container", "iter-blob", null, new MemoryStream("content".GetBytes()));
});
}
}
[Fact]
public void Should_Reject_Wrapped_Chunk_Index()
{
Should.Throw<AbpException>(() => BlobEncryptionCodec.CreateChunkNonce(new byte[8], -1));
}
[Fact]
public void Should_Reject_Unknown_Key_Source()
{
// An unknown source would be written into the header and make the BLOB unreadable
Assert.ThrowsAny<ArgumentException>(() => new BlobEncryptionKey((BlobEncryptionKeySource)99, "passphrase"));
Assert.ThrowsAny<ArgumentException>(() => new BlobEncryptionKey(0, "passphrase"));
}
[Fact]
public async Task Should_Reject_Tampered_Kdf_Iterations()
{
var blobName = "test-blob-tampered-iterations";
await _container4.SaveAsync(blobName, "content".GetBytes());
var rawBytes = GetRawBytes<TestContainer4>(blobName)!;
rawBytes[7] = 0x7F; // Iterations field: far above the allowed maximum
SetRawBytes<TestContainer4>(blobName, rawBytes);
// Must be rejected before deriving the key (a huge iteration count would be a CPU DoS)
await Assert.ThrowsAsync<AbpException>(async () =>
{
using var stream = await _container4.GetAsync(blobName);
});
}
[Fact]
public async Task Should_Reject_Tampered_Header_Salt()
{
var blobName = "test-blob-tampered-salt";
await _container4.SaveAsync(blobName, "content".GetBytes());
var rawBytes = GetRawBytes<TestContainer4>(blobName)!;
rawBytes[12] ^= 0xFF; // Inside the KDF salt; the header is bound to every chunk as AAD
SetRawBytes<TestContainer4>(blobName, rawBytes);
using var stream = await _container4.GetAsync(blobName);
using var output = new MemoryStream();
Assert.ThrowsAny<CryptographicException>(() => stream.CopyTo(output));
}
[Fact]
public async Task Should_Not_Decrypt_Another_Tenants_Blob()
{
var blobName = "test-blob-cross-tenant";
var testContent = "tenant 1 secret".GetBytes();
using (_currentTenant.Change(Guid.NewGuid()))
{
await _container5.SaveAsync(blobName, testContent);
}
// Another tenant resolves its own passphrase for the same key source
using (_currentTenant.Change(Guid.NewGuid()))
{
using var stream = await _container5.GetAsync(blobName);
using var output = new MemoryStream();
Assert.ThrowsAny<CryptographicException>(() => stream.CopyTo(output));
}
}
[Fact]
public void Should_Keep_The_V1_Format_Stable()
{
// Golden vector: deterministic ciphertext built from fixed inputs.
// If this test breaks, the on-disk format changed and existing
// encrypted BLOBs would become unreadable.
const string expected =
"QUJQRQEBAQABhqABAgMEBQYHCAkKCwwNDg8QAAEAAKChoqOkpaanAAAAFaa92MyQIqeyxK979tS+roEOv5MJTSc3BsF2sVmrewTHYbj3/UsAAAAAgMPye1nJF2Vd05yIpGw5Kg==";
var salt = new byte[16];
var baseNonce = new byte[8];
for (var i = 0; i < 16; i++) salt[i] = (byte)(i + 1);
for (var i = 0; i < 8; i++) baseNonce[i] = (byte)(0xA0 + i);
var keyBytes = BlobEncryptionCodec.DeriveKeyBytes("golden-passphrase", salt, 100_000);
var header = BlobEncryptionCodec.BuildHeader(BlobEncryptionKeySource.Container, 100_000, salt, 64 * 1024, baseNonce);
var prefix = BlobEncryptionCodec.CreateBlobPrefix(header);
var aad = BlobEncryptionCodec.BuildAssociatedDataPrefix(prefix, "golden-container", "golden-blob", null);
var plain = "golden vector content"u8.ToArray();
using var cipher = new MemoryStream();
cipher.Write(prefix, 0, prefix.Length);
var chunkRecord = BlobEncryptionCodec.EncryptChunk(keyBytes, aad, baseNonce, 0, plain, plain.Length);
cipher.Write(chunkRecord, 0, chunkRecord.Length);
var terminalRecord = BlobEncryptionCodec.CreateTerminalRecord(keyBytes, aad, baseNonce, 1);
cipher.Write(terminalRecord, 0, terminalRecord.Length);
Convert.ToBase64String(cipher.ToArray()).ShouldBe(expected);
// And the golden ciphertext must keep decrypting to the original content
cipher.Position = prefix.Length;
using var decryptingStream = new ChunkedDecryptingReadStream(cipher, aad, keyBytes, baseNonce, 64 * 1024);
using var output = new MemoryStream();
decryptingStream.CopyTo(output);
output.ToArray().ShouldBe(plain);
}
private byte[]? GetRawBytes<TContainer>(string blobName)
@ -229,23 +664,20 @@ public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase
);
}
private byte[] Decrypt(byte[] encryptedBytes, string passPhrase)
private void SetRawBytes<TContainer>(string blobName, byte[] bytes)
{
using (var decryptedStream = _encryptionService.Decrypt(new MemoryStream(encryptedBytes), passPhrase))
using (var memoryStream = new MemoryStream())
{
decryptedStream.CopyTo(memoryStream);
return memoryStream.ToArray();
}
_provider.SetRawBytes(
BlobContainerNameAttribute.GetContainerName<TContainer>(),
blobName,
bytes
);
}
private sealed class TrackingNonSeekableStream : Stream
{
private readonly MemoryStream _stream;
public int BytesRead { get; private set; }
public bool IsDisposed { get; private set; }
public int DisposeCount { get; private set; }
public TrackingNonSeekableStream(byte[] bytes)
{
@ -269,9 +701,57 @@ public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase
public override int Read(byte[] buffer, int offset, int count)
{
var readCount = _stream.Read(buffer, offset, count);
BytesRead += 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)
{
DisposeCount++;
_stream.Dispose();
}
base.Dispose(disposing);
}
}
private sealed class AsyncOnlyStream : Stream
{
private readonly MemoryStream _stream;
public AsyncOnlyStream(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)
{
throw new InvalidOperationException("Synchronous reads are not allowed on this stream!");
}
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken)
{
return _stream.ReadAsync(buffer, offset, count, cancellationToken);
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
@ -282,7 +762,6 @@ public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase
{
if (disposing)
{
IsDisposed = true;
_stream.Dispose();
}

5
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeInMemoryBlobProvider.cs

@ -53,6 +53,11 @@ public class FakeInMemoryBlobProvider : BlobProviderBase
return _blobs.TryGetValue(GetKey(containerName, blobName), out var bytes) ? bytes : null;
}
public void SetRawBytes(string containerName, string blobName, byte[] bytes)
{
_blobs[GetKey(containerName, blobName)] = bytes;
}
private static string GetKey(string containerName, string blobName)
{
return containerName + "/" + blobName;

33
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeReversingPipelineContributor.cs

@ -1,33 +0,0 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.BlobStoring.Fakes;
/// <summary>
/// A test contributor that reverses the BLOB bytes on both save and get.
/// </summary>
public class FakeReversingPipelineContributor : IBlobPipelineContributor, ITransientDependency
{
public Task<Stream> OnSaveAsync(BlobPipelineSaveArgs args)
{
return Task.FromResult(Reverse(args.BlobStream));
}
public Task<Stream> OnGetAsync(BlobPipelineGetArgs args)
{
return Task.FromResult(Reverse(args.BlobStream));
}
private static Stream Reverse(Stream stream)
{
using (var memoryStream = new MemoryStream())
{
stream.CopyTo(memoryStream);
var bytes = memoryStream.ToArray();
Array.Reverse(bytes);
return new MemoryStream(bytes);
}
}
}

77
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeScopeBoundPipelineContributor.cs

@ -1,77 +0,0 @@
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);
}
}
}

64
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantBlobEncryptionKeyProvider.cs

@ -0,0 +1,64 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Volo.Abp.MultiTenancy;
namespace Volo.Abp.BlobStoring.Fakes;
/// <summary>
/// A custom key provider giving each tenant its own passphrase.
/// </summary>
public class FakeTenantBlobEncryptionKeyProvider : DefaultBlobEncryptionKeyProvider
{
public const string PassPhrasePrefix = "tenant-passphrase-";
protected ICurrentTenant CurrentTenant { get; }
public FakeTenantBlobEncryptionKeyProvider(
ICurrentTenant currentTenant,
IOptions<AbpBlobStoringEncryptionOptions> options)
: base(options)
{
CurrentTenant = currentTenant;
}
public override Task<BlobEncryptionKey> ResolveForEncryptionAsync(
BlobContainerConfiguration configuration,
CancellationToken cancellationToken = default)
{
var containerPassPhrase = GetContainerPassPhraseOrNull(configuration);
if (string.IsNullOrWhiteSpace(containerPassPhrase) && CurrentTenant.Id.HasValue)
{
return Task.FromResult(new BlobEncryptionKey(
BlobEncryptionKeySource.Tenant,
GetPassPhrase(CurrentTenant.Id.Value)
));
}
return base.ResolveForEncryptionAsync(configuration, cancellationToken);
}
public override Task<string> ResolveForDecryptionAsync(
BlobEncryptionKeySource keySource,
BlobContainerConfiguration configuration,
CancellationToken cancellationToken = default)
{
if (keySource == BlobEncryptionKeySource.Tenant)
{
if (!CurrentTenant.Id.HasValue)
{
throw new AbpException("The BLOB was encrypted with a tenant-specific passphrase, but there is no current tenant!");
}
return Task.FromResult(GetPassPhrase(CurrentTenant.Id.Value));
}
return base.ResolveForDecryptionAsync(keySource, configuration, cancellationToken);
}
public static string GetPassPhrase(Guid tenantId)
{
return PassPhrasePrefix + tenantId.ToString("N");
}
}

59
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantPassPhraseSettingValueProvider.cs

@ -1,59 +0,0 @@
#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");
}
}

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

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

140
framework/test/Volo.Abp.Security.Tests/Volo/Abp/Security/Encryption/ByteArrayEncryptionService_Tests.cs

@ -1,140 +0,0 @@
using System;
using System.IO;
using System.Security.Cryptography;
using Shouldly;
using Volo.Abp.Testing;
using Xunit;
namespace Volo.Abp.Security.Encryption;
public class ByteArrayEncryptionService_Tests : AbpIntegratedTest<AbpSecurityTestModule>
{
private readonly IByteArrayEncryptionService _byteArrayEncryptionService;
public ByteArrayEncryptionService_Tests()
{
_byteArrayEncryptionService = GetRequiredService<IByteArrayEncryptionService>();
}
[Theory]
[InlineData(null)]
[InlineData(new byte[0])]
[InlineData(new byte[] { 1, 2, 3, 42, 255 })]
public void Should_Encrypt_And_Decrypt_With_Default_Options(byte[] plainBytes)
{
_byteArrayEncryptionService
.Decrypt(_byteArrayEncryptionService.Encrypt(plainBytes))
.ShouldBe(plainBytes);
}
[Fact]
public void Should_Encrypt_And_Decrypt_Large_Data()
{
var plainBytes = new byte[2 * 1024 * 1024]; // 2 MB
new Random(42).NextBytes(plainBytes);
var cipherBytes = _byteArrayEncryptionService.Encrypt(plainBytes);
cipherBytes.ShouldNotBeNull();
cipherBytes.ShouldNotBe(plainBytes);
_byteArrayEncryptionService.Decrypt(cipherBytes).ShouldBe(plainBytes);
}
[Fact]
public void Should_Encrypt_And_Decrypt_Streams()
{
var plainBytes = new byte[2 * 1024 * 1024]; // 2 MB, spans multiple chunks
new Random(42).NextBytes(plainBytes);
using var plainInput = new MemoryStream(plainBytes);
using var cipherOutput = new MemoryStream();
_byteArrayEncryptionService.Encrypt(plainInput, cipherOutput);
cipherOutput.Position = 0;
using var plainOutput = new MemoryStream();
_byteArrayEncryptionService.Decrypt(cipherOutput, plainOutput);
plainOutput.ToArray().ShouldBe(plainBytes);
}
[Fact]
public void Should_Produce_Different_Output_For_The_Same_Input()
{
var plainBytes = new byte[] { 1, 2, 3, 42, 255 };
var cipherBytes1 = _byteArrayEncryptionService.Encrypt(plainBytes);
var cipherBytes2 = _byteArrayEncryptionService.Encrypt(plainBytes);
cipherBytes1.ShouldNotBe(cipherBytes2);
}
[Fact]
public void Should_Write_Format_Header()
{
var cipherBytes = _byteArrayEncryptionService.Encrypt(new byte[] { 1, 2, 3 });
cipherBytes.ShouldNotBeNull();
cipherBytes.Length.ShouldBeGreaterThan(14);
cipherBytes[0].ShouldBe((byte)1); // Format version
}
[Fact]
public void Should_Fail_To_Decrypt_Tampered_Data()
{
var cipherBytes = _byteArrayEncryptionService.Encrypt(new byte[] { 1, 2, 3, 42, 255 });
cipherBytes![cipherBytes.Length - 1] ^= 0xFF; // Flip the last byte (inside the auth tag)
Assert.ThrowsAny<CryptographicException>(() => _byteArrayEncryptionService.Decrypt(cipherBytes));
}
[Fact]
public void Should_Fail_To_Decrypt_With_Wrong_PassPhrase()
{
var cipherBytes = _byteArrayEncryptionService.Encrypt(new byte[] { 1, 2, 3 }, "passphrase-1");
Assert.ThrowsAny<CryptographicException>(() => _byteArrayEncryptionService.Decrypt(cipherBytes, "passphrase-2"));
}
[Fact]
public void Should_Encrypt_And_Decrypt_With_Custom_PassPhrase_And_Salt()
{
var plainBytes = new byte[] { 1, 2, 3, 42, 255 };
var salt = new byte[] { 9, 8, 7, 6, 5, 4, 3, 2 };
_byteArrayEncryptionService
.Decrypt(_byteArrayEncryptionService.Encrypt(plainBytes, "my-passphrase", salt), "my-passphrase", salt)
.ShouldBe(plainBytes);
}
[Fact]
public void Should_Return_Null_For_Null_Or_Empty_CipherBytes()
{
_byteArrayEncryptionService.Decrypt(null).ShouldBeNull();
_byteArrayEncryptionService.Decrypt(new byte[0]).ShouldBeNull();
}
[Fact]
public void Should_Fail_When_Authenticated_Terminal_Record_Is_Missing()
{
var plainBytes = new byte[128 * 1024];
var cipherBytes = _byteArrayEncryptionService.Encrypt(plainBytes)!;
Array.Resize(ref cipherBytes, cipherBytes.Length - 20); // 4-byte terminal marker + 16-byte GCM tag
Should.Throw<AbpException>(() => _byteArrayEncryptionService.Decrypt(cipherBytes));
}
[Fact]
public void Should_Reject_Oversized_Encoded_Chunk_Size_Before_Allocating()
{
var cipherBytes = _byteArrayEncryptionService.Encrypt(new byte[] { 1 })!;
cipherBytes[2] = 0x7F;
cipherBytes[3] = 0xFF;
cipherBytes[4] = 0xFF;
cipherBytes[5] = 0xFF;
Should.Throw<AbpException>(() => _byteArrayEncryptionService.Decrypt(cipherBytes));
}
}
Loading…
Cancel
Save