Browse Source

Add a BLOB content pipeline and finalize encryption stream handling

- IBlobPipelineContributor pipeline; encryption runs innermost with an authenticated end check
- Adapt FileSystem/AWS to forward-only streams; fail early on unsupported AES-GCM
pull/25836/head
maliming 2 weeks ago
parent
commit
b485243449
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 4
      docs/en/docs-nav.json
  2. 18
      docs/en/framework/infrastructure/blob-storing/aws.md
  3. 74
      docs/en/framework/infrastructure/blob-storing/encryption.md
  4. 25
      docs/en/framework/infrastructure/blob-storing/index.md
  5. 122
      docs/en/framework/infrastructure/blob-storing/pipeline.md
  6. 3
      framework/src/Volo.Abp.BlobStoring.Aws/Properties/AssemblyInfo.cs
  7. 125
      framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProvider.cs
  8. 2
      framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs
  9. 90
      framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/LeaveOpenStreamWrapper.cs
  10. 30
      framework/src/Volo.Abp.BlobStoring.FileSystem/Volo/Abp/BlobStoring/FileSystem/FileSystemBlobProvider.cs
  11. 4
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/AbpBlobStoringEncryptionOptions.cs
  12. 310
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainer.cs
  13. 35
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfiguration.cs
  14. 65
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobContainerConfigurationEncryptionExtensions.cs
  15. 338
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionCodec.cs
  16. 12
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionConfiguration.cs
  17. 8
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionConfigurationNames.cs
  18. 13
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionKey.cs
  19. 51
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionKeyContext.cs
  20. 15
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionKeySource.cs
  21. 121
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineContext.cs
  22. 488
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineScopeStream.cs
  23. 78
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/ChunkedCryptoReadStream.cs
  24. 53
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/ChunkedDecryptingReadStream.cs
  25. 30
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/ChunkedEncryptingReadStream.cs
  26. 13
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/DefaultBlobEncryptionKeyProvider.cs
  27. 26
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobAuthenticatedEndStream.cs
  28. 41
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionCodec.cs
  29. 9
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionKeyProvider.cs
  30. 41
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobPipelineContributor.cs
  31. 64
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/PrefixingReadStream.cs
  32. 12
      framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/SequentialReadStream.cs
  33. 104
      framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderUploadDecision_Tests.cs
  34. 84
      framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderUploadRequest_Tests.cs
  35. 54
      framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/LeaveOpenStreamWrapper_Tests.cs
  36. 158
      framework/test/Volo.Abp.BlobStoring.FileSystem.Tests/Volo/Abp/BlobStoring/FileSystem/FileSystemBlobEncryption_Tests.cs
  37. 84
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/AbpBlobStoringTestModule.cs
  38. 65
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerConfiguration_Tests.cs
  39. 797
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerEncryption_Tests.cs
  40. 618
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerPipeline_Tests.cs
  41. 11
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAPipelineContributor.cs
  42. 89
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAsyncDisposePipelineContributor.cs
  43. 23
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAsyncOnlyDisposableService.cs
  44. 28
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAsyncScopedPipelineContributor.cs
  45. 11
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeBPipelineContributor.cs
  46. 59
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeDisposeThrowingPipelineContributor.cs
  47. 120
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeEarlyStopPipelineContributor.cs
  48. 18
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeFailingGetPipelineContributor.cs
  49. 20
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeInMemoryBlobProvider.cs
  50. 48
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeIoFailingLengthStream.cs
  51. 156
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeMarkerPipelineContributorBase.cs
  52. 59
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeModernAsyncOnlyStream.cs
  53. 23
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeModernAsyncPipelineContributor.cs
  54. 30
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeOriginalRestoringPipelineContributor.cs
  55. 37
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeScopedMarkerService.cs
  56. 86
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeScopedXorPipelineContributor.cs
  57. 65
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeSetThenThrowPipelineContributor.cs
  58. 100
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantAssertingPipelineContributor.cs
  59. 26
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantBlobEncryptionKeyProvider.cs
  60. 29
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantRecordingScopedPipelineContributor.cs
  61. 35
      framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantRecordingScopedService.cs

4
docs/en/docs-nav.json

@ -717,6 +717,10 @@
} }
] ]
}, },
{
"text": "Content Pipeline",
"path": "framework/infrastructure/blob-storing/pipeline.md"
},
{ {
"text": "Encryption", "text": "Encryption",
"path": "framework/infrastructure/blob-storing/encryption.md" "path": "framework/infrastructure/blob-storing/encryption.md"

18
docs/en/framework/infrastructure/blob-storing/aws.md

@ -67,7 +67,7 @@ Configure<AbpBlobStoringOptions>(options =>
* **ProfilesLocation** (string): The path to the aws credentials file to look at. * **ProfilesLocation** (string): The path to the aws credentials file to look at.
* **Region** (string): The system name of the AWS region (e.g., `us-east-1`). **Required** for real AWS S3. Optional when `ServiceURL` is configured for an S3-compatible service; some services accept any value (or `auto` for Cloudflare R2). * **Region** (string): The system name of the AWS region (e.g., `us-east-1`). **Required** for real AWS S3. Optional when `ServiceURL` is configured for an S3-compatible service; some services accept any value (or `auto` for Cloudflare R2).
* **ServiceURL** (string): Custom service URL for S3-compatible APIs (e.g., MinIO, DigitalOcean Spaces, Cloudflare R2). If not specified, the default AWS S3 service URL will be used based on the region. When using S3-compatible services, this should point to your service endpoint (e.g., `https://minio.example.com:9000`). The AWS SDK automatically appends a trailing slash to the configured value. * **ServiceURL** (string): Custom service URL for S3-compatible APIs (e.g., MinIO, DigitalOcean Spaces, Cloudflare R2). If not specified, the default AWS S3 service URL will be used based on the region. When using S3-compatible services, this should point to your service endpoint (e.g., `https://minio.example.com:9000`). The AWS SDK automatically appends a trailing slash to the configured value.
* **DisablePayloadSigning** (bool): Default `false`. When set to `true`, the provider sends `x-amz-content-sha256: UNSIGNED-PAYLOAD` on `PutObject` requests instead of the streaming chunked signature (`STREAMING-AWS4-HMAC-SHA256-PAYLOAD`) that the AWS SDK v4 uses by default. Required for Cloudflare R2 and other S3-compatible services that do not implement streaming signing. The endpoint must be HTTPS when this option is enabled. Leave as `false` for real AWS S3. * **DisablePayloadSigning** (bool): Default `false`. When set to `true`, the provider sends `x-amz-content-sha256: UNSIGNED-PAYLOAD` on `PutObject` and multipart `UploadPart` requests instead of the streaming chunked signature (`STREAMING-AWS4-HMAC-SHA256-PAYLOAD`) that the AWS SDK v4 uses by default. Required for Cloudflare R2 and other S3-compatible services that do not implement streaming signing. The endpoint must be HTTPS when this option is enabled. Leave as `false` for real AWS S3.
* **Policy** (string): An IAM policy in JSON format that you want to use as an inline session policy. * **Policy** (string): An IAM policy in JSON format that you want to use as an inline session policy.
* **DurationSeconds** (int): Validity period(s) of a temporary access certificate,minimum is 900 and the maximum is 3600. **note**: Using sub-accounts operated OSS,if the value is 0. * **DurationSeconds** (int): Validity period(s) of a temporary access certificate,minimum is 900 and the maximum is 3600. **note**: Using sub-accounts operated OSS,if the value is 0.
* **ContainerName** (string): You can specify the container name in Aws. If this is not specified, it uses the name of the BLOB container defined with the `BlobContainerName` attribute (see the [BLOB storing document](../blob-storing)). Please note that Aws has some **rules for naming containers**. A container name must be a valid DNS name, conforming to the [following naming rules](https://docs.aws.amazon.com/AmazonS3/latest/dev/BucketRestrictions.html): * **ContainerName** (string): You can specify the container name in Aws. If this is not specified, it uses the name of the BLOB container defined with the `BlobContainerName` attribute (see the [BLOB storing document](../blob-storing)). Please note that Aws has some **rules for naming containers**. A container name must be a valid DNS name, conforming to the [following naming rules](https://docs.aws.amazon.com/AmazonS3/latest/dev/BucketRestrictions.html):
@ -147,7 +147,21 @@ Configure<AbpBlobStoringOptions>(options =>
> **Note**: When using S3-compatible services, the provider automatically enables path-style requests which are required by most S3-compatible implementations. > **Note**: When using S3-compatible services, the provider automatically enables path-style requests which are required by most S3-compatible implementations.
> **Note on `DisablePayloadSigning`**: AWS SDK v4 sends `PutObject` requests with `x-amz-content-sha256: STREAMING-AWS4-HMAC-SHA256-PAYLOAD`. Cloudflare R2 (and some other S3-compatible services) return `501 NotImplemented` for this signing mode. Setting `DisablePayloadSigning = true` switches to `UNSIGNED-PAYLOAD`, which these services accept. The endpoint must be HTTPS. Leave it `false` for real AWS S3. > **Note on `DisablePayloadSigning`**: AWS SDK v4 sends `PutObject` and multipart `UploadPart` requests with `x-amz-content-sha256: STREAMING-AWS4-HMAC-SHA256-PAYLOAD`. Cloudflare R2 (and some other S3-compatible services) return `501 NotImplemented` for this signing mode. Setting `DisablePayloadSigning = true` switches to `UNSIGNED-PAYLOAD` (for the multipart parts too), which these services accept. The endpoint must be HTTPS. Leave it `false` for real AWS S3.
## Non-Seekable Uploads
The AWS SDK can not rewind a non-seekable stream to retry a failed upload. For the containers using the [encryption](./encryption.md) or the [content pipeline](./pipeline.md) (which produce non-seekable streams), the provider compensates for that; containers without these features keep the plain `PutObject` upload they always had, also for non-seekable streams:
* A source with a known length of up to 16 MB is buffered in memory and uploaded as a regular, retryable `PutObject` request.
* A larger (or unknown-length) source is uploaded as a **multipart upload** (`TransferUtility`), which buffers and retries the upload part by part with constant memory usage.
Notes on the multipart path:
* The `ETag` of a multipart object is not the MD5 of the content.
* The SDK aborts a failed multipart upload, but an abort can also fail (network cut, process exit); when it does, the abort error is what surfaces (the original upload error is replaced). Configure an [AbortIncompleteMultipartUpload lifecycle rule](https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpu-abort-incomplete-mpu-lifecycle-config.html) on the bucket, so incomplete parts do not accumulate storage costs.
* A non-seekable multipart upload uses 5 MB parts, which limits a single BLOB to about 48.8 GB (the 10,000 parts limit of S3).
* Some S3-compatible services do not implement multipart uploads completely; validate your service before enabling encryption or pipeline contributors on large BLOBs. (With a custom `ServiceURL`, the client requests checksums only when required, so no default CRC part checksums are sent.)
## Aws Blob Name Calculator ## Aws Blob Name Calculator

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

@ -7,7 +7,7 @@
# BLOB Encryption # BLOB Encryption
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. 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. The `IBlobProvider` interface stays unchanged, but a provider must handle non-seekable, non-replayable input streams correctly (the built-in providers were adjusted where needed; the MinIO provider still requires a known content length — see the behavioral notes below). 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. 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). > 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).
@ -25,6 +25,12 @@ Configure<AbpBlobStoringOptions>(options =>
container.UseEncryption(); container.UseEncryption();
}); });
}); });
// A passphrase must be configured (here globally); see "Resolving the Passphrase" below
Configure<AbpBlobStoringEncryptionOptions>(options =>
{
options.DefaultPassPhrase = context.Configuration["MyApp:BlobPassPhrase"];
});
```` ````
**Example: Encrypt all containers by default** **Example: Encrypt all containers by default**
@ -47,6 +53,8 @@ Configure<AbpBlobStoringOptions>(options =>
Containers that don't enable encryption are not affected at all. Containers that don't enable encryption are not affected at all.
> `DisableEncryption()` turns the transparent decryption off and also clears this container's **own** passphrase and legacy option. Re-enabling it later with a parameterless `UseEncryption()` restores any values still inherited from the default container configuration; a container-specific passphrase that was cleared has to be passed again. BLOBs that were already stored encrypted are then returned **as stored** (raw `ABPE` ciphertext) while reading, without an error (when the container also uses [pipeline contributors](./pipeline.md), they still run and typically fail on the ciphertext). Re-saving under the old configuration does not help, since the save encrypts again: read the BLOBs **while encryption is still enabled**, export the plain content to a temporary location, apply the configuration change and write the content back.
## Resolving the Passphrase ## Resolving the Passphrase
When encryption is enabled, the passphrase for a **new** BLOB is resolved in the following order: When encryption is enabled, the passphrase for a **new** BLOB is resolved in the following order:
@ -84,60 +92,58 @@ The passphrase resolution is implemented by the `IBlobEncryptionKeyProvider` ser
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: 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 ````csharp
[Dependency(ReplaceServices = true)]
public class MyTenantBlobEncryptionKeyProvider : DefaultBlobEncryptionKeyProvider public class MyTenantBlobEncryptionKeyProvider : DefaultBlobEncryptionKeyProvider
{ {
protected ICurrentTenant CurrentTenant { get; }
public MyTenantBlobEncryptionKeyProvider( public MyTenantBlobEncryptionKeyProvider(
ICurrentTenant currentTenant,
IOptions<AbpBlobStoringEncryptionOptions> options) IOptions<AbpBlobStoringEncryptionOptions> options)
: base(options) : base(options)
{ {
CurrentTenant = currentTenant;
} }
public override async Task<BlobEncryptionKey> ResolveForEncryptionAsync( public override async Task<BlobEncryptionKey> ResolveForEncryptionAsync(
BlobContainerConfiguration configuration, BlobEncryptionKeyContext context,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
// Keep a container-specific passphrase as the highest-priority source // Keep a container-specific passphrase as the highest-priority source
var containerPassPhrase = GetContainerPassPhraseOrNull(configuration); var containerPassPhrase = GetContainerPassPhraseOrNull(context.Configuration);
if (string.IsNullOrWhiteSpace(containerPassPhrase) && CurrentTenant.Id.HasValue) if (string.IsNullOrWhiteSpace(containerPassPhrase) && context.TenantId.HasValue)
{ {
return new BlobEncryptionKey( return new BlobEncryptionKey(
BlobEncryptionKeySource.Tenant, BlobEncryptionKeySource.Tenant,
await GetTenantPassPhraseAsync(CurrentTenant.Id.Value, cancellationToken) await GetTenantPassPhraseAsync(context.TenantId.Value, cancellationToken)
); );
} }
return await base.ResolveForEncryptionAsync(configuration, cancellationToken); return await base.ResolveForEncryptionAsync(context, cancellationToken);
} }
public override async Task<string> ResolveForDecryptionAsync( public override async Task<string> ResolveForDecryptionAsync(
BlobEncryptionKeySource keySource, BlobEncryptionKeySource keySource,
BlobContainerConfiguration configuration, BlobEncryptionKeyContext context,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (keySource == BlobEncryptionKeySource.Tenant) if (keySource == BlobEncryptionKeySource.Tenant)
{ {
if (!CurrentTenant.Id.HasValue) if (!context.TenantId.HasValue)
{ {
throw new AbpException( throw new AbpException(
"The BLOB was encrypted with a tenant-specific passphrase, " + "The BLOB was encrypted with a tenant-specific passphrase, " +
"but there is no current tenant!"); "but there is no current tenant!");
} }
return await GetTenantPassPhraseAsync(CurrentTenant.Id.Value, cancellationToken); return await GetTenantPassPhraseAsync(context.TenantId.Value, cancellationToken);
} }
return await base.ResolveForDecryptionAsync(keySource, configuration, cancellationToken); return await base.ResolveForDecryptionAsync(keySource, context, cancellationToken);
} }
private async Task<string> GetTenantPassPhraseAsync( private Task<string> GetTenantPassPhraseAsync(
Guid tenantId, CancellationToken cancellationToken) Guid tenantId, CancellationToken cancellationToken)
{ {
/* Read the tenant's passphrase from your secret store. // Read the tenant's passphrase from your secret store. It must return
It must return the same value for the lifetime of the tenant's BLOBs. */ // the same value for the lifetime of the tenant's BLOBs.
throw new NotImplementedException();
} }
} }
```` ````
@ -145,8 +151,8 @@ public class MyTenantBlobEncryptionKeyProvider : DefaultBlobEncryptionKeyProvide
Notes on this pattern: Notes on this pattern:
* 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. * 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. * The tenant is taken from `context.TenantId` (the tenant the BLOB belongs to), which is correct for both saving and reading — no ambient `ICurrentTenant` lookup is needed.
* 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. * Tenant passphrases only apply to containers with `IsMultiTenant = true` (the default). A shared (`IsMultiTenant = false`) container runs its BLOB operations in the host context (`context.TenantId` is null there), so the sample never selects the tenant source on such a container and falls back to the container/global passphrase.
## BLOBs Stored Before Enabling Encryption ## BLOBs Stored Before Enabling Encryption
@ -155,15 +161,15 @@ By default, reading a BLOB that does not have the encrypted format fails, so a t
````csharp ````csharp
options.Containers.Configure<ProfilePictureContainer>(container => options.Containers.Configure<ProfilePictureContainer>(container =>
{ {
container.UseEncryption(allowLegacyPlaintext: true); container.UseEncryption(allowLegacyPlainText: true);
}); });
```` ````
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). With this option, content that does not start with the recognized encrypted format magic is returned as-is, **without any authenticity check** — including an encrypted BLOB whose leading magic bytes were corrupted or stripped. (A BLOB that still starts with the format magic but has a corrupted header is *not* returned as plaintext; it fails as an invalid encrypted format.) 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: 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. 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): 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 ````csharp
@ -171,9 +177,9 @@ var bytes = await container.GetAllBytesAsync(blobName);
await container.SaveAsync(blobName, bytes, overrideExisting: true); await container.SaveAsync(blobName, bytes, overrideExisting: true);
```` ````
3. Remove the `allowLegacyPlaintext` option, so reading fails closed again for any content that does not have the encrypted format. 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). > Legacy plaintext content that itself starts with the `ABPE` format magic can not be distinguished from an encrypted BLOB and fails to be read through the encrypted container. Read it with encryption disabled (or from the raw storage) and re-save it once through the encrypted container 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 ## Changing a Passphrase
@ -184,10 +190,12 @@ The format does not support key rotation: a BLOB is only readable with the exact
## Behavioral Changes for Encrypted Containers ## 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`). * The stream returned for an encrypted BLOB (from `GetAsync`) is read-only and non-seekable, and its `Length` is not available; read it sequentially (for example with `CopyToAsync`). (The **encrypting** stream that is uploaded does expose its length when the source exposes both its length and position — that is a save-side detail for providers that need the object size; see the format section.)
* 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). * Opening a BLOB throws an `AbpException` when the content does not have a valid encrypted format. **While reading**, a `CryptographicException` is thrown when the content fails authentication (tampered data or a wrong passphrase), and an `AbpException` when a structural corruption is detected (like a missing end-of-stream record on a truncated BLOB).
* 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. * Each returned chunk is individually authenticated as it is read; the completeness of the whole BLOB (the authenticated terminal record, and that nothing was truncated or appended at the end) is verified only when the decryption stream is read to its end. When [content-pipeline contributors](./pipeline.md) are enabled, the framework runs this end verification when the composed stream returned by `GetAsync` reaches EOF, so a contributor that stops at its own length or end marker can not hide a truncated terminal record. (This relies on the decrypting stream implementing `IBlobAuthenticatedEndStream`, which the built-in one does; a custom `CreateDecryptingStreamAsync` override that wraps the stream must forward that interface, or the check is skipped.) A caller that intentionally reads only a prefix (and disposes) gets authentication for the chunks it consumed, not a completeness guarantee for the whole BLOB.
* 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. * The file system provider retries a failed save only while it is replayable: before the target file was opened, or for a seekable overwrite (where it rewinds the source and truncates the target again). A non-replayable encrypting stream that fails after the target was opened throws, and any partially written content fails closed while reading instead of being returned as damaged data (except with `allowLegacyPlainText`, where a fragment shorter than the format magic is returned as legacy plaintext — see above).
* Some storage providers consume the stream **synchronously** (like the Aliyun provider); they require a source stream that also supports synchronous reads, exactly like they do without encryption.
* The MinIO provider needs the object size before uploading. It works with encrypted content when the source stream exposes its length (and position); a source whose length can not be determined must be materialized (for example, saved as a byte array) first.
## Performance and Cost ## Performance and Cost
@ -211,14 +219,15 @@ Changing the iteration count only affects newly written BLOBs; existing BLOBs ar
* Encryption is authenticated (AES-256-GCM): modified, re-ordered, corrupted or truncated content of a BLOB is detected while reading. * 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. * 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. * Because of the identity binding, the following otherwise-legal operations make the affected encrypted BLOBs permanently unreadable: changing the `IsMultiTenant` value of the container (this affects the BLOBs that were saved under a tenant; BLOBs saved in the host context keep the same null tenant identity and stay readable), 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). The binding is the **logical** identity (the normalized names and the tenant), not the physical location of the provider. Before such a change, read the affected BLOBs under the old configuration and export the plain content — re-saving in place does not help, since the save encrypts again with the old identity — then apply the change and write the content back.
* The container and BLOB names are part of the authenticated identity, so on an encrypted container they must be valid UTF-16 (a name with unpaired surrogates is rejected with an `AbpException`). Normal names are unaffected.
* The data is processed in chunks with **constant memory usage**, independent from the BLOB size. * 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. * 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. * When the source stream exposes both its length and position, the encrypted stream exposes its exact resulting length for providers that require the object size before uploading.
### What Is (Not) Protected ### 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. * Only the BLOB **content** is encrypted. Container names, BLOB names and any provider-level metadata stay in plaintext, so the existence of a BLOB is visible in the storage. The size overhead is deterministic (see below), so the exact plaintext length can be recovered from the stored object size.
* 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). * 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. * 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.
@ -226,7 +235,7 @@ Changing the iteration count only affects newly written BLOBs; existing BLOBs ar
| Error | Cause and solution | | 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`: *The BLOB does not have the encrypted BLOB 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`: *...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`: *...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. | | `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. |
@ -236,4 +245,5 @@ Changing the iteration count only affects newly written BLOBs; existing BLOBs ar
## See Also ## See Also
* [BLOB Storing](../blob-storing) * [BLOB Storing](../blob-storing)
* [BLOB Content Pipeline](./pipeline.md)
* [Creating a custom BLOB storage provider](./custom-provider.md) * [Creating a custom BLOB storage provider](./custom-provider.md)

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

@ -325,9 +325,31 @@ Configure<AbpBlobStoringOptions>(options =>
container.UseEncryption(); container.UseEncryption();
}); });
}); });
// A passphrase must be configured; see the encryption document
Configure<AbpBlobStoringEncryptionOptions>(options =>
{
options.DefaultPassPhrase = context.Configuration["MyApp:BlobPassPhrase"];
});
````
The encryption passphrase can be container-specific or **global**, and per-tenant passphrases can be plugged in over the key provider; every BLOB derives its own encryption key from the passphrase. See the [BLOB Encryption document](./encryption.md) for details.
## Transforming BLOB Content
The BLOB content can be passed through a **pipeline of contributors** (compression, watermarking, content validation...) while it is saved and read, without changing the storage provider:
````csharp
Configure<AbpBlobStoringOptions>(options =>
{
options.Containers.Configure<ProfilePictureContainer>(container =>
{
container.PipelineContributors.Add<GZipBlobPipelineContributor>();
});
});
```` ````
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. See the [BLOB Content Pipeline document](./pipeline.md) for details.
## Extending the BLOB Storing System ## Extending the BLOB Storing System
@ -344,5 +366,6 @@ If you want to create folders and move files between folders, assign permissions
## See Also ## See Also
* [BLOB Content Pipeline](./pipeline.md)
* [BLOB Encryption](./encryption.md) * [BLOB Encryption](./encryption.md)
* [Creating a custom BLOB storage provider](./custom-provider.md) * [Creating a custom BLOB storage provider](./custom-provider.md)

122
docs/en/framework/infrastructure/blob-storing/pipeline.md

@ -0,0 +1,122 @@
```json
//[doc-seo]
{
"Description": "Learn how to transform BLOB content transparently (compression, validation, watermarking...) with pipeline contributors in ABP Framework."
}
```
# BLOB Content Pipeline
The BLOB Storing system can pass the BLOB content through a **pipeline of contributors** while it is saved and read. A contributor transforms the content stream transparently, on top of the configured [storage provider](../blob-storing): compression, watermarking, content validation or any other stream transformation can be implemented without changing the storage provider or the application code that works with `IBlobContainer`.
> Read the [BLOB Storing document](../blob-storing) to understand how to use the BLOB storing system. The pipeline is part of the [Volo.Abp.BlobStoring](https://www.nuget.org/packages/Volo.Abp.BlobStoring) package; no additional package is needed.
## Creating a Pipeline Contributor
A pipeline contributor implements the `IBlobPipelineContributor` interface. The following example compresses the BLOBs with GZip:
````csharp
public class GZipBlobPipelineContributor : IBlobPipelineContributor, ITransientDependency
{
public async Task OnSavingAsync(BlobPipelineContext context)
{
var compressedStream = new MemoryStream();
try
{
using (var gzipStream = new GZipStream(compressedStream, CompressionLevel.Fastest, leaveOpen: true))
{
await context.BlobStream.CopyToAsync(gzipStream, context.CancellationToken);
}
}
catch
{
// A stream is only tracked for disposal by the pipeline once it is assigned
// to context.BlobStream, so dispose it here if the eager work fails first
compressedStream.Dispose();
throw;
}
compressedStream.Position = 0;
context.BlobStream = compressedStream;
}
public Task OnGettingAsync(BlobPipelineContext context)
{
context.BlobStream = new GZipStream(context.BlobStream, CompressionMode.Decompress);
return Task.CompletedTask;
}
}
````
* `OnSavingAsync` is called before the BLOB reaches the storage provider. Replace `context.BlobStream` with the transformed content; it is also allowed to materialize the content eagerly, like the example does (a lazily transforming, read-only wrapper keeps the memory usage constant instead, which is preferable for large BLOBs).
* `OnGettingAsync` is called after the BLOB was read from the storage provider, in the reverse direction of `OnSavingAsync`.
* The `BlobPipelineContext` also provides the normalized container/BLOB names, the container configuration, the tenant id and a scoped `ServiceProvider`. Contributors are resolved from the [dependency injection](../../fundamentals/dependency-injection.md) system (register them like any other service, for example with `ITransientDependency`). While saving, the scope stays alive until the save operation completes; while getting, until the stream returned to the caller is disposed.
### The Stream Ownership Contract
* If a stream (or the DI scope) fails to dispose **after** the storage provider already saved the BLOB, `SaveAsync` still throws that cleanup error even though the data is committed — a retry with the default `overrideExisting: false` would then get a `BlobAlreadyExistsException`.
* **While saving**, do not dispose the stream you received (notice the `leaveOpen: true` in the example): every stream you assign to `context.BlobStream` is disposed after the save, while the original stream stays owned by the caller. A stream is only tracked from the moment it is assigned, so if you create a stream and then do work that may fail (like the eager copy above) before assigning it, dispose it yourself on the failure path.
* **While getting**, the stream you set must dispose the stream you received when it is disposed (a `GZipStream` already does that by default), because the composed stream is returned to the caller as a whole.
## Configuring Containers
Contributors are configured **per container**, like the other container options:
````csharp
Configure<AbpBlobStoringOptions>(options =>
{
options.Containers.Configure<ProfilePictureContainer>(container =>
{
container.PipelineContributors.Add<GZipBlobPipelineContributor>();
});
});
````
Configuring the default container applies the contributor to all containers; a named container can add its own contributors on top of them:
````csharp
Configure<AbpBlobStoringOptions>(options =>
{
options.Containers.ConfigureDefault(container =>
{
container.PipelineContributors.Add<GZipBlobPipelineContributor>();
});
options.Containers.Configure<ProfilePictureContainer>(container =>
{
// Runs after the GZip contributor while saving
container.PipelineContributors.Add<WatermarkPipelineContributor>();
});
});
````
Think of the composition as **global stages plus container stages**: the contributors of the default container run first while saving, then the own ones of the container (each contributor type runs once). The inherited contributors are kept even when a container overrides its storage provider; set `InheritPipelineContributors` to `false` on a container to opt out of the global stages completely:
````csharp
options.Containers.Configure<PublicPictureContainer>(container =>
{
container.InheritPipelineContributors = false;
});
````
## Execution Order and Encryption
* While **saving**, the contributors run in the configuration order, and the built-in [encryption](./encryption.md) always runs **after** them (immediately before the storage provider).
* While **getting**, the decryption runs first and the contributors run in the **reverse** order.
So, contributors always work on the plain content, a compressing contributor always compresses before the encryption (encrypted data can not be compressed), and the stored form is always ciphertext when the encryption is enabled.
## Behavioral Notes
* The stream returned for a container with contributors is generally read-only and non-seekable, and its `Length` is only available when the transformation can provide it. See the behavioral notes of the [BLOB Encryption document](./encryption.md) — the same stream semantics apply to the pipeline.
* When a contributor changes the content size lazily, the final length is unknown to the storage provider; providers that require the object size before uploading need an eagerly materialized (or length-aware) stream.
* Some storage providers consume the stream **synchronously** (like the Aliyun provider); they require contributor streams that also support synchronous reads, exactly like they do without the pipeline.
* Containers without contributors are not affected at all.
> **A contributor that transforms the content is part of the persisted data format.** A BLOB is only readable with the same transforming contributors, in the same order, it was saved with: adding, removing or re-ordering **transforming** contributors on a container that already has BLOBs makes the existing content fail to be read (or, for transformations without an own format check, silently return wrong content). A **metadata-only** contributor that neither consumes nor replaces `context.BlobStream` does not change the stored format, so it can be added to a container with existing BLOBs. A contributor that reads the content to validate it must return a pass-through wrapper (it still counts as consuming the stream); not replacing the stream after reading it would leave an empty/truncated stream for the provider. To change transforming contributors, migrate by reading the BLOBs **with the old configuration** and exporting the plain content, applying the change, and then writing the content back; re-saving in place under the old configuration does not change the stored form.
## See Also
* [BLOB Storing](../blob-storing)
* [BLOB Encryption](./encryption.md)
* [Creating a custom BLOB storage provider](./custom-provider.md)

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

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

125
framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProvider.cs

@ -1,8 +1,11 @@
using System; using System;
using System.IO; using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Amazon.S3; using Amazon.S3;
using Amazon.S3.Model; using Amazon.S3.Model;
using Amazon.S3.Transfer;
using Amazon.S3.Util; using Amazon.S3.Util;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
@ -10,6 +13,10 @@ namespace Volo.Abp.BlobStoring.Aws;
public class AwsBlobProvider : BlobProviderBase, ITransientDependency public class AwsBlobProvider : BlobProviderBase, ITransientDependency
{ {
// Aligned with the TransferUtility threshold under which a non-seekable upload
// would be sent as a single (non-retryable) request instead of multipart
protected const long MaxBufferedUploadLength = 16 * 1024 * 1024;
protected IAwsBlobNameCalculator AwsBlobNameCalculator { get; } protected IAwsBlobNameCalculator AwsBlobNameCalculator { get; }
protected IAmazonS3ClientFactory AmazonS3ClientFactory { get; } protected IAmazonS3ClientFactory AmazonS3ClientFactory { get; }
protected IBlobNormalizeNamingService BlobNormalizeNamingService { get; } protected IBlobNormalizeNamingService BlobNormalizeNamingService { get; }
@ -43,13 +50,30 @@ public class AwsBlobProvider : BlobProviderBase, ITransientDependency
await CreateContainerIfNotExists(amazonS3Client, containerName); await CreateContainerIfNotExists(amazonS3Client, containerName);
} }
await amazonS3Client.PutObjectAsync(new PutObjectRequest if (!RequiresRetrySafeUpload(args))
{ {
BucketName = containerName, await PutObjectAsync(amazonS3Client, containerName, blobName, args.BlobStream, configuration, args.CancellationToken);
Key = blobName, return;
InputStream = args.BlobStream, }
DisablePayloadSigning = configuration.DisablePayloadSigning
}); // The SDK can not retry the upload of a non-seekable stream (like an encrypting
// stream). A small source with a known length is buffered in memory and uploaded
// as a retryable PutObject; anything larger (or with an unknown length) goes
// through a TransferUtility multipart upload, which buffers and retries part by part
var remainingLength = GetRemainingLengthOrNull(args.BlobStream);
if (remainingLength != null && remainingLength <= MaxBufferedUploadLength)
{
using (var bufferedStream = new MemoryStream((int)remainingLength.Value))
{
await args.BlobStream.CopyToAsync(bufferedStream, 81920, args.CancellationToken);
bufferedStream.Position = 0;
await PutObjectAsync(amazonS3Client, containerName, blobName, bufferedStream, configuration, args.CancellationToken);
}
return;
}
await UploadMultipartAsync(amazonS3Client, containerName, blobName, args.BlobStream, configuration, args.CancellationToken);
} }
} }
@ -108,6 +132,95 @@ public class AwsBlobProvider : BlobProviderBase, ITransientDependency
} }
} }
/// <summary>
/// The retry-safe (buffered/multipart) upload only applies to non-seekable streams
/// of containers using the encryption or the content pipeline; other containers
/// keep the plain PutObject behavior they always had.
/// </summary>
protected virtual bool RequiresRetrySafeUpload(BlobProviderSaveArgs args)
{
if (args.BlobStream.CanSeek)
{
return false;
}
return args.Configuration.IsEncryptionEnabled() ||
args.Configuration.GetEffectivePipelineContributors().Any();
}
protected virtual long? GetRemainingLengthOrNull(Stream stream)
{
try
{
var remainingLength = stream.Length - stream.Position;
return remainingLength >= 0 ? remainingLength : null;
}
catch (Exception ex) when (ex is NotSupportedException || ex is IOException)
{
// The length is optional; a probe failure must not fail the save
return null;
}
}
protected virtual async Task PutObjectAsync(
AmazonS3Client amazonS3Client,
string containerName,
string blobName,
Stream blobStream,
AwsBlobProviderConfiguration configuration,
CancellationToken cancellationToken)
{
await amazonS3Client.PutObjectAsync(CreatePutObjectRequest(containerName, blobName, blobStream, configuration), cancellationToken);
}
protected virtual PutObjectRequest CreatePutObjectRequest(
string containerName,
string blobName,
Stream blobStream,
AwsBlobProviderConfiguration configuration)
{
return new PutObjectRequest
{
BucketName = containerName,
Key = blobName,
InputStream = blobStream,
AutoCloseStream = false,
DisablePayloadSigning = configuration.DisablePayloadSigning
};
}
protected virtual async Task UploadMultipartAsync(
AmazonS3Client amazonS3Client,
string containerName,
string blobName,
Stream blobStream,
AwsBlobProviderConfiguration configuration,
CancellationToken cancellationToken)
{
using (var transferUtility = new TransferUtility(amazonS3Client))
{
await transferUtility.UploadAsync(CreateMultipartUploadRequest(containerName, blobName, blobStream, configuration), cancellationToken);
}
}
protected virtual TransferUtilityUploadRequest CreateMultipartUploadRequest(
string containerName,
string blobName,
Stream blobStream,
AwsBlobProviderConfiguration configuration)
{
return new TransferUtilityUploadRequest
{
BucketName = containerName,
Key = blobName,
// The unseekable multipart path of the SDK ignores AutoCloseStream
// and disposes the input, so the ownership is protected by a wrapper
InputStream = new LeaveOpenStreamWrapper(blobStream),
AutoCloseStream = false,
DisablePayloadSigning = configuration.DisablePayloadSigning
};
}
protected virtual async Task<AmazonS3Client> GetAmazonS3Client(BlobProviderArgs args) protected virtual async Task<AmazonS3Client> GetAmazonS3Client(BlobProviderArgs args)
{ {
var configuration = args.Configuration.GetAwsConfiguration(); var configuration = args.Configuration.GetAwsConfiguration();

2
framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs

@ -94,7 +94,7 @@ public class AwsBlobProviderConfiguration
} }
/// <summary> /// <summary>
/// When true, payload signing is disabled on PutObject upload requests so the SDK sends /// When true, payload signing is disabled on PutObject and UploadPart upload requests so the SDK sends
/// <c>x-amz-content-sha256: UNSIGNED-PAYLOAD</c> instead of the streaming chunked signature /// <c>x-amz-content-sha256: UNSIGNED-PAYLOAD</c> instead of the streaming chunked signature
/// (<c>STREAMING-AWS4-HMAC-SHA256-PAYLOAD</c>) that AWS SDK v4 uses by default. Required for /// (<c>STREAMING-AWS4-HMAC-SHA256-PAYLOAD</c>) that AWS SDK v4 uses by default. Required for
/// Cloudflare R2 and other S3-compatible services that do not implement streaming signing. /// Cloudflare R2 and other S3-compatible services that do not implement streaming signing.

90
framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/LeaveOpenStreamWrapper.cs

@ -0,0 +1,90 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Volo.Abp.BlobStoring.Aws;
/// <summary>
/// The unseekable multipart path of the AWS SDK ignores AutoCloseStream and
/// disposes its input; this wrapper protects the ownership of the wrapped stream.
/// </summary>
internal sealed class LeaveOpenStreamWrapper : Stream
{
private readonly Stream _inner;
public LeaveOpenStreamWrapper(Stream inner)
{
_inner = inner;
}
public override bool CanRead => _inner.CanRead;
public override bool CanSeek => _inner.CanSeek;
public override bool CanWrite => false;
// The SDK computes the optional content length from Length/Position, but only
// handles NotSupportedException; translate an IOException of a probe, so an
// unknown length stays "unknown" instead of failing the upload
public override long Length
{
get
{
try
{
return _inner.Length;
}
catch (IOException ex)
{
throw new NotSupportedException("The length of the stream is not available!", ex);
}
}
}
public override long Position
{
get
{
try
{
return _inner.Position;
}
catch (IOException ex)
{
throw new NotSupportedException("The position of the stream is not available!", ex);
}
}
set => _inner.Position = value;
}
public override void Flush()
{
}
public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
#if NETSTANDARD2_0
return _inner.ReadAsync(buffer, offset, count, cancellationToken);
#else
// The SDK reads over this (old) overload; dispatch it over the modern one,
// so a source that only implements ReadAsync(Memory<byte>) keeps working
return _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
#endif
}
#if !NETSTANDARD2_0
public override int Read(Span<byte> buffer) => _inner.Read(buffer);
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
return _inner.ReadAsync(buffer, cancellationToken);
}
#endif
public override long Seek(long offset, SeekOrigin origin) => _inner.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();
// Disposing the wrapper must not dispose the wrapped stream
}

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

@ -31,11 +31,24 @@ public class FileSystemBlobProvider : BlobProviderBase, ITransientDependency
? FileMode.Create ? FileMode.Create
: FileMode.CreateNew; : FileMode.CreateNew;
// Retry only replayable overwrites: a non-seekable source would continue from the // A failure is only retried while it is replayable: before OpenFileStream returns
// middle (corrupted file), and a failed CreateNew leaves the file behind anyway // (the source is untouched), or for a seekable overwrite (the source can seek back
var sourcePosition = args.BlobStream.CanSeek && fileMode == FileMode.Create ? args.BlobStream.Position : -1; // and FileMode.Create truncates the partial content). Otherwise a retry would
// replay a half-consumed source or hit the file a failed CreateNew attempt left behind.
long sourcePosition;
try
{
sourcePosition = args.BlobStream.CanSeek && fileMode == FileMode.Create ? args.BlobStream.Position : -1;
}
catch (Exception ex) when (ex is NotSupportedException || ex is IOException)
{
// A failing position probe degrades to a single, non-replayable attempt
sourcePosition = -1;
}
var targetOpened = false;
await Policy.Handle<IOException>(_ => sourcePosition >= 0) await Policy.Handle<IOException>(_ => sourcePosition >= 0 || !targetOpened)
.WaitAndRetryAsync(2, retryCount => TimeSpan.FromSeconds(retryCount)) .WaitAndRetryAsync(2, retryCount => TimeSpan.FromSeconds(retryCount))
.ExecuteAsync(async () => .ExecuteAsync(async () =>
{ {
@ -44,8 +57,10 @@ public class FileSystemBlobProvider : BlobProviderBase, ITransientDependency
args.BlobStream.Seek(sourcePosition, SeekOrigin.Begin); args.BlobStream.Seek(sourcePosition, SeekOrigin.Begin);
} }
using (var fileStream = File.Open(filePath, fileMode, FileAccess.Write)) using (var fileStream = OpenFileStream(filePath, fileMode))
{ {
targetOpened = true;
await args.BlobStream.CopyToAsync( await args.BlobStream.CopyToAsync(
fileStream, fileStream,
args.CancellationToken args.CancellationToken
@ -82,6 +97,11 @@ public class FileSystemBlobProvider : BlobProviderBase, ITransientDependency
.ExecuteAsync(() => Task.FromResult(File.OpenRead(filePath))); .ExecuteAsync(() => Task.FromResult(File.OpenRead(filePath)));
} }
protected virtual Stream OpenFileStream(string filePath, FileMode fileMode)
{
return File.Open(filePath, fileMode, FileAccess.Write);
}
protected virtual Task<bool> ExistsAsync(string filePath) protected virtual Task<bool> ExistsAsync(string filePath)
{ {
return Task.FromResult(File.Exists(filePath)); return Task.FromResult(File.Exists(filePath));

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

@ -1,5 +1,9 @@
namespace Volo.Abp.BlobStoring; namespace Volo.Abp.BlobStoring;
/// <summary>
/// Global options of the BLOB encryption; enable it per container with the
/// <see cref="BlobContainerConfigurationEncryptionExtensions.UseEncryption"/> extension method.
/// </summary>
public class AbpBlobStoringEncryptionOptions public class AbpBlobStoringEncryptionOptions
{ {
/// <summary> /// <summary>

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

@ -1,5 +1,8 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Runtime.ExceptionServices;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -118,15 +121,39 @@ public class BlobContainer : IBlobContainer
var blobNormalizeNaming = BlobNormalizeNamingService.NormalizeNaming(Configuration, ContainerName, name); var blobNormalizeNaming = BlobNormalizeNamingService.NormalizeNaming(Configuration, ContainerName, name);
var fallbackCancellationToken = CancellationTokenProvider.FallbackToProvider(cancellationToken); var fallbackCancellationToken = CancellationTokenProvider.FallbackToProvider(cancellationToken);
Stream? encryptingStream = null; var contributorTypes = Configuration.GetEffectivePipelineContributors().ToList();
if (BlobEncryptionConfiguration.IsEnabled(Configuration))
{
encryptingStream = await CreateEncryptingStreamAsync(blobNormalizeNaming, stream, fallbackCancellationToken);
stream = encryptingStream;
}
// Every stream the pipeline creates is disposed after the save (disposing the
// encrypting wrapper also zeroes the derived key); the caller keeps the original
var pipelineStreams = new List<Stream>();
AsyncServiceScope? contributorScope = null;
var completed = false;
try try
{ {
if (contributorTypes.Count > 0)
{
contributorScope = ServiceProvider.CreateAsyncScope();
var context = new BlobPipelineContext(
contributorScope.Value.ServiceProvider,
blobNormalizeNaming.ContainerName!,
blobNormalizeNaming.BlobName!,
Configuration,
GetTenantIdOrNull(),
stream,
fallbackCancellationToken
);
context.CreatedStreams = pipelineStreams;
await RunPipelineAsync(context, contributorTypes, saving: true);
stream = context.BlobStream;
}
if (BlobEncryptionConfiguration.IsEnabled(Configuration))
{
stream = await CreateEncryptingStreamAsync(blobNormalizeNaming, stream, fallbackCancellationToken);
pipelineStreams.Add(stream);
}
await Provider.SaveAsync( await Provider.SaveAsync(
new BlobProviderSaveArgs( new BlobProviderSaveArgs(
blobNormalizeNaming.ContainerName!, blobNormalizeNaming.ContainerName!,
@ -137,11 +164,42 @@ public class BlobContainer : IBlobContainer
fallbackCancellationToken fallbackCancellationToken
) )
); );
completed = true;
} }
finally finally
{ {
// Disposing the wrapper zeroes the derived key; the caller's stream is untouched // Best-effort cleanup: a failing Dispose must not prevent releasing the
encryptingStream?.Dispose(); // remaining streams/scope or hide the exception that is already propagating
Exception? disposeException = null;
for (var i = pipelineStreams.Count - 1; i >= 0; i--)
{
try
{
await DisposeStreamAsync(pipelineStreams[i]);
}
catch (Exception ex)
{
disposeException ??= ex;
}
}
if (contributorScope != null)
{
try
{
await contributorScope.Value.DisposeAsync();
}
catch (Exception ex)
{
disposeException ??= ex;
}
}
if (completed && disposeException != null)
{
ExceptionDispatchInfo.Capture(disposeException).Throw();
}
} }
} }
} }
@ -227,23 +285,131 @@ public class BlobContainer : IBlobContainer
return null; return null;
} }
if (!BlobEncryptionConfiguration.IsEnabled(Configuration)) // The provider stream is now owned by the container: reading the configuration
// (a mis-typed value can throw) must not leak it
List<Type> contributorTypes;
bool encryptionEnabled;
try
{
contributorTypes = Configuration.GetEffectivePipelineContributors().ToList();
encryptionEnabled = BlobEncryptionConfiguration.IsEnabled(Configuration);
}
catch
{
await TryDisposeStreamAsync(stream);
throw;
}
// Captured so the pipeline can verify the decryption reached its authenticated
// end (the terminal record) when the composed stream reaches EOF, even if a
// contributor stops reading the decrypted content early. A custom
// CreateDecryptingStreamAsync override that wraps the stream should implement
// IBlobAuthenticatedEndStream (and forward), or this verification is skipped.
IBlobAuthenticatedEndStream? authenticatedEndSource = null;
if (encryptionEnabled)
{
// The method owns the provider stream: it is disposed there on any failure
stream = await CreateDecryptingStreamAsync(blobNormalizeNaming, stream, fallbackCancellationToken);
authenticatedEndSource = stream as IBlobAuthenticatedEndStream;
}
if (contributorTypes.Count == 0)
{ {
// Without contributors the caller reads the decrypting stream directly, so
// reading it to its end verifies the terminal record on its own
return stream; return stream;
} }
// Contributors run in the reverse order after the decryption; the scope stays
// alive until the returned (lazily transforming) stream is disposed. Resolve the
// tenant id once, before the scope is created, so nothing between scope creation
// and the successful return can throw and leak the scope or the provider stream
Guid? tenantId;
try try
{ {
return await CreateDecryptingStreamAsync(blobNormalizeNaming, stream, fallbackCancellationToken); tenantId = GetTenantIdOrNull();
} }
catch catch
{ {
stream.Dispose(); await TryDisposeStreamAsync(stream);
throw;
}
contributorTypes.Reverse();
AsyncServiceScope contributorScope;
try
{
contributorScope = ServiceProvider.CreateAsyncScope();
}
catch
{
await TryDisposeStreamAsync(stream);
throw;
}
var context = new BlobPipelineContext(
contributorScope.ServiceProvider,
blobNormalizeNaming.ContainerName!,
blobNormalizeNaming.BlobName!,
Configuration,
tenantId,
stream,
fallbackCancellationToken
);
try
{
await RunPipelineAsync(context, contributorTypes, saving: false);
return new BlobPipelineScopeStream(context.BlobStream, contributorScope, CurrentTenant, tenantId, authenticatedEndSource);
}
catch
{
// Best-effort cleanup that keeps the original exception: the current
// context stream owns the whole chain down to the provider stream
await TryDisposeStreamAsync(context.BlobStream);
try
{
await contributorScope.DisposeAsync();
}
catch
{
// ignored
}
throw; throw;
} }
} }
} }
/// <summary>
/// Runs the pipeline contributors on the context stream. While saving, the context
/// collects every stream a contributor creates (at assignment, so intermediate
/// replacements within one contributor call are not lost) to be disposed by the
/// caller; while reading, the composed stream is returned to the caller as a whole,
/// so each wrapper owns the stream it received.
/// </summary>
protected virtual async Task RunPipelineAsync(
BlobPipelineContext context,
IReadOnlyList<Type> contributorTypes,
bool saving)
{
foreach (var contributorType in contributorTypes)
{
var contributor = (IBlobPipelineContributor)context.ServiceProvider.GetRequiredService(contributorType);
if (saving)
{
await contributor.OnSavingAsync(context);
}
else
{
await contributor.OnGettingAsync(context);
}
}
}
/// <summary> /// <summary>
/// Wraps the stream for encryption. The caller keeps the ownership of /// Wraps the stream for encryption. The caller keeps the ownership of
/// <paramref name="stream"/>; the wrapper is disposed after the provider call. /// <paramref name="stream"/>; the wrapper is disposed after the provider call.
@ -251,10 +417,13 @@ public class BlobContainer : IBlobContainer
protected virtual async Task<Stream> CreateEncryptingStreamAsync(BlobNormalizeNaming blobNormalizeNaming, Stream stream, CancellationToken cancellationToken) protected virtual async Task<Stream> CreateEncryptingStreamAsync(BlobNormalizeNaming blobNormalizeNaming, Stream stream, CancellationToken cancellationToken)
{ {
// The key is fully resolved before returning, so the scope can be released here // The key is fully resolved before returning, so the scope can be released here
await using (var scope = ServiceProvider.CreateAsyncScope()) var scope = ServiceProvider.CreateAsyncScope();
Stream? encryptingStream = null;
var completed = false;
try
{ {
return await scope.ServiceProvider encryptingStream = await scope.ServiceProvider
.GetRequiredService<BlobEncryptionCodec>() .GetRequiredService<IBlobEncryptionCodec>()
.CreateEncryptingStreamAsync( .CreateEncryptingStreamAsync(
Configuration, Configuration,
blobNormalizeNaming.ContainerName!, blobNormalizeNaming.ContainerName!,
@ -263,20 +432,62 @@ public class BlobContainer : IBlobContainer
stream, stream,
cancellationToken cancellationToken
); );
completed = true;
} }
finally
{
try
{
await scope.DisposeAsync();
}
catch
{
// A failing scope dispose must not leak the created stream (it zeroes
// the key) and must not replace an exception already propagating
if (encryptingStream != null)
{
await TryDisposeStreamAsync(encryptingStream);
}
if (completed)
{
throw;
}
}
}
return encryptingStream!;
} }
/// <summary> /// <summary>
/// Wraps the provider stream for decryption; the returned stream owns it. /// Wraps the provider stream for decryption. The method owns <paramref name="stream"/>:
/// the returned stream disposes it, and it is also disposed when this method fails.
/// Opening throws <see cref="AbpException"/> for format violations; reading throws /// Opening throws <see cref="AbpException"/> for format violations; reading throws
/// <see cref="System.Security.Cryptography.CryptographicException"/> on failed authentication. /// <see cref="System.Security.Cryptography.CryptographicException"/> on failed authentication.
/// The returned stream implements <see cref="IBlobAuthenticatedEndStream"/>, which the
/// content pipeline uses to verify the authenticated end on EOF. An override that wraps
/// the returned stream should implement that interface too (and forward), otherwise the
/// pipeline can not run the end verification for this container.
/// </summary> /// </summary>
protected virtual async Task<Stream> CreateDecryptingStreamAsync(BlobNormalizeNaming blobNormalizeNaming, Stream stream, CancellationToken cancellationToken) protected virtual async Task<Stream> CreateDecryptingStreamAsync(BlobNormalizeNaming blobNormalizeNaming, Stream stream, CancellationToken cancellationToken)
{ {
await using (var scope = ServiceProvider.CreateAsyncScope()) AsyncServiceScope scope;
try
{
scope = ServiceProvider.CreateAsyncScope();
}
catch
{ {
return await scope.ServiceProvider await TryDisposeStreamAsync(stream);
.GetRequiredService<BlobEncryptionCodec>() throw;
}
Stream? decryptingStream = null;
var completed = false;
try
{
decryptingStream = await scope.ServiceProvider
.GetRequiredService<IBlobEncryptionCodec>()
.CreateDecryptingStreamAsync( .CreateDecryptingStreamAsync(
Configuration, Configuration,
blobNormalizeNaming.ContainerName!, blobNormalizeNaming.ContainerName!,
@ -285,6 +496,69 @@ public class BlobContainer : IBlobContainer
stream, stream,
cancellationToken cancellationToken
); );
completed = true;
}
catch
{
// Best-effort cleanup that keeps the original exception
await TryDisposeStreamAsync(stream);
throw;
}
finally
{
try
{
await scope.DisposeAsync();
}
catch
{
// A failing scope dispose must not leak the created stream (it zeroes
// the key and disposes the provider stream — exactly once, since the
// caller does not dispose after this method) and must not replace an
// exception already propagating
if (decryptingStream != null)
{
await TryDisposeStreamAsync(decryptingStream);
}
if (completed)
{
throw;
}
}
}
return decryptingStream!;
}
protected virtual async Task DisposeStreamAsync(Stream stream)
{
#if NETSTANDARD2_0
// Stream has no DisposeAsync on netstandard2.0, but a stream may still implement
// IAsyncDisposable (via Microsoft.Bcl.AsyncInterfaces) for its async-only cleanup
if (stream is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else
{
stream.Dispose();
}
#else
// Also covers wrappers that only implement DisposeAsync
await stream.DisposeAsync();
#endif
}
protected virtual async Task TryDisposeStreamAsync(Stream stream)
{
try
{
await DisposeStreamAsync(stream);
}
catch
{
// ignored: best-effort cleanup during exception handling
} }
} }

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

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations; using JetBrains.Annotations;
using Volo.Abp.Collections; using Volo.Abp.Collections;
@ -32,6 +33,19 @@ public class BlobContainerConfiguration
public ITypeList<IBlobNamingNormalizer> NamingNormalizers { get; } public ITypeList<IBlobNamingNormalizer> NamingNormalizers { get; }
/// <summary>
/// The <see cref="IBlobPipelineContributor"/> implementations transforming
/// the content of this container while it is saved and read.
/// </summary>
public ITypeList<IBlobPipelineContributor> PipelineContributors { get; }
/// <summary>
/// Set false to stop inheriting the pipeline contributors of the default
/// container configuration, so only the own <see cref="PipelineContributors"/>
/// of this container run. Default: true.
/// </summary>
public bool InheritPipelineContributors { get; set; } = true;
[NotNull] private readonly Dictionary<string, object?> _properties; [NotNull] private readonly Dictionary<string, object?> _properties;
private readonly BlobContainerConfiguration? _fallbackConfiguration; private readonly BlobContainerConfiguration? _fallbackConfiguration;
@ -39,6 +53,7 @@ public class BlobContainerConfiguration
public BlobContainerConfiguration(BlobContainerConfiguration? fallbackConfiguration = null) public BlobContainerConfiguration(BlobContainerConfiguration? fallbackConfiguration = null)
{ {
NamingNormalizers = new TypeList<IBlobNamingNormalizer>(); NamingNormalizers = new TypeList<IBlobNamingNormalizer>();
PipelineContributors = new TypeList<IBlobPipelineContributor>();
_fallbackConfiguration = fallbackConfiguration; _fallbackConfiguration = fallbackConfiguration;
_properties = new Dictionary<string, object?>(); _properties = new Dictionary<string, object?>();
} }
@ -57,6 +72,26 @@ public class BlobContainerConfiguration
return NamingNormalizers; return NamingNormalizers;
} }
/// <summary>
/// Returns the pipeline contributors in effect for this container: the contributors
/// of the fallback (default) configuration first, then the own ones (each contributor
/// type runs once). Contributors are provider-independent content transformations,
/// so overriding <see cref="ProviderType"/> does not reset the inherited ones; use
/// <see cref="InheritPipelineContributors"/> to opt out of the inherited ones.
/// </summary>
public IEnumerable<Type> GetEffectivePipelineContributors()
{
if (_fallbackConfiguration == null || !InheritPipelineContributors)
{
return PipelineContributors.Distinct();
}
return _fallbackConfiguration
.GetEffectivePipelineContributors()
.Concat(PipelineContributors)
.Distinct();
}
public T? GetConfigurationOrDefault<T>(string name, T? defaultValue = default) public T? GetConfigurationOrDefault<T>(string name, T? defaultValue = default)
{ {
return (T?)GetConfigurationOrNull(name, defaultValue); return (T?)GetConfigurationOrNull(name, defaultValue);

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

@ -9,39 +9,56 @@ public static class BlobContainerConfigurationEncryptionExtensions
/// omitted parameters keep the already configured (or inherited) values, /// omitted parameters keep the already configured (or inherited) values,
/// so multiple modules can compose the configuration. /// so multiple modules can compose the configuration.
/// </summary> /// </summary>
/// <param name="configuration">The container configuration.</param> /// <param name="containerConfiguration">The container configuration.</param>
/// <param name="passPhrase"> /// <param name="passPhrase">
/// Optional container-specific passphrase. Without one, the passphrase is resolved /// Optional container-specific passphrase. Without one, the passphrase is resolved
/// by the <see cref="IBlobEncryptionKeyProvider"/>. Use /// by the <see cref="IBlobEncryptionKeyProvider"/>. Use
/// <see cref="ClearEncryptionPassPhrase"/> to remove a configured passphrase. /// <see cref="ClearEncryptionPassPhrase"/> to remove a configured passphrase.
/// </param> /// </param>
/// <param name="allowLegacyPlaintext"> /// <param name="allowLegacyPlainText">
/// Allows reading BLOBs stored as plaintext before encryption was enabled: /// Allows reading BLOBs stored as plaintext before encryption was enabled:
/// content without the encrypted format header is then returned as-is, /// content without the encrypted format header is then returned as-is,
/// <b>without any authenticity check</b>. Keep it disabled (default) unless /// <b>without any authenticity check</b>. Keep it disabled (default) unless
/// the container really has such BLOBs. /// the container really has such BLOBs.
/// </param> /// </param>
public static BlobContainerConfiguration UseEncryption( public static BlobContainerConfiguration UseEncryption(
[NotNull] this BlobContainerConfiguration configuration, [NotNull] this BlobContainerConfiguration containerConfiguration,
string? passPhrase = null, string? passPhrase = null,
bool? allowLegacyPlaintext = null) bool? allowLegacyPlainText = null)
{ {
Check.NotNull(configuration, nameof(configuration)); Check.NotNull(containerConfiguration, nameof(containerConfiguration));
configuration.SetConfiguration(BlobEncryptionConfiguration.EnabledName, true); // Validate all arguments before touching the configuration, so a failed
// call does not leave it partially modified
if (passPhrase != null)
{
Check.NotNullOrWhiteSpace(passPhrase, nameof(passPhrase));
}
containerConfiguration.SetConfiguration(BlobEncryptionConfigurationNames.Enabled, true);
if (allowLegacyPlaintext.HasValue) if (allowLegacyPlainText.HasValue)
{ {
configuration.SetConfiguration(BlobEncryptionConfiguration.AllowLegacyPlaintextName, allowLegacyPlaintext.Value); containerConfiguration.SetConfiguration(BlobEncryptionConfigurationNames.AllowLegacyPlainText, allowLegacyPlainText.Value);
} }
if (passPhrase != null) if (passPhrase != null)
{ {
Check.NotNullOrWhiteSpace(passPhrase, nameof(passPhrase)); containerConfiguration.SetConfiguration(BlobEncryptionConfigurationNames.PassPhrase, passPhrase);
configuration.SetConfiguration(BlobEncryptionConfiguration.PassPhraseName, passPhrase);
} }
return configuration; return containerConfiguration;
}
/// <summary>
/// Indicates whether encryption is enabled for this container (own or inherited
/// configuration). Storage providers can use it to detect a transformed content stream.
/// </summary>
public static bool IsEncryptionEnabled([NotNull] this BlobContainerConfiguration containerConfiguration)
{
Check.NotNull(containerConfiguration, nameof(containerConfiguration));
return BlobEncryptionConfiguration.IsEnabled(containerConfiguration);
} }
/// <summary> /// <summary>
@ -50,31 +67,33 @@ public static class BlobContainerConfigurationEncryptionExtensions
/// BLOBs encrypted with the removed passphrase can not be read anymore. /// BLOBs encrypted with the removed passphrase can not be read anymore.
/// </summary> /// </summary>
public static BlobContainerConfiguration ClearEncryptionPassPhrase( public static BlobContainerConfiguration ClearEncryptionPassPhrase(
[NotNull] this BlobContainerConfiguration configuration) [NotNull] this BlobContainerConfiguration containerConfiguration)
{ {
Check.NotNull(configuration, nameof(configuration)); Check.NotNull(containerConfiguration, nameof(containerConfiguration));
// An explicit empty value shadows a passphrase inherited from the // An explicit empty value shadows a passphrase inherited from the
// default (fallback) container configuration. // default (fallback) container configuration.
configuration.SetConfiguration(BlobEncryptionConfiguration.PassPhraseName, string.Empty); containerConfiguration.SetConfiguration(BlobEncryptionConfigurationNames.PassPhrase, string.Empty);
return configuration; return containerConfiguration;
} }
/// <summary> /// <summary>
/// Disables encryption for this container (even when inherited from the default /// Disables encryption for this container (even when inherited from the default
/// configuration) and removes its passphrase/legacy options. Existing encrypted /// configuration) and removes its own passphrase/legacy options. Existing encrypted
/// BLOBs are then returned as stored (still encrypted bytes) while reading. /// BLOBs are then returned as stored (still encrypted bytes) while reading — unless
/// the container also has pipeline contributors, which still run and typically fail
/// on the ciphertext.
/// </summary> /// </summary>
public static BlobContainerConfiguration DisableEncryption( public static BlobContainerConfiguration DisableEncryption(
[NotNull] this BlobContainerConfiguration configuration) [NotNull] this BlobContainerConfiguration containerConfiguration)
{ {
Check.NotNull(configuration, nameof(configuration)); Check.NotNull(containerConfiguration, nameof(containerConfiguration));
configuration.SetConfiguration(BlobEncryptionConfiguration.EnabledName, false); containerConfiguration.SetConfiguration(BlobEncryptionConfigurationNames.Enabled, false);
configuration.ClearConfiguration(BlobEncryptionConfiguration.PassPhraseName); containerConfiguration.ClearConfiguration(BlobEncryptionConfigurationNames.PassPhrase);
configuration.ClearConfiguration(BlobEncryptionConfiguration.AllowLegacyPlaintextName); containerConfiguration.ClearConfiguration(BlobEncryptionConfigurationNames.AllowLegacyPlainText);
return configuration; return containerConfiguration;
} }
} }

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

@ -4,6 +4,8 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using JetBrains.Annotations;
using Microsoft.Extensions.Options;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
namespace Volo.Abp.BlobStoring; namespace Volo.Abp.BlobStoring;
@ -21,7 +23,7 @@ namespace Volo.Abp.BlobStoring;
/// data; the per-BLOB salt gives every BLOB its own derived key. /// data; the per-BLOB salt gives every BLOB its own derived key.
/// </para> /// </para>
/// </summary> /// </summary>
internal sealed class BlobEncryptionCodec : ITransientDependency public class BlobEncryptionCodec : IBlobEncryptionCodec, ITransientDependency
{ {
internal static readonly byte[] Magic = { (byte)'A', (byte)'B', (byte)'P', (byte)'E' }; internal static readonly byte[] Magic = { (byte)'A', (byte)'B', (byte)'P', (byte)'E' };
@ -38,26 +40,38 @@ internal sealed class BlobEncryptionCodec : ITransientDependency
internal const int GcmNonceSize = 12; internal const int GcmNonceSize = 12;
internal const int GcmTagSize = 16; internal const int GcmTagSize = 16;
private readonly IBlobEncryptionKeyProvider _keyProvider; // Rejects invalid UTF-16 instead of silently replacing it: the default encoder
private readonly AbpBlobStoringEncryptionOptions _options; // folds different unpaired surrogates into the same replacement bytes, which
// would let two different names produce the same authenticated identity
private static readonly Encoding StrictUtf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
protected IBlobEncryptionKeyProvider KeyProvider { get; }
protected AbpBlobStoringEncryptionOptions Options { get; }
public BlobEncryptionCodec( public BlobEncryptionCodec(
IBlobEncryptionKeyProvider keyProvider, IBlobEncryptionKeyProvider keyProvider,
Microsoft.Extensions.Options.IOptions<AbpBlobStoringEncryptionOptions> options) IOptions<AbpBlobStoringEncryptionOptions> options)
{ {
_keyProvider = keyProvider; KeyProvider = keyProvider;
_options = options.Value; Options = options.Value;
} }
/// <inheritdoc />
// The key is fully resolved before the stream is returned, so the resolution scope can be released. // The key is fully resolved before the stream is returned, so the resolution scope can be released.
public async Task<Stream> CreateEncryptingStreamAsync( public virtual async Task<Stream> CreateEncryptingStreamAsync(
BlobContainerConfiguration configuration, [NotNull] BlobContainerConfiguration configuration,
string containerName, [NotNull] string containerName,
string blobName, [NotNull] string blobName,
Guid? tenantId, Guid? tenantId,
Stream plainStream, [NotNull] Stream plainStream,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
Check.NotNull(configuration, nameof(configuration));
Check.NotNullOrWhiteSpace(containerName, nameof(containerName));
Check.NotNullOrWhiteSpace(blobName, nameof(blobName));
Check.NotNull(plainStream, nameof(plainStream));
#if NETSTANDARD2_0 #if NETSTANDARD2_0
// Fail before any output is produced, so no partial (corrupted) data is ever written. // 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!"); throw new PlatformNotSupportedException("BLOB encryption requires AES-GCM, which is not available on .NET Standard 2.0!");
@ -67,8 +81,14 @@ internal sealed class BlobEncryptionCodec : ITransientDependency
{ {
throw new PlatformNotSupportedException("AES-GCM is not supported on this platform!"); throw new PlatformNotSupportedException("AES-GCM is not supported on this platform!");
} }
#else
// netstandard2.1 has no AesGcm.IsSupported; constructing an instance is the only
// probe that fails here instead of after the provider has opened the target
using (CreateAesGcm(new byte[32]))
{
}
#endif #endif
var kdfIterations = _options.KdfIterations; var kdfIterations = Options.KdfIterations;
if (kdfIterations < MinKdfIterations || kdfIterations > MaxKdfIterations) if (kdfIterations < MinKdfIterations || kdfIterations > MaxKdfIterations)
{ {
throw new AbpException( throw new AbpException(
@ -76,7 +96,9 @@ internal sealed class BlobEncryptionCodec : ITransientDependency
$"must be between {MinKdfIterations} and {MaxKdfIterations}!"); $"must be between {MinKdfIterations} and {MaxKdfIterations}!");
} }
var key = await _keyProvider.ResolveForEncryptionAsync(configuration, cancellationToken); var key = await KeyProvider.ResolveForEncryptionAsync(
new BlobEncryptionKeyContext(configuration, containerName, blobName, tenantId),
cancellationToken);
var salt = new byte[KdfSaltSize]; var salt = new byte[KdfSaltSize];
var baseNonce = new byte[BaseNonceSize]; var baseNonce = new byte[BaseNonceSize];
@ -88,33 +110,42 @@ internal sealed class BlobEncryptionCodec : ITransientDependency
var header = BuildHeader(key.Source, kdfIterations, salt, ChunkSize, baseNonce); var header = BuildHeader(key.Source, kdfIterations, salt, ChunkSize, baseNonce);
var blobPrefix = CreateBlobPrefix(header); var blobPrefix = CreateBlobPrefix(header);
cancellationToken.ThrowIfCancellationRequested(); // The AAD can reject invalid names; build it before deriving the key, so
var keyBytes = DeriveKeyBytes(key.PassPhrase, salt, kdfIterations); // no derived key is left un-zeroed on the rejection path
var associatedDataPrefix = BuildAssociatedDataPrefix(blobPrefix, containerName, blobName, tenantId);
var encryptedLength = TryCalculateEncryptedLength(plainStream, ChunkSize);
var keyBytes = DeriveKeyBytesOrThrowIfCancelled(key.PassPhrase, salt, kdfIterations, cancellationToken);
return new ChunkedEncryptingReadStream( return new ChunkedEncryptingReadStream(
plainStream, plainStream,
blobPrefix, blobPrefix,
BuildAssociatedDataPrefix(blobPrefix, containerName, blobName, tenantId), associatedDataPrefix,
keyBytes, keyBytes,
baseNonce, baseNonce,
ChunkSize, ChunkSize,
TryCalculateEncryptedLength(plainStream, ChunkSize) encryptedLength
); );
#endif #endif
} }
public async Task<Stream> CreateDecryptingStreamAsync( /// <inheritdoc />
BlobContainerConfiguration configuration, public virtual async Task<Stream> CreateDecryptingStreamAsync(
string containerName, [NotNull] BlobContainerConfiguration configuration,
string blobName, [NotNull] string containerName,
[NotNull] string blobName,
Guid? tenantId, Guid? tenantId,
Stream cipherStream, [NotNull] Stream cipherStream,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
Check.NotNull(configuration, nameof(configuration));
Check.NotNullOrWhiteSpace(containerName, nameof(containerName));
Check.NotNullOrWhiteSpace(blobName, nameof(blobName));
Check.NotNull(cipherStream, nameof(cipherStream));
var prefix = await ReadUpToAsync(cipherStream, Magic.Length + 1, cancellationToken); var prefix = await ReadUpToAsync(cipherStream, Magic.Length + 1, cancellationToken);
if (!HasMagic(prefix)) if (!StartsWithMagic(prefix))
{ {
if (BlobEncryptionConfiguration.IsLegacyPlaintextAllowed(configuration)) if (BlobEncryptionConfiguration.IsLegacyPlainTextAllowed(configuration))
{ {
return new PrefixingReadStream(prefix, cipherStream); return new PrefixingReadStream(prefix, cipherStream);
} }
@ -126,6 +157,13 @@ internal sealed class BlobEncryptionCodec : ITransientDependency
); );
} }
// The full magic already identifies the encrypted format: content truncated
// right after it must fail as corrupted, not fall back to legacy plaintext
if (prefix.Length < Magic.Length + 1)
{
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: missing format version!");
}
if (prefix[Magic.Length] != FormatVersion) if (prefix[Magic.Length] != FormatVersion)
{ {
throw new AbpException($"Unsupported encrypted BLOB format version: {prefix[Magic.Length]}!"); throw new AbpException($"Unsupported encrypted BLOB format version: {prefix[Magic.Length]}!");
@ -139,6 +177,12 @@ internal sealed class BlobEncryptionCodec : ITransientDependency
{ {
throw new PlatformNotSupportedException("AES-GCM is not supported on this platform!"); throw new PlatformNotSupportedException("AES-GCM is not supported on this platform!");
} }
#else
// netstandard2.1 has no AesGcm.IsSupported; constructing an instance is the only
// probe that fails here instead of after the key is resolved and derived
using (CreateAesGcm(new byte[32]))
{
}
#endif #endif
var header = await ReadExactlyAsync(cipherStream, HeaderSize, cancellationToken); var header = await ReadExactlyAsync(cipherStream, HeaderSize, cancellationToken);
if (header == null) if (header == null)
@ -158,8 +202,10 @@ internal sealed class BlobEncryptionCodec : ITransientDependency
} }
var iterations = ReadInt32BigEndian(header, 2); var iterations = ReadInt32BigEndian(header, 2);
if (iterations <= 0 || iterations > MaxKdfIterations) if (iterations < MinKdfIterations || iterations > MaxKdfIterations)
{ {
// Accepting fewer iterations than any legitimate writer ever used would let
// attacker-crafted content turn reads into a cheap passphrase-guessing oracle
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid KDF iteration count!"); throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid KDF iteration count!");
} }
@ -175,21 +221,23 @@ internal sealed class BlobEncryptionCodec : ITransientDependency
var baseNonce = new byte[BaseNonceSize]; var baseNonce = new byte[BaseNonceSize];
Array.Copy(header, 26, baseNonce, 0, 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]; var blobPrefix = new byte[Magic.Length + 1 + HeaderSize];
Array.Copy(prefix, 0, blobPrefix, 0, Magic.Length + 1); Array.Copy(prefix, 0, blobPrefix, 0, Magic.Length + 1);
Array.Copy(header, 0, blobPrefix, Magic.Length + 1, HeaderSize); Array.Copy(header, 0, blobPrefix, Magic.Length + 1, HeaderSize);
// The AAD can reject invalid names; build it before deriving the key, so
// no derived key is left un-zeroed on the rejection path
var associatedDataPrefix = BuildAssociatedDataPrefix(blobPrefix, containerName, blobName, tenantId);
var passPhrase = await KeyProvider.ResolveForDecryptionAsync(
(BlobEncryptionKeySource)keySource,
new BlobEncryptionKeyContext(configuration, containerName, blobName, tenantId),
cancellationToken
);
var keyBytes = DeriveKeyBytesOrThrowIfCancelled(passPhrase, salt, iterations, cancellationToken);
return new ChunkedDecryptingReadStream( return new ChunkedDecryptingReadStream(
cipherStream, cipherStream,
BuildAssociatedDataPrefix(blobPrefix, containerName, blobName, tenantId), associatedDataPrefix,
keyBytes, keyBytes,
baseNonce, baseNonce,
chunkSize chunkSize
@ -213,8 +261,17 @@ internal sealed class BlobEncryptionCodec : ITransientDependency
// from another BLOB name, container or tenant. // from another BLOB name, container or tenant.
internal static byte[] BuildAssociatedDataPrefix(byte[] blobPrefix, string containerName, string blobName, Guid? tenantId) internal static byte[] BuildAssociatedDataPrefix(byte[] blobPrefix, string containerName, string blobName, Guid? tenantId)
{ {
var containerNameBytes = Encoding.UTF8.GetBytes(containerName); byte[] containerNameBytes;
var blobNameBytes = Encoding.UTF8.GetBytes(blobName); byte[] blobNameBytes;
try
{
containerNameBytes = StrictUtf8.GetBytes(containerName);
blobNameBytes = StrictUtf8.GetBytes(blobName);
}
catch (EncoderFallbackException ex)
{
throw new AbpException("The container/BLOB name contains invalid characters (unpaired surrogates), so it can not be bound to the encrypted content!", ex);
}
var tenantIdBytes = tenantId?.ToByteArray() ?? Array.Empty<byte>(); var tenantIdBytes = tenantId?.ToByteArray() ?? Array.Empty<byte>();
var prefix = new byte[blobPrefix.Length + 4 + containerNameBytes.Length + 4 + blobNameBytes.Length + 4 + tenantIdBytes.Length]; var prefix = new byte[blobPrefix.Length + 4 + containerNameBytes.Length + 4 + blobNameBytes.Length + 4 + tenantIdBytes.Length];
@ -246,19 +303,85 @@ internal sealed class BlobEncryptionCodec : ITransientDependency
return prefix; return prefix;
} }
// The synchronous PBKDF2 can not observe the token itself; check before the
// (expensive) derivation and once more after it, so a cancellation during the
// derivation is not lost
private static byte[] DeriveKeyBytesOrThrowIfCancelled(string passPhrase, byte[] salt, int iterations, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var keyBytes = DeriveKeyBytes(passPhrase, salt, iterations);
if (cancellationToken.IsCancellationRequested)
{
#if !NETSTANDARD2_0
CryptographicOperations.ZeroMemory(keyBytes);
#endif
cancellationToken.ThrowIfCancellationRequested();
}
return keyBytes;
}
internal static byte[] DeriveKeyBytes(string passPhrase, byte[] salt, int iterations) internal static byte[] DeriveKeyBytes(string passPhrase, byte[] salt, int iterations)
{ {
#if NETSTANDARD2_0 #if NETSTANDARD2_0
throw new PlatformNotSupportedException("BLOB encryption requires AES-GCM, which is not available on .NET Standard 2.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 #else
using var password = new Rfc2898DeriveBytes(passPhrase, salt, iterations, HashAlgorithmName.SHA256); // Encode the passphrase to bytes with strict UTF-8 explicitly, so every target
return password.GetBytes(32); // framework derives the same key and an invalid passphrase (unpaired surrogates)
// is rejected the same way — the string overloads differ across frameworks (net8+
// throws on invalid UTF-16, netstandard2.1 silently replaces it)
byte[] passwordBytes;
try
{
passwordBytes = StrictUtf8.GetBytes(passPhrase);
}
catch (EncoderFallbackException ex)
{
throw new AbpException("The BLOB encryption passphrase contains invalid characters (unpaired surrogates)!", ex);
}
try
{
#if NET8_0_OR_GREATER
return Rfc2898DeriveBytes.Pbkdf2(passwordBytes, salt, iterations, HashAlgorithmName.SHA256, 32);
#else
using var password = new Rfc2898DeriveBytes(passwordBytes, salt, iterations, HashAlgorithmName.SHA256);
return password.GetBytes(32);
#endif
}
finally
{
CryptographicOperations.ZeroMemory(passwordBytes);
}
#endif
}
// One AES-GCM instance is bound to the per-BLOB key and reused for every chunk, so a
// stream sets up the key schedule once instead of per chunk. Typed as IDisposable so the
// streams that hold it still compile on netstandard2.0 (where creation throws first).
internal static IDisposable CreateChunkCipher(byte[] keyBytes)
{
#if NETSTANDARD2_0
throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!");
#else
return CreateAesGcm(keyBytes);
#endif #endif
} }
internal static byte[] EncryptChunk(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex, byte[] plainChunk, int plainChunkLength) internal static byte[] EncryptChunk(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex, byte[] plainChunk, int plainChunkLength)
{
using (var cipher = CreateChunkCipher(keyBytes))
{
return EncryptChunkCore(cipher, CreateChunkAssociatedData(associatedDataPrefix, chunkIndex), CreateChunkNonce(baseNonce, chunkIndex), plainChunk, plainChunkLength);
}
}
// The cipher, associated data and nonce are passed in fully built so the streams can reuse
// one of each and only rewrite the trailing chunk index, instead of reconstructing the
// AES-GCM key schedule and reallocating the whole identity (which grows with the
// container/BLOB name) for every chunk
internal static byte[] EncryptChunkCore(IDisposable cipher, byte[] associatedData, byte[] nonce, byte[] plainChunk, int plainChunkLength)
{ {
#if NETSTANDARD2_0 #if NETSTANDARD2_0
throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!"); throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!");
@ -266,32 +389,34 @@ internal sealed class BlobEncryptionCodec : ITransientDependency
var record = new byte[ChunkLengthPrefixSize + plainChunkLength + GcmTagSize]; var record = new byte[ChunkLengthPrefixSize + plainChunkLength + GcmTagSize];
WriteInt32BigEndian(record, 0, plainChunkLength); WriteInt32BigEndian(record, 0, plainChunkLength);
using (var aesGcm = CreateAesGcm(keyBytes)) ((AesGcm)cipher).Encrypt(
{ nonce,
aesGcm.Encrypt( plainChunk.AsSpan(0, plainChunkLength),
CreateChunkNonce(baseNonce, chunkIndex), record.AsSpan(ChunkLengthPrefixSize, plainChunkLength),
plainChunk.AsSpan(0, plainChunkLength), record.AsSpan(ChunkLengthPrefixSize + plainChunkLength, GcmTagSize),
record.AsSpan(ChunkLengthPrefixSize, plainChunkLength), associatedData
record.AsSpan(ChunkLengthPrefixSize + plainChunkLength, GcmTagSize), );
CreateChunkAssociatedData(associatedDataPrefix, chunkIndex)
);
}
return record; return record;
#endif #endif
} }
internal static byte[] DecryptChunk(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex, byte[] cipherChunk, byte[] tag) internal static byte[] DecryptChunk(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex, byte[] cipherChunk, byte[] tag)
{
using (var cipher = CreateChunkCipher(keyBytes))
{
return DecryptChunkCore(cipher, CreateChunkAssociatedData(associatedDataPrefix, chunkIndex), CreateChunkNonce(baseNonce, chunkIndex), cipherChunk, tag);
}
}
internal static byte[] DecryptChunkCore(IDisposable cipher, byte[] associatedData, byte[] nonce, byte[] cipherChunk, byte[] tag)
{ {
#if NETSTANDARD2_0 #if NETSTANDARD2_0
throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!"); throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!");
#else #else
var plainChunk = new byte[cipherChunk.Length]; var plainChunk = new byte[cipherChunk.Length];
using (var aesGcm = CreateAesGcm(keyBytes)) // Throws CryptographicException if the authentication tag is invalid.
{ ((AesGcm)cipher).Decrypt(nonce, cipherChunk, tag, plainChunk, associatedData);
// Throws CryptographicException if the authentication tag is invalid.
aesGcm.Decrypt(CreateChunkNonce(baseNonce, chunkIndex), cipherChunk, tag, plainChunk, CreateChunkAssociatedData(associatedDataPrefix, chunkIndex));
}
return plainChunk; return plainChunk;
#endif #endif
@ -299,42 +424,46 @@ internal sealed class BlobEncryptionCodec : ITransientDependency
// The authenticated terminal record makes truncation of complete chunks detectable // The authenticated terminal record makes truncation of complete chunks detectable
internal static byte[] CreateTerminalRecord(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex) internal static byte[] CreateTerminalRecord(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex)
{
using (var cipher = CreateChunkCipher(keyBytes))
{
return CreateTerminalRecordCore(cipher, CreateChunkAssociatedData(associatedDataPrefix, chunkIndex), CreateChunkNonce(baseNonce, chunkIndex));
}
}
internal static byte[] CreateTerminalRecordCore(IDisposable cipher, byte[] associatedData, byte[] nonce)
{ {
#if NETSTANDARD2_0 #if NETSTANDARD2_0
throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!"); throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!");
#else #else
var record = new byte[ChunkLengthPrefixSize + GcmTagSize]; var record = new byte[ChunkLengthPrefixSize + GcmTagSize];
using (var aesGcm = CreateAesGcm(keyBytes)) ((AesGcm)cipher).Encrypt(
{ nonce,
aesGcm.Encrypt( Array.Empty<byte>(),
CreateChunkNonce(baseNonce, chunkIndex), Array.Empty<byte>(),
Array.Empty<byte>(), record.AsSpan(ChunkLengthPrefixSize, GcmTagSize),
Array.Empty<byte>(), associatedData
record.AsSpan(ChunkLengthPrefixSize, GcmTagSize), );
CreateChunkAssociatedData(associatedDataPrefix, chunkIndex)
);
}
return record; return record;
#endif #endif
} }
internal static void VerifyTerminalRecord(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex, byte[] tag) internal static void VerifyTerminalRecord(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex, byte[] tag)
{
using (var cipher = CreateChunkCipher(keyBytes))
{
VerifyTerminalRecordCore(cipher, CreateChunkAssociatedData(associatedDataPrefix, chunkIndex), CreateChunkNonce(baseNonce, chunkIndex), tag);
}
}
internal static void VerifyTerminalRecordCore(IDisposable cipher, byte[] associatedData, byte[] nonce, byte[] tag)
{ {
#if NETSTANDARD2_0 #if NETSTANDARD2_0
throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!"); throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!");
#else #else
using (var aesGcm = CreateAesGcm(keyBytes)) // Throws CryptographicException if the tag is invalid.
{ ((AesGcm)cipher).Decrypt(nonce, Array.Empty<byte>(), tag, Array.Empty<byte>(), associatedData);
// Throws CryptographicException if the tag is invalid.
aesGcm.Decrypt(
CreateChunkNonce(baseNonce, chunkIndex),
Array.Empty<byte>(),
tag,
Array.Empty<byte>(),
CreateChunkAssociatedData(associatedDataPrefix, chunkIndex)
);
}
#endif #endif
} }
@ -362,6 +491,34 @@ internal sealed class BlobEncryptionCodec : ITransientDependency
return associatedData; return associatedData;
} }
// A stream builds one nonce and one associated-data buffer with these, then rewrites only
// the trailing chunk index per chunk with WriteChunkIndex; both hold the index as their
// last 4 bytes, so the fixed prefix is copied once instead of once per chunk
internal static byte[] CreateReusableChunkNonce(byte[] baseNonce)
{
var nonce = new byte[GcmNonceSize];
Array.Copy(baseNonce, 0, nonce, 0, BaseNonceSize);
return nonce;
}
internal static byte[] CreateReusableAssociatedData(byte[] associatedDataPrefix)
{
var associatedData = new byte[associatedDataPrefix.Length + 4];
Array.Copy(associatedDataPrefix, 0, associatedData, 0, associatedDataPrefix.Length);
return associatedData;
}
internal static void WriteChunkIndex(byte[] nonceOrAssociatedData, 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!");
}
WriteInt32BigEndian(nonceOrAssociatedData, nonceOrAssociatedData.Length - 4, chunkIndex);
}
internal static int GetCipherChunkSize(byte[] lengthPrefix, int maxCipherChunkSize) internal static int GetCipherChunkSize(byte[] lengthPrefix, int maxCipherChunkSize)
{ {
if (lengthPrefix.Length == 0) if (lengthPrefix.Length == 0)
@ -426,7 +583,13 @@ internal sealed class BlobEncryptionCodec : ITransientDependency
var totalReadCount = 0; var totalReadCount = 0;
while (totalReadCount < count) while (totalReadCount < count)
{ {
#if NETSTANDARD2_0
var readCount = await stream.ReadAsync(buffer, totalReadCount, count - totalReadCount, cancellationToken); var readCount = await stream.ReadAsync(buffer, totalReadCount, count - totalReadCount, cancellationToken);
#else
// The modern overload dispatches correctly for streams that only
// implement ReadAsync(Memory<byte>)
var readCount = await stream.ReadAsync(buffer.AsMemory(totalReadCount, count - totalReadCount), cancellationToken);
#endif
if (readCount == 0) if (readCount == 0)
{ {
break; break;
@ -445,9 +608,9 @@ internal sealed class BlobEncryptionCodec : ITransientDependency
return result; return result;
} }
private static bool HasMagic(byte[] prefix) private static bool StartsWithMagic(byte[] prefix)
{ {
if (prefix.Length < Magic.Length + 1) if (prefix.Length < Magic.Length)
{ {
return false; return false;
} }
@ -465,11 +628,10 @@ internal sealed class BlobEncryptionCodec : ITransientDependency
private static long? TryCalculateEncryptedLength(Stream plainStream, int chunkSize) private static long? TryCalculateEncryptedLength(Stream plainStream, int chunkSize)
{ {
if (!plainStream.CanSeek) // Not gated on CanSeek: a forward-only stream can still report Length/Position.
{ // Both are required: without Position the remaining length is unknown (the stream
return null; // may already be partially consumed), and guessing it would report a wrong
} // ciphertext length and cause a short write on length-strict providers.
try try
{ {
var plainLength = plainStream.Length - plainStream.Position; var plainLength = plainStream.Length - plainStream.Position;
@ -481,14 +643,22 @@ internal sealed class BlobEncryptionCodec : ITransientDependency
var fullChunkCount = plainLength / chunkSize; var fullChunkCount = plainLength / chunkSize;
var chunkRecordCount = fullChunkCount + (plainLength % chunkSize > 0 ? 1 : 0) + 1; // +1: terminal record var chunkRecordCount = fullChunkCount + (plainLength % chunkSize > 0 ? 1 : 0) + 1; // +1: terminal record
// The chunk index (including the terminal record) is a 32-bit value; fail
// before any output instead of after writing terabytes of ciphertext
if (chunkRecordCount - 1 > int.MaxValue)
{
throw new AbpException("The content is too large for the encrypted BLOB format (chunk index overflow)!");
}
checked checked
{ {
return Magic.Length + 1L + HeaderSize + plainLength + return Magic.Length + 1L + HeaderSize + plainLength +
chunkRecordCount * (ChunkLengthPrefixSize + GcmTagSize); chunkRecordCount * (ChunkLengthPrefixSize + GcmTagSize);
} }
} }
catch (NotSupportedException) catch (Exception ex) when (ex is NotSupportedException || ex is IOException)
{ {
// The length is optional; a probe failure must not fail the save
return null; return null;
} }
catch (OverflowException) catch (OverflowException)

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

@ -6,24 +6,20 @@ namespace Volo.Abp.BlobStoring;
/// </summary> /// </summary>
internal static class BlobEncryptionConfiguration 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) public static bool IsEnabled(BlobContainerConfiguration configuration)
{ {
return configuration.GetConfigurationOrDefault(EnabledName, false); return configuration.GetConfigurationOrDefault(BlobEncryptionConfigurationNames.Enabled, false);
} }
public static string? GetPassPhraseOrNull(BlobContainerConfiguration configuration) public static string? GetPassPhraseOrNull(BlobContainerConfiguration configuration)
{ {
// An explicit empty value shadows an inherited passphrase (see UseEncryption). // An explicit empty value shadows an inherited passphrase (see UseEncryption).
var passPhrase = configuration.GetConfigurationOrDefault<string?>(PassPhraseName); var passPhrase = configuration.GetConfigurationOrDefault<string?>(BlobEncryptionConfigurationNames.PassPhrase);
return string.IsNullOrWhiteSpace(passPhrase) ? null : passPhrase; return string.IsNullOrWhiteSpace(passPhrase) ? null : passPhrase;
} }
public static bool IsLegacyPlaintextAllowed(BlobContainerConfiguration configuration) public static bool IsLegacyPlainTextAllowed(BlobContainerConfiguration configuration)
{ {
return configuration.GetConfigurationOrDefault(AllowLegacyPlaintextName, false); return configuration.GetConfigurationOrDefault(BlobEncryptionConfigurationNames.AllowLegacyPlainText, false);
} }
} }

8
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionConfigurationNames.cs

@ -0,0 +1,8 @@
namespace Volo.Abp.BlobStoring;
public static class BlobEncryptionConfigurationNames
{
public const string Enabled = "BlobEncryption.Enabled";
public const string PassPhrase = "BlobEncryption.PassPhrase";
public const string AllowLegacyPlainText = "BlobEncryption.AllowLegacyPlainText";
}

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

@ -6,13 +6,24 @@ namespace Volo.Abp.BlobStoring;
/// <summary> /// <summary>
/// The passphrase resolved for encrypting a BLOB, together with its source. /// The passphrase resolved for encrypting a BLOB, together with its source.
/// </summary> /// </summary>
public sealed class BlobEncryptionKey public class BlobEncryptionKey
{ {
/// <summary>
/// The source the passphrase was resolved from; it is recorded in the encrypted
/// BLOB and routes the BLOB back to the same source while decrypting.
/// </summary>
public BlobEncryptionKeySource Source { get; } public BlobEncryptionKeySource Source { get; }
/// <summary>
/// The passphrase the encryption key of the BLOB is derived from.
/// </summary>
[NotNull] [NotNull]
public string PassPhrase { get; } public string PassPhrase { get; }
/// <summary>
/// Creates the resolved key; <paramref name="source"/> must be a defined
/// <see cref="BlobEncryptionKeySource"/> value and the passphrase non-empty.
/// </summary>
public BlobEncryptionKey(BlobEncryptionKeySource source, [NotNull] string passPhrase) public BlobEncryptionKey(BlobEncryptionKeySource source, [NotNull] string passPhrase)
{ {
if (source < BlobEncryptionKeySource.Container || source > BlobEncryptionKeySource.Global) if (source < BlobEncryptionKeySource.Container || source > BlobEncryptionKeySource.Global)

51
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobEncryptionKeyContext.cs

@ -0,0 +1,51 @@
using System;
using JetBrains.Annotations;
namespace Volo.Abp.BlobStoring;
/// <summary>
/// The identity of the BLOB an encryption key is resolved for. It lets a custom
/// <see cref="IBlobEncryptionKeyProvider"/> select the key by the container, the
/// BLOB name or the tenant — not only by the container configuration.
/// </summary>
public class BlobEncryptionKeyContext
{
/// <summary>
/// The configuration of the container the BLOB belongs to (with the container
/// passphrase, if one was set with <c>UseEncryption</c>).
/// </summary>
[NotNull]
public BlobContainerConfiguration Configuration { get; }
/// <summary>
/// The normalized container name.
/// </summary>
[NotNull]
public string ContainerName { get; }
/// <summary>
/// The normalized BLOB name.
/// </summary>
[NotNull]
public string BlobName { get; }
/// <summary>
/// The tenant of the BLOB operation (null for the host or a shared container).
/// </summary>
public Guid? TenantId { get; }
/// <summary>
/// Creates the context; the names are expected in their normalized form.
/// </summary>
public BlobEncryptionKeyContext(
[NotNull] BlobContainerConfiguration configuration,
[NotNull] string containerName,
[NotNull] string blobName,
Guid? tenantId)
{
Configuration = Check.NotNull(configuration, nameof(configuration));
ContainerName = Check.NotNullOrWhiteSpace(containerName, nameof(containerName));
BlobName = Check.NotNullOrWhiteSpace(blobName, nameof(blobName));
TenantId = tenantId;
}
}

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

@ -7,12 +7,21 @@ namespace Volo.Abp.BlobStoring;
/// </summary> /// </summary>
public enum BlobEncryptionKeySource : byte public enum BlobEncryptionKeySource : byte
{ {
/// <summary>The container-specific passphrase (see the UseEncryption extension method).</summary> /// <summary>
/// The container-specific passphrase, set with
/// <see cref="BlobContainerConfigurationEncryptionExtensions.UseEncryption"/>.
/// </summary>
Container = 1, Container = 1,
/// <summary>A tenant-specific passphrase, provided by a custom <see cref="IBlobEncryptionKeyProvider"/>; unused by the default provider.</summary> /// <summary>
/// A tenant-specific passphrase, provided by a custom
/// <see cref="IBlobEncryptionKeyProvider"/>; unused by the default provider.
/// </summary>
Tenant = 2, Tenant = 2,
/// <summary>The global passphrase (see <see cref="AbpBlobStoringEncryptionOptions.DefaultPassPhrase"/>).</summary> /// <summary>
/// The global passphrase, from
/// <see cref="AbpBlobStoringEncryptionOptions.DefaultPassPhrase"/>.
/// </summary>
Global = 3 Global = 3
} }

121
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineContext.cs

@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using JetBrains.Annotations;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.BlobStoring;
/// <summary>
/// The context an <see cref="IBlobPipelineContributor"/> works on. A contributor
/// transforms the content by replacing <see cref="BlobStream"/> with a wrapper;
/// see <see cref="IBlobPipelineContributor"/> for the stream ownership contract.
/// </summary>
public class BlobPipelineContext : IServiceProviderAccessor
{
/// <summary>
/// The scoped service provider of the pipeline. While saving, the scope stays
/// alive until the save operation completes; while getting, until the stream
/// returned to the caller is disposed — so lazily transforming wrappers can
/// keep using their scoped services.
/// </summary>
[NotNull]
public IServiceProvider ServiceProvider { get; }
/// <summary>
/// The normalized container name.
/// </summary>
[NotNull]
public string ContainerName { get; }
/// <summary>
/// The normalized BLOB name.
/// </summary>
[NotNull]
public string BlobName { get; }
/// <summary>
/// The configuration of the container the BLOB belongs to.
/// </summary>
[NotNull]
public BlobContainerConfiguration Configuration { get; }
/// <summary>
/// The tenant of the BLOB operation (null for the host or a shared container).
/// </summary>
public Guid? TenantId { get; }
/// <summary>
/// The cancellation token of the BLOB operation. Pass it to any I/O the contributor
/// performs while <see cref="IBlobPipelineContributor.OnSavingAsync"/> /
/// <see cref="IBlobPipelineContributor.OnGettingAsync"/> runs. A lazy read wrapper
/// returned from <c>OnGettingAsync</c> must instead honor the token passed to each of
/// its own <c>Read</c>/<c>ReadAsync</c> calls (this token is captured once at
/// <c>GetAsync</c> time and is not updated per read).
/// </summary>
public CancellationToken CancellationToken { get; }
/// <summary>
/// The content stream. Replace it with a (typically lazily transforming,
/// read-only) wrapper to transform the content.
/// </summary>
[NotNull]
public Stream BlobStream {
get => _blobStream;
set
{
_blobStream = Check.NotNull(value, nameof(value));
TrackCreatedStream(value);
}
}
private Stream _blobStream;
private readonly Stream _initialStream;
// While saving, every stream the pipeline creates is collected here (at
// assignment, so intermediate replacements within one contributor call are
// not lost) to be disposed after the save; the initial (caller-owned)
// stream is never collected. Null while getting.
internal List<Stream>? CreatedStreams { get; set; }
/// <summary>
/// Creates the context; the names are expected in their normalized form and
/// <paramref name="blobStream"/> is the initial (untransformed) content.
/// </summary>
public BlobPipelineContext(
[NotNull] IServiceProvider serviceProvider,
[NotNull] string containerName,
[NotNull] string blobName,
[NotNull] BlobContainerConfiguration configuration,
Guid? tenantId,
[NotNull] Stream blobStream,
CancellationToken cancellationToken = default)
{
ServiceProvider = Check.NotNull(serviceProvider, nameof(serviceProvider));
ContainerName = Check.NotNullOrWhiteSpace(containerName, nameof(containerName));
BlobName = Check.NotNullOrWhiteSpace(blobName, nameof(blobName));
Configuration = Check.NotNull(configuration, nameof(configuration));
TenantId = tenantId;
_initialStream = _blobStream = Check.NotNull(blobStream, nameof(blobStream));
CancellationToken = cancellationToken;
}
private void TrackCreatedStream(Stream stream)
{
if (CreatedStreams == null || ReferenceEquals(stream, _initialStream))
{
return;
}
foreach (var existingStream in CreatedStreams)
{
if (ReferenceEquals(existingStream, stream))
{
return;
}
}
CreatedStreams.Add(stream);
}
}

488
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/BlobPipelineScopeStream.cs

@ -0,0 +1,488 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.MultiTenancy;
using Volo.Abp.Threading;
namespace Volo.Abp.BlobStoring;
/// <summary>
/// Keeps the service scope and the tenant context of the pipeline contributors
/// until the returned (lazily transforming) stream is disposed: every member the
/// wrappers may run work in — including the disposal of the scope itself — executes
/// in the tenant the BLOB belongs to, not in the ambient tenant of the caller.
/// </summary>
internal sealed class BlobPipelineScopeStream : Stream
{
private readonly Stream _inner;
private readonly AsyncServiceScope _scope;
private readonly ICurrentTenant _currentTenant;
private readonly Guid? _tenantId;
private readonly IBlobAuthenticatedEndStream? _authenticatedEndSource;
private bool _authenticatedEndChecked;
private bool _faulted;
private bool _disposed;
public BlobPipelineScopeStream(
Stream inner,
AsyncServiceScope scope,
ICurrentTenant currentTenant,
Guid? tenantId,
IBlobAuthenticatedEndStream? authenticatedEndSource = null)
{
_inner = inner;
_scope = scope;
_currentTenant = currentTenant;
_tenantId = tenantId;
// When encryption is enabled, this is the innermost decrypting stream. Its
// terminal record is verified when this composed stream reaches EOF, so a
// contributor that stops before the content ends can not hide a truncation.
_authenticatedEndSource = authenticatedEndSource;
}
public override bool CanRead
{
get
{
if (_disposed)
{
return false;
}
using (_currentTenant.Change(_tenantId))
{
return _inner.CanRead;
}
}
}
private void EnsureNotDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(GetType().FullName);
}
}
public override bool CanSeek
{
get
{
if (_disposed)
{
return false;
}
using (_currentTenant.Change(_tenantId))
{
return _inner.CanSeek;
}
}
}
public override bool CanWrite => false;
public override bool CanTimeout
{
get
{
if (_disposed)
{
return false;
}
using (_currentTenant.Change(_tenantId))
{
return _inner.CanTimeout;
}
}
}
public override int ReadTimeout
{
get
{
EnsureNotDisposed();
using (_currentTenant.Change(_tenantId))
{
return _inner.ReadTimeout;
}
}
set
{
EnsureNotDisposed();
using (_currentTenant.Change(_tenantId))
{
_inner.ReadTimeout = value;
}
}
}
public override long Length
{
get
{
EnsureNotDisposed();
using (_currentTenant.Change(_tenantId))
{
return _inner.Length;
}
}
}
public override long Position
{
get
{
EnsureNotDisposed();
using (_currentTenant.Change(_tenantId))
{
return _inner.Position;
}
}
set
{
EnsureNotDisposed();
using (_currentTenant.Change(_tenantId))
{
_inner.Position = value;
}
}
}
public override void Flush()
{
EnsureNotDisposed();
using (_currentTenant.Change(_tenantId))
{
_inner.Flush();
}
}
public override async Task FlushAsync(CancellationToken cancellationToken)
{
EnsureNotDisposed();
using (_currentTenant.Change(_tenantId))
{
await _inner.FlushAsync(cancellationToken);
}
}
public override int Read(byte[] buffer, int offset, int count)
{
EnsureNotDisposed();
EnsureNotFaulted();
using (_currentTenant.Change(_tenantId))
{
int read;
try
{
read = _inner.Read(buffer, offset, count);
}
catch
{
// A failed read faults permanently, so a retry layer can not silently continue
// from a position where an inner contributor already consumed bytes
_faulted = true;
throw;
}
VerifyAuthenticatedEndIfNeeded(read, count);
return read;
}
}
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
EnsureNotDisposed();
EnsureNotFaulted();
// A token cancelled before any I/O leaves the stream untouched (and healthy for a
// retry); once a read has started, any failure faults it permanently
cancellationToken.ThrowIfCancellationRequested();
using (_currentTenant.Change(_tenantId))
{
int read;
try
{
#if NETSTANDARD2_0
read = await _inner.ReadAsync(buffer, offset, count, cancellationToken);
#else
// Dispatch over the modern overload, so a wrapper that only implements
// ReadAsync(Memory<byte>) also works for callers of the old overload
read = await _inner.ReadAsync(buffer.AsMemory(offset, count), cancellationToken);
#endif
}
catch
{
_faulted = true;
throw;
}
await VerifyAuthenticatedEndIfNeededAsync(read, count, cancellationToken);
return read;
}
}
// Runs when the composed stream reaches EOF on a real (non-zero-count) read. A
// legitimate partial read (stopping early and disposing) never reaches EOF, so it
// is not affected. A failed check faults the stream permanently, so a read-retry
// layer can not swallow the integrity error and later see a normal EOF.
private void VerifyAuthenticatedEndIfNeeded(int read, int count)
{
if (read != 0 || count == 0 || _authenticatedEndChecked || _authenticatedEndSource == null)
{
return;
}
try
{
_authenticatedEndSource.EnsureReadToAuthenticatedEnd();
_authenticatedEndChecked = true;
}
catch
{
// The check ran and failed on integrity: mark it done and fault permanently
_authenticatedEndChecked = true;
_faulted = true;
throw;
}
}
private async ValueTask VerifyAuthenticatedEndIfNeededAsync(int read, int count, CancellationToken cancellationToken)
{
if (read != 0 || count == 0 || _authenticatedEndChecked || _authenticatedEndSource == null)
{
return;
}
// A token cancelled before the check runs leaves it un-run and the stream healthy, so
// a retry with a live token can still verify the end
cancellationToken.ThrowIfCancellationRequested();
try
{
await _authenticatedEndSource.EnsureReadToAuthenticatedEndAsync(cancellationToken);
_authenticatedEndChecked = true;
}
catch (OperationCanceledException)
{
// The decrypting stream owns the consumption state and faults itself on a mid-read
// cancellation; a cancellation it lets through without faulting (for example the
// token trips in the gap after the check above) leaves it healthy, so the outer
// must not fault either — a retry with a live token can still verify the end
throw;
}
catch
{
// A real integrity failure is permanent, so a read-retry layer can not swallow it
// and later see a normal EOF
_authenticatedEndChecked = true;
_faulted = true;
throw;
}
}
private void EnsureNotFaulted()
{
if (_faulted)
{
throw new AbpException("The stream can not be read anymore, because a previous read operation has failed!");
}
}
#if !NETSTANDARD2_0
// Forwarded so a wrapper that only implements the modern overloads is not
// degraded to the byte[] fallback of the base class
public override int Read(Span<byte> buffer)
{
EnsureNotDisposed();
EnsureNotFaulted();
using (_currentTenant.Change(_tenantId))
{
int read;
try
{
read = _inner.Read(buffer);
}
catch
{
_faulted = true;
throw;
}
VerifyAuthenticatedEndIfNeeded(read, buffer.Length);
return read;
}
}
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
EnsureNotDisposed();
EnsureNotFaulted();
cancellationToken.ThrowIfCancellationRequested();
using (_currentTenant.Change(_tenantId))
{
int read;
try
{
read = await _inner.ReadAsync(buffer, cancellationToken);
}
catch
{
_faulted = true;
throw;
}
await VerifyAuthenticatedEndIfNeededAsync(read, buffer.Length, cancellationToken);
return read;
}
}
#endif
public override long Seek(long offset, SeekOrigin origin)
{
EnsureNotDisposed();
using (_currentTenant.Change(_tenantId))
{
return _inner.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 && !_disposed)
{
_disposed = true;
// The tenant context is best-effort: failing to enter it must not skip the
// resource release (which would leak the provider stream, the scope and the
// derived key), and a later dispose can not recover it since _disposed is set
IDisposable? tenantChange = null;
try
{
tenantChange = _currentTenant.Change(_tenantId);
}
catch
{
// ignored: release the resources below without the tenant context
}
try
{
DisposeInnerAndScope();
}
finally
{
tenantChange?.Dispose();
}
}
base.Dispose(disposing);
}
private void DisposeInnerAndScope()
{
try
{
#if NETSTANDARD2_0
// Stream has no DisposeAsync on netstandard2.0, but the inner stream
// may still implement IAsyncDisposable for its async-only cleanup
if (_inner is IAsyncDisposable innerAsyncDisposable)
{
AsyncHelper.RunSync(() => innerAsyncDisposable.DisposeAsync().AsTask());
}
else
{
_inner.Dispose();
}
#else
// Also covers wrappers that only implement DisposeAsync
AsyncHelper.RunSync(() => _inner.DisposeAsync().AsTask());
#endif
}
catch
{
// The stream failure is the root cause; a scope dispose
// failure on top of it must not replace it
try
{
AsyncHelper.RunSync(() => _scope.DisposeAsync().AsTask());
}
catch
{
// ignored
}
throw;
}
// A synchronous scope dispose throws when a scoped service only
// implements IAsyncDisposable, so always release it asynchronously
AsyncHelper.RunSync(() => _scope.DisposeAsync().AsTask());
}
#if !NETSTANDARD2_0
public override async ValueTask DisposeAsync()
{
if (!_disposed)
{
_disposed = true;
IDisposable? tenantChange = null;
try
{
tenantChange = _currentTenant.Change(_tenantId);
}
catch
{
// ignored: release the resources below without the tenant context
}
try
{
await DisposeInnerAndScopeAsync();
}
finally
{
tenantChange?.Dispose();
}
}
await base.DisposeAsync();
}
private async ValueTask DisposeInnerAndScopeAsync()
{
try
{
await _inner.DisposeAsync();
}
catch
{
try
{
await _scope.DisposeAsync();
}
catch
{
// ignored: the stream failure is the root cause
}
throw;
}
await _scope.DisposeAsync();
}
#endif
}

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

@ -65,6 +65,84 @@ internal abstract class ChunkedCryptoReadStream : SequentialReadStream
protected abstract Task<byte[]?> ProduceNextAsync(CancellationToken cancellationToken); protected abstract Task<byte[]?> ProduceNextAsync(CancellationToken cancellationToken);
internal void EnsureReadToAuthenticatedEndCore()
{
// A previous read that faulted the stream (for example an authentication failure
// a contributor swallowed) must not be recovered by re-entering ProduceNext here
EnsureCanServe();
try
{
if (IsAtAuthenticatedEnd())
{
return;
}
// Producing the next record either verifies the terminal record (null) or
// returns another content chunk, which means the consumer stopped early
ThrowIfMoreContent(ProduceNext());
}
catch
{
// Fault the stream so the failure can not be swallowed by reading again
MarkFaulted();
throw;
}
}
internal async ValueTask EnsureReadToAuthenticatedEndCoreAsync(CancellationToken cancellationToken)
{
EnsureCanServe();
// A token cancelled before any I/O leaves the stream untouched, so it can stay
// healthy for a retry (the same rule the normal read path applies). Once a read has
// started, any failure must fault: a mid-read cancellation already consumed and
// discarded bytes of the non-seekable cipher stream, so a retry that resumed from the
// middle of the terminal record would misreport a valid BLOB as corrupt
cancellationToken.ThrowIfCancellationRequested();
try
{
if (IsAtAuthenticatedEnd())
{
return;
}
ThrowIfMoreContent(await ProduceNextAsync(cancellationToken));
}
catch
{
MarkFaulted();
throw;
}
}
private bool IsAtAuthenticatedEnd()
{
if (_finished)
{
return true;
}
if (_outputBuffer != null && _outputBufferPosition < _outputBuffer.Length)
{
throw new AbpException(
"The encrypted BLOB was not read to its authenticated end, so its completeness can not be verified " +
"(a content-pipeline contributor stopped reading the content before the end).");
}
return false;
}
private void ThrowIfMoreContent(byte[]? next)
{
if (next != null)
{
throw new AbpException(
"The encrypted BLOB was not read to its authenticated end, so its completeness can not be verified " +
"(a content-pipeline contributor stopped reading the content before the end).");
}
SetOutputBuffer(null);
}
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
if (disposing && _outputBuffer != null) if (disposing && _outputBuffer != null)

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

@ -1,19 +1,24 @@
using System; using System;
using System.IO; using System.IO;
using System.Security.Cryptography;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Volo.Abp.Threading;
namespace Volo.Abp.BlobStoring; namespace Volo.Abp.BlobStoring;
/// <summary> /// <summary>
/// Decrypts the cipher stream chunk by chunk while being read. /// Decrypts the cipher stream chunk by chunk while being read. It is the only stream
/// with an authenticated terminal record, so it is the one implementing
/// <see cref="IBlobAuthenticatedEndStream"/>.
/// </summary> /// </summary>
internal class ChunkedDecryptingReadStream : ChunkedCryptoReadStream internal class ChunkedDecryptingReadStream : ChunkedCryptoReadStream, IBlobAuthenticatedEndStream
{ {
private readonly Stream _cipherStream; private readonly Stream _cipherStream;
private readonly byte[] _associatedDataPrefix; private readonly byte[] _associatedData;
private readonly byte[] _keyBytes; private readonly byte[] _keyBytes;
private readonly byte[] _baseNonce; private readonly IDisposable _chunkCipher;
private readonly byte[] _nonce;
private readonly int _chunkSize; private readonly int _chunkSize;
private int _chunkIndex; private int _chunkIndex;
private bool _disposed; private bool _disposed;
@ -26,12 +31,24 @@ internal class ChunkedDecryptingReadStream : ChunkedCryptoReadStream
int chunkSize) int chunkSize)
{ {
_cipherStream = cipherStream; _cipherStream = cipherStream;
_associatedDataPrefix = associatedDataPrefix; // One reusable cipher and buffer each; only the trailing chunk index changes per chunk
_associatedData = BlobEncryptionCodec.CreateReusableAssociatedData(associatedDataPrefix);
_keyBytes = keyBytes; _keyBytes = keyBytes;
_baseNonce = baseNonce; _chunkCipher = BlobEncryptionCodec.CreateChunkCipher(keyBytes);
_nonce = BlobEncryptionCodec.CreateReusableChunkNonce(baseNonce);
_chunkSize = chunkSize; _chunkSize = chunkSize;
} }
public void EnsureReadToAuthenticatedEnd()
{
EnsureReadToAuthenticatedEndCore();
}
public ValueTask EnsureReadToAuthenticatedEndAsync(CancellationToken cancellationToken = default)
{
return EnsureReadToAuthenticatedEndCoreAsync(cancellationToken);
}
protected override byte[]? ProduceNext() protected override byte[]? ProduceNext()
{ {
var cipherChunkSize = BlobEncryptionCodec.GetCipherChunkSize( var cipherChunkSize = BlobEncryptionCodec.GetCipherChunkSize(
@ -46,7 +63,8 @@ internal class ChunkedDecryptingReadStream : ChunkedCryptoReadStream
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid terminal record!"); throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid terminal record!");
} }
BlobEncryptionCodec.VerifyTerminalRecord(_keyBytes, _associatedDataPrefix, _baseNonce, _chunkIndex, terminalTag); SetChunkIndex(_chunkIndex);
BlobEncryptionCodec.VerifyTerminalRecordCore(_chunkCipher, _associatedData, _nonce, terminalTag);
return null; return null;
} }
@ -70,7 +88,8 @@ internal class ChunkedDecryptingReadStream : ChunkedCryptoReadStream
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid terminal record!"); throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid terminal record!");
} }
BlobEncryptionCodec.VerifyTerminalRecord(_keyBytes, _associatedDataPrefix, _baseNonce, _chunkIndex, terminalTag); SetChunkIndex(_chunkIndex);
BlobEncryptionCodec.VerifyTerminalRecordCore(_chunkCipher, _associatedData, _nonce, terminalTag);
return null; return null;
} }
@ -87,20 +106,33 @@ internal class ChunkedDecryptingReadStream : ChunkedCryptoReadStream
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: truncated chunk!"); 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); SetChunkIndex(_chunkIndex);
var plainChunk = BlobEncryptionCodec.DecryptChunkCore(_chunkCipher, _associatedData, _nonce, cipherChunk, tag);
_chunkIndex++; _chunkIndex++;
return plainChunk; return plainChunk;
} }
private void SetChunkIndex(int chunkIndex)
{
BlobEncryptionCodec.WriteChunkIndex(_nonce, chunkIndex);
BlobEncryptionCodec.WriteChunkIndex(_associatedData, chunkIndex);
}
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
if (disposing && !_disposed) if (disposing && !_disposed)
{ {
_disposed = true; _disposed = true;
_chunkCipher.Dispose();
ClearKeyBytes(); ClearKeyBytes();
try try
{ {
#if NETSTANDARD2_0
_cipherStream.Dispose(); _cipherStream.Dispose();
#else
// Also covers a provider stream that only implements DisposeAsync
AsyncHelper.RunSync(() => _cipherStream.DisposeAsync().AsTask());
#endif
} }
finally finally
{ {
@ -119,6 +151,7 @@ internal class ChunkedDecryptingReadStream : ChunkedCryptoReadStream
if (!_disposed) if (!_disposed)
{ {
_disposed = true; _disposed = true;
_chunkCipher.Dispose();
ClearKeyBytes(); ClearKeyBytes();
try try
{ {
@ -141,7 +174,7 @@ internal class ChunkedDecryptingReadStream : ChunkedCryptoReadStream
#if NETSTANDARD2_0 #if NETSTANDARD2_0
Array.Clear(_keyBytes, 0, _keyBytes.Length); Array.Clear(_keyBytes, 0, _keyBytes.Length);
#else #else
System.Security.Cryptography.CryptographicOperations.ZeroMemory(_keyBytes); CryptographicOperations.ZeroMemory(_keyBytes);
#endif #endif
} }
} }

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

@ -1,4 +1,6 @@
using System;
using System.IO; using System.IO;
using System.Security.Cryptography;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -11,9 +13,10 @@ internal class ChunkedEncryptingReadStream : ChunkedCryptoReadStream
{ {
private readonly Stream _plainStream; private readonly Stream _plainStream;
private readonly byte[] _prefix; private readonly byte[] _prefix;
private readonly byte[] _associatedDataPrefix; private readonly byte[] _associatedData;
private readonly byte[] _keyBytes; private readonly byte[] _keyBytes;
private readonly byte[] _baseNonce; private readonly IDisposable _chunkCipher;
private readonly byte[] _nonce;
private readonly int _chunkSize; private readonly int _chunkSize;
private bool _prefixEmitted; private bool _prefixEmitted;
private bool _terminalEmitted; private bool _terminalEmitted;
@ -31,9 +34,11 @@ internal class ChunkedEncryptingReadStream : ChunkedCryptoReadStream
{ {
_plainStream = plainStream; _plainStream = plainStream;
_prefix = prefix; _prefix = prefix;
_associatedDataPrefix = associatedDataPrefix; // One reusable cipher and buffer each; only the trailing chunk index changes per chunk
_associatedData = BlobEncryptionCodec.CreateReusableAssociatedData(associatedDataPrefix);
_keyBytes = keyBytes; _keyBytes = keyBytes;
_baseNonce = baseNonce; _chunkCipher = BlobEncryptionCodec.CreateChunkCipher(keyBytes);
_nonce = BlobEncryptionCodec.CreateReusableChunkNonce(baseNonce);
_chunkSize = chunkSize; _chunkSize = chunkSize;
} }
@ -80,19 +85,28 @@ internal class ChunkedEncryptingReadStream : ChunkedCryptoReadStream
} }
_terminalEmitted = true; _terminalEmitted = true;
return BlobEncryptionCodec.CreateTerminalRecord(_keyBytes, _associatedDataPrefix, _baseNonce, _chunkIndex); SetChunkIndex(_chunkIndex);
return BlobEncryptionCodec.CreateTerminalRecordCore(_chunkCipher, _associatedData, _nonce);
} }
var chunkBytes = BlobEncryptionCodec.EncryptChunk(_keyBytes, _associatedDataPrefix, _baseNonce, _chunkIndex, plainChunk, plainChunk.Length); SetChunkIndex(_chunkIndex);
var chunkBytes = BlobEncryptionCodec.EncryptChunkCore(_chunkCipher, _associatedData, _nonce, plainChunk, plainChunk.Length);
_chunkIndex++; _chunkIndex++;
return chunkBytes; return chunkBytes;
} }
private void SetChunkIndex(int chunkIndex)
{
BlobEncryptionCodec.WriteChunkIndex(_nonce, chunkIndex);
BlobEncryptionCodec.WriteChunkIndex(_associatedData, chunkIndex);
}
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
// Do not dispose the plain stream; it is owned by the caller. // Do not dispose the plain stream; it is owned by the caller.
if (disposing) if (disposing)
{ {
_chunkCipher.Dispose();
ClearKeyBytes(); ClearKeyBytes();
} }
@ -102,9 +116,9 @@ internal class ChunkedEncryptingReadStream : ChunkedCryptoReadStream
private void ClearKeyBytes() private void ClearKeyBytes()
{ {
#if NETSTANDARD2_0 #if NETSTANDARD2_0
System.Array.Clear(_keyBytes, 0, _keyBytes.Length); Array.Clear(_keyBytes, 0, _keyBytes.Length);
#else #else
System.Security.Cryptography.CryptographicOperations.ZeroMemory(_keyBytes); CryptographicOperations.ZeroMemory(_keyBytes);
#endif #endif
} }
} }

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

@ -1,5 +1,6 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using JetBrains.Annotations;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Volo.Abp.DependencyInjection; using Volo.Abp.DependencyInjection;
@ -20,13 +21,15 @@ public class DefaultBlobEncryptionKeyProvider : IBlobEncryptionKeyProvider, ITra
Options = options.Value; Options = options.Value;
} }
/// <inheritdoc />
public virtual Task<BlobEncryptionKey> ResolveForEncryptionAsync( public virtual Task<BlobEncryptionKey> ResolveForEncryptionAsync(
BlobContainerConfiguration configuration, [NotNull] BlobEncryptionKeyContext context,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
Check.NotNull(context, nameof(context));
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var containerPassPhrase = GetContainerPassPhraseOrNull(configuration); var containerPassPhrase = GetContainerPassPhraseOrNull(context.Configuration);
if (!string.IsNullOrWhiteSpace(containerPassPhrase)) if (!string.IsNullOrWhiteSpace(containerPassPhrase))
{ {
return Task.FromResult(new BlobEncryptionKey(BlobEncryptionKeySource.Container, containerPassPhrase!)); return Task.FromResult(new BlobEncryptionKey(BlobEncryptionKeySource.Container, containerPassPhrase!));
@ -44,18 +47,20 @@ public class DefaultBlobEncryptionKeyProvider : IBlobEncryptionKeyProvider, ITra
); );
} }
/// <inheritdoc />
public virtual Task<string> ResolveForDecryptionAsync( public virtual Task<string> ResolveForDecryptionAsync(
BlobEncryptionKeySource keySource, BlobEncryptionKeySource keySource,
BlobContainerConfiguration configuration, [NotNull] BlobEncryptionKeyContext context,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
Check.NotNull(context, nameof(context));
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
string? passPhrase; string? passPhrase;
switch (keySource) switch (keySource)
{ {
case BlobEncryptionKeySource.Container: case BlobEncryptionKeySource.Container:
passPhrase = GetContainerPassPhraseOrNull(configuration); passPhrase = GetContainerPassPhraseOrNull(context.Configuration);
break; break;
case BlobEncryptionKeySource.Tenant: case BlobEncryptionKeySource.Tenant:
throw new AbpException( throw new AbpException(

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

@ -0,0 +1,26 @@
using System.Threading;
using System.Threading.Tasks;
namespace Volo.Abp.BlobStoring;
/// <summary>
/// Implemented by a read stream (like the decrypting stream) that can verify it was
/// read to an authenticated end. The content pipeline calls it when the composed
/// stream returned by <c>GetAsync</c> reaches EOF, so a contributor stopping before
/// the end can not hide a truncation. A stream that wraps such a stream (for example
/// a custom <c>CreateDecryptingStreamAsync</c> override) should implement this
/// interface too and forward the calls to the wrapped stream, or the end verification
/// is skipped for pipeline reads.
/// </summary>
public interface IBlobAuthenticatedEndStream
{
/// <summary>
/// Throws if the stream has not been consumed up to its authenticated end.
/// </summary>
void EnsureReadToAuthenticatedEnd();
/// <summary>
/// Throws if the stream has not been consumed up to its authenticated end.
/// </summary>
ValueTask EnsureReadToAuthenticatedEndAsync(CancellationToken cancellationToken = default);
}

41
framework/src/Volo.Abp.BlobStoring/Volo/Abp/BlobStoring/IBlobEncryptionCodec.cs

@ -0,0 +1,41 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
namespace Volo.Abp.BlobStoring;
/// <summary>
/// Encrypts and decrypts the BLOB content stream (authenticated, chunked AES-256-GCM).
/// Replace this service to change the encryption format or algorithm; the built-in
/// <see cref="BlobEncryptionCodec"/> implements version 1 of the format.
/// </summary>
public interface IBlobEncryptionCodec
{
/// <summary>
/// Wraps <paramref name="plainStream"/> in a read-only stream that encrypts the
/// content while it is read. The container and BLOB names are expected in their
/// normalized form.
/// </summary>
Task<Stream> CreateEncryptingStreamAsync(
[NotNull] BlobContainerConfiguration configuration,
[NotNull] string containerName,
[NotNull] string blobName,
Guid? tenantId,
[NotNull] Stream plainStream,
CancellationToken cancellationToken = default);
/// <summary>
/// Wraps <paramref name="cipherStream"/> in a read-only stream that decrypts the
/// content while it is read. The container and BLOB names are expected in their
/// normalized form.
/// </summary>
Task<Stream> CreateDecryptingStreamAsync(
[NotNull] BlobContainerConfiguration configuration,
[NotNull] string containerName,
[NotNull] string blobName,
Guid? tenantId,
[NotNull] Stream cipherStream,
CancellationToken cancellationToken = default);
}

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

@ -1,5 +1,6 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using JetBrains.Annotations;
namespace Volo.Abp.BlobStoring; namespace Volo.Abp.BlobStoring;
@ -7,7 +8,9 @@ namespace Volo.Abp.BlobStoring;
/// Resolves the passphrase used to encrypt/decrypt the BLOBs of a container. /// 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 /// 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 /// or another secret store (the provider must be able to return the passphrase
/// itself; hardware-backed non-exportable keys are not supported). /// itself; hardware-backed non-exportable keys are not supported). The
/// <see cref="BlobEncryptionKeyContext"/> carries the container/BLOB name and the
/// tenant, so the passphrase can be selected by the BLOB identity too.
/// </summary> /// </summary>
public interface IBlobEncryptionKeyProvider public interface IBlobEncryptionKeyProvider
{ {
@ -15,7 +18,7 @@ public interface IBlobEncryptionKeyProvider
/// Resolves the passphrase (and its source) to encrypt a new BLOB; throws if none is available. /// Resolves the passphrase (and its source) to encrypt a new BLOB; throws if none is available.
/// </summary> /// </summary>
Task<BlobEncryptionKey> ResolveForEncryptionAsync( Task<BlobEncryptionKey> ResolveForEncryptionAsync(
BlobContainerConfiguration configuration, [NotNull] BlobEncryptionKeyContext context,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
/// <summary> /// <summary>
@ -24,6 +27,6 @@ public interface IBlobEncryptionKeyProvider
/// </summary> /// </summary>
Task<string> ResolveForDecryptionAsync( Task<string> ResolveForDecryptionAsync(
BlobEncryptionKeySource keySource, BlobEncryptionKeySource keySource,
BlobContainerConfiguration configuration, [NotNull] BlobEncryptionKeyContext context,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
} }

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

@ -0,0 +1,41 @@
using System.Threading.Tasks;
using JetBrains.Annotations;
namespace Volo.Abp.BlobStoring;
/// <summary>
/// Transforms the BLOB content stream (compression, watermarking, validation...)
/// while it is saved and read. Contributors are configured per container with
/// <see cref="BlobContainerConfiguration.PipelineContributors"/> and run in the
/// configuration order while saving and in the reverse order while reading.
/// The built-in encryption always runs after the contributors while saving (and
/// before them while reading), so contributors always work on the plain content.
/// </summary>
public interface IBlobPipelineContributor
{
/// <summary>
/// Transform the content by replacing <see cref="BlobPipelineContext.BlobStream"/>:
/// with a lazily transforming read-only wrapper (best for large content), or with an
/// eagerly materialized stream. A replacement must leave the stream it received open:
/// every stream <b>assigned</b> to <see cref="BlobPipelineContext.BlobStream"/> is disposed
/// after the save, while the original stream stays owned by the caller. A stream is only
/// tracked from the moment it is assigned, so if you create a stream and then do work
/// that may fail before assigning it, dispose it yourself on the failure path.
/// <para>
/// Not replacing the stream is only valid for a contributor that does not consume the
/// content (for example a metadata check). A contributor that reads the content to
/// validate it must return a pass-through wrapper that validates the bytes as they
/// flow (or an eagerly materialized replacement) — reading the content without
/// replacing the stream would leave an empty/truncated stream for the provider.
/// </para>
/// </summary>
Task OnSavingAsync([NotNull] BlobPipelineContext context);
/// <summary>
/// Reverse the save-time transformation by replacing
/// <see cref="BlobPipelineContext.BlobStream"/> the same way. Here a replacement
/// must dispose the stream it received when it is disposed, since the composed
/// stream is returned to the caller as a whole.
/// </summary>
Task OnGettingAsync([NotNull] BlobPipelineContext context);
}

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

@ -2,6 +2,7 @@ using System;
using System.IO; using System.IO;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Volo.Abp.Threading;
namespace Volo.Abp.BlobStoring; namespace Volo.Abp.BlobStoring;
@ -12,29 +13,55 @@ internal sealed class PrefixingReadStream : SequentialReadStream
{ {
private readonly byte[] _prefix; private readonly byte[] _prefix;
private readonly Stream _stream; private readonly Stream _stream;
private readonly long? _length;
private int _prefixPosition; private int _prefixPosition;
public PrefixingReadStream(byte[] prefix, Stream stream) public PrefixingReadStream(byte[] prefix, Stream stream)
{ {
_prefix = prefix; _prefix = prefix;
_stream = stream; _stream = stream;
// What this stream serves is the prefix plus whatever remains of the underlying
// stream from its current position — not the underlying total length, which
// would overstate it when the provider stream did not start at position 0
try
{
_length = _prefix.Length + (_stream.Length - _stream.Position);
}
catch (Exception ex) when (ex is NotSupportedException || ex is IOException)
{
_length = null;
}
} }
public override bool CanRead => _stream.CanRead; public override bool CanRead => !IsDisposed && _stream.CanRead;
// Legacy plaintext BLOBs had a usable Length before encryption was enabled; // Legacy plaintext BLOBs had a usable Length before encryption was enabled; it is
// keep it available when the underlying stream knows it. // known when the underlying stream reports both its length and position. Position
public override long Length => _stream.CanSeek ? _stream.Length : throw new NotSupportedException(); // reports the bytes served, so Length - Position stays meaningful for length-aware
// consumers (like re-encrypting the legacy content)
public override long Length => _length ?? throw new NotSupportedException();
public override long Position
{
get => _position;
set => throw new NotSupportedException();
}
private long _position;
protected override int ReadCore(byte[] buffer, int offset, int count) protected override int ReadCore(byte[] buffer, int offset, int count)
{ {
var prefixReadCount = TryCopyFromPrefix(buffer, offset, count); var prefixReadCount = TryCopyFromPrefix(buffer, offset, count);
if (prefixReadCount > 0) if (prefixReadCount > 0)
{ {
_position += prefixReadCount;
return prefixReadCount; return prefixReadCount;
} }
return _stream.Read(buffer, offset, count); var readCount = _stream.Read(buffer, offset, count);
_position += readCount;
return readCount;
} }
protected override async Task<int> ReadCoreAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) protected override async Task<int> ReadCoreAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
@ -42,10 +69,19 @@ internal sealed class PrefixingReadStream : SequentialReadStream
var prefixReadCount = TryCopyFromPrefix(buffer, offset, count); var prefixReadCount = TryCopyFromPrefix(buffer, offset, count);
if (prefixReadCount > 0) if (prefixReadCount > 0)
{ {
_position += prefixReadCount;
return prefixReadCount; return prefixReadCount;
} }
return await _stream.ReadAsync(buffer, offset, count, cancellationToken); #if NETSTANDARD2_0
var readCount = await _stream.ReadAsync(buffer, offset, count, cancellationToken);
#else
// The modern overload dispatches correctly for streams that only
// implement ReadAsync(Memory<byte>)
var readCount = await _stream.ReadAsync(buffer.AsMemory(offset, count), cancellationToken);
#endif
_position += readCount;
return readCount;
} }
private int TryCopyFromPrefix(byte[] buffer, int offset, int count) private int TryCopyFromPrefix(byte[] buffer, int offset, int count)
@ -68,7 +104,21 @@ internal sealed class PrefixingReadStream : SequentialReadStream
IsDisposed = true; IsDisposed = true;
try try
{ {
_stream.Dispose(); #if NETSTANDARD2_0
// Stream has no DisposeAsync on netstandard2.0, but the provider stream
// may still implement IAsyncDisposable for its async-only cleanup
if (_stream is IAsyncDisposable asyncDisposable)
{
AsyncHelper.RunSync(() => asyncDisposable.DisposeAsync().AsTask());
}
else
{
_stream.Dispose();
}
#else
// Also covers a provider stream that only implements DisposeAsync
AsyncHelper.RunSync(() => _stream.DisposeAsync().AsTask());
#endif
} }
finally finally
{ {

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

@ -14,7 +14,15 @@ internal abstract class SequentialReadStream : Stream
protected bool IsDisposed { get; set; } protected bool IsDisposed { get; set; }
public override bool CanRead => true; // Once faulted, no further read can succeed; a failed authenticated-end check
// uses it so the failure can not be swallowed by a read-retry layer
protected void MarkFaulted()
{
_faulted = true;
}
// False once disposed, so it stays consistent with Read throwing ObjectDisposedException
public override bool CanRead => !IsDisposed;
public override bool CanSeek => false; public override bool CanSeek => false;
@ -100,7 +108,7 @@ internal abstract class SequentialReadStream : Stream
base.Dispose(disposing); base.Dispose(disposing);
} }
private void EnsureCanServe() protected void EnsureCanServe()
{ {
if (IsDisposed) if (IsDisposed)
{ {

104
framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderUploadDecision_Tests.cs

@ -0,0 +1,104 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Shouldly;
using Xunit;
namespace Volo.Abp.BlobStoring.Aws;
public class AwsBlobProviderUploadDecision_Tests
{
private readonly ExposedAwsBlobProvider _provider = new ExposedAwsBlobProvider();
[Fact]
public void Should_Keep_The_Plain_PutObject_Behavior_For_Untransformed_Containers()
{
// A non-seekable stream of a container without encryption/pipeline
// must be uploaded exactly like before
var args = CreateArgs(new BlobContainerConfiguration(), new NonSeekableStream());
_provider.RequiresRetrySafeUploadPublic(args).ShouldBeFalse();
}
[Fact]
public void Should_Use_The_Retry_Safe_Upload_For_An_Encrypted_Container()
{
var configuration = new BlobContainerConfiguration().UseEncryption("test-passphrase");
var args = CreateArgs(configuration, new NonSeekableStream());
_provider.RequiresRetrySafeUploadPublic(args).ShouldBeTrue();
}
[Fact]
public void Should_Use_The_Retry_Safe_Upload_For_A_Container_With_PipelineContributors()
{
var configuration = new BlobContainerConfiguration();
configuration.PipelineContributors.Add<FakePipelineContributor>();
var args = CreateArgs(configuration, new NonSeekableStream());
_provider.RequiresRetrySafeUploadPublic(args).ShouldBeTrue();
}
[Fact]
public void Should_Not_Use_The_Retry_Safe_Upload_For_A_Seekable_Stream()
{
var configuration = new BlobContainerConfiguration().UseEncryption("test-passphrase");
var args = CreateArgs(configuration, new MemoryStream());
_provider.RequiresRetrySafeUploadPublic(args).ShouldBeFalse();
}
private static BlobProviderSaveArgs CreateArgs(BlobContainerConfiguration configuration, Stream stream)
{
return new BlobProviderSaveArgs("test-container", configuration, "test-blob", stream);
}
private sealed class ExposedAwsBlobProvider : AwsBlobProvider
{
public ExposedAwsBlobProvider()
: base(null!, null!, null!)
{
}
public bool RequiresRetrySafeUploadPublic(BlobProviderSaveArgs args)
{
return RequiresRetrySafeUpload(args);
}
}
private sealed class FakePipelineContributor : IBlobPipelineContributor
{
public Task OnSavingAsync(BlobPipelineContext context)
{
return Task.CompletedTask;
}
public Task OnGettingAsync(BlobPipelineContext context)
{
return Task.CompletedTask;
}
}
private sealed class NonSeekableStream : Stream
{
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) => 0;
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();
}
}

84
framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderUploadRequest_Tests.cs

@ -0,0 +1,84 @@
using System.IO;
using Amazon.S3.Model;
using Amazon.S3.Transfer;
using Shouldly;
using Xunit;
namespace Volo.Abp.BlobStoring.Aws;
public class AwsBlobProviderUploadRequest_Tests
{
private readonly ExposedAwsBlobProvider _provider = new ExposedAwsBlobProvider();
[Fact]
public void Should_Wrap_The_Stream_And_Disable_Auto_Close_For_A_Multipart_Request()
{
// The SDK's non-seekable multipart path ignores AutoCloseStream and disposes the
// input, so the source must be protected by the leave-open wrapper
var source = new MemoryStream();
var configuration = new AwsBlobProviderConfiguration(new BlobContainerConfiguration()) { DisablePayloadSigning = true };
var request = _provider.CreateMultipartUploadRequestPublic("bucket", "key", source, configuration);
request.InputStream.ShouldBeOfType<LeaveOpenStreamWrapper>();
request.AutoCloseStream.ShouldBeFalse();
request.DisablePayloadSigning.ShouldBe(true);
request.BucketName.ShouldBe("bucket");
request.Key.ShouldBe("key");
}
[Fact]
public void Should_Keep_The_Source_Open_After_The_Multipart_Input_Is_Disposed()
{
var source = new MemoryStream();
var configuration = new AwsBlobProviderConfiguration(new BlobContainerConfiguration());
var request = _provider.CreateMultipartUploadRequestPublic("bucket", "key", source, configuration);
request.InputStream.Dispose(); // the SDK disposes the input on the multipart path
source.CanRead.ShouldBeTrue(); // the wrapper must have left the source open
}
[Fact]
public void Should_Propagate_Disable_Payload_Signing_And_Keep_Ownership_For_A_Put_Object_Request()
{
var source = new MemoryStream();
var configuration = new AwsBlobProviderConfiguration(new BlobContainerConfiguration()) { DisablePayloadSigning = true };
var request = _provider.CreatePutObjectRequestPublic("bucket", "key", source, configuration);
request.InputStream.ShouldBeSameAs(source);
request.AutoCloseStream.ShouldBeFalse();
request.DisablePayloadSigning.ShouldBe(true);
}
[Fact]
public void Should_Keep_Disable_Payload_Signing_Off_By_Default_For_A_Put_Object_Request()
{
var configuration = new AwsBlobProviderConfiguration(new BlobContainerConfiguration());
var request = _provider.CreatePutObjectRequestPublic("bucket", "key", new MemoryStream(), configuration);
request.DisablePayloadSigning.ShouldBe(false);
}
private sealed class ExposedAwsBlobProvider : AwsBlobProvider
{
public ExposedAwsBlobProvider()
: base(null!, null!, null!)
{
}
public TransferUtilityUploadRequest CreateMultipartUploadRequestPublic(
string containerName, string blobName, Stream blobStream, AwsBlobProviderConfiguration configuration)
{
return CreateMultipartUploadRequest(containerName, blobName, blobStream, configuration);
}
public PutObjectRequest CreatePutObjectRequestPublic(
string containerName, string blobName, Stream blobStream, AwsBlobProviderConfiguration configuration)
{
return CreatePutObjectRequest(containerName, blobName, blobStream, configuration);
}
}
}

54
framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/LeaveOpenStreamWrapper_Tests.cs

@ -0,0 +1,54 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Shouldly;
using Volo.Abp.BlobStoring.Fakes;
using Xunit;
namespace Volo.Abp.BlobStoring.Aws;
public class LeaveOpenStreamWrapper_Tests
{
[Fact]
public async Task Should_Bridge_The_Old_Read_Overload_To_The_Modern_One_And_Leave_The_Stream_Open()
{
var content = new byte[1000];
new Random(42).NextBytes(content);
using var inner = new FakeModernAsyncOnlyStream(new MemoryStream(content));
var wrapper = new LeaveOpenStreamWrapper(inner);
// The SDK reads over the old overload; the inner stream only supports the modern one
var buffer = new byte[content.Length];
var totalReadCount = 0;
while (totalReadCount < buffer.Length)
{
var readCount = await wrapper.ReadAsync(buffer, totalReadCount, buffer.Length - totalReadCount, default);
if (readCount == 0)
{
break;
}
totalReadCount += readCount;
}
totalReadCount.ShouldBe(content.Length);
buffer.SequenceEqual(content).ShouldBeTrue();
wrapper.Dispose();
// The wrapped stream stays open (a disposed one would throw here)
(await inner.ReadAsync(new byte[1].AsMemory(), default)).ShouldBe(0);
}
[Fact]
public void Should_Translate_A_Failing_Length_Probe_For_The_Sdk()
{
// The SDK only handles NotSupportedException while probing the content length
using var inner = new FakeIoFailingLengthStream(new MemoryStream(new byte[10]));
var wrapper = new LeaveOpenStreamWrapper(inner);
Should.Throw<NotSupportedException>(() => wrapper.Length);
Should.Throw<NotSupportedException>(() => wrapper.Position);
}
}

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

@ -4,9 +4,11 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Shouldly; using Shouldly;
using Volo.Abp.BlobStoring.TestObjects; using Volo.Abp.BlobStoring.TestObjects;
using Volo.Abp.DependencyInjection;
using Volo.Abp.MultiTenancy; using Volo.Abp.MultiTenancy;
using Xunit; using Xunit;
@ -16,7 +18,7 @@ public class FileSystemBlobEncryption_Tests : AbpBlobStoringFileSystemTestBase
{ {
private readonly IBlobContainer<TestContainer4> _container4; // UseEncryption("container4-passphrase") private readonly IBlobContainer<TestContainer4> _container4; // UseEncryption("container4-passphrase")
private readonly IBlobContainer<TestContainer5> _container5; // UseEncryption() -> key provider (tenant setting / global options) private readonly IBlobContainer<TestContainer5> _container5; // UseEncryption() -> key provider (tenant setting / global options)
private readonly IBlobContainer<TestContainer6> _container6; // UseEncryption("container6-passphrase", allowLegacyPlaintext: true) private readonly IBlobContainer<TestContainer6> _container6; // UseEncryption("container6-passphrase", allowLegacyPlainText: true)
private readonly IBlobFilePathCalculator _filePathCalculator; private readonly IBlobFilePathCalculator _filePathCalculator;
private readonly IBlobContainerConfigurationProvider _configurationProvider; private readonly IBlobContainerConfigurationProvider _configurationProvider;
private readonly ICurrentTenant _currentTenant; private readonly ICurrentTenant _currentTenant;
@ -284,6 +286,156 @@ public class FileSystemBlobEncryption_Tests : AbpBlobStoringFileSystemTestBase
source.FaultsInjected.ShouldBe(1); // No second attempt source.FaultsInjected.ShouldBe(1); // No second attempt
} }
[Fact]
public async Task Should_Retry_A_Transient_Failure_Before_The_Target_Is_Opened()
{
// The encrypting wrapper is not seekable, but nothing is consumed while the
// target file can not even be opened, so such a failure is safe to retry
var blobName = "fs-retry-open-phase";
await _container4.SaveAsync(blobName, "first content".GetBytes(), overrideExisting: true);
using (var fileLock = File.Open(GetFilePath<TestContainer4>(blobName), FileMode.Open, FileAccess.Read, FileShare.None))
{
var saveTask = _container4.SaveAsync(blobName, "second content".GetBytes(), overrideExisting: true);
await Task.Delay(300); // The first attempt fails while the file is locked
fileLock.Dispose();
await saveTask;
}
(await _container4.GetAllBytesAsync(blobName)).ShouldBe("second content".GetBytes());
}
[Fact]
public async Task Should_Not_Retry_A_New_Save_After_The_Target_File_Was_Created()
{
// A failed CreateNew attempt leaves the file behind, so retrying would only
// hit the leftover; even a seekable source must fail after a mid-write fault
var container8 = GetRequiredService<IBlobContainer<TestContainer8>>();
var content = new byte[64 * 1024];
new Random(42).NextBytes(content);
var source = new FaultOnceSeekableStream(content);
await Assert.ThrowsAsync<IOException>(async () =>
{
await container8.SaveAsync("fs-retry-create-new", source);
});
source.FaultsInjected.ShouldBe(1); // No second attempt
}
[Fact]
public async Task Should_Retry_A_Failed_Open_For_A_New_Save()
{
var content = new byte[64 * 1024];
new Random(42).NextBytes(content);
var source = new MemoryStream(content);
var provider = new FaultingOpenFileSystemBlobProvider(_filePathCalculator, source);
await provider.SaveAsync(new BlobProviderSaveArgs(
BlobContainerNameAttribute.GetContainerName<TestContainer8>(),
_configurationProvider.Get<TestContainer8>(),
"fs-retry-open-create-new",
source
));
provider.OpenAttempts.ShouldBe(2); // The first open failed, the retry succeeded
provider.SourcePositionAtFirstFault.ShouldBe(0); // Nothing was consumed before the failure
var fileBytes = await File.ReadAllBytesAsync(GetFilePath<TestContainer8>("fs-retry-open-create-new"));
fileBytes.SequenceEqual(content).ShouldBeTrue();
}
[DisableConventionalRegistration]
private sealed class FaultingOpenFileSystemBlobProvider : FileSystemBlobProvider
{
private readonly Stream _source;
public int OpenAttempts { get; private set; }
public long SourcePositionAtFirstFault { get; private set; } = -1;
public FaultingOpenFileSystemBlobProvider(IBlobFilePathCalculator filePathCalculator, Stream source)
: base(filePathCalculator)
{
_source = source;
}
protected override Stream OpenFileStream(string filePath, FileMode fileMode)
{
OpenAttempts++;
if (OpenAttempts == 1)
{
SourcePositionAtFirstFault = _source.Position;
throw new IOException("Injected open failure!");
}
return base.OpenFileStream(filePath, fileMode);
}
}
[Fact]
public async Task Should_Save_When_The_Position_Probe_Of_A_Seekable_Stream_Fails()
{
// A failing probe degrades to a single, non-replayable attempt instead of failing the save
var container8 = GetRequiredService<IBlobContainer<TestContainer8>>();
var content = "position probe failure content".GetBytes();
using var source = new PositionThrowingSeekableStream(content);
await container8.SaveAsync("fs-position-probe", source, overrideExisting: true);
(await container8.GetAllBytesAsync("fs-position-probe")).ShouldBe(content);
}
private sealed class PositionThrowingSeekableStream : Stream
{
private readonly MemoryStream _stream;
private bool _positionFaultInjected;
public PositionThrowingSeekableStream(byte[] bytes)
{
_stream = new MemoryStream(bytes);
}
public override bool CanRead => true;
public override bool CanSeek => true;
public override bool CanWrite => false;
public override long Length => _stream.Length;
public override long Position
{
get
{
// Fail once, transiently, on the first probe
if (!_positionFaultInjected)
{
_positionFaultInjected = true;
throw new IOException("The position is not available!");
}
return _stream.Position;
}
set => _stream.Position = value;
}
public override void Flush()
{
}
public override int Read(byte[] buffer, int offset, int count) => _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);
}
}
private sealed class FaultOnceSeekableStream : Stream private sealed class FaultOnceSeekableStream : Stream
{ {
private readonly MemoryStream _stream; private readonly MemoryStream _stream;
@ -318,7 +470,7 @@ public class FileSystemBlobEncryption_Tests : AbpBlobStoringFileSystemTestBase
return ReadCore(() => _stream.Read(buffer, offset, count)); return ReadCore(() => _stream.Read(buffer, offset, count));
} }
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{ {
return Task.FromResult(ReadCore(() => _stream.Read(buffer, offset, count))); return Task.FromResult(ReadCore(() => _stream.Read(buffer, offset, count)));
} }
@ -402,7 +554,7 @@ public class FileSystemBlobEncryption_Tests : AbpBlobStoringFileSystemTestBase
throw new InvalidOperationException("Synchronous reads are not allowed on this stream!"); 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) public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{ {
return _stream.ReadAsync(buffer, offset, count, cancellationToken); return _stream.ReadAsync(buffer, offset, count, cancellationToken);
} }

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

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using NSubstitute; using NSubstitute;
using Volo.Abp.Autofac; using Volo.Abp.Autofac;
using Volo.Abp.BlobStoring.Fakes; using Volo.Abp.BlobStoring.Fakes;
@ -24,7 +25,7 @@ public class AbpBlobStoringTestModule : AbpModule
serviceProvider => serviceProvider.GetRequiredService<FakeInMemoryBlobProvider>() serviceProvider => serviceProvider.GetRequiredService<FakeInMemoryBlobProvider>()
); );
context.Services.AddTransient<IBlobEncryptionKeyProvider, FakeTenantBlobEncryptionKeyProvider>(); context.Services.Replace(ServiceDescriptor.Transient<IBlobEncryptionKeyProvider, FakeTenantBlobEncryptionKeyProvider>());
Configure<AbpBlobStoringEncryptionOptions>(options => Configure<AbpBlobStoringEncryptionOptions>(options =>
{ {
@ -66,7 +67,7 @@ public class AbpBlobStoringTestModule : AbpModule
.Configure<TestContainer6>(container => .Configure<TestContainer6>(container =>
{ {
container.ProviderType = typeof(FakeInMemoryBlobProvider); container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.UseEncryption("container6-passphrase", allowLegacyPlaintext: true); container.UseEncryption("container6-passphrase", allowLegacyPlainText: true);
}) })
.Configure<TestContainer7>(container => .Configure<TestContainer7>(container =>
{ {
@ -77,6 +78,85 @@ public class AbpBlobStoringTestModule : AbpModule
.Configure<TestContainer8>(container => .Configure<TestContainer8>(container =>
{ {
container.ProviderType = typeof(FakeInMemoryBlobProvider); container.ProviderType = typeof(FakeInMemoryBlobProvider);
})
.Configure("pipeline-markers", container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.PipelineContributors.Add<FakeAPipelineContributor>();
container.PipelineContributors.Add<FakeBPipelineContributor>();
})
.Configure("pipeline-encrypted", container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.UseEncryption("pipeline-passphrase");
container.PipelineContributors.Add<FakeAPipelineContributor>();
})
.Configure("pipeline-scoped", container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.PipelineContributors.Add<FakeScopedXorPipelineContributor>();
})
.Configure("get-bad-encryption-config", container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
// A mis-typed value makes reading the encryption flag throw while getting
container.SetConfiguration(BlobEncryptionConfigurationNames.Enabled, "not-a-bool");
})
.Configure("pipeline-failing-get", container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.PipelineContributors.Add<FakeFailingGetPipelineContributor>();
})
.Configure("pipeline-set-throw-save", container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.PipelineContributors.Add<FakeSetThenThrowPipelineContributor>();
})
.Configure("pipeline-partial-get", container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.PipelineContributors.Add<FakeFailingGetPipelineContributor>();
container.PipelineContributors.Add<FakeAPipelineContributor>();
})
.Configure("pipeline-dispose-throw", container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.PipelineContributors.Add<FakeScopedXorPipelineContributor>();
container.PipelineContributors.Add<FakeDisposeThrowingPipelineContributor>();
})
.Configure("pipeline-async-scoped", container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.PipelineContributors.Add<FakeAsyncScopedPipelineContributor>();
})
.Configure("pipeline-encrypted-earlystop", container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.UseEncryption("earlystop-passphrase");
container.PipelineContributors.Add<FakeEarlyStopPipelineContributor>();
})
.Configure("pipeline-unwrap", container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.PipelineContributors.Add<FakeAPipelineContributor>();
container.PipelineContributors.Add<FakeOriginalRestoringPipelineContributor>();
})
.Configure("pipeline-async-dispose", container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.PipelineContributors.Add<FakeAsyncDisposePipelineContributor>();
})
.Configure("pipeline-modern-async", container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.PipelineContributors.Add<FakeModernAsyncPipelineContributor>();
})
.Configure("pipeline-shared-tenant", container =>
{
container.ProviderType = typeof(FakeInMemoryBlobProvider);
container.IsMultiTenant = false;
container.PipelineContributors.Add<FakeTenantAssertingPipelineContributor>();
container.PipelineContributors.Add<FakeTenantRecordingScopedPipelineContributor>();
}); });
}); });
} }

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

@ -104,14 +104,14 @@ public class BlobContainerConfiguration_Tests
public void Should_Keep_The_Configured_PassPhrase_When_UseEncryption_Is_Called_Again() public void Should_Keep_The_Configured_PassPhrase_When_UseEncryption_Is_Called_Again()
{ {
var configuration = new BlobContainerConfiguration(); var configuration = new BlobContainerConfiguration();
configuration.UseEncryption("first-passphrase", allowLegacyPlaintext: true); configuration.UseEncryption("first-passphrase", allowLegacyPlainText: true);
// Another module just ensuring that encryption is enabled must not // Another module just ensuring that encryption is enabled must not
// change the configured key or the legacy option. // change the configured key or the legacy option.
configuration.UseEncryption(); configuration.UseEncryption();
BlobEncryptionConfiguration.GetPassPhraseOrNull(configuration).ShouldBe("first-passphrase"); BlobEncryptionConfiguration.GetPassPhraseOrNull(configuration).ShouldBe("first-passphrase");
BlobEncryptionConfiguration.IsLegacyPlaintextAllowed(configuration).ShouldBeTrue(); BlobEncryptionConfiguration.IsLegacyPlainTextAllowed(configuration).ShouldBeTrue();
} }
[Fact] [Fact]
@ -129,12 +129,71 @@ public class BlobContainerConfiguration_Tests
BlobEncryptionConfiguration.GetPassPhraseOrNull(defaultConfig).ShouldBe("default-passphrase"); BlobEncryptionConfiguration.GetPassPhraseOrNull(defaultConfig).ShouldBe("default-passphrase");
} }
[Fact]
public void Should_Compose_Default_And_Named_Container_PipelineContributors()
{
var defaultConfig = new BlobContainerConfiguration();
defaultConfig.PipelineContributors.Add<FakeAPipelineContributor>();
var namedConfig = new BlobContainerConfiguration(defaultConfig);
namedConfig.PipelineContributors.Add<FakeBPipelineContributor>();
namedConfig.GetEffectivePipelineContributors()
.ShouldBe([typeof(FakeAPipelineContributor), typeof(FakeBPipelineContributor)]);
defaultConfig.GetEffectivePipelineContributors().ShouldBe([typeof(FakeAPipelineContributor)]);
}
[Fact]
public void Should_Keep_Inherited_PipelineContributors_When_The_Provider_Is_Overridden()
{
var defaultConfig = new BlobContainerConfiguration();
defaultConfig.ProviderType = typeof(FakeBlobProvider1);
defaultConfig.PipelineContributors.Add<FakeAPipelineContributor>();
var namedConfig = new BlobContainerConfiguration(defaultConfig);
namedConfig.ProviderType = typeof(FakeBlobProvider2);
namedConfig.GetEffectivePipelineContributors().ShouldBe([typeof(FakeAPipelineContributor)]);
}
[Fact]
public void Should_Not_Duplicate_A_PipelineContributor_Configured_On_Both_Levels()
{
var defaultConfig = new BlobContainerConfiguration();
defaultConfig.PipelineContributors.Add<FakeAPipelineContributor>();
defaultConfig.PipelineContributors.Add<FakeAPipelineContributor>();
var namedConfig = new BlobContainerConfiguration(defaultConfig);
namedConfig.PipelineContributors.Add<FakeAPipelineContributor>();
// A contributor type runs once, on every configuration level
defaultConfig.GetEffectivePipelineContributors().ShouldBe([typeof(FakeAPipelineContributor)]);
namedConfig.GetEffectivePipelineContributors().ShouldBe([typeof(FakeAPipelineContributor)]);
}
[Fact]
public void Should_Opt_Out_Of_The_Inherited_PipelineContributors()
{
var defaultConfig = new BlobContainerConfiguration();
defaultConfig.PipelineContributors.Add<FakeAPipelineContributor>();
var namedConfig = new BlobContainerConfiguration(defaultConfig);
namedConfig.InheritPipelineContributors = false;
namedConfig.PipelineContributors.Add<FakeBPipelineContributor>();
namedConfig.GetEffectivePipelineContributors().ShouldBe([typeof(FakeBPipelineContributor)]);
}
[Fact] [Fact]
public void Should_Reject_Empty_PassPhrase() public void Should_Reject_Empty_PassPhrase()
{ {
var configuration = new BlobContainerConfiguration(); var configuration = new BlobContainerConfiguration();
Assert.ThrowsAny<ArgumentException>(() => configuration.UseEncryption("")); Assert.ThrowsAny<ArgumentException>(() => configuration.UseEncryption(""));
Assert.ThrowsAny<ArgumentException>(() => configuration.UseEncryption(" ")); Assert.ThrowsAny<ArgumentException>(() => configuration.UseEncryption(" ", allowLegacyPlainText: true));
// A failed call must not leave the configuration partially modified
BlobEncryptionConfiguration.IsEnabled(configuration).ShouldBeFalse();
BlobEncryptionConfiguration.IsLegacyPlainTextAllowed(configuration).ShouldBeFalse();
} }
} }

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

@ -4,7 +4,9 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Shouldly; using Shouldly;
using Volo.Abp.BlobStoring.Fakes; using Volo.Abp.BlobStoring.Fakes;
using Volo.Abp.BlobStoring.TestObjects; using Volo.Abp.BlobStoring.TestObjects;
@ -17,7 +19,7 @@ public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase
{ {
private readonly IBlobContainer<TestContainer4> _container4; // UseEncryption("container4-passphrase") private readonly IBlobContainer<TestContainer4> _container4; // UseEncryption("container4-passphrase")
private readonly IBlobContainer<TestContainer5> _container5; // UseEncryption() -> key provider (tenant setting / global options) private readonly IBlobContainer<TestContainer5> _container5; // UseEncryption() -> key provider (tenant setting / global options)
private readonly IBlobContainer<TestContainer6> _container6; // UseEncryption("container6-passphrase", allowLegacyPlaintext: true) private readonly IBlobContainer<TestContainer6> _container6; // UseEncryption("container6-passphrase", allowLegacyPlainText: true)
private readonly FakeInMemoryBlobProvider _provider; private readonly FakeInMemoryBlobProvider _provider;
private readonly ICurrentTenant _currentTenant; private readonly ICurrentTenant _currentTenant;
@ -351,6 +353,63 @@ public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase
} }
} }
[Fact]
public async Task Should_Expose_Exact_Encrypted_Length_For_A_Length_Aware_Forward_Only_Input()
{
// CanSeek is false, but Length/Position are readable; MinIO-like providers need the length
var codec = GetRequiredService<BlobEncryptionCodec>();
var configuration = new BlobContainerConfiguration().UseEncryption("length-passphrase");
var source = new LengthAwareForwardOnlyStream(new byte[64 * 1024 + 1]);
using var encryptedStream = await codec.CreateEncryptingStreamAsync(configuration, "test-container", "length-blob-fo", null, source);
var reportedLength = encryptedStream.Length;
using var output = new MemoryStream();
encryptedStream.CopyTo(output);
reportedLength.ShouldBe(output.Length);
}
private sealed class LengthAwareForwardOnlyStream : Stream
{
private readonly MemoryStream _stream;
public LengthAwareForwardOnlyStream(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 => _stream.Length;
public override long Position
{
get => _stream.Position;
set => throw new NotSupportedException();
}
public override void Flush()
{
}
public override int Read(byte[] buffer, int offset, int count) => _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);
}
}
[Fact] [Fact]
public async Task Should_Reject_A_Blob_Copied_To_Another_Name() public async Task Should_Reject_A_Blob_Copied_To_Another_Name()
{ {
@ -409,7 +468,7 @@ public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase
[Fact] [Fact]
public async Task Should_Throw_When_Cancelled_Before_Saving() public async Task Should_Throw_When_Cancelled_Before_Saving()
{ {
using var cts = new System.Threading.CancellationTokenSource(); using var cts = new CancellationTokenSource();
cts.Cancel(); cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
@ -423,7 +482,7 @@ public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase
{ {
await _container4.SaveAsync("cancelled-read", "content".GetBytes()); await _container4.SaveAsync("cancelled-read", "content".GetBytes());
using var cts = new System.Threading.CancellationTokenSource(); using var cts = new CancellationTokenSource();
cts.Cancel(); cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
@ -441,7 +500,9 @@ public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase
await Assert.ThrowsAsync<AbpException>(async () => await Assert.ThrowsAsync<AbpException>(async () =>
{ {
await defaultProvider.ResolveForDecryptionAsync(BlobEncryptionKeySource.Tenant, new BlobContainerConfiguration()); await defaultProvider.ResolveForDecryptionAsync(
BlobEncryptionKeySource.Tenant,
new BlobEncryptionKeyContext(new BlobContainerConfiguration(), "c", "b", null));
}); });
} }
@ -455,7 +516,8 @@ public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase
await Assert.ThrowsAsync<AbpException>(async () => await Assert.ThrowsAsync<AbpException>(async () =>
{ {
await defaultProvider.ResolveForEncryptionAsync(configuration); await defaultProvider.ResolveForEncryptionAsync(
new BlobEncryptionKeyContext(configuration, "c", "b", null));
}); });
} }
@ -499,7 +561,7 @@ public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase
{ {
var blobName = "tiny-legacy-blob"; var blobName = "tiny-legacy-blob";
var tinyContent = new byte[] { 1, 2, 3 }; var tinyContent = new byte[] { 1, 2, 3 };
SetRawBytes<TestContainer6>(blobName, tinyContent); // allowLegacyPlaintext: true SetRawBytes<TestContainer6>(blobName, tinyContent); // allowLegacyPlainText: true
(await _container6.GetAllBytesAsync(blobName)).SequenceEqual(tinyContent).ShouldBeTrue(); (await _container6.GetAllBytesAsync(blobName)).SequenceEqual(tinyContent).ShouldBeTrue();
} }
@ -554,7 +616,9 @@ public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase
[Fact] [Fact]
public void Should_Reject_Wrapped_Chunk_Index() public void Should_Reject_Wrapped_Chunk_Index()
{ {
Should.Throw<AbpException>(() => BlobEncryptionCodec.CreateChunkNonce(new byte[8], -1)); // The production streams write the chunk index into a reusable nonce/AAD buffer with
// WriteChunkIndex, so a wrapped (negative) index must be rejected there
Should.Throw<AbpException>(() => BlobEncryptionCodec.WriteChunkIndex(new byte[BlobEncryptionCodec.GcmNonceSize], -1));
} }
[Fact] [Fact]
@ -620,7 +684,7 @@ public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase
} }
[Fact] [Fact]
public void Should_Keep_The_V1_Format_Stable() public async Task Should_Keep_The_V1_Format_Stable()
{ {
// Golden vector: deterministic ciphertext built from fixed inputs. // Golden vector: deterministic ciphertext built from fixed inputs.
// If this test breaks, the on-disk format changed and existing // If this test breaks, the on-disk format changed and existing
@ -654,6 +718,721 @@ public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase
using var output = new MemoryStream(); using var output = new MemoryStream();
decryptingStream.CopyTo(output); decryptingStream.CopyTo(output);
output.ToArray().ShouldBe(plain); output.ToArray().ShouldBe(plain);
// The full production reader must also keep reading historical v1 data:
// header parsing, key routing and the decrypting state machine included
var codec = GetRequiredService<BlobEncryptionCodec>();
var configuration = new BlobContainerConfiguration().UseEncryption("golden-passphrase");
using var historicalBlob = new MemoryStream(Convert.FromBase64String(expected));
using var readerStream = await codec.CreateDecryptingStreamAsync(configuration, "golden-container", "golden-blob", null, historicalBlob);
using var readerOutput = new MemoryStream();
await readerStream.CopyToAsync(readerOutput);
readerOutput.ToArray().ShouldBe(plain);
}
[Fact]
public async Task Should_Keep_The_V1_Format_Stable_For_Tenant_Blobs()
{
// Pins the AAD encoding of the tenant id: BLOBs of a tenant, stored with
// the v1 format, must stay readable by the current production reader
const string expected =
"QUJQRQEBAQABhqABAgMEBQYHCAkKCwwNDg8QAAEAAKChoqOkpaanAAAAHKa92MyQIqewxKJu99K+u4sDv5kVGZfI/vGgGbUD0JfeWzh4JbOYAQz5Axr1AAAAAIAHZhGwF81jmA7v8d0GZF0=";
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 tenantId = new Guid("11111111-2222-3333-4444-555555555555");
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", tenantId);
var plain = "golden tenant 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);
var codec = GetRequiredService<BlobEncryptionCodec>();
var configuration = new BlobContainerConfiguration().UseEncryption("golden-passphrase");
using var historicalBlob = new MemoryStream(Convert.FromBase64String(expected));
using var readerStream = await codec.CreateDecryptingStreamAsync(configuration, "golden-container", "golden-blob", tenantId, historicalBlob);
using var output = new MemoryStream();
await readerStream.CopyToAsync(output);
output.ToArray().ShouldBe(plain);
}
[Fact]
public async Task Should_Encrypt_And_Decrypt_A_Modern_Async_Only_Source()
{
var content = new byte[100_000];
new Random(42).NextBytes(content);
using (var source = new FakeModernAsyncOnlyStream(new MemoryStream(content)))
{
await _container4.SaveAsync("modern-source", source, overrideExisting: true);
}
(await _container4.GetAllBytesAsync("modern-source")).SequenceEqual(content).ShouldBeTrue();
// A modern-async-only cipher stream (like a modern provider response) works too
var codec = GetRequiredService<BlobEncryptionCodec>();
var configuration = new BlobContainerConfiguration().UseEncryption("modern-passphrase");
byte[] cipherBytes;
using (var encryptingStream = await codec.CreateEncryptingStreamAsync(configuration, "modern-container", "modern-blob", null, new MemoryStream(content)))
using (var cipherBuffer = new MemoryStream())
{
await encryptingStream.CopyToAsync(cipherBuffer);
cipherBytes = cipherBuffer.ToArray();
}
using var decryptingStream = await codec.CreateDecryptingStreamAsync(
configuration, "modern-container", "modern-blob", null, new FakeModernAsyncOnlyStream(new MemoryStream(cipherBytes)));
using var output = new MemoryStream();
await decryptingStream.CopyToAsync(output);
output.ToArray().SequenceEqual(content).ShouldBeTrue();
}
[Fact]
public async Task Should_Reject_Content_Truncated_Right_After_The_Magic_Even_When_Legacy_Is_Allowed()
{
// A full "ABPE" magic already identifies the encrypted format: it must fail
// as corrupted instead of being returned as (unauthenticated) legacy plaintext
SetRawBytes<TestContainer6>("magic-only", "ABPE"u8.ToArray());
var exception = await Assert.ThrowsAsync<AbpException>(async () =>
{
await _container6.GetAsync("magic-only");
});
exception.Message.ShouldContain("missing format version");
}
[Fact]
public async Task Should_Reject_A_Header_With_Fewer_Iterations_Than_The_Writer_Minimum()
{
// Accepting fewer iterations than any legitimate writer ever used would let
// attacker-crafted content turn reads into a cheap passphrase-guessing oracle
var salt = new byte[16];
var baseNonce = new byte[8];
const int lowIterations = 50_000;
var keyBytes = BlobEncryptionCodec.DeriveKeyBytes("oracle-passphrase", salt, lowIterations);
var header = BlobEncryptionCodec.BuildHeader(BlobEncryptionKeySource.Container, lowIterations, salt, 64 * 1024, baseNonce);
var prefix = BlobEncryptionCodec.CreateBlobPrefix(header);
var aad = BlobEncryptionCodec.BuildAssociatedDataPrefix(prefix, "oracle-container", "oracle-blob", null);
var plain = "oracle 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);
cipher.Position = 0;
var codec = GetRequiredService<BlobEncryptionCodec>();
var configuration = new BlobContainerConfiguration().UseEncryption("oracle-passphrase");
var exception = await Assert.ThrowsAsync<AbpException>(async () =>
{
await codec.CreateDecryptingStreamAsync(configuration, "oracle-container", "oracle-blob", null, cipher);
});
exception.Message.ShouldContain("invalid KDF iteration count");
}
[Fact]
public async Task Should_Not_Expose_A_Length_When_The_Position_Is_Not_Readable()
{
// Length is known but Position throws: the remaining length is genuinely
// unknown (the stream may be partially consumed), so no length is exposed
// rather than a guessed (possibly too-long) one that would short-write
var codec = GetRequiredService<BlobEncryptionCodec>();
var configuration = new BlobContainerConfiguration().UseEncryption("length-passphrase");
var content = new byte[64 * 1024 + 1];
using var source = new LengthOnlyStream(content);
using var encryptedStream = await codec.CreateEncryptingStreamAsync(configuration, "length-container", "length-blob", null, source);
Should.Throw<NotSupportedException>(() => encryptedStream.Length);
// The content still round-trips correctly, only the length is unknown
using var output = new MemoryStream();
await encryptedStream.CopyToAsync(output);
output.Length.ShouldBeGreaterThan(content.Length);
}
private sealed class LengthOnlyStream : Stream
{
private readonly MemoryStream _inner;
public LengthOnlyStream(byte[] bytes)
{
_inner = new MemoryStream(bytes);
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => _inner.Length;
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) => _inner.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)
{
_inner.Dispose();
}
base.Dispose(disposing);
}
}
[Fact]
public async Task Should_Save_A_Source_Whose_Length_Probe_Fails()
{
var content = "length probe failure content".GetBytes();
using var source = new FakeIoFailingLengthStream(new MemoryStream(content));
await _container4.SaveAsync("length-probe-failure", source, overrideExisting: true);
(await _container4.GetAllBytesAsync("length-probe-failure")).ShouldBe(content);
}
[Fact]
public async Task Should_Keep_The_V1_Writer_Output_Stable_For_Multi_Chunk_Content()
{
// Pins the production writer state machine (prefix, chunk framing, terminal
// record) on multi-chunk content, and that the production reader reads it
const string expected =
"QUJQRQEBAQABhqABAgMEBQYHCAkKCwwNDg8QAAAAEKChoqOkpaanAAAAEKa92MyQIqep1KB78Ib9pZvT8su7m9hTNktwSiqr1SWvAAAAEEnATYRJsL9GiPFOI/UsQ+rri8bl03Het8U62zMtQuKGAAAACALRHX4ndOvYmGqf4ahSHre7Jn85GCHKZAAAAAATN3tj7GdkC3MxveC+K07f";
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, 16, baseNonce);
var prefix = BlobEncryptionCodec.CreateBlobPrefix(header);
var aad = BlobEncryptionCodec.BuildAssociatedDataPrefix(prefix, "golden-container", "golden-blob", null);
var plain = "golden multi chunk writer vector content"u8.ToArray(); // 40 bytes -> 3 chunks of 16
using var writerStream = new ChunkedEncryptingReadStream(new MemoryStream(plain), prefix, aad, (byte[])keyBytes.Clone(), baseNonce, 16, null);
using var cipher = new MemoryStream();
await writerStream.CopyToAsync(cipher);
Convert.ToBase64String(cipher.ToArray()).ShouldBe(expected);
var codec = GetRequiredService<BlobEncryptionCodec>();
var configuration = new BlobContainerConfiguration().UseEncryption("golden-passphrase");
using var historicalBlob = new MemoryStream(Convert.FromBase64String(expected));
using var readerStream = await codec.CreateDecryptingStreamAsync(configuration, "golden-container", "golden-blob", null, historicalBlob);
using var output = new MemoryStream();
await readerStream.CopyToAsync(output);
output.ToArray().ShouldBe(plain);
}
[Fact]
public async Task Should_Keep_The_Exact_Length_When_Re_Encrypting_A_Legacy_Stream()
{
var content = new byte[1000];
new Random(42).NextBytes(content);
// Simulate the legacy replay stream: the first bytes were consumed as the format probe
var underlying = new MemoryStream(content);
var probeBytes = new byte[5];
underlying.Read(probeBytes, 0, probeBytes.Length);
using var legacyStream = new PrefixingReadStream(probeBytes, underlying);
var codec = GetRequiredService<BlobEncryptionCodec>();
var configuration = new BlobContainerConfiguration().UseEncryption("legacy-reencrypt-passphrase");
using var encryptingStream = await codec.CreateEncryptingStreamAsync(configuration, "legacy-container", "legacy-blob", null, legacyStream);
var reportedLength = encryptingStream.Length; // Length - Position of the legacy stream is known
using var output = new MemoryStream();
await encryptingStream.CopyToAsync(output);
reportedLength.ShouldBe(output.Length);
}
[Fact]
public async Task Should_Reject_Content_Too_Large_For_The_Chunk_Index_Upfront()
{
var codec = GetRequiredService<BlobEncryptionCodec>();
var configuration = new BlobContainerConfiguration().UseEncryption("huge-passphrase");
var exception = await Assert.ThrowsAsync<AbpException>(async () =>
{
await codec.CreateEncryptingStreamAsync(configuration, "huge-container", "huge-blob", null, new HugeLengthStream());
});
exception.Message.ShouldContain("too large");
}
private sealed class HugeLengthStream : Stream
{
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => 200_000_000_000_000_000; // Far beyond int.MaxValue chunks
public override long Position
{
get => 0;
set => throw new NotSupportedException();
}
public override void Flush()
{
}
public override int Read(byte[] buffer, int offset, int count) => 0;
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();
}
[Fact]
public async Task Should_Reject_A_Blob_Name_With_Invalid_Utf16()
{
// Different unpaired surrogates would fold into the same replacement bytes,
// giving two different names the same authenticated identity
var codec = GetRequiredService<BlobEncryptionCodec>();
var configuration = new BlobContainerConfiguration().UseEncryption("surrogate-passphrase");
var exception = await Assert.ThrowsAsync<AbpException>(async () =>
{
await codec.CreateEncryptingStreamAsync(configuration, "surrogate-container", "x\uD800", null, new MemoryStream());
});
exception.Message.ShouldContain("invalid characters");
}
[Fact]
public void Should_Report_The_Served_Length_Of_A_Legacy_Prefixing_Stream_When_The_Underlying_Is_Offset()
{
// The underlying provider stream is not at position 0: the prefixing wrapper
// must report prefix + remaining, not the underlying total length
var content = new byte[1000];
new Random(42).NextBytes(content);
var underlying = new MemoryStream(content);
underlying.Position = 2; // Simulate a provider stream that did not start at 0
var probe = new byte[5];
underlying.Read(probe, 0, probe.Length); // The magic probe consumed 5 more bytes
using var prefixing = new PrefixingReadStream(probe, underlying);
// Served = prefix (5) + remaining underlying (1000 - 7) = 998
prefixing.Length.ShouldBe(content.Length - 2);
using var output = new MemoryStream();
prefixing.CopyTo(output);
output.Length.ShouldBe(content.Length - 2);
}
[Fact]
public void Should_Report_The_Bytes_Served_As_The_Position_Of_A_Prefixing_Stream()
{
var underlying = new MemoryStream(new byte[100]);
using var prefixing = new PrefixingReadStream(new byte[5], underlying);
var buffer = new byte[105];
var read = prefixing.Read(buffer, 0, buffer.Length);
(prefixing.Length - prefixing.Position).ShouldBe(prefixing.Length - read);
}
[Fact]
public async Task Should_Pass_The_Blob_Identity_To_The_Key_Provider()
{
// The key context carries the normalized container/BLOB name and the tenant,
// so a custom provider can select the key by the BLOB identity
// The codec is a public, replaceable service; construct it with a recording key
// provider directly instead of reaching into its internals
var recordingProvider = new RecordingKeyProvider();
var codec = new BlobEncryptionCodec(
recordingProvider,
GetRequiredService<IOptions<AbpBlobStoringEncryptionOptions>>());
var tenantId = Guid.NewGuid();
var configuration = new BlobContainerConfiguration().UseEncryption("identity-passphrase");
using var stream = await codec.CreateEncryptingStreamAsync(configuration, "the-container", "the-blob", tenantId, new MemoryStream());
using var output = new MemoryStream();
await stream.CopyToAsync(output);
recordingProvider.LastContext.ShouldNotBeNull();
recordingProvider.LastContext!.ContainerName.ShouldBe("the-container");
recordingProvider.LastContext.BlobName.ShouldBe("the-blob");
recordingProvider.LastContext.TenantId.ShouldBe(tenantId);
}
private sealed class RecordingKeyProvider : IBlobEncryptionKeyProvider
{
public BlobEncryptionKeyContext? LastContext { get; private set; }
public Task<BlobEncryptionKey> ResolveForEncryptionAsync(BlobEncryptionKeyContext context, CancellationToken cancellationToken = default)
{
LastContext = context;
return Task.FromResult(new BlobEncryptionKey(BlobEncryptionKeySource.Container, "identity-passphrase"));
}
public Task<string> ResolveForDecryptionAsync(BlobEncryptionKeySource keySource, BlobEncryptionKeyContext context, CancellationToken cancellationToken = default)
{
LastContext = context;
return Task.FromResult("identity-passphrase");
}
}
[Fact]
public async Task Should_Not_Recover_A_Faulted_Decrypting_Stream_In_The_End_Check()
{
// Insert a forged chunk record (no key needed) before the terminal record. Reading
// it faults the stream without incrementing the chunk index, so the original
// terminal would still verify at the same index — the end check must keep the fault
var codec = GetRequiredService<BlobEncryptionCodec>();
var configuration = new BlobContainerConfiguration().UseEncryption("fault-passphrase");
byte[] cipher;
using (var encrypting = await codec.CreateEncryptingStreamAsync(configuration, "fault-container", "fault-blob", null, new MemoryStream()))
using (var buffer = new MemoryStream())
{
await encrypting.CopyToAsync(buffer);
cipher = buffer.ToArray();
}
// A forged content record: 4-byte length + 16 random cipher bytes + 16 random tag
var forged = new byte[4 + 16 + 16];
forged[0] = 0; forged[1] = 0; forged[2] = 0; forged[3] = 16;
new Random(7).NextBytes(forged.AsSpan(4));
// Splice it in right before the 20-byte terminal record
var tampered = new byte[cipher.Length + forged.Length];
Array.Copy(cipher, 0, tampered, 0, cipher.Length - 20);
Array.Copy(forged, 0, tampered, cipher.Length - 20, forged.Length);
Array.Copy(cipher, cipher.Length - 20, tampered, cipher.Length - 20 + forged.Length, 20);
using var decrypting = await codec.CreateDecryptingStreamAsync(configuration, "fault-container", "fault-blob", null, new MemoryStream(tampered));
var readBuffer = new byte[1024];
// Reading the forged record faults the stream
Assert.ThrowsAny<Exception>(() =>
{
while (decrypting.Read(readBuffer, 0, readBuffer.Length) > 0)
{
}
});
// The end check must not "recover" the faulted stream by verifying the terminal
Should.Throw<Exception>(() => ((IBlobAuthenticatedEndStream)decrypting).EnsureReadToAuthenticatedEnd());
}
[Fact]
public async Task Should_Reject_Data_Appended_After_The_Terminal_Record()
{
// The terminal record marks the authenticated end; any trailing bytes after it mean
// the stored ciphertext was extended, so reading to the end must fail
var codec = GetRequiredService<BlobEncryptionCodec>();
var configuration = new BlobContainerConfiguration().UseEncryption("append-passphrase");
var content = "content that ends cleanly".GetBytes();
byte[] cipher;
using (var encrypting = await codec.CreateEncryptingStreamAsync(configuration, "append-container", "append-blob", null, new MemoryStream(content)))
using (var buffer = new MemoryStream())
{
await encrypting.CopyToAsync(buffer);
cipher = buffer.ToArray();
}
// Append one byte after the valid terminal record
var appended = new byte[cipher.Length + 1];
Array.Copy(cipher, appended, cipher.Length);
appended[cipher.Length] = 0x42;
using var decrypting = await codec.CreateDecryptingStreamAsync(configuration, "append-container", "append-blob", null, new MemoryStream(appended));
var readBuffer = new byte[content.Length + 1024];
await Assert.ThrowsAsync<AbpException>(async () =>
{
while (await decrypting.ReadAsync(readBuffer, 0, readBuffer.Length) > 0)
{
}
});
}
[Fact]
public async Task Should_Not_Fault_A_Healthy_Stream_When_The_End_Check_Is_Cancelled()
{
var codec = GetRequiredService<BlobEncryptionCodec>();
var configuration = new BlobContainerConfiguration().UseEncryption("cancel-passphrase");
var content = "cancel end-check content".GetBytes();
byte[] cipher;
using (var encrypting = await codec.CreateEncryptingStreamAsync(configuration, "cancel-container", "cancel-blob", null, new MemoryStream(content)))
using (var buffer = new MemoryStream())
{
await encrypting.CopyToAsync(buffer);
cipher = buffer.ToArray();
}
var cancelSource = new CancellationHonoringStream(new MemoryStream(cipher));
using var decryptingStream = await codec.CreateDecryptingStreamAsync(configuration, "cancel-container", "cancel-blob", null, cancelSource);
var decrypting = (IBlobAuthenticatedEndStream)decryptingStream;
// Consume all content, but not the terminal record yet
var readBuffer = new byte[content.Length];
var total = 0;
while (total < readBuffer.Length)
{
var read = await decryptingStream.ReadAsync(readBuffer.AsMemory(total, readBuffer.Length - total));
total += read;
}
readBuffer.ShouldBe(content);
// The terminal read is cancelled: it must not permanently fault the healthy stream
cancelSource.HonorCancellation = true;
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await decrypting.EnsureReadToAuthenticatedEndAsync(new CancellationToken(canceled: true));
});
// A retry with a live token still verifies the terminal record
cancelSource.HonorCancellation = false;
await decrypting.EnsureReadToAuthenticatedEndAsync(CancellationToken.None);
}
[Fact]
public async Task Should_Fault_When_The_End_Check_Is_Cancelled_After_Consuming_Part_Of_The_Terminal_Record()
{
// A cancellation before any I/O leaves the stream healthy (see the test above), but a
// cancellation after the terminal record was partially consumed can not: the consumed
// bytes are gone from the non-seekable cipher stream, so a retry would parse from the
// middle of the record and misreport a valid BLOB as corrupt. The stream must fault
var codec = GetRequiredService<BlobEncryptionCodec>();
var configuration = new BlobContainerConfiguration().UseEncryption("partial-cancel-passphrase");
var content = "partial cancel end-check content".GetBytes();
byte[] cipher;
using (var encrypting = await codec.CreateEncryptingStreamAsync(configuration, "partial-cancel-container", "partial-cancel-blob", null, new MemoryStream(content)))
using (var buffer = new MemoryStream())
{
await encrypting.CopyToAsync(buffer);
cipher = buffer.ToArray();
}
var cancelSource = new PartialReadThenCancelStream(new MemoryStream(cipher));
using var decryptingStream = await codec.CreateDecryptingStreamAsync(configuration, "partial-cancel-container", "partial-cancel-blob", null, cancelSource);
var decrypting = (IBlobAuthenticatedEndStream)decryptingStream;
// Consume all content, but not the terminal record yet
var readBuffer = new byte[content.Length];
var total = 0;
while (total < readBuffer.Length)
{
var read = await decryptingStream.ReadAsync(readBuffer.AsMemory(total, readBuffer.Length - total));
total += read;
}
readBuffer.ShouldBe(content);
// The terminal read consumes one byte and is then cancelled mid-record
cancelSource.TripOnNextReads = true;
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await decrypting.EnsureReadToAuthenticatedEndAsync(CancellationToken.None);
});
// The stream must now be faulted: a retry must report the fault, not resume parsing
// from the middle of the terminal record and surface a false corruption error
cancelSource.TripOnNextReads = false;
var retry = await Assert.ThrowsAsync<AbpException>(async () =>
{
await decrypting.EnsureReadToAuthenticatedEndAsync(CancellationToken.None);
});
retry.Message.ShouldContain("a previous read operation has failed");
}
private sealed class PartialReadThenCancelStream : Stream
{
private readonly Stream _inner;
private int _tripStep;
// When set, the next read returns a single byte and the read after it throws
// OperationCanceledException, simulating a provider that consumes part of the
// terminal record and is then cancelled mid-read
public bool TripOnNextReads { get; set; }
public PartialReadThenCancelStream(Stream inner)
{
_inner = inner;
}
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) => _inner.Read(buffer, offset, count);
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
return ReadTrippedAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
}
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
return ReadTrippedAsync(buffer, cancellationToken);
}
private ValueTask<int> ReadTrippedAsync(Memory<byte> buffer, CancellationToken cancellationToken)
{
if (TripOnNextReads)
{
if (_tripStep == 0)
{
_tripStep++;
// Consume a single byte of the terminal record before the cancellation
return _inner.ReadAsync(buffer.Slice(0, Math.Min(1, buffer.Length)), cancellationToken);
}
throw new OperationCanceledException();
}
return _inner.ReadAsync(buffer, 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)
{
_inner.Dispose();
}
base.Dispose(disposing);
}
}
private sealed class CancellationHonoringStream : Stream
{
private readonly Stream _inner;
public bool HonorCancellation { get; set; }
public CancellationHonoringStream(Stream inner)
{
_inner = inner;
}
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) => _inner.Read(buffer, offset, count);
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
if (HonorCancellation && cancellationToken.IsCancellationRequested)
{
throw new OperationCanceledException(cancellationToken);
}
return _inner.ReadAsync(buffer, cancellationToken);
}
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
if (HonorCancellation && cancellationToken.IsCancellationRequested)
{
throw new OperationCanceledException(cancellationToken);
}
return _inner.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)
{
_inner.Dispose();
}
base.Dispose(disposing);
}
}
[Fact]
public async Task Should_Not_Expose_A_Legacy_Plaintext_Stream_As_Authenticated_End()
{
// Legacy plaintext has no authenticated terminal, so its stream must not claim to
var codec = GetRequiredService<BlobEncryptionCodec>();
var configuration = new BlobContainerConfiguration().UseEncryption("legacy-passphrase", allowLegacyPlainText: true);
using var legacy = await codec.CreateDecryptingStreamAsync(configuration, "legacy-container", "legacy-blob", null, new MemoryStream("plain content".GetBytes()));
legacy.ShouldNotBeAssignableTo<IBlobAuthenticatedEndStream>();
}
[Fact]
public async Task Should_Reject_A_PassPhrase_With_Invalid_Utf16()
{
// Consistent across target frameworks: an unpaired surrogate passphrase is rejected
var codec = GetRequiredService<BlobEncryptionCodec>();
var configuration = new BlobContainerConfiguration().UseEncryption("x\uD800");
var exception = await Assert.ThrowsAsync<AbpException>(async () =>
{
await codec.CreateEncryptingStreamAsync(configuration, "surrogate-pass-container", "b", null, new MemoryStream());
});
exception.Message.ShouldContain("invalid characters");
} }
private byte[]? GetRawBytes<TContainer>(string blobName) private byte[]? GetRawBytes<TContainer>(string blobName)
@ -749,7 +1528,7 @@ public class BlobContainerEncryption_Tests : AbpBlobStoringTestBase
throw new InvalidOperationException("Synchronous reads are not allowed on this stream!"); 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) public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{ {
return _stream.ReadAsync(buffer, offset, count, cancellationToken); return _stream.ReadAsync(buffer, offset, count, cancellationToken);
} }

618
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/BlobContainerPipeline_Tests.cs

@ -0,0 +1,618 @@
using System;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Volo.Abp.BlobStoring.Fakes;
using Xunit;
namespace Volo.Abp.BlobStoring;
public class BlobContainerPipeline_Tests : AbpBlobStoringTestBase
{
private readonly IBlobContainerFactory _blobContainerFactory;
private readonly FakeInMemoryBlobProvider _fakeProvider;
public BlobContainerPipeline_Tests()
{
_blobContainerFactory = GetRequiredService<IBlobContainerFactory>();
_fakeProvider = GetRequiredService<FakeInMemoryBlobProvider>();
}
[Fact]
public async Task Should_Transform_While_Saving_And_Restore_While_Getting()
{
var container = _blobContainerFactory.Create("pipeline-markers");
var content = "pipeline content".GetBytes();
using var source = new MemoryStream(content);
await container.SaveAsync("markers-blob", source);
// Contributors run in the configuration order while saving: A wraps first,
// B wraps the result, so the stored form starts with the marker of B
var rawBytes = _fakeProvider.GetRawBytesOrNull("pipeline-markers", "markers-blob");
rawBytes.ShouldNotBeNull();
Encoding.UTF8.GetString(rawBytes, 0, 4).ShouldBe("B>A>");
source.CanRead.ShouldBeTrue(); // The caller keeps the ownership of the original stream
(await container.GetAllBytesAsync("markers-blob")).ShouldBe(content);
}
[Fact]
public async Task Should_Run_Contributors_On_The_Plain_Content_When_Encryption_Is_Enabled()
{
var container = _blobContainerFactory.Create("pipeline-encrypted");
var content = "encrypted pipeline content".GetBytes();
await container.SaveAsync("encrypted-blob", content);
// The encryption always runs after the contributors, so the stored form is ciphertext
var rawBytes = _fakeProvider.GetRawBytesOrNull("pipeline-encrypted", "encrypted-blob");
rawBytes.ShouldNotBeNull();
Encoding.ASCII.GetString(rawBytes, 0, 4).ShouldBe("ABPE");
(await container.GetAllBytesAsync("encrypted-blob")).ShouldBe(content);
}
[Fact]
public async Task Should_Keep_The_Contributor_Scope_Alive_Until_The_Returned_Stream_Is_Disposed()
{
var container = _blobContainerFactory.Create("pipeline-scoped");
var content = "scoped pipeline content".GetBytes();
await container.SaveAsync("scoped-blob", content);
var stream = await container.GetAsync("scoped-blob");
// The scoped service is used lazily here, after GetAsync already returned
using var result = new MemoryStream();
await stream.CopyToAsync(result);
result.ToArray().ShouldBe(content);
var disposedCountBefore = FakeScopedMarkerService.DisposedCount;
stream.Dispose();
FakeScopedMarkerService.DisposedCount.ShouldBe(disposedCountBefore + 1);
}
[Fact]
public async Task Should_Dispose_The_Provider_Stream_When_Reading_The_Configuration_Fails()
{
var container = _blobContainerFactory.Create("get-bad-encryption-config");
// Save through a plain (well-configured) container to the same provider key,
// then read through the mis-configured one so the get fails after the provider
// stream was obtained
_fakeProvider.SetRawBytes("get-bad-encryption-config", "config-fail-blob", "content".GetBytes());
await Assert.ThrowsAnyAsync<Exception>(async () =>
{
await container.GetAsync("config-fail-blob");
});
_fakeProvider.LastServedStream.ShouldNotBeNull();
_fakeProvider.LastServedStream!.Disposed.ShouldBeTrue();
}
[Fact]
public async Task Should_Dispose_The_Provider_Stream_When_A_Get_Contributor_Fails()
{
var container = _blobContainerFactory.Create("pipeline-failing-get");
await container.SaveAsync("failing-blob", "failing content".GetBytes());
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await container.GetAsync("failing-blob");
});
_fakeProvider.LastServedStream.ShouldNotBeNull();
_fakeProvider.LastServedStream!.Disposed.ShouldBeTrue();
}
[Fact]
public async Task Should_Dispose_The_Stream_Of_A_Contributor_That_Fails_After_Replacing_It()
{
var container = _blobContainerFactory.Create("pipeline-set-throw-save");
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await container.SaveAsync("set-throw-blob", "content".GetBytes());
});
FakeSetThenThrowPipelineContributor.LastCreatedStream.ShouldNotBeNull();
FakeSetThenThrowPipelineContributor.LastCreatedStream!.Disposed.ShouldBeTrue();
}
[Fact]
public async Task Should_Dispose_The_Whole_Chain_When_A_Later_Get_Contributor_Fails()
{
// The first contributor already wrapped the provider stream when the second one fails
var container = _blobContainerFactory.Create("pipeline-partial-get");
await container.SaveAsync("partial-get-blob", "partial content".GetBytes());
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await container.GetAsync("partial-get-blob");
});
_fakeProvider.LastServedStream.ShouldNotBeNull();
_fakeProvider.LastServedStream!.Disposed.ShouldBeTrue();
}
[Fact]
public async Task Should_Release_The_Remaining_Streams_And_The_Scope_When_A_Dispose_Fails()
{
var container = _blobContainerFactory.Create("pipeline-dispose-throw");
var disposedCountBefore = FakeScopedMarkerService.DisposedCount;
// The save itself succeeds; the injected failure surfaces from the cleanup
var exception = await Assert.ThrowsAsync<IOException>(async () =>
{
await container.SaveAsync("dispose-throw-blob", "content".GetBytes());
});
exception.Message.ShouldContain("Injected dispose failure");
FakeScopedMarkerService.DisposedCount.ShouldBe(disposedCountBefore + 1); // The scope was still released
}
[Fact]
public async Task Should_Keep_The_Container_Tenant_Context_While_The_Returned_Stream_Is_Read()
{
// A tenant reads a shared (IsMultiTenant = false) container: the lazy
// transformation must still run in the host context of the container
var container = _blobContainerFactory.Create("pipeline-shared-tenant");
var currentTenant = GetRequiredService<Volo.Abp.MultiTenancy.ICurrentTenant>();
var content = "shared tenant content".GetBytes();
using (currentTenant.Change(Guid.NewGuid()))
{
await container.SaveAsync("shared-tenant-blob", content);
using var stream = await container.GetAsync("shared-tenant-blob");
using var result = new MemoryStream();
await stream.CopyToAsync(result); // The wrapper asserts the tenant context here
result.ToArray().ShouldBe(content);
}
}
[Fact]
public async Task Should_Not_Degrade_A_Modern_Async_Only_Wrapper_Stream()
{
var container = _blobContainerFactory.Create("pipeline-modern-async");
var content = "modern async content".GetBytes();
await container.SaveAsync("modern-async-blob", content);
using var stream = await container.GetAsync("modern-async-blob");
using var result = new MemoryStream();
await stream.CopyToAsync(result); // Uses ReadAsync(Memory<byte>) on modern runtimes
result.ToArray().ShouldBe(content);
// Callers of the old overload must get the same bridging
using var oldOverloadStream = await container.GetAsync("modern-async-blob");
var buffer = new byte[content.Length];
var totalReadCount = 0;
while (totalReadCount < buffer.Length)
{
var readCount = await oldOverloadStream.ReadAsync(buffer, totalReadCount, buffer.Length - totalReadCount, default);
if (readCount == 0)
{
break;
}
totalReadCount += readCount;
}
buffer.ShouldBe(content);
}
[Fact]
public async Task Should_Dispose_The_Contributor_Scope_In_The_Container_Tenant_Context()
{
var container = _blobContainerFactory.Create("pipeline-shared-tenant");
var currentTenant = GetRequiredService<Volo.Abp.MultiTenancy.ICurrentTenant>();
var content = "scope dispose tenant content".GetBytes();
using (currentTenant.Change(Guid.NewGuid()))
{
await container.SaveAsync("scope-dispose-tenant-blob", content, overrideExisting: true);
var stream = await container.GetAsync("scope-dispose-tenant-blob");
using (var result = new MemoryStream())
{
await stream.CopyToAsync(result);
}
FakeTenantRecordingScopedService.Reset();
stream.Dispose(); // Synchronous dispose from the tenant context
FakeTenantRecordingScopedService.HasRecordedDispose.ShouldBeTrue();
FakeTenantRecordingScopedService.LastDisposeTenantId.ShouldBeNull(); // The container is shared (host)
var asyncStream = await container.GetAsync("scope-dispose-tenant-blob");
using (var result = new MemoryStream())
{
await asyncStream.CopyToAsync(result);
}
FakeTenantRecordingScopedService.Reset();
await asyncStream.DisposeAsync(); // Asynchronous dispose from the tenant context
FakeTenantRecordingScopedService.HasRecordedDispose.ShouldBeTrue();
FakeTenantRecordingScopedService.LastDisposeTenantId.ShouldBeNull();
}
}
[Fact]
public async Task Should_Not_Dispose_The_Original_Stream_When_A_Contributor_Sets_It_Back()
{
// A wraps the original, then the second contributor sets the original back:
// the pipeline must not treat the caller-owned stream as its own
var container = _blobContainerFactory.Create("pipeline-unwrap");
var content = "unwrap content".GetBytes();
using var source = new MemoryStream(content);
FakeOriginalRestoringPipelineContributor.RestoreTo = source;
try
{
await container.SaveAsync("unwrap-blob", source);
}
finally
{
FakeOriginalRestoringPipelineContributor.RestoreTo = null;
}
source.CanRead.ShouldBeTrue(); // The caller keeps the ownership of the original stream
_fakeProvider.GetRawBytesOrNull("pipeline-unwrap", "unwrap-blob").ShouldBe(content);
}
[Fact]
public async Task Should_Release_A_Pipeline_Stream_That_Only_Cleans_Up_In_DisposeAsync()
{
var container = _blobContainerFactory.Create("pipeline-async-dispose");
await container.SaveAsync("async-dispose-blob", "async dispose content".GetBytes());
FakeAsyncDisposePipelineContributor.LastSaveStream.ShouldNotBeNull();
FakeAsyncDisposePipelineContributor.LastSaveStream!.AsyncDisposed.ShouldBeTrue();
// The intermediate stream created within the same contributor call is disposed too
FakeAsyncDisposePipelineContributor.IntermediateSaveStream.ShouldNotBeNull();
FakeAsyncDisposePipelineContributor.IntermediateSaveStream!.AsyncDisposed.ShouldBeTrue();
}
[Fact]
public void Should_Bridge_A_Synchronous_Dispose_To_The_Async_Cleanup_Of_The_Cipher_Stream()
{
// The decrypting stream owns the provider (cipher) stream; a synchronous
// Dispose of it must still run the async-only cleanup of that stream
var cipherStream = new AsyncOnlyDisposeStream();
var decryptingStream = new ChunkedDecryptingReadStream(
cipherStream, new byte[16], new byte[32], new byte[8], 64 * 1024);
decryptingStream.Dispose();
cipherStream.AsyncDisposed.ShouldBeTrue();
}
private sealed class AsyncOnlyDisposeStream : Stream
{
public bool AsyncDisposed { get; private 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) => 0;
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();
// The cleanup only happens asynchronously; a synchronous Dispose does nothing,
// so the test fails if the decrypting stream does not bridge to DisposeAsync
public override ValueTask DisposeAsync()
{
AsyncDisposed = true;
return default;
}
}
[Fact]
public async Task Should_Bridge_A_Synchronous_Dispose_To_The_Async_Cleanup_Of_A_Get_Wrapper()
{
var container = _blobContainerFactory.Create("pipeline-async-dispose");
var content = "sync dispose bridge content".GetBytes();
await container.SaveAsync("sync-bridge-blob", content, overrideExisting: true);
using (var stream = await container.GetAsync("sync-bridge-blob"))
{
using var result = new MemoryStream();
await stream.CopyToAsync(result);
result.ToArray().ShouldBe(content);
} // The synchronous using must still trigger the async-only cleanup
FakeAsyncDisposePipelineContributor.LastGetStream.ShouldNotBeNull();
FakeAsyncDisposePipelineContributor.LastGetStream!.AsyncDisposed.ShouldBeTrue();
_fakeProvider.LastServedStream.ShouldNotBeNull();
_fakeProvider.LastServedStream!.Disposed.ShouldBeTrue();
}
[Fact]
public async Task Should_Forward_The_Timeout_Capability_Of_The_Wrapped_Stream()
{
var currentTenant = GetRequiredService<Volo.Abp.MultiTenancy.ICurrentTenant>();
var scope = GetRequiredService<IServiceProvider>().CreateAsyncScope();
var inner = new TimeoutCapableStream();
await using var stream = new BlobPipelineScopeStream(inner, scope, currentTenant, null);
stream.CanTimeout.ShouldBeTrue();
stream.ReadTimeout = 1234;
stream.ReadTimeout.ShouldBe(1234);
}
private sealed class TimeoutCapableStream : MemoryStream
{
private int _readTimeout = -1;
public override bool CanTimeout => true;
public override int ReadTimeout
{
get => _readTimeout;
set => _readTimeout = value;
}
}
[Fact]
public async Task Should_Not_Fault_The_Composed_Stream_When_The_End_Source_Cancels_Without_Faulting()
{
// The decrypting stream lets a cancellation before any I/O through while staying
// healthy (so a retry can still verify). The composed outer stream must mirror that:
// it must not permanently fault on such a cancellation, or the retry can never verify
var currentTenant = GetRequiredService<Volo.Abp.MultiTenancy.ICurrentTenant>();
var scope = GetRequiredService<IServiceProvider>().CreateAsyncScope();
var endSource = new CancelOnceHealthyAuthenticatedEndStream();
await using var stream = new BlobPipelineScopeStream(endSource, scope, currentTenant, null, endSource);
var buffer = new byte[16];
// The first read reaches EOF and runs the end check, which cancels while staying healthy
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await stream.ReadAsync(buffer, 0, buffer.Length);
});
// A retry must still be able to run (and pass) the end check, not hit a faulted stream
var read = await stream.ReadAsync(buffer, 0, buffer.Length);
read.ShouldBe(0);
endSource.EndCheckAttempts.ShouldBe(2);
}
private sealed class CancelOnceHealthyAuthenticatedEndStream : Stream, IBlobAuthenticatedEndStream
{
public int EndCheckAttempts { get; private 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) => 0;
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
=> Task.FromResult(0);
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
=> new ValueTask<int>(0);
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();
public void EnsureReadToAuthenticatedEnd()
{
}
public ValueTask EnsureReadToAuthenticatedEndAsync(CancellationToken cancellationToken = default)
{
EndCheckAttempts++;
if (EndCheckAttempts == 1)
{
// Cancelled before any I/O: the source stays healthy, exactly like the real
// decrypting stream when the token trips just after the outer's own check
throw new OperationCanceledException();
}
return default;
}
}
[Fact]
public async Task Should_Fault_The_Composed_Stream_When_An_Inner_Read_Fails_After_Consuming()
{
// An inner contributor consumes a byte from the stream below it and then fails: the
// composed stream must fault so a read-retry layer can not silently continue from the
// consumed position and hand the caller content that is missing that byte
var currentTenant = GetRequiredService<Volo.Abp.MultiTenancy.ICurrentTenant>();
var scope = GetRequiredService<IServiceProvider>().CreateAsyncScope();
var underlying = new MemoryStream(new byte[] { 1, 2, 3, 4 });
var inner = new ConsumeThenThrowStream(underlying);
await using var stream = new BlobPipelineScopeStream(inner, scope, currentTenant, null);
var buffer = new byte[16];
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await stream.ReadAsync(buffer, 0, buffer.Length);
});
// The failed read must have faulted the stream permanently
var retry = await Assert.ThrowsAsync<AbpException>(async () =>
{
await stream.ReadAsync(buffer, 0, buffer.Length);
});
retry.Message.ShouldContain("a previous read operation has failed");
}
private sealed class ConsumeThenThrowStream : Stream
{
private readonly Stream _inner;
private bool _thrown;
public ConsumeThenThrowStream(Stream inner)
{
_inner = inner;
}
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 NotSupportedException();
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
if (!_thrown)
{
_thrown = true;
// Consume one byte from the stream below, then fail without handing it up
_ = _inner.Read(new byte[1], 0, 1);
throw new OperationCanceledException();
}
return _inner.ReadAsync(buffer, 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)
{
_inner.Dispose();
}
base.Dispose(disposing);
}
}
[Fact]
public async Task Should_Verify_The_Authenticated_End_Through_A_Contributor_That_Stops_Early()
{
var container = _blobContainerFactory.Create("pipeline-encrypted-earlystop");
var content = new byte[100_000];
new Random(42).NextBytes(content);
await container.SaveAsync("earlystop-blob", content, overrideExisting: true);
// The normal round trip works: reading to the end verifies the terminal record
(await container.GetAllBytesAsync("earlystop-blob")).ShouldBe(content);
// Strip the 20-byte terminal record from the stored ciphertext
var raw = _fakeProvider.GetRawBytesOrNull("pipeline-encrypted-earlystop", "earlystop-blob");
raw.ShouldNotBeNull();
var truncated = new byte[raw!.Length - 20];
Array.Copy(raw, truncated, truncated.Length);
_fakeProvider.SetRawBytes("pipeline-encrypted-earlystop", "earlystop-blob", truncated);
// Even though the contributor stops at its own declared length, reading the
// composed stream to EOF must now fail because the terminal record is gone
using var stream = await container.GetAsync("earlystop-blob");
var buffer = new byte[content.Length + 1024];
await Assert.ThrowsAsync<AbpException>(async () =>
{
await ReadAllAsync(stream, buffer);
});
// The failure must be permanent: reading again must not swallow it and return
// a normal EOF (which a read-retry layer would treat as a complete read)
await Assert.ThrowsAsync<AbpException>(async () =>
{
await stream.ReadAsync(buffer, 0, buffer.Length);
});
}
private static async Task ReadAllAsync(Stream stream, byte[] buffer)
{
int read;
var offset = 0;
while ((read = await stream.ReadAsync(buffer, offset, buffer.Length - offset)) > 0)
{
offset += read;
}
}
[Fact]
public void Should_Not_Forward_Reads_After_The_Scope_Stream_Is_Disposed()
{
// After dispose the contributor scope is gone; reads must not reach the inner
// stream (a use-after-scope), and CanRead must be consistent with that
var currentTenant = GetRequiredService<Volo.Abp.MultiTenancy.ICurrentTenant>();
var scope = GetRequiredService<IServiceProvider>().CreateAsyncScope();
var inner = new MemoryStream(new byte[10]);
var stream = new BlobPipelineScopeStream(inner, scope, currentTenant, null);
stream.Dispose();
stream.CanRead.ShouldBeFalse();
Should.Throw<ObjectDisposedException>(() => stream.Read(new byte[1], 0, 1));
}
[Fact]
public async Task Should_Support_A_Synchronous_Dispose_With_An_Async_Only_Scoped_Service()
{
var container = _blobContainerFactory.Create("pipeline-async-scoped");
var content = "async scoped content".GetBytes();
await container.SaveAsync("async-scoped-blob", content);
var stream = await container.GetAsync("async-scoped-blob");
using var result = new MemoryStream();
await stream.CopyToAsync(result);
result.ToArray().ShouldBe(content);
var asyncDisposedCountBefore = FakeAsyncOnlyDisposableService.AsyncDisposedCount;
stream.Dispose(); // Must not throw although the scoped service is async-only disposable
FakeAsyncOnlyDisposableService.AsyncDisposedCount.ShouldBe(asyncDisposedCountBefore + 1);
}
}

11
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAPipelineContributor.cs

@ -0,0 +1,11 @@
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.BlobStoring.Fakes;
public class FakeAPipelineContributor : FakeMarkerPipelineContributorBase, ITransientDependency
{
public FakeAPipelineContributor()
: base("A>")
{
}
}

89
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAsyncDisposePipelineContributor.cs

@ -0,0 +1,89 @@
#nullable enable
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.BlobStoring.Fakes;
/// <summary>
/// Wraps the content with a stream whose cleanup only happens in DisposeAsync,
/// so tests can verify the pipeline releases its streams asynchronously — on the
/// save side and also when the caller disposes the returned stream synchronously.
/// </summary>
public class FakeAsyncDisposePipelineContributor : IBlobPipelineContributor, ITransientDependency
{
public static AsyncDisposeOnlyStream? LastSaveStream { get; private set; }
public static AsyncDisposeOnlyStream? LastGetStream { get; private set; }
public static AsyncDisposeOnlyStream? IntermediateSaveStream { get; private set; }
public Task OnSavingAsync(BlobPipelineContext context)
{
// Two replacements in one call: the intermediate stream must also be collected
context.BlobStream = IntermediateSaveStream = new AsyncDisposeOnlyStream(context.BlobStream, ownsInner: false);
context.BlobStream = LastSaveStream = new AsyncDisposeOnlyStream(context.BlobStream, ownsInner: false);
return Task.CompletedTask;
}
public Task OnGettingAsync(BlobPipelineContext context)
{
// The get-side contract: the wrapper owns the received stream
context.BlobStream = LastGetStream = new AsyncDisposeOnlyStream(context.BlobStream, ownsInner: true);
return Task.CompletedTask;
}
public sealed class AsyncDisposeOnlyStream : Stream
{
private readonly Stream _inner;
private readonly bool _ownsInner;
public bool AsyncDisposed { get; private set; }
public AsyncDisposeOnlyStream(Stream inner, bool ownsInner)
{
_inner = inner;
_ownsInner = ownsInner;
}
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) => _inner.Read(buffer, offset, count);
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
return _inner.ReadAsync(buffer, 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();
// The cleanup only happens asynchronously; the synchronous Dispose is a no-op
public override async ValueTask DisposeAsync()
{
AsyncDisposed = true;
if (_ownsInner)
{
await _inner.DisposeAsync();
}
await base.DisposeAsync();
}
}
}

23
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAsyncOnlyDisposableService.cs

@ -0,0 +1,23 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.BlobStoring.Fakes;
/// <summary>
/// A scoped service that only implements <see cref="IAsyncDisposable"/>: a synchronous
/// dispose of the owning scope would throw for such a service.
/// </summary>
public class FakeAsyncOnlyDisposableService : IScopedDependency, IAsyncDisposable
{
private static int _asyncDisposedCount;
public static int AsyncDisposedCount => Volatile.Read(ref _asyncDisposedCount);
public ValueTask DisposeAsync()
{
Interlocked.Increment(ref _asyncDisposedCount);
return default;
}
}

28
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeAsyncScopedPipelineContributor.cs

@ -0,0 +1,28 @@
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.BlobStoring.Fakes;
/// <summary>
/// Holds an async-only disposable scoped service without transforming the content.
/// </summary>
public class FakeAsyncScopedPipelineContributor : IBlobPipelineContributor, ITransientDependency
{
// ReSharper disable once NotAccessedField.Local
private readonly FakeAsyncOnlyDisposableService _service;
public FakeAsyncScopedPipelineContributor(FakeAsyncOnlyDisposableService service)
{
_service = service;
}
public Task OnSavingAsync(BlobPipelineContext context)
{
return Task.CompletedTask;
}
public Task OnGettingAsync(BlobPipelineContext context)
{
return Task.CompletedTask;
}
}

11
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeBPipelineContributor.cs

@ -0,0 +1,11 @@
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.BlobStoring.Fakes;
public class FakeBPipelineContributor : FakeMarkerPipelineContributorBase, ITransientDependency
{
public FakeBPipelineContributor()
: base("B>")
{
}
}

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

@ -0,0 +1,59 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.BlobStoring.Fakes;
/// <summary>
/// Wraps the stream with a pass-through wrapper that fails on Dispose, so tests
/// can verify the best-effort cleanup of the pipeline.
/// </summary>
public class FakeDisposeThrowingPipelineContributor : IBlobPipelineContributor, ITransientDependency
{
public Task OnSavingAsync(BlobPipelineContext context)
{
context.BlobStream = new DisposeThrowingStream(context.BlobStream);
return Task.CompletedTask;
}
public Task OnGettingAsync(BlobPipelineContext context)
{
return Task.CompletedTask;
}
private sealed class DisposeThrowingStream : Stream
{
private readonly Stream _inner;
public DisposeThrowingStream(Stream inner)
{
_inner = inner;
}
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) => _inner.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)
{
throw new IOException("Injected dispose failure!");
}
}
}

120
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeEarlyStopPipelineContributor.cs

@ -0,0 +1,120 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.BlobStoring.Fakes;
/// <summary>
/// On saving, prepends the 4-byte content length; on getting, returns a wrapper that
/// stops at that declared length — reproducing a contributor that reaches its own EOF
/// before the decryption stream's authenticated end.
/// </summary>
public class FakeEarlyStopPipelineContributor : IBlobPipelineContributor, ITransientDependency
{
public async Task OnSavingAsync(BlobPipelineContext context)
{
using var content = new MemoryStream();
await context.BlobStream.CopyToAsync(content, 81920, context.CancellationToken);
var output = new MemoryStream();
var lengthPrefix = BitConverter.GetBytes((int)content.Length);
output.Write(lengthPrefix, 0, lengthPrefix.Length);
content.Position = 0;
await content.CopyToAsync(output, 81920, context.CancellationToken);
output.Position = 0;
context.BlobStream = output;
}
public async Task OnGettingAsync(BlobPipelineContext context)
{
var lengthPrefix = new byte[4];
await ReadExactlyAsync(context.BlobStream, lengthPrefix, context.CancellationToken);
var contentLength = BitConverter.ToInt32(lengthPrefix, 0);
context.BlobStream = new LengthLimitedStream(context.BlobStream, contentLength);
}
private static async Task ReadExactlyAsync(Stream stream, byte[] buffer, CancellationToken cancellationToken)
{
var total = 0;
while (total < buffer.Length)
{
var read = await stream.ReadAsync(buffer.AsMemory(total, buffer.Length - total), cancellationToken);
if (read == 0)
{
throw new AbpException("Unexpected end of stream while reading the length prefix!");
}
total += read;
}
}
private sealed class LengthLimitedStream : Stream
{
private readonly Stream _inner;
private long _remaining;
public LengthLimitedStream(Stream inner, long length)
{
_inner = inner;
_remaining = length;
}
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()
{
}
// Stops at the declared length without reading the rest of the inner stream
public override int Read(byte[] buffer, int offset, int count)
{
if (_remaining <= 0)
{
return 0;
}
var toRead = (int)Math.Min(count, _remaining);
var read = _inner.Read(buffer, offset, toRead);
_remaining -= read;
return read;
}
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
if (_remaining <= 0)
{
return 0;
}
var toRead = (int)Math.Min(buffer.Length, _remaining);
var read = await _inner.ReadAsync(buffer.Slice(0, toRead), cancellationToken);
_remaining -= read;
return read;
}
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)
{
_inner.Dispose();
}
base.Dispose(disposing);
}
}
}

18
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeFailingGetPipelineContributor.cs

@ -0,0 +1,18 @@
using System;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.BlobStoring.Fakes;
public class FakeFailingGetPipelineContributor : IBlobPipelineContributor, ITransientDependency
{
public Task OnSavingAsync(BlobPipelineContext context)
{
return Task.CompletedTask;
}
public Task OnGettingAsync(BlobPipelineContext context)
{
throw new InvalidOperationException("This contributor can not read content back!");
}
}

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

@ -39,11 +39,13 @@ public class FakeInMemoryBlobProvider : BlobProviderBase
return Task.FromResult(_blobs.ContainsKey(GetKey(args.ContainerName, args.BlobName))); return Task.FromResult(_blobs.ContainsKey(GetKey(args.ContainerName, args.BlobName)));
} }
public TrackingMemoryStream? LastServedStream { get; private set; }
public override Task<Stream?> GetOrNullAsync(BlobProviderGetArgs args) public override Task<Stream?> GetOrNullAsync(BlobProviderGetArgs args)
{ {
return Task.FromResult<Stream?>( return Task.FromResult<Stream?>(
_blobs.TryGetValue(GetKey(args.ContainerName, args.BlobName), out var bytes) _blobs.TryGetValue(GetKey(args.ContainerName, args.BlobName), out var bytes)
? new MemoryStream(bytes) ? LastServedStream = new TrackingMemoryStream(bytes)
: null : null
); );
} }
@ -62,4 +64,20 @@ public class FakeInMemoryBlobProvider : BlobProviderBase
{ {
return containerName + "/" + blobName; return containerName + "/" + blobName;
} }
public sealed class TrackingMemoryStream : MemoryStream
{
public bool Disposed { get; private set; }
public TrackingMemoryStream(byte[] bytes)
: base(bytes)
{
}
protected override void Dispose(bool disposing)
{
Disposed = true;
base.Dispose(disposing);
}
}
} }

48
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeIoFailingLengthStream.cs

@ -0,0 +1,48 @@
using System;
using System.IO;
namespace Volo.Abp.BlobStoring.Fakes;
/// <summary>
/// A readable, non-seekable stream whose length/position probes fail with an
/// <see cref="IOException"/> — a legal stream shape the optional probes must tolerate.
/// </summary>
public sealed class FakeIoFailingLengthStream : Stream
{
private readonly Stream _inner;
public FakeIoFailingLengthStream(Stream inner)
{
_inner = inner;
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new IOException("The length is not available!");
public override long Position
{
get => throw new IOException("The position is not available!");
set => throw new NotSupportedException();
}
public override void Flush()
{
}
public override int Read(byte[] buffer, int offset, int count) => _inner.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)
{
_inner.Dispose();
}
base.Dispose(disposing);
}
}

156
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeMarkerPipelineContributorBase.cs

@ -0,0 +1,156 @@
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
namespace Volo.Abp.BlobStoring.Fakes;
/// <summary>
/// Prepends a marker while saving and verifies/strips it while getting,
/// so tests can observe the raw stored form and the execution order.
/// </summary>
public abstract class FakeMarkerPipelineContributorBase : IBlobPipelineContributor
{
private readonly byte[] _marker;
protected FakeMarkerPipelineContributorBase(string marker)
{
_marker = Encoding.UTF8.GetBytes(marker);
}
public Task OnSavingAsync(BlobPipelineContext context)
{
context.BlobStream = new MarkerPrependingStream(_marker, context.BlobStream);
return Task.CompletedTask;
}
public Task OnGettingAsync(BlobPipelineContext context)
{
context.BlobStream = new MarkerStrippingStream(_marker, context.BlobStream);
return Task.CompletedTask;
}
private sealed class MarkerPrependingStream : Stream
{
private readonly byte[] _marker;
private readonly Stream _inner;
private int _markerPosition;
public MarkerPrependingStream(byte[] marker, Stream inner)
{
_marker = marker;
_inner = inner;
}
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)
{
if (_markerPosition < _marker.Length)
{
var toCopy = Math.Min(count, _marker.Length - _markerPosition);
Array.Copy(_marker, _markerPosition, buffer, offset, toCopy);
_markerPosition += toCopy;
return toCopy;
}
return _inner.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();
// The save-side contract: the wrapper leaves the received stream open
}
private sealed class MarkerStrippingStream : Stream
{
private readonly byte[] _marker;
private readonly Stream _inner;
private bool _markerConsumed;
public MarkerStrippingStream(byte[] marker, Stream inner)
{
_marker = marker;
_inner = inner;
}
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)
{
ConsumeMarker();
return _inner.Read(buffer, offset, count);
}
private void ConsumeMarker()
{
if (_markerConsumed)
{
return;
}
_markerConsumed = true;
var markerBytes = new byte[_marker.Length];
var readCount = 0;
while (readCount < markerBytes.Length)
{
var read = _inner.Read(markerBytes, readCount, markerBytes.Length - readCount);
if (read <= 0)
{
break;
}
readCount += read;
}
if (readCount != _marker.Length || !((ReadOnlySpan<byte>)markerBytes).SequenceEqual(_marker))
{
throw new AbpException($"The expected content marker '{Encoding.UTF8.GetString(_marker)}' was not found!");
}
}
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)
{
// The get-side contract: the wrapper owns the received stream
if (disposing)
{
_inner.Dispose();
}
base.Dispose(disposing);
}
}
}

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

@ -0,0 +1,59 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Volo.Abp.BlobStoring.Fakes;
/// <summary>
/// A stream that only supports the modern async read overload; the synchronous
/// read throws, like some modern network/response streams.
/// </summary>
public sealed class FakeModernAsyncOnlyStream : Stream
{
private readonly Stream _inner;
public FakeModernAsyncOnlyStream(Stream inner)
{
_inner = inner;
}
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("This stream only supports the modern async read overload!");
}
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
return _inner.ReadAsync(buffer, 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)
{
_inner.Dispose();
}
base.Dispose(disposing);
}
}

23
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeModernAsyncPipelineContributor.cs

@ -0,0 +1,23 @@
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.BlobStoring.Fakes;
/// <summary>
/// Wraps the content with a stream that only supports the modern async read
/// overload, so tests can verify the pipeline does not degrade such a stream
/// to the synchronous byte[] fallback of the Stream base class.
/// </summary>
public class FakeModernAsyncPipelineContributor : IBlobPipelineContributor, ITransientDependency
{
public Task OnSavingAsync(BlobPipelineContext context)
{
return Task.CompletedTask;
}
public Task OnGettingAsync(BlobPipelineContext context)
{
context.BlobStream = new FakeModernAsyncOnlyStream(context.BlobStream);
return Task.CompletedTask;
}
}

30
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeOriginalRestoringPipelineContributor.cs

@ -0,0 +1,30 @@
#nullable enable
using System.IO;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.BlobStoring.Fakes;
/// <summary>
/// Sets the content stream back to a stream chosen by the test (the caller's
/// original), so tests can verify the pipeline never treats it as its own.
/// </summary>
public class FakeOriginalRestoringPipelineContributor : IBlobPipelineContributor, ITransientDependency
{
public static Stream? RestoreTo { get; set; }
public Task OnSavingAsync(BlobPipelineContext context)
{
if (RestoreTo != null)
{
context.BlobStream = RestoreTo;
}
return Task.CompletedTask;
}
public Task OnGettingAsync(BlobPipelineContext context)
{
return Task.CompletedTask;
}
}

37
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeScopedMarkerService.cs

@ -0,0 +1,37 @@
using System;
using System.Threading;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.BlobStoring.Fakes;
/// <summary>
/// A scoped service used by <see cref="FakeScopedXorPipelineContributor"/> to prove
/// that the contributor scope stays alive while the returned stream is being read.
/// </summary>
public class FakeScopedMarkerService : IScopedDependency, IDisposable
{
private static int _disposedCount;
public static int DisposedCount => Volatile.Read(ref _disposedCount);
private bool _disposed;
public byte Transform(byte value)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(FakeScopedMarkerService));
}
return (byte)(value ^ 0x5A);
}
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
Interlocked.Increment(ref _disposedCount);
}
}
}

86
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeScopedXorPipelineContributor.cs

@ -0,0 +1,86 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.BlobStoring.Fakes;
/// <summary>
/// XOR-transforms the content through a scoped service that is used lazily,
/// while the stream is being read.
/// </summary>
public class FakeScopedXorPipelineContributor : IBlobPipelineContributor, ITransientDependency
{
private readonly FakeScopedMarkerService _markerService;
public FakeScopedXorPipelineContributor(FakeScopedMarkerService markerService)
{
_markerService = markerService;
}
public Task OnSavingAsync(BlobPipelineContext context)
{
context.BlobStream = new XorStream(_markerService, context.BlobStream, ownsInner: false);
return Task.CompletedTask;
}
public Task OnGettingAsync(BlobPipelineContext context)
{
context.BlobStream = new XorStream(_markerService, context.BlobStream, ownsInner: true);
return Task.CompletedTask;
}
private sealed class XorStream : Stream
{
private readonly FakeScopedMarkerService _markerService;
private readonly Stream _inner;
private readonly bool _ownsInner;
public XorStream(FakeScopedMarkerService markerService, Stream inner, bool ownsInner)
{
_markerService = markerService;
_inner = inner;
_ownsInner = ownsInner;
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override void Flush()
{
}
public override int Read(byte[] buffer, int offset, int count)
{
var readCount = _inner.Read(buffer, offset, count);
for (var i = 0; i < readCount; i++)
{
buffer[offset + i] = _markerService.Transform(buffer[offset + i]);
}
return readCount;
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (disposing && _ownsInner)
{
_inner.Dispose();
}
base.Dispose(disposing);
}
}
}

65
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeSetThenThrowPipelineContributor.cs

@ -0,0 +1,65 @@
#nullable enable
using System;
using System.IO;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.BlobStoring.Fakes;
/// <summary>
/// Replaces the stream and then fails, so tests can verify that the
/// already-created stream is not leaked.
/// </summary>
public class FakeSetThenThrowPipelineContributor : IBlobPipelineContributor, ITransientDependency
{
public static TrackableStream? LastCreatedStream { get; private set; }
public Task OnSavingAsync(BlobPipelineContext context)
{
context.BlobStream = LastCreatedStream = new TrackableStream(context.BlobStream);
throw new InvalidOperationException("This contributor fails after replacing the stream!");
}
public Task OnGettingAsync(BlobPipelineContext context)
{
return Task.CompletedTask;
}
public sealed class TrackableStream : Stream
{
private readonly Stream _inner;
public bool Disposed { get; private set; }
public TrackableStream(Stream inner)
{
_inner = inner;
}
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) => _inner.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)
{
Disposed = true;
base.Dispose(disposing);
}
}
}

100
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantAssertingPipelineContributor.cs

@ -0,0 +1,100 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
using Volo.Abp.MultiTenancy;
namespace Volo.Abp.BlobStoring.Fakes;
/// <summary>
/// Asserts, lazily while the content is read, that the ambient tenant is the
/// tenant the BLOB operation belongs to.
/// </summary>
public class FakeTenantAssertingPipelineContributor : IBlobPipelineContributor, ITransientDependency
{
private readonly ICurrentTenant _currentTenant;
public FakeTenantAssertingPipelineContributor(ICurrentTenant currentTenant)
{
_currentTenant = currentTenant;
}
public Task OnSavingAsync(BlobPipelineContext context)
{
return Task.CompletedTask;
}
public Task OnGettingAsync(BlobPipelineContext context)
{
context.BlobStream = new TenantAssertingStream(context.BlobStream, _currentTenant, context.TenantId);
return Task.CompletedTask;
}
private sealed class TenantAssertingStream : Stream
{
private readonly Stream _inner;
private readonly ICurrentTenant _currentTenant;
private readonly Guid? _expectedTenantId;
public TenantAssertingStream(Stream inner, ICurrentTenant currentTenant, Guid? expectedTenantId)
{
_inner = inner;
_currentTenant = currentTenant;
_expectedTenantId = expectedTenantId;
}
public override bool CanRead
{
get
{
// Stream.CopyToAsync reads CanRead before the first ReadAsync call
AssertTenant();
return 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)
{
AssertTenant();
return _inner.Read(buffer, offset, count);
}
private void AssertTenant()
{
if (_currentTenant.Id != _expectedTenantId)
{
throw new AbpException(
$"The lazy transformation ran in the tenant '{_currentTenant.Id?.ToString() ?? "host"}' " +
$"instead of the tenant of the BLOB operation ('{_expectedTenantId?.ToString() ?? "host"}')!");
}
}
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)
{
_inner.Dispose();
}
base.Dispose(disposing);
}
}
}

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

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

29
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantRecordingScopedPipelineContributor.cs

@ -0,0 +1,29 @@
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.BlobStoring.Fakes;
/// <summary>
/// Holds a <see cref="FakeTenantRecordingScopedService"/> in the contributor scope
/// without transforming the content.
/// </summary>
public class FakeTenantRecordingScopedPipelineContributor : IBlobPipelineContributor, ITransientDependency
{
// ReSharper disable once NotAccessedField.Local
private readonly FakeTenantRecordingScopedService _service;
public FakeTenantRecordingScopedPipelineContributor(FakeTenantRecordingScopedService service)
{
_service = service;
}
public Task OnSavingAsync(BlobPipelineContext context)
{
return Task.CompletedTask;
}
public Task OnGettingAsync(BlobPipelineContext context)
{
return Task.CompletedTask;
}
}

35
framework/test/Volo.Abp.BlobStoring.Tests/Volo/Abp/BlobStoring/Fakes/FakeTenantRecordingScopedService.cs

@ -0,0 +1,35 @@
using System;
using Volo.Abp.DependencyInjection;
using Volo.Abp.MultiTenancy;
namespace Volo.Abp.BlobStoring.Fakes;
/// <summary>
/// Records the ambient tenant at the moment the owning scope disposes it, so tests
/// can verify the scope is released in the tenant of the BLOB operation.
/// </summary>
public class FakeTenantRecordingScopedService : IScopedDependency, IDisposable
{
public static Guid? LastDisposeTenantId { get; private set; }
public static bool HasRecordedDispose { get; private set; }
private readonly ICurrentTenant _currentTenant;
public FakeTenantRecordingScopedService(ICurrentTenant currentTenant)
{
_currentTenant = currentTenant;
}
public static void Reset()
{
LastDisposeTenantId = null;
HasRecordedDispose = false;
}
public void Dispose()
{
LastDisposeTenantId = _currentTenant.Id;
HasRecordedDispose = true;
}
}
Loading…
Cancel
Save