mirror of https://github.com/abpframework/abp.git
44 changed files with 2818 additions and 2073 deletions
@ -0,0 +1,3 @@ |
|||
using System.Runtime.CompilerServices; |
|||
|
|||
[assembly: InternalsVisibleTo("Volo.Abp.BlobStoring.Tests")] |
|||
@ -0,0 +1,523 @@ |
|||
using System; |
|||
using System.IO; |
|||
using System.Security.Cryptography; |
|||
using System.Text; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace Volo.Abp.BlobStoring; |
|||
|
|||
/// <summary>
|
|||
/// Implements the encrypted BLOB format (version 1) using AES-256-GCM.
|
|||
/// Not available on .NET Standard 2.0 (no AES-GCM).
|
|||
/// <para>
|
|||
/// Format: "ABPE" magic (4) + format version (1) + header (34: algorithm 1,
|
|||
/// key source 1, KDF iterations 4, random per-BLOB KDF salt 16, chunk size 4,
|
|||
/// base nonce 8), followed by authenticated chunk records (4-byte big-endian
|
|||
/// cipher length, cipher chunk, 16-byte tag) and an authenticated zero-length
|
|||
/// terminal record. The whole prefix, the storage identity (container, BLOB
|
|||
/// name, tenant) and the chunk index are bound to every chunk as associated
|
|||
/// data; the per-BLOB salt gives every BLOB its own derived key.
|
|||
/// </para>
|
|||
/// </summary>
|
|||
internal sealed class BlobEncryptionCodec : ITransientDependency |
|||
{ |
|||
internal static readonly byte[] Magic = { (byte)'A', (byte)'B', (byte)'P', (byte)'E' }; |
|||
|
|||
internal const byte FormatVersion = 1; |
|||
internal const byte AlgorithmAesGcm = 1; |
|||
internal const int MinKdfIterations = 100_000; |
|||
internal const int MaxKdfIterations = 600_000; // reader cap: bounded headroom above the writer constant
|
|||
internal const int KdfSaltSize = 16; |
|||
internal const int ChunkSize = 64 * 1024; |
|||
internal const int MaxChunkSize = 1024 * 1024; // reader cap: bounds allocations driven by the (pre-authentication) header
|
|||
internal const int BaseNonceSize = 8; |
|||
internal const int HeaderSize = 34; // algorithm(1) + keySource(1) + iterations(4) + salt(16) + chunkSize(4) + baseNonce(8)
|
|||
internal const int ChunkLengthPrefixSize = 4; |
|||
internal const int GcmNonceSize = 12; |
|||
internal const int GcmTagSize = 16; |
|||
|
|||
private readonly IBlobEncryptionKeyProvider _keyProvider; |
|||
private readonly AbpBlobStoringEncryptionOptions _options; |
|||
|
|||
public BlobEncryptionCodec( |
|||
IBlobEncryptionKeyProvider keyProvider, |
|||
Microsoft.Extensions.Options.IOptions<AbpBlobStoringEncryptionOptions> options) |
|||
{ |
|||
_keyProvider = keyProvider; |
|||
_options = options.Value; |
|||
} |
|||
|
|||
// The key is fully resolved before the stream is returned, so the resolution scope can be released.
|
|||
public async Task<Stream> CreateEncryptingStreamAsync( |
|||
BlobContainerConfiguration configuration, |
|||
string containerName, |
|||
string blobName, |
|||
Guid? tenantId, |
|||
Stream plainStream, |
|||
CancellationToken cancellationToken = default) |
|||
{ |
|||
#if NETSTANDARD2_0
|
|||
// Fail before any output is produced, so no partial (corrupted) data is ever written.
|
|||
throw new PlatformNotSupportedException("BLOB encryption requires AES-GCM, which is not available on .NET Standard 2.0!"); |
|||
#else
|
|||
#if NET8_0_OR_GREATER
|
|||
if (!AesGcm.IsSupported) |
|||
{ |
|||
throw new PlatformNotSupportedException("AES-GCM is not supported on this platform!"); |
|||
} |
|||
#endif
|
|||
var kdfIterations = _options.KdfIterations; |
|||
if (kdfIterations < MinKdfIterations || kdfIterations > MaxKdfIterations) |
|||
{ |
|||
throw new AbpException( |
|||
$"{nameof(AbpBlobStoringEncryptionOptions)}.{nameof(AbpBlobStoringEncryptionOptions.KdfIterations)} " + |
|||
$"must be between {MinKdfIterations} and {MaxKdfIterations}!"); |
|||
} |
|||
|
|||
var key = await _keyProvider.ResolveForEncryptionAsync(configuration, cancellationToken); |
|||
|
|||
var salt = new byte[KdfSaltSize]; |
|||
var baseNonce = new byte[BaseNonceSize]; |
|||
using (var random = RandomNumberGenerator.Create()) |
|||
{ |
|||
random.GetBytes(salt); |
|||
random.GetBytes(baseNonce); |
|||
} |
|||
|
|||
var header = BuildHeader(key.Source, kdfIterations, salt, ChunkSize, baseNonce); |
|||
var blobPrefix = CreateBlobPrefix(header); |
|||
cancellationToken.ThrowIfCancellationRequested(); |
|||
var keyBytes = DeriveKeyBytes(key.PassPhrase, salt, kdfIterations); |
|||
|
|||
return new ChunkedEncryptingReadStream( |
|||
plainStream, |
|||
blobPrefix, |
|||
BuildAssociatedDataPrefix(blobPrefix, containerName, blobName, tenantId), |
|||
keyBytes, |
|||
baseNonce, |
|||
ChunkSize, |
|||
TryCalculateEncryptedLength(plainStream, ChunkSize) |
|||
); |
|||
#endif
|
|||
} |
|||
|
|||
public async Task<Stream> CreateDecryptingStreamAsync( |
|||
BlobContainerConfiguration configuration, |
|||
string containerName, |
|||
string blobName, |
|||
Guid? tenantId, |
|||
Stream cipherStream, |
|||
CancellationToken cancellationToken = default) |
|||
{ |
|||
var prefix = await ReadUpToAsync(cipherStream, Magic.Length + 1, cancellationToken); |
|||
if (!HasMagic(prefix)) |
|||
{ |
|||
if (BlobEncryptionConfiguration.IsLegacyPlaintextAllowed(configuration)) |
|||
{ |
|||
return new PrefixingReadStream(prefix, cipherStream); |
|||
} |
|||
|
|||
throw new AbpException( |
|||
"The BLOB does not have the encrypted BLOB format. If it was stored before encryption " + |
|||
"was enabled for the container, enable reading legacy plaintext BLOBs explicitly " + |
|||
"(see the UseEncryption extension method). Otherwise the BLOB is corrupted or tampered." |
|||
); |
|||
} |
|||
|
|||
if (prefix[Magic.Length] != FormatVersion) |
|||
{ |
|||
throw new AbpException($"Unsupported encrypted BLOB format version: {prefix[Magic.Length]}!"); |
|||
} |
|||
|
|||
#if NETSTANDARD2_0
|
|||
throw new PlatformNotSupportedException("BLOB decryption requires AES-GCM, which is not available on .NET Standard 2.0!"); |
|||
#else
|
|||
#if NET8_0_OR_GREATER
|
|||
if (!AesGcm.IsSupported) |
|||
{ |
|||
throw new PlatformNotSupportedException("AES-GCM is not supported on this platform!"); |
|||
} |
|||
#endif
|
|||
var header = await ReadExactlyAsync(cipherStream, HeaderSize, cancellationToken); |
|||
if (header == null) |
|||
{ |
|||
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: missing header!"); |
|||
} |
|||
|
|||
if (header[0] != AlgorithmAesGcm) |
|||
{ |
|||
throw new AbpException($"Unsupported encrypted BLOB algorithm: {header[0]}!"); |
|||
} |
|||
|
|||
var keySource = header[1]; |
|||
if (keySource < (byte)BlobEncryptionKeySource.Container || keySource > (byte)BlobEncryptionKeySource.Global) |
|||
{ |
|||
throw new AbpException($"Unknown BLOB encryption key source: {keySource}!"); |
|||
} |
|||
|
|||
var iterations = ReadInt32BigEndian(header, 2); |
|||
if (iterations <= 0 || iterations > MaxKdfIterations) |
|||
{ |
|||
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid KDF iteration count!"); |
|||
} |
|||
|
|||
var salt = new byte[KdfSaltSize]; |
|||
Array.Copy(header, 6, salt, 0, KdfSaltSize); |
|||
|
|||
var chunkSize = ReadInt32BigEndian(header, 22); |
|||
if (chunkSize <= 0 || chunkSize > MaxChunkSize) |
|||
{ |
|||
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid chunk size!"); |
|||
} |
|||
|
|||
var baseNonce = new byte[BaseNonceSize]; |
|||
Array.Copy(header, 26, baseNonce, 0, BaseNonceSize); |
|||
|
|||
var passPhrase = await _keyProvider.ResolveForDecryptionAsync( |
|||
(BlobEncryptionKeySource)keySource, |
|||
configuration, |
|||
cancellationToken |
|||
); |
|||
cancellationToken.ThrowIfCancellationRequested(); |
|||
var keyBytes = DeriveKeyBytes(passPhrase, salt, iterations); |
|||
|
|||
var blobPrefix = new byte[Magic.Length + 1 + HeaderSize]; |
|||
Array.Copy(prefix, 0, blobPrefix, 0, Magic.Length + 1); |
|||
Array.Copy(header, 0, blobPrefix, Magic.Length + 1, HeaderSize); |
|||
|
|||
return new ChunkedDecryptingReadStream( |
|||
cipherStream, |
|||
BuildAssociatedDataPrefix(blobPrefix, containerName, blobName, tenantId), |
|||
keyBytes, |
|||
baseNonce, |
|||
chunkSize |
|||
); |
|||
#endif
|
|||
} |
|||
|
|||
internal static byte[] BuildHeader(BlobEncryptionKeySource keySource, int iterations, byte[] salt, int chunkSize, byte[] baseNonce) |
|||
{ |
|||
var header = new byte[HeaderSize]; |
|||
header[0] = AlgorithmAesGcm; |
|||
header[1] = (byte)keySource; |
|||
WriteInt32BigEndian(header, 2, iterations); |
|||
Array.Copy(salt, 0, header, 6, KdfSaltSize); |
|||
WriteInt32BigEndian(header, 22, chunkSize); |
|||
Array.Copy(baseNonce, 0, header, 26, BaseNonceSize); |
|||
return header; |
|||
} |
|||
|
|||
// Length-prefixed identity fields: a validly encrypted BLOB can not be read
|
|||
// from another BLOB name, container or tenant.
|
|||
internal static byte[] BuildAssociatedDataPrefix(byte[] blobPrefix, string containerName, string blobName, Guid? tenantId) |
|||
{ |
|||
var containerNameBytes = Encoding.UTF8.GetBytes(containerName); |
|||
var blobNameBytes = Encoding.UTF8.GetBytes(blobName); |
|||
var tenantIdBytes = tenantId?.ToByteArray() ?? Array.Empty<byte>(); |
|||
|
|||
var prefix = new byte[blobPrefix.Length + 4 + containerNameBytes.Length + 4 + blobNameBytes.Length + 4 + tenantIdBytes.Length]; |
|||
var offset = 0; |
|||
|
|||
Array.Copy(blobPrefix, 0, prefix, offset, blobPrefix.Length); |
|||
offset += blobPrefix.Length; |
|||
|
|||
offset = WriteLengthPrefixed(prefix, offset, containerNameBytes); |
|||
offset = WriteLengthPrefixed(prefix, offset, blobNameBytes); |
|||
WriteLengthPrefixed(prefix, offset, tenantIdBytes); |
|||
|
|||
return prefix; |
|||
} |
|||
|
|||
private static int WriteLengthPrefixed(byte[] buffer, int offset, byte[] bytes) |
|||
{ |
|||
WriteInt32BigEndian(buffer, offset, bytes.Length); |
|||
Array.Copy(bytes, 0, buffer, offset + 4, bytes.Length); |
|||
return offset + 4 + bytes.Length; |
|||
} |
|||
|
|||
internal static byte[] CreateBlobPrefix(byte[] header) |
|||
{ |
|||
var prefix = new byte[Magic.Length + 1 + header.Length]; |
|||
Magic.CopyTo(prefix, 0); |
|||
prefix[Magic.Length] = FormatVersion; |
|||
Array.Copy(header, 0, prefix, Magic.Length + 1, header.Length); |
|||
return prefix; |
|||
} |
|||
|
|||
internal static byte[] DeriveKeyBytes(string passPhrase, byte[] salt, int iterations) |
|||
{ |
|||
#if NETSTANDARD2_0
|
|||
throw new PlatformNotSupportedException("BLOB encryption requires AES-GCM, which is not available on .NET Standard 2.0!"); |
|||
#elif NET8_0_OR_GREATER
|
|||
return Rfc2898DeriveBytes.Pbkdf2(passPhrase, salt, iterations, HashAlgorithmName.SHA256, 32); |
|||
#else
|
|||
using var password = new Rfc2898DeriveBytes(passPhrase, salt, iterations, HashAlgorithmName.SHA256); |
|||
return password.GetBytes(32); |
|||
#endif
|
|||
} |
|||
|
|||
internal static byte[] EncryptChunk(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex, byte[] plainChunk, int plainChunkLength) |
|||
{ |
|||
#if NETSTANDARD2_0
|
|||
throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!"); |
|||
#else
|
|||
var record = new byte[ChunkLengthPrefixSize + plainChunkLength + GcmTagSize]; |
|||
WriteInt32BigEndian(record, 0, plainChunkLength); |
|||
|
|||
using (var aesGcm = CreateAesGcm(keyBytes)) |
|||
{ |
|||
aesGcm.Encrypt( |
|||
CreateChunkNonce(baseNonce, chunkIndex), |
|||
plainChunk.AsSpan(0, plainChunkLength), |
|||
record.AsSpan(ChunkLengthPrefixSize, plainChunkLength), |
|||
record.AsSpan(ChunkLengthPrefixSize + plainChunkLength, GcmTagSize), |
|||
CreateChunkAssociatedData(associatedDataPrefix, chunkIndex) |
|||
); |
|||
} |
|||
|
|||
return record; |
|||
#endif
|
|||
} |
|||
|
|||
internal static byte[] DecryptChunk(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex, byte[] cipherChunk, byte[] tag) |
|||
{ |
|||
#if NETSTANDARD2_0
|
|||
throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!"); |
|||
#else
|
|||
var plainChunk = new byte[cipherChunk.Length]; |
|||
using (var aesGcm = CreateAesGcm(keyBytes)) |
|||
{ |
|||
// Throws CryptographicException if the authentication tag is invalid.
|
|||
aesGcm.Decrypt(CreateChunkNonce(baseNonce, chunkIndex), cipherChunk, tag, plainChunk, CreateChunkAssociatedData(associatedDataPrefix, chunkIndex)); |
|||
} |
|||
|
|||
return plainChunk; |
|||
#endif
|
|||
} |
|||
|
|||
// The authenticated terminal record makes truncation of complete chunks detectable
|
|||
internal static byte[] CreateTerminalRecord(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex) |
|||
{ |
|||
#if NETSTANDARD2_0
|
|||
throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!"); |
|||
#else
|
|||
var record = new byte[ChunkLengthPrefixSize + GcmTagSize]; |
|||
using (var aesGcm = CreateAesGcm(keyBytes)) |
|||
{ |
|||
aesGcm.Encrypt( |
|||
CreateChunkNonce(baseNonce, chunkIndex), |
|||
Array.Empty<byte>(), |
|||
Array.Empty<byte>(), |
|||
record.AsSpan(ChunkLengthPrefixSize, GcmTagSize), |
|||
CreateChunkAssociatedData(associatedDataPrefix, chunkIndex) |
|||
); |
|||
} |
|||
|
|||
return record; |
|||
#endif
|
|||
} |
|||
|
|||
internal static void VerifyTerminalRecord(byte[] keyBytes, byte[] associatedDataPrefix, byte[] baseNonce, int chunkIndex, byte[] tag) |
|||
{ |
|||
#if NETSTANDARD2_0
|
|||
throw new PlatformNotSupportedException("AES-GCM is not available on .NET Standard 2.0!"); |
|||
#else
|
|||
using (var aesGcm = CreateAesGcm(keyBytes)) |
|||
{ |
|||
// Throws CryptographicException if the tag is invalid.
|
|||
aesGcm.Decrypt( |
|||
CreateChunkNonce(baseNonce, chunkIndex), |
|||
Array.Empty<byte>(), |
|||
tag, |
|||
Array.Empty<byte>(), |
|||
CreateChunkAssociatedData(associatedDataPrefix, chunkIndex) |
|||
); |
|||
} |
|||
#endif
|
|||
} |
|||
|
|||
// Nonce = 8-byte random base + 4-byte chunk index; the per-BLOB key (random salt)
|
|||
// makes cross-BLOB reuse harmless and the index keeps it unique within the BLOB.
|
|||
internal static byte[] CreateChunkNonce(byte[] baseNonce, int chunkIndex) |
|||
{ |
|||
if (chunkIndex < 0) |
|||
{ |
|||
// A wrapped chunk index would repeat a nonce for the same key, which breaks AES-GCM.
|
|||
throw new AbpException("The data is too large: the maximum chunk count has been exceeded!"); |
|||
} |
|||
|
|||
var nonce = new byte[GcmNonceSize]; |
|||
Array.Copy(baseNonce, 0, nonce, 0, BaseNonceSize); |
|||
WriteInt32BigEndian(nonce, BaseNonceSize, chunkIndex); |
|||
return nonce; |
|||
} |
|||
|
|||
internal static byte[] CreateChunkAssociatedData(byte[] associatedDataPrefix, int chunkIndex) |
|||
{ |
|||
var associatedData = new byte[associatedDataPrefix.Length + 4]; |
|||
Array.Copy(associatedDataPrefix, 0, associatedData, 0, associatedDataPrefix.Length); |
|||
WriteInt32BigEndian(associatedData, associatedDataPrefix.Length, chunkIndex); |
|||
return associatedData; |
|||
} |
|||
|
|||
internal static int GetCipherChunkSize(byte[] lengthPrefix, int maxCipherChunkSize) |
|||
{ |
|||
if (lengthPrefix.Length == 0) |
|||
{ |
|||
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: missing terminal record!"); |
|||
} |
|||
|
|||
if (lengthPrefix.Length < ChunkLengthPrefixSize) |
|||
{ |
|||
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: truncated chunk!"); |
|||
} |
|||
|
|||
var cipherChunkSize = ReadInt32BigEndian(lengthPrefix, 0); |
|||
if (cipherChunkSize < 0 || cipherChunkSize > maxCipherChunkSize) |
|||
{ |
|||
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid chunk length!"); |
|||
} |
|||
|
|||
return cipherChunkSize; |
|||
} |
|||
|
|||
internal static byte[]? ReadExactly(Stream stream, int count) |
|||
{ |
|||
var buffer = ReadUpTo(stream, count); |
|||
return buffer.Length == count ? buffer : null; |
|||
} |
|||
|
|||
internal static byte[] ReadUpTo(Stream stream, int count) |
|||
{ |
|||
var buffer = new byte[count]; |
|||
var totalReadCount = 0; |
|||
while (totalReadCount < count) |
|||
{ |
|||
var readCount = stream.Read(buffer, totalReadCount, count - totalReadCount); |
|||
if (readCount == 0) |
|||
{ |
|||
break; |
|||
} |
|||
|
|||
totalReadCount += readCount; |
|||
} |
|||
|
|||
if (totalReadCount == count) |
|||
{ |
|||
return buffer; |
|||
} |
|||
|
|||
var result = new byte[totalReadCount]; |
|||
Array.Copy(buffer, 0, result, 0, totalReadCount); |
|||
return result; |
|||
} |
|||
|
|||
internal static async Task<byte[]?> ReadExactlyAsync(Stream stream, int count, CancellationToken cancellationToken = default) |
|||
{ |
|||
var buffer = await ReadUpToAsync(stream, count, cancellationToken); |
|||
return buffer.Length == count ? buffer : null; |
|||
} |
|||
|
|||
internal static async Task<byte[]> ReadUpToAsync(Stream stream, int count, CancellationToken cancellationToken = default) |
|||
{ |
|||
var buffer = new byte[count]; |
|||
var totalReadCount = 0; |
|||
while (totalReadCount < count) |
|||
{ |
|||
var readCount = await stream.ReadAsync(buffer, totalReadCount, count - totalReadCount, cancellationToken); |
|||
if (readCount == 0) |
|||
{ |
|||
break; |
|||
} |
|||
|
|||
totalReadCount += readCount; |
|||
} |
|||
|
|||
if (totalReadCount == count) |
|||
{ |
|||
return buffer; |
|||
} |
|||
|
|||
var result = new byte[totalReadCount]; |
|||
Array.Copy(buffer, 0, result, 0, totalReadCount); |
|||
return result; |
|||
} |
|||
|
|||
private static bool HasMagic(byte[] prefix) |
|||
{ |
|||
if (prefix.Length < Magic.Length + 1) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
for (var i = 0; i < Magic.Length; i++) |
|||
{ |
|||
if (prefix[i] != Magic[i]) |
|||
{ |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
|
|||
private static long? TryCalculateEncryptedLength(Stream plainStream, int chunkSize) |
|||
{ |
|||
if (!plainStream.CanSeek) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
try |
|||
{ |
|||
var plainLength = plainStream.Length - plainStream.Position; |
|||
if (plainLength < 0) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var fullChunkCount = plainLength / chunkSize; |
|||
var chunkRecordCount = fullChunkCount + (plainLength % chunkSize > 0 ? 1 : 0) + 1; // +1: terminal record
|
|||
|
|||
checked |
|||
{ |
|||
return Magic.Length + 1L + HeaderSize + plainLength + |
|||
chunkRecordCount * (ChunkLengthPrefixSize + GcmTagSize); |
|||
} |
|||
} |
|||
catch (NotSupportedException) |
|||
{ |
|||
return null; |
|||
} |
|||
catch (OverflowException) |
|||
{ |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
#if !NETSTANDARD2_0
|
|||
private static AesGcm CreateAesGcm(byte[] keyBytes) |
|||
{ |
|||
#if NET8_0_OR_GREATER
|
|||
return new AesGcm(keyBytes, GcmTagSize); |
|||
#else
|
|||
return new AesGcm(keyBytes); |
|||
#endif
|
|||
} |
|||
#endif
|
|||
|
|||
private static void WriteInt32BigEndian(byte[] buffer, int offset, int value) |
|||
{ |
|||
buffer[offset] = (byte)(value >> 24); |
|||
buffer[offset + 1] = (byte)(value >> 16); |
|||
buffer[offset + 2] = (byte)(value >> 8); |
|||
buffer[offset + 3] = (byte)value; |
|||
} |
|||
|
|||
private static int ReadInt32BigEndian(byte[] buffer, int offset) |
|||
{ |
|||
return (buffer[offset] << 24) | (buffer[offset + 1] << 16) | (buffer[offset + 2] << 8) | buffer[offset + 3]; |
|||
} |
|||
} |
|||
@ -0,0 +1,29 @@ |
|||
namespace Volo.Abp.BlobStoring; |
|||
|
|||
/// <summary>
|
|||
/// Reads the encryption values of a container configuration (set by the
|
|||
/// UseEncryption/DisableEncryption extension methods, inherited over the fallback chain).
|
|||
/// </summary>
|
|||
internal static class BlobEncryptionConfiguration |
|||
{ |
|||
public const string EnabledName = "Abp.BlobStoring.Encryption.Enabled"; |
|||
public const string PassPhraseName = "Abp.BlobStoring.Encryption.PassPhrase"; |
|||
public const string AllowLegacyPlaintextName = "Abp.BlobStoring.Encryption.AllowLegacyPlaintext"; |
|||
|
|||
public static bool IsEnabled(BlobContainerConfiguration configuration) |
|||
{ |
|||
return configuration.GetConfigurationOrDefault(EnabledName, false); |
|||
} |
|||
|
|||
public static string? GetPassPhraseOrNull(BlobContainerConfiguration configuration) |
|||
{ |
|||
// An explicit empty value shadows an inherited passphrase (see UseEncryption).
|
|||
var passPhrase = configuration.GetConfigurationOrDefault<string?>(PassPhraseName); |
|||
return string.IsNullOrWhiteSpace(passPhrase) ? null : passPhrase; |
|||
} |
|||
|
|||
public static bool IsLegacyPlaintextAllowed(BlobContainerConfiguration configuration) |
|||
{ |
|||
return configuration.GetConfigurationOrDefault(AllowLegacyPlaintextName, false); |
|||
} |
|||
} |
|||
@ -1,59 +0,0 @@ |
|||
using System; |
|||
using System.IO; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace Volo.Abp.BlobStoring; |
|||
|
|||
/// <summary>
|
|||
/// A pipeline contributor that encrypts BLOBs on save and decrypts them on read.
|
|||
/// <para>
|
|||
/// The passphrase is resolved in the following order:
|
|||
/// 1. Container-specific passphrase (see <c>UseEncryption</c> extension method).
|
|||
/// 2. <see cref="IBlobEncryptionKeyProvider"/> (tenant-specific setting, then the global passphrase).
|
|||
/// </para>
|
|||
/// </summary>
|
|||
public class BlobEncryptionContributor : IBlobPipelineContributor, ITransientDependency |
|||
{ |
|||
protected IBlobEncryptionService EncryptionService { get; } |
|||
|
|||
protected IBlobEncryptionKeyProvider EncryptionKeyProvider { get; } |
|||
|
|||
public BlobEncryptionContributor( |
|||
IBlobEncryptionService encryptionService, |
|||
IBlobEncryptionKeyProvider encryptionKeyProvider) |
|||
{ |
|||
EncryptionService = encryptionService; |
|||
EncryptionKeyProvider = encryptionKeyProvider; |
|||
} |
|||
|
|||
public virtual async Task<Stream> OnSaveAsync(BlobPipelineSaveArgs args) |
|||
{ |
|||
var passPhrase = await GetPassPhraseAsync(args); |
|||
return EncryptionService.Encrypt(args.BlobStream, passPhrase); |
|||
} |
|||
|
|||
public virtual async Task<Stream> OnGetAsync(BlobPipelineGetArgs args) |
|||
{ |
|||
var passPhrase = await GetPassPhraseAsync(args); |
|||
return EncryptionService.Decrypt(args.BlobStream, passPhrase); |
|||
} |
|||
|
|||
protected virtual async Task<string> GetPassPhraseAsync(BlobProviderArgs args) |
|||
{ |
|||
var passPhrase = |
|||
args.Configuration.GetConfigurationOrDefault<string>(BlobStoringEncryptionConfigurationNames.PassPhrase) ?? |
|||
await EncryptionKeyProvider.GetPassPhraseOrNullAsync(args.Configuration, args.CancellationToken); |
|||
|
|||
if (passPhrase.IsNullOrEmpty()) |
|||
{ |
|||
throw new AbpException( |
|||
$"BLOB encryption is enabled for the container '{args.ContainerName}', but no passphrase could be resolved. " + |
|||
$"Pass a passphrase to the UseEncryption extension method, set the '{BlobStoringEncryptionSettings.TenantPassPhrase}' " + |
|||
$"setting for the current tenant or configure {nameof(AbpBlobStoringEncryptionOptions)}.{nameof(AbpBlobStoringEncryptionOptions.DefaultPassPhrase)}." |
|||
); |
|||
} |
|||
|
|||
return passPhrase!; |
|||
} |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
using System; |
|||
using JetBrains.Annotations; |
|||
|
|||
namespace Volo.Abp.BlobStoring; |
|||
|
|||
/// <summary>
|
|||
/// The passphrase resolved for encrypting a BLOB, together with its source.
|
|||
/// </summary>
|
|||
public sealed class BlobEncryptionKey |
|||
{ |
|||
public BlobEncryptionKeySource Source { get; } |
|||
|
|||
[NotNull] |
|||
public string PassPhrase { get; } |
|||
|
|||
public BlobEncryptionKey(BlobEncryptionKeySource source, [NotNull] string passPhrase) |
|||
{ |
|||
if (source < BlobEncryptionKeySource.Container || source > BlobEncryptionKeySource.Global) |
|||
{ |
|||
// The source is stored in the BLOB header and validated while reading;
|
|||
// an unknown value would make the BLOB permanently unreadable.
|
|||
throw new ArgumentException($"Unknown BLOB encryption key source: {source}!", nameof(source)); |
|||
} |
|||
|
|||
Source = source; |
|||
PassPhrase = Check.NotNullOrWhiteSpace(passPhrase, nameof(passPhrase)); |
|||
} |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
namespace Volo.Abp.BlobStoring; |
|||
|
|||
/// <summary>
|
|||
/// Identifies where the encryption passphrase of a BLOB comes from. The value is
|
|||
/// stored in the BLOB header, so decryption uses the same source again even if
|
|||
/// other sources are configured later.
|
|||
/// </summary>
|
|||
public enum BlobEncryptionKeySource : byte |
|||
{ |
|||
/// <summary>The container-specific passphrase (see the UseEncryption extension method).</summary>
|
|||
Container = 1, |
|||
|
|||
/// <summary>A tenant-specific passphrase, provided by a custom <see cref="IBlobEncryptionKeyProvider"/>; unused by the default provider.</summary>
|
|||
Tenant = 2, |
|||
|
|||
/// <summary>The global passphrase (see <see cref="AbpBlobStoringEncryptionOptions.DefaultPassPhrase"/>).</summary>
|
|||
Global = 3 |
|||
} |
|||
@ -1,559 +0,0 @@ |
|||
using System; |
|||
using System.IO; |
|||
using System.Security.Cryptography; |
|||
using Microsoft.Extensions.Options; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.Security.Encryption; |
|||
|
|||
namespace Volo.Abp.BlobStoring; |
|||
|
|||
/// <summary>
|
|||
/// Default implementation of <see cref="IBlobEncryptionService"/>.
|
|||
/// <para>
|
|||
/// Inherits the authenticated (AEAD) chunked encryption from
|
|||
/// <see cref="ByteArrayEncryptionService"/> and exposes it as pull-style
|
|||
/// read streams, as required by the BLOB pipeline. Memory usage is constant,
|
|||
/// independent from the BLOB size.
|
|||
/// </para>
|
|||
/// <para>
|
|||
/// Format of an encrypted BLOB: 4 bytes magic ("ABPE") + 1 byte BLOB format version,
|
|||
/// followed by the <see cref="ByteArrayEncryptionService"/> output
|
|||
/// (its own header + authenticated chunks). BLOBs without the magic header
|
|||
/// are returned as-is on decryption, so BLOBs stored before encryption was
|
|||
/// enabled stay readable.
|
|||
/// </para>
|
|||
/// </summary>
|
|||
public class BlobEncryptionService : ByteArrayEncryptionService, IBlobEncryptionService |
|||
{ |
|||
protected static readonly byte[] MagicHeader = { (byte)'A', (byte)'B', (byte)'P', (byte)'E' }; |
|||
|
|||
protected const byte BlobFormatVersion = 1; |
|||
|
|||
public BlobEncryptionService(IOptions<AbpByteArrayEncryptionOptions> options) |
|||
: base(options) |
|||
{ |
|||
} |
|||
|
|||
public virtual Stream Encrypt(Stream plainStream, string passPhrase) |
|||
{ |
|||
Check.NotNull(plainStream, nameof(plainStream)); |
|||
Check.NotNullOrWhiteSpace(passPhrase, nameof(passPhrase)); |
|||
|
|||
if (Options.ChunkSize <= 0 || Options.ChunkSize > MaximumChunkSize) |
|||
{ |
|||
throw new AbpException($"{nameof(Options.ChunkSize)} must be between 1 and {MaximumChunkSize} bytes!"); |
|||
} |
|||
|
|||
var algorithm = GetEncryptionAlgorithm(); |
|||
var keyBytes = DeriveKeyBytes(passPhrase, Options.DefaultSalt, algorithm); |
|||
|
|||
var baseNonce = new byte[BaseNonceSize]; |
|||
using (var random = RandomNumberGenerator.Create()) |
|||
{ |
|||
random.GetBytes(baseNonce); |
|||
} |
|||
|
|||
var header = BuildHeader(algorithm, Options.ChunkSize, baseNonce); |
|||
|
|||
return new ChunkedEncryptingReadStream( |
|||
this, |
|||
plainStream, |
|||
header, |
|||
keyBytes, |
|||
baseNonce, |
|||
algorithm, |
|||
Options.ChunkSize, |
|||
TryCalculateEncryptedLength(plainStream, algorithm, Options.ChunkSize) |
|||
); |
|||
} |
|||
|
|||
public virtual Stream Decrypt(Stream cipherStream, string passPhrase) |
|||
{ |
|||
Check.NotNull(cipherStream, nameof(cipherStream)); |
|||
Check.NotNullOrWhiteSpace(passPhrase, nameof(passPhrase)); |
|||
|
|||
var prefix = ReadUpTo(cipherStream, MagicHeader.Length + 1); |
|||
if (prefix.Length < MagicHeader.Length + 1 || !HasMagicHeader(prefix)) |
|||
{ |
|||
// Not an encrypted BLOB, return as-is for backward compatibility
|
|||
return new PrefixingReadStream(prefix, cipherStream); |
|||
} |
|||
|
|||
if (prefix[MagicHeader.Length] != BlobFormatVersion) |
|||
{ |
|||
throw new AbpException($"Unsupported BLOB encryption format version: {prefix[MagicHeader.Length]}!"); |
|||
} |
|||
|
|||
var header = ReadExactly(cipherStream, HeaderSize); |
|||
if (header == null) |
|||
{ |
|||
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: missing header!"); |
|||
} |
|||
|
|||
if (header[0] != FormatVersion) |
|||
{ |
|||
throw new AbpException($"Unsupported encryption format version: {header[0]}!"); |
|||
} |
|||
|
|||
var algorithm = header[1]; |
|||
if (algorithm != AlgorithmAesGcm && algorithm != AlgorithmAesCbcHmacSha256) |
|||
{ |
|||
throw new AbpException($"Unsupported encryption algorithm: {algorithm}!"); |
|||
} |
|||
|
|||
var chunkSize = ReadInt32BigEndian(header, 2); |
|||
if (chunkSize <= 0 || chunkSize > MaximumChunkSize) |
|||
{ |
|||
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid chunk size!"); |
|||
} |
|||
|
|||
var baseNonce = new byte[BaseNonceSize]; |
|||
Array.Copy(header, 6, baseNonce, 0, BaseNonceSize); |
|||
|
|||
var keyBytes = DeriveKeyBytes(passPhrase, Options.DefaultSalt, algorithm); |
|||
|
|||
return new ChunkedDecryptingReadStream( |
|||
this, |
|||
cipherStream, |
|||
header, |
|||
keyBytes, |
|||
baseNonce, |
|||
algorithm, |
|||
chunkSize, |
|||
algorithm == AlgorithmAesGcm ? GcmTagSize : HmacSize, |
|||
algorithm == AlgorithmAesGcm ? chunkSize : chunkSize + AesBlockSize |
|||
); |
|||
} |
|||
|
|||
internal byte[] EncryptChunkToBytes(byte algorithm, byte[] keyBytes, byte[] header, byte[] baseNonce, int chunkIndex, byte[] plainChunk, int plainChunkLength) |
|||
{ |
|||
using (var chunkStream = new MemoryStream()) |
|||
{ |
|||
EncryptChunk(algorithm, keyBytes, header, baseNonce, chunkIndex, plainChunk, plainChunkLength, chunkStream); |
|||
return chunkStream.ToArray(); |
|||
} |
|||
} |
|||
|
|||
internal byte[]? ReadExactlyCore(Stream stream, int count) |
|||
{ |
|||
return ReadExactly(stream, count); |
|||
} |
|||
|
|||
internal byte[] ReadUpToCore(Stream stream, int count) |
|||
{ |
|||
return ReadUpTo(stream, count); |
|||
} |
|||
|
|||
internal byte[] DecryptChunkCore(byte algorithm, byte[] keyBytes, byte[] header, byte[] baseNonce, int chunkIndex, byte[] cipherChunk, byte[] tag) |
|||
{ |
|||
return DecryptChunk(algorithm, keyBytes, header, baseNonce, chunkIndex, cipherChunk, tag); |
|||
} |
|||
|
|||
internal byte[] WriteTerminalRecordToBytes(byte algorithm, byte[] keyBytes, byte[] header, byte[] baseNonce, int chunkIndex) |
|||
{ |
|||
using (var stream = new MemoryStream()) |
|||
{ |
|||
WriteTerminalRecord(algorithm, keyBytes, header, baseNonce, chunkIndex, stream); |
|||
return stream.ToArray(); |
|||
} |
|||
} |
|||
|
|||
internal void VerifyTerminalRecordCore(byte algorithm, byte[] keyBytes, byte[] header, byte[] baseNonce, int chunkIndex, byte[] tag) |
|||
{ |
|||
VerifyTerminalRecord(algorithm, keyBytes, header, baseNonce, chunkIndex, tag); |
|||
} |
|||
|
|||
private static long? TryCalculateEncryptedLength(Stream plainStream, byte algorithm, int chunkSize) |
|||
{ |
|||
if (!plainStream.CanSeek) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
try |
|||
{ |
|||
var plainLength = plainStream.Length - plainStream.Position; |
|||
if (plainLength < 0) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var tagSize = algorithm == AlgorithmAesGcm ? GcmTagSize : HmacSize; |
|||
var fullChunkCount = plainLength / chunkSize; |
|||
var remainder = plainLength % chunkSize; |
|||
|
|||
checked |
|||
{ |
|||
var length = MagicHeader.Length + 1L + HeaderSize + ChunkLengthPrefixSize + tagSize; |
|||
length += fullChunkCount * (ChunkLengthPrefixSize + tagSize + GetCipherChunkLength(algorithm, chunkSize)); |
|||
if (remainder > 0) |
|||
{ |
|||
length += ChunkLengthPrefixSize + tagSize + GetCipherChunkLength(algorithm, remainder); |
|||
} |
|||
|
|||
return length; |
|||
} |
|||
} |
|||
catch (NotSupportedException) |
|||
{ |
|||
return null; |
|||
} |
|||
catch (OverflowException) |
|||
{ |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
private static long GetCipherChunkLength(byte algorithm, long plainChunkLength) |
|||
{ |
|||
return algorithm == AlgorithmAesGcm |
|||
? plainChunkLength |
|||
: ((plainChunkLength / AesBlockSize) + 1) * AesBlockSize; |
|||
} |
|||
|
|||
private static bool HasMagicHeader(byte[] prefix) |
|||
{ |
|||
for (var i = 0; i < MagicHeader.Length; i++) |
|||
{ |
|||
if (prefix[i] != MagicHeader[i]) |
|||
{ |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
|
|||
private static int ReadInt32BigEndian(byte[] buffer, int offset) |
|||
{ |
|||
return (buffer[offset] << 24) | (buffer[offset + 1] << 16) | (buffer[offset + 2] << 8) | buffer[offset + 3]; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// A read-only, non-seekable stream that serves output produced chunk by chunk,
|
|||
/// so memory usage stays constant regardless of the total data size.
|
|||
/// </summary>
|
|||
private abstract class ChunkedCryptoReadStream : Stream |
|||
{ |
|||
private readonly long? _length; |
|||
private byte[]? _outputBuffer; |
|||
private int _outputBufferPosition; |
|||
private bool _finished; |
|||
|
|||
protected ChunkedCryptoReadStream(long? length = null) |
|||
{ |
|||
_length = length; |
|||
} |
|||
|
|||
public override bool CanRead => true; |
|||
|
|||
public override bool CanSeek => false; |
|||
|
|||
public override bool CanWrite => false; |
|||
|
|||
public override long Length => _length ?? throw new NotSupportedException(); |
|||
|
|||
public override long Position |
|||
{ |
|||
get => throw new NotSupportedException(); |
|||
set => throw new NotSupportedException(); |
|||
} |
|||
|
|||
public override void Flush() |
|||
{ |
|||
} |
|||
|
|||
public override int Read(byte[] buffer, int offset, int count) |
|||
{ |
|||
while (true) |
|||
{ |
|||
if (_outputBuffer != null && _outputBufferPosition < _outputBuffer.Length) |
|||
{ |
|||
var toCopy = Math.Min(count, _outputBuffer.Length - _outputBufferPosition); |
|||
Array.Copy(_outputBuffer, _outputBufferPosition, buffer, offset, toCopy); |
|||
_outputBufferPosition += toCopy; |
|||
return toCopy; |
|||
} |
|||
|
|||
if (_finished) |
|||
{ |
|||
return 0; |
|||
} |
|||
|
|||
_outputBuffer = ProduceNext(); |
|||
_outputBufferPosition = 0; |
|||
|
|||
if (_outputBuffer == null) |
|||
{ |
|||
_finished = true; |
|||
return 0; |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Produces the next output bytes, or null when there is no more output.
|
|||
/// </summary>
|
|||
protected abstract byte[]? ProduceNext(); |
|||
|
|||
public override long Seek(long offset, SeekOrigin origin) |
|||
{ |
|||
throw new NotSupportedException(); |
|||
} |
|||
|
|||
public override void SetLength(long value) |
|||
{ |
|||
throw new NotSupportedException(); |
|||
} |
|||
|
|||
public override void Write(byte[] buffer, int offset, int count) |
|||
{ |
|||
throw new NotSupportedException(); |
|||
} |
|||
} |
|||
|
|||
private class ChunkedEncryptingReadStream : ChunkedCryptoReadStream |
|||
{ |
|||
private readonly BlobEncryptionService _owner; |
|||
private readonly Stream _plainStream; |
|||
private readonly byte[] _header; |
|||
private readonly byte[] _keyBytes; |
|||
private readonly byte[] _baseNonce; |
|||
private readonly byte _algorithm; |
|||
private readonly int _chunkSize; |
|||
private bool _terminalEmitted; |
|||
private bool _headerEmitted; |
|||
private int _chunkIndex; |
|||
|
|||
public ChunkedEncryptingReadStream( |
|||
BlobEncryptionService owner, |
|||
Stream plainStream, |
|||
byte[] header, |
|||
byte[] keyBytes, |
|||
byte[] baseNonce, |
|||
byte algorithm, |
|||
int chunkSize, |
|||
long? encryptedLength) |
|||
: base(encryptedLength) |
|||
{ |
|||
_owner = owner; |
|||
_plainStream = plainStream; |
|||
_header = header; |
|||
_keyBytes = keyBytes; |
|||
_baseNonce = baseNonce; |
|||
_algorithm = algorithm; |
|||
_chunkSize = chunkSize; |
|||
} |
|||
|
|||
protected override byte[]? ProduceNext() |
|||
{ |
|||
if (!_headerEmitted) |
|||
{ |
|||
_headerEmitted = true; |
|||
|
|||
var prefix = new byte[MagicHeader.Length + 1 + _header.Length]; |
|||
MagicHeader.CopyTo(prefix, 0); |
|||
prefix[MagicHeader.Length] = BlobFormatVersion; |
|||
Array.Copy(_header, 0, prefix, MagicHeader.Length + 1, _header.Length); |
|||
return prefix; |
|||
} |
|||
|
|||
var plainChunk = _owner.ReadUpToCore(_plainStream, _chunkSize); |
|||
if (plainChunk.Length == 0) |
|||
{ |
|||
if (_terminalEmitted) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
_terminalEmitted = true; |
|||
return _owner.WriteTerminalRecordToBytes( |
|||
_algorithm, |
|||
_keyBytes, |
|||
_header, |
|||
_baseNonce, |
|||
_chunkIndex |
|||
); |
|||
} |
|||
|
|||
return _owner.EncryptChunkToBytes( |
|||
_algorithm, |
|||
_keyBytes, |
|||
_header, |
|||
_baseNonce, |
|||
_chunkIndex++, |
|||
plainChunk, |
|||
plainChunk.Length |
|||
); |
|||
} |
|||
|
|||
protected override void Dispose(bool disposing) |
|||
{ |
|||
// Do not dispose the plain stream; it is owned by the caller.
|
|||
} |
|||
} |
|||
|
|||
private class ChunkedDecryptingReadStream : ChunkedCryptoReadStream |
|||
{ |
|||
private readonly BlobEncryptionService _owner; |
|||
private readonly Stream _cipherStream; |
|||
private readonly byte[] _header; |
|||
private readonly byte[] _keyBytes; |
|||
private readonly byte[] _baseNonce; |
|||
private readonly byte _algorithm; |
|||
private readonly int _tagSize; |
|||
private readonly int _maxCipherChunkSize; |
|||
private int _chunkIndex; |
|||
|
|||
public ChunkedDecryptingReadStream( |
|||
BlobEncryptionService owner, |
|||
Stream cipherStream, |
|||
byte[] header, |
|||
byte[] keyBytes, |
|||
byte[] baseNonce, |
|||
byte algorithm, |
|||
int chunkSize, |
|||
int tagSize, |
|||
int maxCipherChunkSize) |
|||
{ |
|||
_owner = owner; |
|||
_cipherStream = cipherStream; |
|||
_header = header; |
|||
_keyBytes = keyBytes; |
|||
_baseNonce = baseNonce; |
|||
_algorithm = algorithm; |
|||
_tagSize = tagSize; |
|||
_maxCipherChunkSize = maxCipherChunkSize; |
|||
} |
|||
|
|||
protected override byte[]? ProduceNext() |
|||
{ |
|||
var lengthPrefix = _owner.ReadUpToCore(_cipherStream, 4); |
|||
if (lengthPrefix.Length == 0) |
|||
{ |
|||
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: missing terminal record!"); |
|||
} |
|||
|
|||
if (lengthPrefix.Length < 4) |
|||
{ |
|||
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: truncated chunk!"); |
|||
} |
|||
|
|||
var cipherChunkSize = ReadInt32BigEndian(lengthPrefix, 0); |
|||
if (cipherChunkSize == 0) |
|||
{ |
|||
var terminalTag = _owner.ReadExactlyCore(_cipherStream, _tagSize); |
|||
if (terminalTag == null || _owner.ReadUpToCore(_cipherStream, 1).Length != 0) |
|||
{ |
|||
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid terminal record!"); |
|||
} |
|||
|
|||
_owner.VerifyTerminalRecordCore( |
|||
_algorithm, |
|||
_keyBytes, |
|||
_header, |
|||
_baseNonce, |
|||
_chunkIndex, |
|||
terminalTag |
|||
); |
|||
return null; |
|||
} |
|||
|
|||
if (cipherChunkSize < 0 || cipherChunkSize > _maxCipherChunkSize) |
|||
{ |
|||
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid chunk length!"); |
|||
} |
|||
|
|||
var cipherChunk = _owner.ReadExactlyCore(_cipherStream, cipherChunkSize); |
|||
var tag = _owner.ReadExactlyCore(_cipherStream, _tagSize); |
|||
if (cipherChunk == null || tag == null) |
|||
{ |
|||
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: truncated chunk!"); |
|||
} |
|||
|
|||
return _owner.DecryptChunkCore( |
|||
_algorithm, |
|||
_keyBytes, |
|||
_header, |
|||
_baseNonce, |
|||
_chunkIndex++, |
|||
cipherChunk, |
|||
tag |
|||
); |
|||
} |
|||
|
|||
protected override void Dispose(bool disposing) |
|||
{ |
|||
if (disposing) |
|||
{ |
|||
_cipherStream.Dispose(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private sealed class PrefixingReadStream : Stream |
|||
{ |
|||
private readonly byte[] _prefix; |
|||
private readonly Stream _stream; |
|||
private int _prefixPosition; |
|||
|
|||
public PrefixingReadStream(byte[] prefix, Stream stream) |
|||
{ |
|||
_prefix = prefix; |
|||
_stream = stream; |
|||
} |
|||
|
|||
public override bool CanRead => _stream.CanRead; |
|||
public override bool CanSeek => false; |
|||
public override bool CanWrite => false; |
|||
public override long Length => throw new NotSupportedException(); |
|||
|
|||
public override long Position |
|||
{ |
|||
get => throw new NotSupportedException(); |
|||
set => throw new NotSupportedException(); |
|||
} |
|||
|
|||
public override void Flush() |
|||
{ |
|||
} |
|||
|
|||
public override int Read(byte[] buffer, int offset, int count) |
|||
{ |
|||
if (_prefixPosition < _prefix.Length) |
|||
{ |
|||
var readCount = Math.Min(count, _prefix.Length - _prefixPosition); |
|||
Array.Copy(_prefix, _prefixPosition, buffer, offset, readCount); |
|||
_prefixPosition += readCount; |
|||
return readCount; |
|||
} |
|||
|
|||
return _stream.Read(buffer, offset, count); |
|||
} |
|||
|
|||
public override long Seek(long offset, SeekOrigin origin) |
|||
{ |
|||
throw new NotSupportedException(); |
|||
} |
|||
|
|||
public override void SetLength(long value) |
|||
{ |
|||
throw new NotSupportedException(); |
|||
} |
|||
|
|||
public override void Write(byte[] buffer, int offset, int count) |
|||
{ |
|||
throw new NotSupportedException(); |
|||
} |
|||
|
|||
protected override void Dispose(bool disposing) |
|||
{ |
|||
if (disposing) |
|||
{ |
|||
_stream.Dispose(); |
|||
} |
|||
|
|||
base.Dispose(disposing); |
|||
} |
|||
} |
|||
} |
|||
@ -1,26 +0,0 @@ |
|||
using System.IO; |
|||
using System.Threading; |
|||
using JetBrains.Annotations; |
|||
|
|||
namespace Volo.Abp.BlobStoring; |
|||
|
|||
public class BlobPipelineGetArgs : BlobProviderArgs |
|||
{ |
|||
[NotNull] |
|||
public Stream BlobStream { get; } |
|||
|
|||
public BlobPipelineGetArgs( |
|||
[NotNull] string containerName, |
|||
[NotNull] BlobContainerConfiguration configuration, |
|||
[NotNull] string blobName, |
|||
[NotNull] Stream blobStream, |
|||
CancellationToken cancellationToken = default) |
|||
: base( |
|||
containerName, |
|||
configuration, |
|||
blobName, |
|||
cancellationToken) |
|||
{ |
|||
BlobStream = Check.NotNull(blobStream, nameof(blobStream)); |
|||
} |
|||
} |
|||
@ -1,26 +0,0 @@ |
|||
using System.IO; |
|||
using System.Threading; |
|||
using JetBrains.Annotations; |
|||
|
|||
namespace Volo.Abp.BlobStoring; |
|||
|
|||
public class BlobPipelineSaveArgs : BlobProviderArgs |
|||
{ |
|||
[NotNull] |
|||
public Stream BlobStream { get; } |
|||
|
|||
public BlobPipelineSaveArgs( |
|||
[NotNull] string containerName, |
|||
[NotNull] BlobContainerConfiguration configuration, |
|||
[NotNull] string blobName, |
|||
[NotNull] Stream blobStream, |
|||
CancellationToken cancellationToken = default) |
|||
: base( |
|||
containerName, |
|||
configuration, |
|||
blobName, |
|||
cancellationToken) |
|||
{ |
|||
BlobStream = Check.NotNull(blobStream, nameof(blobStream)); |
|||
} |
|||
} |
|||
@ -1,10 +0,0 @@ |
|||
namespace Volo.Abp.BlobStoring; |
|||
|
|||
public static class BlobStoringEncryptionConfigurationNames |
|||
{ |
|||
/// <summary>
|
|||
/// Configuration name used to store a container-specific encryption passphrase
|
|||
/// on <see cref="BlobContainerConfiguration"/>.
|
|||
/// </summary>
|
|||
public const string PassPhrase = "Abp.BlobStoring.Encryption.PassPhrase"; |
|||
} |
|||
@ -1,16 +0,0 @@ |
|||
using Volo.Abp.Settings; |
|||
|
|||
namespace Volo.Abp.BlobStoring; |
|||
|
|||
public class BlobStoringEncryptionSettingDefinitionProvider : SettingDefinitionProvider |
|||
{ |
|||
public override void Define(ISettingDefinitionContext context) |
|||
{ |
|||
context.Add( |
|||
new SettingDefinition( |
|||
BlobStoringEncryptionSettings.TenantPassPhrase, |
|||
isEncrypted: true |
|||
).WithProviders(TenantSettingValueProvider.ProviderName) |
|||
); |
|||
} |
|||
} |
|||
@ -1,9 +0,0 @@ |
|||
namespace Volo.Abp.BlobStoring; |
|||
|
|||
public static class BlobStoringEncryptionSettings |
|||
{ |
|||
/// <summary>
|
|||
/// Setting name for the tenant-specific encryption passphrase.
|
|||
/// </summary>
|
|||
public const string TenantPassPhrase = "Abp.BlobStoring.Encryption.TenantPassPhrase"; |
|||
} |
|||
@ -0,0 +1,103 @@ |
|||
using System; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Volo.Abp.BlobStoring; |
|||
|
|||
/// <summary>
|
|||
/// A read-only, non-seekable stream that serves output produced chunk by chunk.
|
|||
/// </summary>
|
|||
internal abstract class ChunkedCryptoReadStream : SequentialReadStream |
|||
{ |
|||
private readonly long? _length; |
|||
private byte[]? _outputBuffer; |
|||
private int _outputBufferPosition; |
|||
private long _position; |
|||
private bool _finished; |
|||
|
|||
protected ChunkedCryptoReadStream(long? length = null) |
|||
{ |
|||
_length = length; |
|||
} |
|||
|
|||
public override long Length => _length ?? throw new NotSupportedException(); |
|||
|
|||
// Some storage SDKs (like AWS S3) compute the upload size as Length - Position,
|
|||
// so the getter reports the number of bytes served so far instead of throwing.
|
|||
public override long Position |
|||
{ |
|||
get => _position; |
|||
set => throw new NotSupportedException(); |
|||
} |
|||
|
|||
protected sealed override int ReadCore(byte[] buffer, int offset, int count) |
|||
{ |
|||
while (true) |
|||
{ |
|||
var copiedCount = TryCopyFromOutputBuffer(buffer, offset, count); |
|||
if (copiedCount > 0 || _finished) |
|||
{ |
|||
return copiedCount; |
|||
} |
|||
|
|||
SetOutputBuffer(ProduceNext()); |
|||
} |
|||
} |
|||
|
|||
protected sealed override async Task<int> ReadCoreAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) |
|||
{ |
|||
while (true) |
|||
{ |
|||
var copiedCount = TryCopyFromOutputBuffer(buffer, offset, count); |
|||
if (copiedCount > 0 || _finished) |
|||
{ |
|||
return copiedCount; |
|||
} |
|||
|
|||
SetOutputBuffer(await ProduceNextAsync(cancellationToken)); |
|||
} |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Produces the next output bytes, or null when there is no more output.
|
|||
/// </summary>
|
|||
protected abstract byte[]? ProduceNext(); |
|||
|
|||
protected abstract Task<byte[]?> ProduceNextAsync(CancellationToken cancellationToken); |
|||
|
|||
protected override void Dispose(bool disposing) |
|||
{ |
|||
if (disposing && _outputBuffer != null) |
|||
{ |
|||
Array.Clear(_outputBuffer, 0, _outputBuffer.Length); |
|||
_outputBuffer = null; |
|||
} |
|||
|
|||
base.Dispose(disposing); |
|||
} |
|||
|
|||
private int TryCopyFromOutputBuffer(byte[] buffer, int offset, int count) |
|||
{ |
|||
if (_outputBuffer == null || _outputBufferPosition >= _outputBuffer.Length) |
|||
{ |
|||
return 0; |
|||
} |
|||
|
|||
var toCopy = Math.Min(count, _outputBuffer.Length - _outputBufferPosition); |
|||
Array.Copy(_outputBuffer, _outputBufferPosition, buffer, offset, toCopy); |
|||
_outputBufferPosition += toCopy; |
|||
_position += toCopy; |
|||
return toCopy; |
|||
} |
|||
|
|||
private void SetOutputBuffer(byte[]? outputBuffer) |
|||
{ |
|||
_outputBuffer = outputBuffer; |
|||
_outputBufferPosition = 0; |
|||
|
|||
if (outputBuffer == null) |
|||
{ |
|||
_finished = true; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,147 @@ |
|||
using System; |
|||
using System.IO; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Volo.Abp.BlobStoring; |
|||
|
|||
/// <summary>
|
|||
/// Decrypts the cipher stream chunk by chunk while being read.
|
|||
/// </summary>
|
|||
internal class ChunkedDecryptingReadStream : ChunkedCryptoReadStream |
|||
{ |
|||
private readonly Stream _cipherStream; |
|||
private readonly byte[] _associatedDataPrefix; |
|||
private readonly byte[] _keyBytes; |
|||
private readonly byte[] _baseNonce; |
|||
private readonly int _chunkSize; |
|||
private int _chunkIndex; |
|||
private bool _disposed; |
|||
|
|||
public ChunkedDecryptingReadStream( |
|||
Stream cipherStream, |
|||
byte[] associatedDataPrefix, |
|||
byte[] keyBytes, |
|||
byte[] baseNonce, |
|||
int chunkSize) |
|||
{ |
|||
_cipherStream = cipherStream; |
|||
_associatedDataPrefix = associatedDataPrefix; |
|||
_keyBytes = keyBytes; |
|||
_baseNonce = baseNonce; |
|||
_chunkSize = chunkSize; |
|||
} |
|||
|
|||
protected override byte[]? ProduceNext() |
|||
{ |
|||
var cipherChunkSize = BlobEncryptionCodec.GetCipherChunkSize( |
|||
BlobEncryptionCodec.ReadUpTo(_cipherStream, BlobEncryptionCodec.ChunkLengthPrefixSize), |
|||
_chunkSize |
|||
); |
|||
if (cipherChunkSize == 0) |
|||
{ |
|||
var terminalTag = BlobEncryptionCodec.ReadExactly(_cipherStream, BlobEncryptionCodec.GcmTagSize); |
|||
if (terminalTag == null || BlobEncryptionCodec.ReadUpTo(_cipherStream, 1).Length != 0) |
|||
{ |
|||
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid terminal record!"); |
|||
} |
|||
|
|||
BlobEncryptionCodec.VerifyTerminalRecord(_keyBytes, _associatedDataPrefix, _baseNonce, _chunkIndex, terminalTag); |
|||
return null; |
|||
} |
|||
|
|||
return DecryptPayload( |
|||
BlobEncryptionCodec.ReadExactly(_cipherStream, cipherChunkSize), |
|||
BlobEncryptionCodec.ReadExactly(_cipherStream, BlobEncryptionCodec.GcmTagSize) |
|||
); |
|||
} |
|||
|
|||
protected override async Task<byte[]?> ProduceNextAsync(CancellationToken cancellationToken) |
|||
{ |
|||
var cipherChunkSize = BlobEncryptionCodec.GetCipherChunkSize( |
|||
await BlobEncryptionCodec.ReadUpToAsync(_cipherStream, BlobEncryptionCodec.ChunkLengthPrefixSize, cancellationToken), |
|||
_chunkSize |
|||
); |
|||
if (cipherChunkSize == 0) |
|||
{ |
|||
var terminalTag = await BlobEncryptionCodec.ReadExactlyAsync(_cipherStream, BlobEncryptionCodec.GcmTagSize, cancellationToken); |
|||
if (terminalTag == null || (await BlobEncryptionCodec.ReadUpToAsync(_cipherStream, 1, cancellationToken)).Length != 0) |
|||
{ |
|||
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: invalid terminal record!"); |
|||
} |
|||
|
|||
BlobEncryptionCodec.VerifyTerminalRecord(_keyBytes, _associatedDataPrefix, _baseNonce, _chunkIndex, terminalTag); |
|||
return null; |
|||
} |
|||
|
|||
return DecryptPayload( |
|||
await BlobEncryptionCodec.ReadExactlyAsync(_cipherStream, cipherChunkSize, cancellationToken), |
|||
await BlobEncryptionCodec.ReadExactlyAsync(_cipherStream, BlobEncryptionCodec.GcmTagSize, cancellationToken) |
|||
); |
|||
} |
|||
|
|||
private byte[] DecryptPayload(byte[]? cipherChunk, byte[]? tag) |
|||
{ |
|||
if (cipherChunk == null || tag == null) |
|||
{ |
|||
throw new AbpException("The encrypted BLOB is corrupted or has an invalid format: truncated chunk!"); |
|||
} |
|||
|
|||
var plainChunk = BlobEncryptionCodec.DecryptChunk(_keyBytes, _associatedDataPrefix, _baseNonce, _chunkIndex, cipherChunk, tag); |
|||
_chunkIndex++; |
|||
return plainChunk; |
|||
} |
|||
|
|||
protected override void Dispose(bool disposing) |
|||
{ |
|||
if (disposing && !_disposed) |
|||
{ |
|||
_disposed = true; |
|||
ClearKeyBytes(); |
|||
try |
|||
{ |
|||
_cipherStream.Dispose(); |
|||
} |
|||
finally |
|||
{ |
|||
base.Dispose(disposing); |
|||
} |
|||
|
|||
return; |
|||
} |
|||
|
|||
base.Dispose(disposing); |
|||
} |
|||
|
|||
#if !NETSTANDARD2_0
|
|||
public override async ValueTask DisposeAsync() |
|||
{ |
|||
if (!_disposed) |
|||
{ |
|||
_disposed = true; |
|||
ClearKeyBytes(); |
|||
try |
|||
{ |
|||
await _cipherStream.DisposeAsync(); |
|||
} |
|||
finally |
|||
{ |
|||
await base.DisposeAsync(); |
|||
} |
|||
|
|||
return; |
|||
} |
|||
|
|||
await base.DisposeAsync(); |
|||
} |
|||
#endif
|
|||
|
|||
private void ClearKeyBytes() |
|||
{ |
|||
#if NETSTANDARD2_0
|
|||
Array.Clear(_keyBytes, 0, _keyBytes.Length); |
|||
#else
|
|||
System.Security.Cryptography.CryptographicOperations.ZeroMemory(_keyBytes); |
|||
#endif
|
|||
} |
|||
} |
|||
@ -0,0 +1,110 @@ |
|||
using System.IO; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Volo.Abp.BlobStoring; |
|||
|
|||
/// <summary>
|
|||
/// Encrypts the source stream chunk by chunk while being read.
|
|||
/// </summary>
|
|||
internal class ChunkedEncryptingReadStream : ChunkedCryptoReadStream |
|||
{ |
|||
private readonly Stream _plainStream; |
|||
private readonly byte[] _prefix; |
|||
private readonly byte[] _associatedDataPrefix; |
|||
private readonly byte[] _keyBytes; |
|||
private readonly byte[] _baseNonce; |
|||
private readonly int _chunkSize; |
|||
private bool _prefixEmitted; |
|||
private bool _terminalEmitted; |
|||
private int _chunkIndex; |
|||
|
|||
public ChunkedEncryptingReadStream( |
|||
Stream plainStream, |
|||
byte[] prefix, |
|||
byte[] associatedDataPrefix, |
|||
byte[] keyBytes, |
|||
byte[] baseNonce, |
|||
int chunkSize, |
|||
long? encryptedLength) |
|||
: base(encryptedLength) |
|||
{ |
|||
_plainStream = plainStream; |
|||
_prefix = prefix; |
|||
_associatedDataPrefix = associatedDataPrefix; |
|||
_keyBytes = keyBytes; |
|||
_baseNonce = baseNonce; |
|||
_chunkSize = chunkSize; |
|||
} |
|||
|
|||
protected override byte[]? ProduceNext() |
|||
{ |
|||
var prefix = TryProducePrefix(); |
|||
if (prefix != null) |
|||
{ |
|||
return prefix; |
|||
} |
|||
|
|||
return ProducePayload(BlobEncryptionCodec.ReadUpTo(_plainStream, _chunkSize)); |
|||
} |
|||
|
|||
protected override async Task<byte[]?> ProduceNextAsync(CancellationToken cancellationToken) |
|||
{ |
|||
var prefix = TryProducePrefix(); |
|||
if (prefix != null) |
|||
{ |
|||
return prefix; |
|||
} |
|||
|
|||
return ProducePayload(await BlobEncryptionCodec.ReadUpToAsync(_plainStream, _chunkSize, cancellationToken)); |
|||
} |
|||
|
|||
private byte[]? TryProducePrefix() |
|||
{ |
|||
if (_prefixEmitted) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
_prefixEmitted = true; |
|||
return _prefix; |
|||
} |
|||
|
|||
private byte[]? ProducePayload(byte[] plainChunk) |
|||
{ |
|||
if (plainChunk.Length == 0) |
|||
{ |
|||
if (_terminalEmitted) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
_terminalEmitted = true; |
|||
return BlobEncryptionCodec.CreateTerminalRecord(_keyBytes, _associatedDataPrefix, _baseNonce, _chunkIndex); |
|||
} |
|||
|
|||
var chunkBytes = BlobEncryptionCodec.EncryptChunk(_keyBytes, _associatedDataPrefix, _baseNonce, _chunkIndex, plainChunk, plainChunk.Length); |
|||
_chunkIndex++; |
|||
return chunkBytes; |
|||
} |
|||
|
|||
protected override void Dispose(bool disposing) |
|||
{ |
|||
// Do not dispose the plain stream; it is owned by the caller.
|
|||
if (disposing) |
|||
{ |
|||
ClearKeyBytes(); |
|||
} |
|||
|
|||
base.Dispose(disposing); |
|||
} |
|||
|
|||
private void ClearKeyBytes() |
|||
{ |
|||
#if NETSTANDARD2_0
|
|||
System.Array.Clear(_keyBytes, 0, _keyBytes.Length); |
|||
#else
|
|||
System.Security.Cryptography.CryptographicOperations.ZeroMemory(_keyBytes); |
|||
#endif
|
|||
} |
|||
} |
|||
@ -1,54 +1,92 @@ |
|||
using System; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Extensions.Options; |
|||
using Volo.Abp.DependencyInjection; |
|||
using Volo.Abp.MultiTenancy; |
|||
using Volo.Abp.Settings; |
|||
|
|||
namespace Volo.Abp.BlobStoring; |
|||
|
|||
/// <summary>
|
|||
/// Default implementation of <see cref="IBlobEncryptionKeyProvider"/>.
|
|||
/// Resolves a tenant-specific passphrase from the setting system
|
|||
/// (<see cref="BlobStoringEncryptionSettings.TenantPassPhrase"/>) when a tenant is available,
|
|||
/// otherwise falls back to the global passphrase
|
|||
/// (<see cref="AbpBlobStoringEncryptionOptions.DefaultPassPhrase"/>).
|
|||
/// Resolves the container passphrase first, then the global
|
|||
/// <see cref="AbpBlobStoringEncryptionOptions.DefaultPassPhrase"/>; decryption uses
|
|||
/// only the source recorded in the BLOB header. Replace this service for
|
|||
/// tenant-specific or externally stored passphrases.
|
|||
/// </summary>
|
|||
public class DefaultBlobEncryptionKeyProvider : IBlobEncryptionKeyProvider, ITransientDependency |
|||
{ |
|||
protected ICurrentTenant CurrentTenant { get; } |
|||
|
|||
protected ISettingProvider SettingProvider { get; } |
|||
|
|||
protected AbpBlobStoringEncryptionOptions Options { get; } |
|||
|
|||
public DefaultBlobEncryptionKeyProvider( |
|||
ICurrentTenant currentTenant, |
|||
ISettingProvider settingProvider, |
|||
IOptions<AbpBlobStoringEncryptionOptions> options) |
|||
public DefaultBlobEncryptionKeyProvider(IOptions<AbpBlobStoringEncryptionOptions> options) |
|||
{ |
|||
CurrentTenant = currentTenant; |
|||
SettingProvider = settingProvider; |
|||
Options = options.Value; |
|||
} |
|||
|
|||
public virtual async Task<string?> GetPassPhraseOrNullAsync( |
|||
public virtual Task<BlobEncryptionKey> ResolveForEncryptionAsync( |
|||
BlobContainerConfiguration configuration, |
|||
CancellationToken cancellationToken = default) |
|||
{ |
|||
if (CurrentTenant.Id.HasValue) |
|||
cancellationToken.ThrowIfCancellationRequested(); |
|||
|
|||
var containerPassPhrase = GetContainerPassPhraseOrNull(configuration); |
|||
if (!string.IsNullOrWhiteSpace(containerPassPhrase)) |
|||
{ |
|||
var tenantPassPhrase = await SettingProvider.GetOrNullAsync( |
|||
BlobStoringEncryptionSettings.TenantPassPhrase |
|||
); |
|||
return Task.FromResult(new BlobEncryptionKey(BlobEncryptionKeySource.Container, containerPassPhrase!)); |
|||
} |
|||
|
|||
if (!string.IsNullOrWhiteSpace(Options.DefaultPassPhrase)) |
|||
{ |
|||
return Task.FromResult(new BlobEncryptionKey(BlobEncryptionKeySource.Global, Options.DefaultPassPhrase!)); |
|||
} |
|||
|
|||
throw new AbpException( |
|||
"BLOB encryption is enabled, but no passphrase could be resolved. " + |
|||
"Pass a passphrase to the UseEncryption extension method or configure " + |
|||
$"{nameof(AbpBlobStoringEncryptionOptions)}.{nameof(AbpBlobStoringEncryptionOptions.DefaultPassPhrase)}." |
|||
); |
|||
} |
|||
|
|||
public virtual Task<string> ResolveForDecryptionAsync( |
|||
BlobEncryptionKeySource keySource, |
|||
BlobContainerConfiguration configuration, |
|||
CancellationToken cancellationToken = default) |
|||
{ |
|||
cancellationToken.ThrowIfCancellationRequested(); |
|||
|
|||
if (!tenantPassPhrase.IsNullOrEmpty()) |
|||
{ |
|||
return tenantPassPhrase; |
|||
} |
|||
string? passPhrase; |
|||
switch (keySource) |
|||
{ |
|||
case BlobEncryptionKeySource.Container: |
|||
passPhrase = GetContainerPassPhraseOrNull(configuration); |
|||
break; |
|||
case BlobEncryptionKeySource.Tenant: |
|||
throw new AbpException( |
|||
"The BLOB was encrypted with a tenant-specific passphrase, but the default " + |
|||
$"key provider does not supply tenant keys. Replace the {nameof(IBlobEncryptionKeyProvider)} " + |
|||
"service with the implementation that was used to encrypt the BLOB." |
|||
); |
|||
case BlobEncryptionKeySource.Global: |
|||
passPhrase = Options.DefaultPassPhrase; |
|||
break; |
|||
default: |
|||
throw new AbpException($"Unknown BLOB encryption key source: {keySource}!"); |
|||
} |
|||
|
|||
return Options.DefaultPassPhrase; |
|||
if (string.IsNullOrWhiteSpace(passPhrase)) |
|||
{ |
|||
throw new AbpException( |
|||
$"The BLOB was encrypted with the '{keySource}' passphrase, " + |
|||
"but that passphrase is not available anymore, so the BLOB can not be decrypted." |
|||
); |
|||
} |
|||
|
|||
return Task.FromResult(passPhrase!); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Returns the container-specific passphrase, so derived providers can keep it
|
|||
/// as the highest-priority source.
|
|||
/// </summary>
|
|||
protected virtual string? GetContainerPassPhraseOrNull(BlobContainerConfiguration configuration) |
|||
{ |
|||
return BlobEncryptionConfiguration.GetPassPhraseOrNull(configuration); |
|||
} |
|||
} |
|||
|
|||
@ -1,27 +0,0 @@ |
|||
using System.IO; |
|||
|
|||
namespace Volo.Abp.BlobStoring; |
|||
|
|||
/// <summary>
|
|||
/// Encrypts and decrypts BLOB streams with authenticated encryption
|
|||
/// (see <see cref="Volo.Abp.Security.Encryption.IByteArrayEncryptionService"/>).
|
|||
/// Memory usage is constant, independent from the BLOB size.
|
|||
/// </summary>
|
|||
public interface IBlobEncryptionService |
|||
{ |
|||
/// <summary>
|
|||
/// Wraps the given stream so that the content read from it is encrypted.
|
|||
/// The returned stream starts with a small header (magic bytes and format version,
|
|||
/// followed by the encryption format header), the authenticated cipher chunks,
|
|||
/// and an authenticated terminal record. When the input length is known, the returned
|
|||
/// stream exposes the exact encrypted <see cref="Stream.Length"/>.
|
|||
/// </summary>
|
|||
Stream Encrypt(Stream plainStream, string passPhrase); |
|||
|
|||
/// <summary>
|
|||
/// Wraps the given stream so that the content read from it is decrypted.
|
|||
/// If the stream does not carry the encryption header, its content is returned unchanged
|
|||
/// (assumed to be stored before encryption was enabled).
|
|||
/// </summary>
|
|||
Stream Decrypt(Stream cipherStream, string passPhrase); |
|||
} |
|||
@ -1,24 +0,0 @@ |
|||
using System.IO; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Volo.Abp.BlobStoring; |
|||
|
|||
/// <summary>
|
|||
/// A contributor to the BLOB pipeline. Contributors are executed inside the
|
|||
/// <see cref="BlobContainer"/>, before/after the actual <see cref="IBlobProvider"/> call,
|
|||
/// and can transform the BLOB stream (e.g. encryption, compression).
|
|||
/// </summary>
|
|||
public interface IBlobPipelineContributor |
|||
{ |
|||
/// <summary>
|
|||
/// Called before a BLOB is saved by the provider.
|
|||
/// Return the (possibly transformed) stream to be stored.
|
|||
/// </summary>
|
|||
Task<Stream> OnSaveAsync(BlobPipelineSaveArgs args); |
|||
|
|||
/// <summary>
|
|||
/// Called after a BLOB is read from the provider.
|
|||
/// Return the (possibly transformed) stream to be returned to the caller.
|
|||
/// </summary>
|
|||
Task<Stream> OnGetAsync(BlobPipelineGetArgs args); |
|||
} |
|||
@ -0,0 +1,105 @@ |
|||
using System; |
|||
using System.IO; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Volo.Abp.BlobStoring; |
|||
|
|||
/// <summary>
|
|||
/// Serves the already-consumed prefix bytes first, then the rest of the underlying stream.
|
|||
/// </summary>
|
|||
internal sealed class PrefixingReadStream : SequentialReadStream |
|||
{ |
|||
private readonly byte[] _prefix; |
|||
private readonly Stream _stream; |
|||
private int _prefixPosition; |
|||
|
|||
public PrefixingReadStream(byte[] prefix, Stream stream) |
|||
{ |
|||
_prefix = prefix; |
|||
_stream = stream; |
|||
} |
|||
|
|||
public override bool CanRead => _stream.CanRead; |
|||
|
|||
// Legacy plaintext BLOBs had a usable Length before encryption was enabled;
|
|||
// keep it available when the underlying stream knows it.
|
|||
public override long Length => _stream.CanSeek ? _stream.Length : throw new NotSupportedException(); |
|||
|
|||
protected override int ReadCore(byte[] buffer, int offset, int count) |
|||
{ |
|||
var prefixReadCount = TryCopyFromPrefix(buffer, offset, count); |
|||
if (prefixReadCount > 0) |
|||
{ |
|||
return prefixReadCount; |
|||
} |
|||
|
|||
return _stream.Read(buffer, offset, count); |
|||
} |
|||
|
|||
protected override async Task<int> ReadCoreAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) |
|||
{ |
|||
var prefixReadCount = TryCopyFromPrefix(buffer, offset, count); |
|||
if (prefixReadCount > 0) |
|||
{ |
|||
return prefixReadCount; |
|||
} |
|||
|
|||
return await _stream.ReadAsync(buffer, offset, count, cancellationToken); |
|||
} |
|||
|
|||
private int TryCopyFromPrefix(byte[] buffer, int offset, int count) |
|||
{ |
|||
if (_prefixPosition >= _prefix.Length) |
|||
{ |
|||
return 0; |
|||
} |
|||
|
|||
var readCount = Math.Min(count, _prefix.Length - _prefixPosition); |
|||
Array.Copy(_prefix, _prefixPosition, buffer, offset, readCount); |
|||
_prefixPosition += readCount; |
|||
return readCount; |
|||
} |
|||
|
|||
protected override void Dispose(bool disposing) |
|||
{ |
|||
if (disposing && !IsDisposed) |
|||
{ |
|||
IsDisposed = true; |
|||
try |
|||
{ |
|||
_stream.Dispose(); |
|||
} |
|||
finally |
|||
{ |
|||
base.Dispose(disposing); |
|||
} |
|||
|
|||
return; |
|||
} |
|||
|
|||
base.Dispose(disposing); |
|||
} |
|||
|
|||
#if !NETSTANDARD2_0
|
|||
public override async ValueTask DisposeAsync() |
|||
{ |
|||
if (!IsDisposed) |
|||
{ |
|||
IsDisposed = true; |
|||
try |
|||
{ |
|||
await _stream.DisposeAsync(); |
|||
} |
|||
finally |
|||
{ |
|||
await base.DisposeAsync(); |
|||
} |
|||
|
|||
return; |
|||
} |
|||
|
|||
await base.DisposeAsync(); |
|||
} |
|||
#endif
|
|||
} |
|||
@ -0,0 +1,138 @@ |
|||
using System; |
|||
using System.IO; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
|
|||
namespace Volo.Abp.BlobStoring; |
|||
|
|||
/// <summary>
|
|||
/// A read-only, non-seekable, forward-only stream; a failed read faults it permanently.
|
|||
/// </summary>
|
|||
internal abstract class SequentialReadStream : Stream |
|||
{ |
|||
private bool _faulted; |
|||
|
|||
protected bool IsDisposed { get; set; } |
|||
|
|||
public override bool CanRead => true; |
|||
|
|||
public override bool CanSeek => false; |
|||
|
|||
public override bool CanWrite => false; |
|||
|
|||
public override long Length => throw new NotSupportedException(); |
|||
|
|||
public override long Position |
|||
{ |
|||
get => throw new NotSupportedException(); |
|||
set => throw new NotSupportedException(); |
|||
} |
|||
|
|||
public override void Flush() |
|||
{ |
|||
} |
|||
|
|||
public override int Read(byte[] buffer, int offset, int count) |
|||
{ |
|||
ValidateReadArguments(buffer, offset, count); |
|||
EnsureCanServe(); |
|||
|
|||
if (count == 0) |
|||
{ |
|||
return 0; |
|||
} |
|||
|
|||
try |
|||
{ |
|||
return ReadCore(buffer, offset, count); |
|||
} |
|||
catch |
|||
{ |
|||
_faulted = true; |
|||
throw; |
|||
} |
|||
} |
|||
|
|||
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) |
|||
{ |
|||
ValidateReadArguments(buffer, offset, count); |
|||
EnsureCanServe(); |
|||
cancellationToken.ThrowIfCancellationRequested(); |
|||
|
|||
if (count == 0) |
|||
{ |
|||
return 0; |
|||
} |
|||
|
|||
try |
|||
{ |
|||
return await ReadCoreAsync(buffer, offset, count, cancellationToken); |
|||
} |
|||
catch |
|||
{ |
|||
_faulted = true; |
|||
throw; |
|||
} |
|||
} |
|||
|
|||
protected abstract int ReadCore(byte[] buffer, int offset, int count); |
|||
|
|||
protected abstract Task<int> ReadCoreAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken); |
|||
|
|||
public override long Seek(long offset, SeekOrigin origin) |
|||
{ |
|||
throw new NotSupportedException(); |
|||
} |
|||
|
|||
public override void SetLength(long value) |
|||
{ |
|||
throw new NotSupportedException(); |
|||
} |
|||
|
|||
public override void Write(byte[] buffer, int offset, int count) |
|||
{ |
|||
throw new NotSupportedException(); |
|||
} |
|||
|
|||
protected override void Dispose(bool disposing) |
|||
{ |
|||
IsDisposed = true; |
|||
base.Dispose(disposing); |
|||
} |
|||
|
|||
private void EnsureCanServe() |
|||
{ |
|||
if (IsDisposed) |
|||
{ |
|||
throw new ObjectDisposedException(GetType().FullName); |
|||
} |
|||
|
|||
if (_faulted) |
|||
{ |
|||
throw new AbpException("The stream can not be read anymore, because a previous read operation has failed!"); |
|||
} |
|||
} |
|||
|
|||
private static void ValidateReadArguments(byte[] buffer, int offset, int count) |
|||
{ |
|||
if (buffer == null) |
|||
{ |
|||
throw new ArgumentNullException(nameof(buffer)); |
|||
} |
|||
|
|||
if (offset < 0) |
|||
{ |
|||
throw new ArgumentOutOfRangeException(nameof(offset)); |
|||
} |
|||
|
|||
if (count < 0) |
|||
{ |
|||
throw new ArgumentOutOfRangeException(nameof(count)); |
|||
} |
|||
|
|||
if (buffer.Length - offset < count) |
|||
{ |
|||
throw new ArgumentException("The sum of offset and count is larger than the buffer length!"); |
|||
} |
|||
} |
|||
} |
|||
@ -1,49 +0,0 @@ |
|||
using System.Text; |
|||
|
|||
namespace Volo.Abp.Security.Encryption; |
|||
|
|||
/// <summary>
|
|||
/// Options used by <see cref="IByteArrayEncryptionService"/>.
|
|||
/// These options are independent from <see cref="AbpStringEncryptionOptions"/>;
|
|||
/// changing them does not affect <see cref="IStringEncryptionService"/>.
|
|||
/// </summary>
|
|||
public class AbpByteArrayEncryptionOptions |
|||
{ |
|||
/// <summary>
|
|||
/// Default password to encrypt/decrypt data.
|
|||
/// It's recommended to set to another value for security.
|
|||
/// Default value: "x9V4qL2mZ8sT1pRe"
|
|||
/// </summary>
|
|||
public string DefaultPassPhrase { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// This constant string is used as a "salt" value for the key derivation function calls.
|
|||
/// Default value: Encoding.ASCII.GetBytes("kT8!qW2e")
|
|||
/// </summary>
|
|||
public byte[] DefaultSalt { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// Iteration count of the PBKDF2 key derivation function.
|
|||
/// Default value: 100000.
|
|||
/// WARNING: Changing this value makes previously encrypted data undecryptable,
|
|||
/// since the key is derived again with the current value during decryption.
|
|||
/// </summary>
|
|||
public int DeriveBytesIterations { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// Size (in bytes) of the plaintext chunks that are encrypted and authenticated
|
|||
/// one by one while processing streams. Larger data is processed in constant memory,
|
|||
/// independent from the total data size.
|
|||
/// Default value: 65536 (64 KB).
|
|||
/// Maximum value: 16777216 (16 MB).
|
|||
/// </summary>
|
|||
public int ChunkSize { get; set; } |
|||
|
|||
public AbpByteArrayEncryptionOptions() |
|||
{ |
|||
DefaultPassPhrase = "x9V4qL2mZ8sT1pRe"; |
|||
DefaultSalt = Encoding.ASCII.GetBytes("kT8!qW2e"); |
|||
DeriveBytesIterations = 100000; |
|||
ChunkSize = 64 * 1024; |
|||
} |
|||
} |
|||
@ -1,530 +0,0 @@ |
|||
using System; |
|||
using System.IO; |
|||
using System.Security.Cryptography; |
|||
using Microsoft.Extensions.Options; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace Volo.Abp.Security.Encryption; |
|||
|
|||
/// <summary>
|
|||
/// Implements <see cref="IByteArrayEncryptionService"/> using authenticated encryption.
|
|||
/// Uses AES-256-GCM on platforms that support it, and falls back to
|
|||
/// AES-256-CBC + HMAC-SHA256 (encrypt-then-MAC) on .NET Standard 2.0.
|
|||
/// <para>
|
|||
/// Output format: a 14-byte header (version, algorithm, chunk size, base nonce),
|
|||
/// followed by authenticated chunks: 4-byte big-endian cipher length, cipher chunk, authentication tag,
|
|||
/// and an authenticated zero-length terminal record.
|
|||
/// The header and the chunk index are bound to every chunk as associated data,
|
|||
/// so chunks can not be re-ordered, truncated or moved between files.
|
|||
/// </para>
|
|||
/// </summary>
|
|||
public class ByteArrayEncryptionService : IByteArrayEncryptionService, ITransientDependency |
|||
{ |
|||
protected AbpByteArrayEncryptionOptions Options { get; } |
|||
|
|||
protected const byte FormatVersion = 1; |
|||
protected const byte AlgorithmAesGcm = 1; |
|||
protected const byte AlgorithmAesCbcHmacSha256 = 2; |
|||
protected const int BaseNonceSize = 8; |
|||
protected const int HeaderSize = 14; // version(1) + algorithm(1) + chunkSize(4) + baseNonce(8)
|
|||
protected const int ChunkLengthPrefixSize = 4; |
|||
protected const int GcmNonceSize = 12; |
|||
protected const int GcmTagSize = 16; |
|||
protected const int HmacSize = 32; |
|||
protected const int AesBlockSize = 16; |
|||
protected const int MaximumChunkSize = 16 * 1024 * 1024; |
|||
|
|||
public ByteArrayEncryptionService(IOptions<AbpByteArrayEncryptionOptions> options) |
|||
{ |
|||
Options = options.Value; |
|||
} |
|||
|
|||
public virtual byte[]? Encrypt(byte[]? plainBytes, string? passPhrase = null, byte[]? salt = null) |
|||
{ |
|||
if (plainBytes == null) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
using var plainStream = new MemoryStream(plainBytes, writable: false); |
|||
using var cipherStream = new MemoryStream(); |
|||
Encrypt(plainStream, cipherStream, passPhrase, salt); |
|||
return cipherStream.ToArray(); |
|||
} |
|||
|
|||
public virtual byte[]? Decrypt(byte[]? cipherBytes, string? passPhrase = null, byte[]? salt = null) |
|||
{ |
|||
if (cipherBytes == null || cipherBytes.Length == 0) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
using var cipherStream = new MemoryStream(cipherBytes, writable: false); |
|||
using var plainStream = new MemoryStream(); |
|||
Decrypt(cipherStream, plainStream, passPhrase, salt); |
|||
return plainStream.ToArray(); |
|||
} |
|||
|
|||
public virtual void Encrypt(Stream plainStream, Stream cipherStream, string? passPhrase = null, byte[]? salt = null) |
|||
{ |
|||
Check.NotNull(plainStream, nameof(plainStream)); |
|||
Check.NotNull(cipherStream, nameof(cipherStream)); |
|||
|
|||
if (Options.ChunkSize <= 0 || Options.ChunkSize > MaximumChunkSize) |
|||
{ |
|||
throw new AbpException($"{nameof(Options.ChunkSize)} must be between 1 and {MaximumChunkSize} bytes!"); |
|||
} |
|||
|
|||
var algorithm = GetEncryptionAlgorithm(); |
|||
var keyBytes = DeriveKeyBytes(passPhrase ?? Options.DefaultPassPhrase, salt ?? Options.DefaultSalt, algorithm); |
|||
|
|||
var baseNonce = new byte[BaseNonceSize]; |
|||
using (var random = RandomNumberGenerator.Create()) |
|||
{ |
|||
random.GetBytes(baseNonce); |
|||
} |
|||
|
|||
var header = BuildHeader(algorithm, Options.ChunkSize, baseNonce); |
|||
cipherStream.Write(header, 0, header.Length); |
|||
|
|||
var buffer = new byte[Options.ChunkSize]; |
|||
var chunkIndex = 0; |
|||
int readCount; |
|||
while ((readCount = plainStream.Read(buffer, 0, buffer.Length)) > 0) |
|||
{ |
|||
EncryptChunk(algorithm, keyBytes, header, baseNonce, chunkIndex, buffer, readCount, cipherStream); |
|||
chunkIndex++; |
|||
} |
|||
|
|||
WriteTerminalRecord(algorithm, keyBytes, header, baseNonce, chunkIndex, cipherStream); |
|||
} |
|||
|
|||
public virtual void Decrypt(Stream cipherStream, Stream plainStream, string? passPhrase = null, byte[]? salt = null) |
|||
{ |
|||
Check.NotNull(cipherStream, nameof(cipherStream)); |
|||
Check.NotNull(plainStream, nameof(plainStream)); |
|||
|
|||
var header = ReadExactly(cipherStream, HeaderSize); |
|||
if (header == null) |
|||
{ |
|||
throw new AbpException("The encrypted data is corrupted or has an invalid format: missing header!"); |
|||
} |
|||
|
|||
if (header[0] != FormatVersion) |
|||
{ |
|||
throw new AbpException($"Unsupported encryption format version: {header[0]}!"); |
|||
} |
|||
|
|||
var algorithm = header[1]; |
|||
if (algorithm != AlgorithmAesGcm && algorithm != AlgorithmAesCbcHmacSha256) |
|||
{ |
|||
throw new AbpException($"Unsupported encryption algorithm: {algorithm}!"); |
|||
} |
|||
|
|||
var chunkSize = ReadInt32BigEndian(header, 2); |
|||
if (chunkSize <= 0 || chunkSize > MaximumChunkSize) |
|||
{ |
|||
throw new AbpException("The encrypted data is corrupted or has an invalid format: invalid chunk size!"); |
|||
} |
|||
|
|||
var baseNonce = new byte[BaseNonceSize]; |
|||
Array.Copy(header, 6, baseNonce, 0, BaseNonceSize); |
|||
|
|||
var keyBytes = DeriveKeyBytes(passPhrase ?? Options.DefaultPassPhrase, salt ?? Options.DefaultSalt, algorithm); |
|||
|
|||
var tagSize = algorithm == AlgorithmAesGcm ? GcmTagSize : HmacSize; |
|||
var maxCipherChunkSize = algorithm == AlgorithmAesGcm ? chunkSize : chunkSize + AesBlockSize; |
|||
|
|||
var chunkIndex = 0; |
|||
while (true) |
|||
{ |
|||
var lengthPrefix = ReadUpTo(cipherStream, ChunkLengthPrefixSize); |
|||
if (lengthPrefix.Length == 0) |
|||
{ |
|||
throw new AbpException("The encrypted data is corrupted or has an invalid format: missing terminal record!"); |
|||
} |
|||
|
|||
if (lengthPrefix.Length < ChunkLengthPrefixSize) |
|||
{ |
|||
throw new AbpException("The encrypted data is corrupted or has an invalid format: truncated chunk!"); |
|||
} |
|||
|
|||
var cipherChunkSize = ReadInt32BigEndian(lengthPrefix, 0); |
|||
if (cipherChunkSize == 0) |
|||
{ |
|||
var terminalTag = ReadExactly(cipherStream, tagSize); |
|||
if (terminalTag == null || ReadUpTo(cipherStream, 1).Length != 0) |
|||
{ |
|||
throw new AbpException("The encrypted data is corrupted or has an invalid format: invalid terminal record!"); |
|||
} |
|||
|
|||
VerifyTerminalRecord(algorithm, keyBytes, header, baseNonce, chunkIndex, terminalTag); |
|||
break; |
|||
} |
|||
|
|||
if (cipherChunkSize < 0 || cipherChunkSize > maxCipherChunkSize) |
|||
{ |
|||
throw new AbpException("The encrypted data is corrupted or has an invalid format: invalid chunk length!"); |
|||
} |
|||
|
|||
var cipherChunk = ReadExactly(cipherStream, cipherChunkSize); |
|||
var tag = ReadExactly(cipherStream, tagSize); |
|||
if (cipherChunk == null || tag == null) |
|||
{ |
|||
throw new AbpException("The encrypted data is corrupted or has an invalid format: truncated chunk!"); |
|||
} |
|||
|
|||
var plainChunk = DecryptChunk(algorithm, keyBytes, header, baseNonce, chunkIndex, cipherChunk, tag); |
|||
plainStream.Write(plainChunk, 0, plainChunk.Length); |
|||
chunkIndex++; |
|||
} |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Gets the algorithm used while encrypting. Decryption supports both algorithms
|
|||
/// (except AES-GCM on .NET Standard 2.0, where it is not available).
|
|||
/// </summary>
|
|||
protected virtual byte GetEncryptionAlgorithm() |
|||
{ |
|||
#if NETSTANDARD2_0
|
|||
return AlgorithmAesCbcHmacSha256; |
|||
#else
|
|||
return AlgorithmAesGcm; |
|||
#endif
|
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Derives the key material using PBKDF2-SHA1 (Rfc2898DeriveBytes). SHA1 is used on all
|
|||
/// target frameworks on purpose, so that the derived key is deterministic across platforms.
|
|||
/// Returns 32 bytes for AES-256-GCM, or 64 bytes (32 encryption + 32 MAC) for AES-256-CBC-HMAC.
|
|||
/// </summary>
|
|||
protected virtual byte[] DeriveKeyBytes(string passPhrase, byte[] salt, byte algorithm) |
|||
{ |
|||
var keyLength = algorithm == AlgorithmAesGcm ? 32 : 64; |
|||
#if NET8_0_OR_GREATER
|
|||
return Rfc2898DeriveBytes.Pbkdf2(passPhrase, salt, Options.DeriveBytesIterations, HashAlgorithmName.SHA1, keyLength); |
|||
#else
|
|||
// The default hash algorithm of this constructor is SHA1.
|
|||
using var password = new Rfc2898DeriveBytes(passPhrase, salt, Options.DeriveBytesIterations); |
|||
return password.GetBytes(keyLength); |
|||
#endif
|
|||
} |
|||
|
|||
protected virtual void EncryptChunk(byte algorithm, byte[] keyBytes, byte[] header, byte[] baseNonce, int chunkIndex, byte[] plainChunk, int plainChunkLength, Stream cipherStream) |
|||
{ |
|||
var associatedData = CreateChunkAssociatedData(header, chunkIndex); |
|||
byte[] cipherChunk; |
|||
byte[] tag; |
|||
|
|||
if (algorithm == AlgorithmAesGcm) |
|||
{ |
|||
#if NETSTANDARD2_0
|
|||
throw new AbpException("AES-GCM is not supported on this platform (.NET Standard 2.0)!"); |
|||
#else
|
|||
cipherChunk = new byte[plainChunkLength]; |
|||
tag = new byte[GcmTagSize]; |
|||
using (var aesGcm = CreateAesGcm(keyBytes)) |
|||
{ |
|||
aesGcm.Encrypt(CreateChunkNonce(baseNonce, chunkIndex), plainChunk.AsSpan(0, plainChunkLength), cipherChunk, tag, associatedData); |
|||
} |
|||
#endif
|
|||
} |
|||
else |
|||
{ |
|||
cipherChunk = AesCbcEncryptChunk(keyBytes, baseNonce, chunkIndex, plainChunk, plainChunkLength); |
|||
tag = ComputeChunkMac(keyBytes, associatedData, cipherChunk); |
|||
} |
|||
|
|||
var lengthPrefix = new byte[ChunkLengthPrefixSize]; |
|||
WriteInt32BigEndian(lengthPrefix, 0, cipherChunk.Length); |
|||
cipherStream.Write(lengthPrefix, 0, lengthPrefix.Length); |
|||
cipherStream.Write(cipherChunk, 0, cipherChunk.Length); |
|||
cipherStream.Write(tag, 0, tag.Length); |
|||
} |
|||
|
|||
protected virtual void WriteTerminalRecord( |
|||
byte algorithm, |
|||
byte[] keyBytes, |
|||
byte[] header, |
|||
byte[] baseNonce, |
|||
int chunkIndex, |
|||
Stream cipherStream) |
|||
{ |
|||
var lengthPrefix = new byte[ChunkLengthPrefixSize]; |
|||
cipherStream.Write(lengthPrefix, 0, lengthPrefix.Length); |
|||
|
|||
var tag = ComputeTerminalTag(algorithm, keyBytes, header, baseNonce, chunkIndex); |
|||
cipherStream.Write(tag, 0, tag.Length); |
|||
} |
|||
|
|||
protected virtual void VerifyTerminalRecord( |
|||
byte algorithm, |
|||
byte[] keyBytes, |
|||
byte[] header, |
|||
byte[] baseNonce, |
|||
int chunkIndex, |
|||
byte[] tag) |
|||
{ |
|||
if (algorithm == AlgorithmAesGcm) |
|||
{ |
|||
#if NETSTANDARD2_0
|
|||
throw new AbpException("AES-GCM encrypted data can not be decrypted on this platform (.NET Standard 2.0)!"); |
|||
#else
|
|||
using (var aesGcm = CreateAesGcm(keyBytes)) |
|||
{ |
|||
aesGcm.Decrypt( |
|||
CreateChunkNonce(baseNonce, chunkIndex), |
|||
Array.Empty<byte>(), |
|||
tag, |
|||
Array.Empty<byte>(), |
|||
CreateChunkAssociatedData(header, chunkIndex) |
|||
); |
|||
} |
|||
#endif
|
|||
} |
|||
else |
|||
{ |
|||
var expectedTag = ComputeTerminalTag(algorithm, keyBytes, header, baseNonce, chunkIndex); |
|||
if (!FixedTimeEquals(expectedTag, tag)) |
|||
{ |
|||
throw new CryptographicException("The encrypted data is tampered, corrupted or the passphrase/salt is wrong!"); |
|||
} |
|||
} |
|||
} |
|||
|
|||
protected virtual byte[] ComputeTerminalTag( |
|||
byte algorithm, |
|||
byte[] keyBytes, |
|||
byte[] header, |
|||
byte[] baseNonce, |
|||
int chunkIndex) |
|||
{ |
|||
var associatedData = CreateChunkAssociatedData(header, chunkIndex); |
|||
if (algorithm == AlgorithmAesGcm) |
|||
{ |
|||
#if NETSTANDARD2_0
|
|||
throw new AbpException("AES-GCM is not supported on this platform (.NET Standard 2.0)!"); |
|||
#else
|
|||
var tag = new byte[GcmTagSize]; |
|||
using (var aesGcm = CreateAesGcm(keyBytes)) |
|||
{ |
|||
aesGcm.Encrypt( |
|||
CreateChunkNonce(baseNonce, chunkIndex), |
|||
Array.Empty<byte>(), |
|||
Array.Empty<byte>(), |
|||
tag, |
|||
associatedData |
|||
); |
|||
} |
|||
|
|||
return tag; |
|||
#endif
|
|||
} |
|||
|
|||
return ComputeChunkMac(keyBytes, associatedData, Array.Empty<byte>()); |
|||
} |
|||
|
|||
protected virtual byte[] DecryptChunk(byte algorithm, byte[] keyBytes, byte[] header, byte[] baseNonce, int chunkIndex, byte[] cipherChunk, byte[] tag) |
|||
{ |
|||
var associatedData = CreateChunkAssociatedData(header, chunkIndex); |
|||
|
|||
if (algorithm == AlgorithmAesGcm) |
|||
{ |
|||
#if NETSTANDARD2_0
|
|||
throw new AbpException("AES-GCM encrypted data can not be decrypted on this platform (.NET Standard 2.0)!"); |
|||
#else
|
|||
var plainChunk = new byte[cipherChunk.Length]; |
|||
using (var aesGcm = CreateAesGcm(keyBytes)) |
|||
{ |
|||
// Throws CryptographicException if the authentication tag is invalid.
|
|||
aesGcm.Decrypt(CreateChunkNonce(baseNonce, chunkIndex), cipherChunk, tag, plainChunk, associatedData); |
|||
} |
|||
|
|||
return plainChunk; |
|||
#endif
|
|||
} |
|||
else |
|||
{ |
|||
var expectedTag = ComputeChunkMac(keyBytes, associatedData, cipherChunk); |
|||
if (!FixedTimeEquals(expectedTag, tag)) |
|||
{ |
|||
throw new CryptographicException("The encrypted data is tampered, corrupted or the passphrase/salt is wrong!"); |
|||
} |
|||
|
|||
return AesCbcDecryptChunk(keyBytes, baseNonce, chunkIndex, cipherChunk); |
|||
} |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Creates the 12-byte nonce of a chunk: 8-byte random base nonce + 4-byte big-endian chunk index.
|
|||
/// Since the base nonce is random per encryption operation and the index is unique per chunk,
|
|||
/// a nonce never repeats for the same key.
|
|||
/// </summary>
|
|||
protected virtual byte[] CreateChunkNonce(byte[] baseNonce, int chunkIndex) |
|||
{ |
|||
var nonce = new byte[GcmNonceSize]; |
|||
Array.Copy(baseNonce, 0, nonce, 0, BaseNonceSize); |
|||
WriteInt32BigEndian(nonce, BaseNonceSize, chunkIndex); |
|||
return nonce; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Creates the associated data of a chunk: the header + 4-byte big-endian chunk index.
|
|||
/// This binds every chunk to its position and to the file it belongs to.
|
|||
/// </summary>
|
|||
protected virtual byte[] CreateChunkAssociatedData(byte[] header, int chunkIndex) |
|||
{ |
|||
var associatedData = new byte[HeaderSize + 4]; |
|||
Array.Copy(header, 0, associatedData, 0, HeaderSize); |
|||
WriteInt32BigEndian(associatedData, HeaderSize, chunkIndex); |
|||
return associatedData; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Encrypts a chunk with AES-256-CBC. The IV of each chunk is derived from the MAC key,
|
|||
/// the base nonce and the chunk index, so it is unique and unpredictable per chunk.
|
|||
/// </summary>
|
|||
protected virtual byte[] AesCbcEncryptChunk(byte[] keyBytes, byte[] baseNonce, int chunkIndex, byte[] plainChunk, int plainChunkLength) |
|||
{ |
|||
using var aes = Aes.Create(); |
|||
aes.Mode = CipherMode.CBC; |
|||
using var encryptor = aes.CreateEncryptor(GetAesCbcEncryptionKey(keyBytes), DeriveAesCbcChunkIV(keyBytes, baseNonce, chunkIndex)); |
|||
return encryptor.TransformFinalBlock(plainChunk, 0, plainChunkLength); |
|||
} |
|||
|
|||
protected virtual byte[] AesCbcDecryptChunk(byte[] keyBytes, byte[] baseNonce, int chunkIndex, byte[] cipherChunk) |
|||
{ |
|||
using var aes = Aes.Create(); |
|||
aes.Mode = CipherMode.CBC; |
|||
using var decryptor = aes.CreateDecryptor(GetAesCbcEncryptionKey(keyBytes), DeriveAesCbcChunkIV(keyBytes, baseNonce, chunkIndex)); |
|||
return decryptor.TransformFinalBlock(cipherChunk, 0, cipherChunk.Length); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Computes the HMAC-SHA256 of a chunk over its associated data and cipher bytes (encrypt-then-MAC).
|
|||
/// </summary>
|
|||
protected virtual byte[] ComputeChunkMac(byte[] keyBytes, byte[] associatedData, byte[] cipherChunk) |
|||
{ |
|||
using var hmac = new HMACSHA256(GetAesCbcMacKey(keyBytes)); |
|||
hmac.TransformBlock(associatedData, 0, associatedData.Length, null, 0); |
|||
hmac.TransformFinalBlock(cipherChunk, 0, cipherChunk.Length); |
|||
return hmac.Hash!; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Derives the IV of a CBC chunk: first 16 bytes of HMAC-SHA256(MAC key, "IV" + chunk nonce).
|
|||
/// </summary>
|
|||
protected virtual byte[] DeriveAesCbcChunkIV(byte[] keyBytes, byte[] baseNonce, int chunkIndex) |
|||
{ |
|||
var input = CreateChunkNonce(baseNonce, chunkIndex); |
|||
input[0] ^= 0xFF; // Domain separation from the GCM nonce, just in case.
|
|||
using var hmac = new HMACSHA256(GetAesCbcMacKey(keyBytes)); |
|||
var hash = hmac.ComputeHash(input); |
|||
var iv = new byte[AesBlockSize]; |
|||
Array.Copy(hash, 0, iv, 0, AesBlockSize); |
|||
return iv; |
|||
} |
|||
|
|||
protected virtual byte[] GetAesCbcEncryptionKey(byte[] keyBytes) |
|||
{ |
|||
var key = new byte[32]; |
|||
Array.Copy(keyBytes, 0, key, 0, 32); |
|||
return key; |
|||
} |
|||
|
|||
protected virtual byte[] GetAesCbcMacKey(byte[] keyBytes) |
|||
{ |
|||
var key = new byte[32]; |
|||
Array.Copy(keyBytes, 32, key, 0, 32); |
|||
return key; |
|||
} |
|||
|
|||
#if !NETSTANDARD2_0
|
|||
private static AesGcm CreateAesGcm(byte[] keyBytes) |
|||
{ |
|||
#if NET8_0_OR_GREATER
|
|||
return new AesGcm(keyBytes, GcmTagSize); |
|||
#else
|
|||
return new AesGcm(keyBytes); |
|||
#endif
|
|||
} |
|||
#endif
|
|||
|
|||
protected virtual byte[] BuildHeader(byte algorithm, int chunkSize, byte[] baseNonce) |
|||
{ |
|||
var header = new byte[HeaderSize]; |
|||
header[0] = FormatVersion; |
|||
header[1] = algorithm; |
|||
WriteInt32BigEndian(header, 2, chunkSize); |
|||
Array.Copy(baseNonce, 0, header, 6, BaseNonceSize); |
|||
return header; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Reads exactly <paramref name="count"/> bytes from the stream.
|
|||
/// Returns null if the stream ends before <paramref name="count"/> bytes could be read.
|
|||
/// </summary>
|
|||
protected virtual byte[]? ReadExactly(Stream stream, int count) |
|||
{ |
|||
var buffer = ReadUpTo(stream, count); |
|||
return buffer.Length == count ? buffer : null; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Reads up to <paramref name="count"/> bytes from the stream.
|
|||
/// May return fewer bytes (or an empty array) only if the stream ends.
|
|||
/// </summary>
|
|||
protected virtual byte[] ReadUpTo(Stream stream, int count) |
|||
{ |
|||
var buffer = new byte[count]; |
|||
var totalReadCount = 0; |
|||
while (totalReadCount < count) |
|||
{ |
|||
var readCount = stream.Read(buffer, totalReadCount, count - totalReadCount); |
|||
if (readCount == 0) |
|||
{ |
|||
break; |
|||
} |
|||
|
|||
totalReadCount += readCount; |
|||
} |
|||
|
|||
if (totalReadCount == count) |
|||
{ |
|||
return buffer; |
|||
} |
|||
|
|||
var result = new byte[totalReadCount]; |
|||
Array.Copy(buffer, 0, result, 0, totalReadCount); |
|||
return result; |
|||
} |
|||
|
|||
private static void WriteInt32BigEndian(byte[] buffer, int offset, int value) |
|||
{ |
|||
buffer[offset] = (byte)(value >> 24); |
|||
buffer[offset + 1] = (byte)(value >> 16); |
|||
buffer[offset + 2] = (byte)(value >> 8); |
|||
buffer[offset + 3] = (byte)value; |
|||
} |
|||
|
|||
private static int ReadInt32BigEndian(byte[] buffer, int offset) |
|||
{ |
|||
return (buffer[offset] << 24) | (buffer[offset + 1] << 16) | (buffer[offset + 2] << 8) | buffer[offset + 3]; |
|||
} |
|||
|
|||
private static bool FixedTimeEquals(byte[] a, byte[] b) |
|||
{ |
|||
if (a.Length != b.Length) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
var diff = 0; |
|||
for (var i = 0; i < a.Length; i++) |
|||
{ |
|||
diff |= a[i] ^ b[i]; |
|||
} |
|||
|
|||
return diff == 0; |
|||
} |
|||
} |
|||
@ -1,59 +0,0 @@ |
|||
using System.IO; |
|||
|
|||
namespace Volo.Abp.Security.Encryption; |
|||
|
|||
/// <summary>
|
|||
/// Can be used to encrypt/decrypt binary data (files, images, serialized objects etc.)
|
|||
/// with authenticated encryption.
|
|||
/// Use <see cref="AbpByteArrayEncryptionOptions"/> to configure default values.
|
|||
/// This service is independent from <see cref="IStringEncryptionService"/>;
|
|||
/// data encrypted by one of them can not be decrypted by the other.
|
|||
/// </summary>
|
|||
public interface IByteArrayEncryptionService |
|||
{ |
|||
/// <summary>
|
|||
/// Encrypts binary data.
|
|||
/// </summary>
|
|||
/// <param name="plainBytes">The data in plain format</param>
|
|||
/// <param name="passPhrase">A phrase to use as the encryption key (optional, uses default if not provided)</param>
|
|||
/// <param name="salt">Salt value (optional, uses default if not provided)</param>
|
|||
/// <returns>Encrypted data, including a format header and authentication tags</returns>
|
|||
byte[]? Encrypt(byte[]? plainBytes, string? passPhrase = null, byte[]? salt = null); |
|||
|
|||
/// <summary>
|
|||
/// Decrypts binary data that is encrypted by the <see cref="Encrypt(byte[], string?, byte[])"/> method.
|
|||
/// </summary>
|
|||
/// <param name="cipherBytes">The data in encrypted format</param>
|
|||
/// <param name="passPhrase">A phrase to use as the encryption key (optional, uses default if not provided)</param>
|
|||
/// <param name="salt">Salt value (optional, uses default if not provided)</param>
|
|||
/// <returns>Decrypted data</returns>
|
|||
/// <exception cref="System.Security.Cryptography.CryptographicException">
|
|||
/// Thrown when the data is tampered, corrupted or the passphrase/salt is wrong.
|
|||
/// </exception>
|
|||
byte[]? Decrypt(byte[]? cipherBytes, string? passPhrase = null, byte[]? salt = null); |
|||
|
|||
/// <summary>
|
|||
/// Encrypts a stream into another stream. The data is processed in chunks,
|
|||
/// so the memory usage is constant and independent from the total data size.
|
|||
/// Each chunk is authenticated before the next one is written.
|
|||
/// </summary>
|
|||
/// <param name="plainStream">The stream to read the plain data from</param>
|
|||
/// <param name="cipherStream">The stream to write the encrypted data to</param>
|
|||
/// <param name="passPhrase">A phrase to use as the encryption key (optional, uses default if not provided)</param>
|
|||
/// <param name="salt">Salt value (optional, uses default if not provided)</param>
|
|||
void Encrypt(Stream plainStream, Stream cipherStream, string? passPhrase = null, byte[]? salt = null); |
|||
|
|||
/// <summary>
|
|||
/// Decrypts a stream that is encrypted by the <see cref="Encrypt(Stream, Stream, string?, byte[])"/> method.
|
|||
/// Each chunk's authentication tag is verified before its plaintext is written
|
|||
/// to the <paramref name="plainStream"/>, so tampered data is never released.
|
|||
/// </summary>
|
|||
/// <param name="cipherStream">The stream to read the encrypted data from</param>
|
|||
/// <param name="plainStream">The stream to write the decrypted data to</param>
|
|||
/// <param name="passPhrase">A phrase to use as the encryption key (optional, uses default if not provided)</param>
|
|||
/// <param name="salt">Salt value (optional, uses default if not provided)</param>
|
|||
/// <exception cref="System.Security.Cryptography.CryptographicException">
|
|||
/// Thrown when the data is tampered, corrupted or the passphrase/salt is wrong.
|
|||
/// </exception>
|
|||
void Decrypt(Stream cipherStream, Stream plainStream, string? passPhrase = null, byte[]? salt = null); |
|||
} |
|||
@ -0,0 +1,424 @@ |
|||
#nullable enable |
|||
using System; |
|||
using System.IO; |
|||
using System.Linq; |
|||
using System.Security.Cryptography; |
|||
using System.Text; |
|||
using System.Threading.Tasks; |
|||
using Shouldly; |
|||
using Volo.Abp.BlobStoring.TestObjects; |
|||
using Volo.Abp.MultiTenancy; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.BlobStoring.FileSystem; |
|||
|
|||
public class FileSystemBlobEncryption_Tests : AbpBlobStoringFileSystemTestBase |
|||
{ |
|||
private readonly IBlobContainer<TestContainer4> _container4; // UseEncryption("container4-passphrase")
|
|||
private readonly IBlobContainer<TestContainer5> _container5; // UseEncryption() -> key provider (tenant setting / global options)
|
|||
private readonly IBlobContainer<TestContainer6> _container6; // UseEncryption("container6-passphrase", allowLegacyPlaintext: true)
|
|||
private readonly IBlobFilePathCalculator _filePathCalculator; |
|||
private readonly IBlobContainerConfigurationProvider _configurationProvider; |
|||
private readonly ICurrentTenant _currentTenant; |
|||
|
|||
public FileSystemBlobEncryption_Tests() |
|||
{ |
|||
_container4 = GetRequiredService<IBlobContainer<TestContainer4>>(); |
|||
_container5 = GetRequiredService<IBlobContainer<TestContainer5>>(); |
|||
_container6 = GetRequiredService<IBlobContainer<TestContainer6>>(); |
|||
_filePathCalculator = GetRequiredService<IBlobFilePathCalculator>(); |
|||
_configurationProvider = GetRequiredService<IBlobContainerConfigurationProvider>(); |
|||
_currentTenant = GetRequiredService<ICurrentTenant>(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Store_Encrypted_Bytes_On_Disk_And_Read_Them_Back() |
|||
{ |
|||
var blobName = "fs-encrypted-roundtrip"; |
|||
var testContent = "file system test content".GetBytes(); |
|||
|
|||
await _container4.SaveAsync(blobName, testContent); |
|||
|
|||
var fileBytes = await File.ReadAllBytesAsync(GetFilePath<TestContainer4>(blobName)); |
|||
fileBytes.SequenceEqual(testContent).ShouldBeFalse(); |
|||
Encoding.ASCII.GetString(fileBytes.Take(4).ToArray()).ShouldBe("ABPE"); |
|||
|
|||
(await _container4.GetAllBytesAsync(blobName)).SequenceEqual(testContent).ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Save_And_Get_Empty_And_Multi_Chunk_Blobs() |
|||
{ |
|||
await _container4.SaveAsync("fs-empty", Array.Empty<byte>()); |
|||
(await _container4.GetAllBytesAsync("fs-empty")).ShouldBeEmpty(); |
|||
|
|||
var largeContent = new byte[3 * 1024 * 1024 + 123]; // Spans many 64 KB chunks
|
|||
new Random(42).NextBytes(largeContent); |
|||
|
|||
await _container4.SaveAsync("fs-large", largeContent); |
|||
|
|||
(await _container4.GetAllBytesAsync("fs-large")).SequenceEqual(largeContent).ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Override_An_Existing_Encrypted_Blob() |
|||
{ |
|||
var blobName = "fs-override"; |
|||
await _container4.SaveAsync(blobName, "first content".GetBytes()); |
|||
await _container4.SaveAsync(blobName, "second content".GetBytes(), overrideExisting: true); |
|||
|
|||
(await _container4.GetAllBytesAsync(blobName)).ShouldBe("second content".GetBytes()); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Save_From_Async_Only_Source_To_Disk() |
|||
{ |
|||
var blobName = "fs-async-only"; |
|||
var testContent = new byte[192 * 1024]; |
|||
new Random(42).NextBytes(testContent); |
|||
|
|||
await _container4.SaveAsync(blobName, new AsyncOnlyStream(testContent)); |
|||
|
|||
using var result = await _container4.GetAsync(blobName); |
|||
using var output = new MemoryStream(); |
|||
await result.CopyToAsync(output); |
|||
|
|||
output.ToArray().ShouldBe(testContent); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Read_Legacy_Plaintext_File_When_Allowed() |
|||
{ |
|||
var blobName = "fs-legacy"; |
|||
var legacyContent = "plaintext file from before encryption".GetBytes(); |
|||
WriteRawFile<TestContainer6>(blobName, legacyContent); |
|||
|
|||
(await _container6.GetAllBytesAsync(blobName)).SequenceEqual(legacyContent).ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Reject_Legacy_Plaintext_File_By_Default() |
|||
{ |
|||
var blobName = "fs-legacy-rejected"; |
|||
WriteRawFile<TestContainer4>(blobName, "plaintext file".GetBytes()); |
|||
|
|||
await Assert.ThrowsAsync<AbpException>(async () => |
|||
{ |
|||
using var stream = await _container4.GetAsync(blobName); |
|||
}); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Detect_Tampered_File_On_Disk() |
|||
{ |
|||
var blobName = "fs-tampered"; |
|||
var content = new byte[128 * 1024]; |
|||
new Random(42).NextBytes(content); |
|||
await _container4.SaveAsync(blobName, content); |
|||
|
|||
var filePath = GetFilePath<TestContainer4>(blobName); |
|||
var fileBytes = await File.ReadAllBytesAsync(filePath); |
|||
fileBytes[100] ^= 0xFF; // Inside the first cipher chunk
|
|||
await File.WriteAllBytesAsync(filePath, fileBytes); |
|||
|
|||
using var stream = await _container4.GetAsync(blobName); |
|||
using var output = new MemoryStream(); |
|||
|
|||
Assert.ThrowsAny<CryptographicException>(() => stream.CopyTo(output)); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Detect_Truncated_File_On_Disk() |
|||
{ |
|||
var blobName = "fs-truncated"; |
|||
await _container4.SaveAsync(blobName, new byte[128 * 1024]); |
|||
|
|||
var filePath = GetFilePath<TestContainer4>(blobName); |
|||
var fileBytes = await File.ReadAllBytesAsync(filePath); |
|||
Array.Resize(ref fileBytes, fileBytes.Length - 20); // Cut the terminal record
|
|||
await File.WriteAllBytesAsync(filePath, fileBytes); |
|||
|
|||
using var stream = await _container4.GetAsync(blobName); |
|||
using var output = new MemoryStream(); |
|||
|
|||
Should.Throw<AbpException>(() => stream.CopyTo(output)); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Fail_Closed_When_File_Magic_Is_Tampered() |
|||
{ |
|||
var blobName = "fs-tampered-magic"; |
|||
await _container4.SaveAsync(blobName, "secret".GetBytes()); |
|||
|
|||
var filePath = GetFilePath<TestContainer4>(blobName); |
|||
var fileBytes = await File.ReadAllBytesAsync(filePath); |
|||
fileBytes[0] ^= 0xFF; |
|||
await File.WriteAllBytesAsync(filePath, fileBytes); |
|||
|
|||
await Assert.ThrowsAsync<AbpException>(async () => |
|||
{ |
|||
using var stream = await _container4.GetAsync(blobName); |
|||
}); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Support_Exists_And_Delete_For_Encrypted_Blobs() |
|||
{ |
|||
var blobName = "fs-exists-delete"; |
|||
await _container4.SaveAsync(blobName, "content".GetBytes()); |
|||
|
|||
(await _container4.ExistsAsync(blobName)).ShouldBeTrue(); |
|||
(await _container4.DeleteAsync(blobName)).ShouldBeTrue(); |
|||
(await _container4.ExistsAsync(blobName)).ShouldBeFalse(); |
|||
(await _container4.GetOrNullAsync(blobName)).ShouldBeNull(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Isolate_Tenant_Blobs_In_Separate_Files() |
|||
{ |
|||
var blobName = "fs-tenant-isolation"; |
|||
var tenant1 = Guid.NewGuid(); |
|||
var tenant2 = Guid.NewGuid(); |
|||
|
|||
using (_currentTenant.Change(tenant1)) |
|||
{ |
|||
await _container5.SaveAsync(blobName, "tenant 1 content".GetBytes()); |
|||
} |
|||
|
|||
using (_currentTenant.Change(tenant2)) |
|||
{ |
|||
await _container5.SaveAsync(blobName, "tenant 2 content".GetBytes()); |
|||
} |
|||
|
|||
string tenant1Path, tenant2Path; |
|||
using (_currentTenant.Change(tenant1)) |
|||
{ |
|||
tenant1Path = GetFilePath<TestContainer5>(blobName); |
|||
(await _container5.GetAllBytesAsync(blobName)).ShouldBe("tenant 1 content".GetBytes()); |
|||
} |
|||
|
|||
using (_currentTenant.Change(tenant2)) |
|||
{ |
|||
tenant2Path = GetFilePath<TestContainer5>(blobName); |
|||
(await _container5.GetAllBytesAsync(blobName)).ShouldBe("tenant 2 content".GetBytes()); |
|||
} |
|||
|
|||
tenant1Path.ShouldNotBe(tenant2Path); |
|||
File.Exists(tenant1Path).ShouldBeTrue(); |
|||
File.Exists(tenant2Path).ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Reject_A_File_Moved_Between_Tenants() |
|||
{ |
|||
var blobName = "fs-moved-between-tenants"; |
|||
var tenant1 = Guid.NewGuid(); |
|||
var tenant2 = Guid.NewGuid(); |
|||
|
|||
string tenant1Path, tenant2Path; |
|||
using (_currentTenant.Change(tenant1)) |
|||
{ |
|||
await _container4.SaveAsync(blobName, "tenant 1 secret".GetBytes()); |
|||
tenant1Path = GetFilePath<TestContainer4>(blobName); |
|||
} |
|||
|
|||
using (_currentTenant.Change(tenant2)) |
|||
{ |
|||
tenant2Path = GetFilePath<TestContainer4>(blobName); |
|||
} |
|||
|
|||
// Same container passphrase for both tenants: only the identity binding
|
|||
// makes the copied file unreadable at the new location.
|
|||
Directory.CreateDirectory(Path.GetDirectoryName(tenant2Path)!); |
|||
File.Copy(tenant1Path, tenant2Path); |
|||
|
|||
using (_currentTenant.Change(tenant2)) |
|||
{ |
|||
using var stream = await _container4.GetAsync(blobName); |
|||
using var output = new MemoryStream(); |
|||
|
|||
Assert.ThrowsAny<CryptographicException>(() => stream.CopyTo(output)); |
|||
} |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Use_Global_PassPhrase_On_Disk_Without_Tenant() |
|||
{ |
|||
var blobName = "fs-global-key"; |
|||
await _container5.SaveAsync(blobName, "global content".GetBytes()); |
|||
|
|||
var fileBytes = await File.ReadAllBytesAsync(GetFilePath<TestContainer5>(blobName)); |
|||
fileBytes[6].ShouldBe((byte)BlobEncryptionKeySource.Global); |
|||
|
|||
(await _container5.GetAllBytesAsync(blobName)).ShouldBe("global content".GetBytes()); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Retry_And_Produce_A_Complete_File_For_A_Replayable_Source() |
|||
{ |
|||
// TestContainer8 is not encrypted, so the seekable source reaches the provider directly
|
|||
var container8 = GetRequiredService<IBlobContainer<TestContainer8>>(); |
|||
var content = new byte[64 * 1024]; |
|||
new Random(42).NextBytes(content); |
|||
var source = new FaultOnceSeekableStream(content); |
|||
|
|||
await container8.SaveAsync("fs-retry-replayable", source, overrideExisting: true); |
|||
|
|||
source.FaultsInjected.ShouldBe(1); // First attempt failed, the retry succeeded
|
|||
(await container8.GetAllBytesAsync("fs-retry-replayable")).SequenceEqual(content).ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Not_Retry_A_Non_Replayable_Encrypted_Save() |
|||
{ |
|||
// The encrypting wrapper is not seekable, so a mid-write failure must not be retried
|
|||
var content = new byte[64 * 1024]; |
|||
new Random(42).NextBytes(content); |
|||
var source = new FaultOnceSeekableStream(content, reportSeekable: false); |
|||
|
|||
await Assert.ThrowsAsync<IOException>(async () => |
|||
{ |
|||
await _container4.SaveAsync("fs-retry-non-replayable", source, overrideExisting: true); |
|||
}); |
|||
|
|||
source.FaultsInjected.ShouldBe(1); // No second attempt
|
|||
} |
|||
|
|||
private sealed class FaultOnceSeekableStream : Stream |
|||
{ |
|||
private readonly MemoryStream _stream; |
|||
private readonly bool _reportSeekable; |
|||
private bool _faulted; |
|||
|
|||
public int FaultsInjected { get; private set; } |
|||
|
|||
public FaultOnceSeekableStream(byte[] bytes, bool reportSeekable = true) |
|||
{ |
|||
_stream = new MemoryStream(bytes); |
|||
_reportSeekable = reportSeekable; |
|||
} |
|||
|
|||
public override bool CanRead => true; |
|||
public override bool CanSeek => _reportSeekable; |
|||
public override bool CanWrite => false; |
|||
public override long Length => _reportSeekable ? _stream.Length : throw new NotSupportedException(); |
|||
|
|||
public override long Position |
|||
{ |
|||
get => _stream.Position; |
|||
set => _stream.Position = value; |
|||
} |
|||
|
|||
public override void Flush() |
|||
{ |
|||
} |
|||
|
|||
public override int Read(byte[] buffer, int offset, int count) |
|||
{ |
|||
return ReadCore(() => _stream.Read(buffer, offset, count)); |
|||
} |
|||
|
|||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) |
|||
{ |
|||
return Task.FromResult(ReadCore(() => _stream.Read(buffer, offset, count))); |
|||
} |
|||
|
|||
private int ReadCore(Func<int> read) |
|||
{ |
|||
// Fail once in the middle of the content
|
|||
if (!_faulted && _stream.Position >= _stream.Length / 2) |
|||
{ |
|||
_faulted = true; |
|||
FaultsInjected++; |
|||
throw new IOException("Injected I/O failure!"); |
|||
} |
|||
|
|||
return read(); |
|||
} |
|||
|
|||
public override long Seek(long offset, SeekOrigin origin) |
|||
{ |
|||
return _reportSeekable ? _stream.Seek(offset, origin) : throw new NotSupportedException(); |
|||
} |
|||
|
|||
public override void SetLength(long value) => throw new NotSupportedException(); |
|||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); |
|||
|
|||
protected override void Dispose(bool disposing) |
|||
{ |
|||
if (disposing) |
|||
{ |
|||
_stream.Dispose(); |
|||
} |
|||
|
|||
base.Dispose(disposing); |
|||
} |
|||
} |
|||
|
|||
private string GetFilePath<TContainer>(string blobName) |
|||
{ |
|||
return _filePathCalculator.Calculate( |
|||
new BlobProviderGetArgs( |
|||
BlobContainerNameAttribute.GetContainerName<TContainer>(), |
|||
_configurationProvider.Get<TContainer>(), |
|||
blobName |
|||
) |
|||
); |
|||
} |
|||
|
|||
private void WriteRawFile<TContainer>(string blobName, byte[] bytes) |
|||
{ |
|||
var filePath = GetFilePath<TContainer>(blobName); |
|||
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); |
|||
File.WriteAllBytes(filePath, bytes); |
|||
} |
|||
|
|||
private sealed class AsyncOnlyStream : Stream |
|||
{ |
|||
private readonly MemoryStream _stream; |
|||
|
|||
public AsyncOnlyStream(byte[] bytes) |
|||
{ |
|||
_stream = new MemoryStream(bytes); |
|||
} |
|||
|
|||
public override bool CanRead => true; |
|||
public override bool CanSeek => false; |
|||
public override bool CanWrite => false; |
|||
public override long Length => throw new NotSupportedException(); |
|||
|
|||
public override long Position |
|||
{ |
|||
get => throw new NotSupportedException(); |
|||
set => throw new NotSupportedException(); |
|||
} |
|||
|
|||
public override void Flush() |
|||
{ |
|||
} |
|||
|
|||
public override int Read(byte[] buffer, int offset, int count) |
|||
{ |
|||
throw new InvalidOperationException("Synchronous reads are not allowed on this stream!"); |
|||
} |
|||
|
|||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, System.Threading.CancellationToken cancellationToken) |
|||
{ |
|||
return _stream.ReadAsync(buffer, offset, count, cancellationToken); |
|||
} |
|||
|
|||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); |
|||
public override void SetLength(long value) => throw new NotSupportedException(); |
|||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); |
|||
|
|||
protected override void Dispose(bool disposing) |
|||
{ |
|||
if (disposing) |
|||
{ |
|||
_stream.Dispose(); |
|||
} |
|||
|
|||
base.Dispose(disposing); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,127 @@ |
|||
#nullable enable |
|||
/* |
|||
//Please set the correct connection string in secrets.json and continue the test.
|
|||
using System; |
|||
using System.IO; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Shouldly; |
|||
using Volo.Abp.BlobStoring.TestObjects; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.BlobStoring.Minio; |
|||
|
|||
public class MinioBlobEncryption_Tests : AbpBlobStoringMinioTestBase |
|||
{ |
|||
private readonly IBlobContainer<TestContainer4> _container4; // UseEncryption("container4-passphrase")
|
|||
|
|||
public MinioBlobEncryption_Tests() |
|||
{ |
|||
_container4 = GetRequiredService<IBlobContainer<TestContainer4>>(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Save_And_Get_Encrypted_Blob() |
|||
{ |
|||
var blobName = "minio-encrypted-roundtrip"; |
|||
var testContent = "minio test content".GetBytes(); |
|||
|
|||
await _container4.SaveAsync(blobName, testContent); |
|||
|
|||
(await _container4.GetAllBytesAsync(blobName)).SequenceEqual(testContent).ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Save_And_Get_Empty_And_Multi_Chunk_Blobs() |
|||
{ |
|||
await _container4.SaveAsync("minio-empty", Array.Empty<byte>()); |
|||
(await _container4.GetAllBytesAsync("minio-empty")).ShouldBeEmpty(); |
|||
|
|||
// MinIO reads BlobStream.Length before uploading, so this verifies the
|
|||
// exact encrypted length calculation against a real object store.
|
|||
var largeContent = new byte[3 * 1024 * 1024 + 123]; // Spans many 64 KB chunks
|
|||
new Random(42).NextBytes(largeContent); |
|||
|
|||
await _container4.SaveAsync("minio-large", largeContent); |
|||
|
|||
(await _container4.GetAllBytesAsync("minio-large")).SequenceEqual(largeContent).ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Override_An_Existing_Encrypted_Blob() |
|||
{ |
|||
var blobName = "minio-override"; |
|||
await _container4.SaveAsync(blobName, "first content".GetBytes()); |
|||
await _container4.SaveAsync(blobName, "second content".GetBytes(), overrideExisting: true); |
|||
|
|||
(await _container4.GetAllBytesAsync(blobName)).ShouldBe("second content".GetBytes()); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Support_Exists_And_Delete_For_Encrypted_Blobs() |
|||
{ |
|||
var blobName = "minio-exists-delete"; |
|||
await _container4.SaveAsync(blobName, "content".GetBytes()); |
|||
|
|||
(await _container4.ExistsAsync(blobName)).ShouldBeTrue(); |
|||
(await _container4.DeleteAsync(blobName)).ShouldBeTrue(); |
|||
(await _container4.ExistsAsync(blobName)).ShouldBeFalse(); |
|||
(await _container4.GetOrNullAsync(blobName)).ShouldBeNull(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_Reject_Non_Seekable_Source_Because_Minio_Requires_The_Length() |
|||
{ |
|||
// The MinIO provider reads BlobStream.Length; for a non-seekable source the
|
|||
// encrypted length is unknown, so saving fails (same as without encryption).
|
|||
await Assert.ThrowsAsync<NotSupportedException>(async () => |
|||
{ |
|||
await _container4.SaveAsync("minio-non-seekable", new NonSeekableStream("content".GetBytes())); |
|||
}); |
|||
} |
|||
|
|||
private sealed class NonSeekableStream : Stream |
|||
{ |
|||
private readonly MemoryStream _stream; |
|||
|
|||
public NonSeekableStream(byte[] bytes) |
|||
{ |
|||
_stream = new MemoryStream(bytes); |
|||
} |
|||
|
|||
public override bool CanRead => true; |
|||
public override bool CanSeek => false; |
|||
public override bool CanWrite => false; |
|||
public override long Length => throw new NotSupportedException(); |
|||
|
|||
public override long Position |
|||
{ |
|||
get => throw new NotSupportedException(); |
|||
set => throw new NotSupportedException(); |
|||
} |
|||
|
|||
public override void Flush() |
|||
{ |
|||
} |
|||
|
|||
public override int Read(byte[] buffer, int offset, int count) |
|||
{ |
|||
return _stream.Read(buffer, offset, count); |
|||
} |
|||
|
|||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); |
|||
public override void SetLength(long value) => throw new NotSupportedException(); |
|||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); |
|||
|
|||
protected override void Dispose(bool disposing) |
|||
{ |
|||
if (disposing) |
|||
{ |
|||
_stream.Dispose(); |
|||
} |
|||
|
|||
base.Dispose(disposing); |
|||
} |
|||
} |
|||
} |
|||
*/ |
|||
@ -1,33 +0,0 @@ |
|||
using System; |
|||
using System.IO; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace Volo.Abp.BlobStoring.Fakes; |
|||
|
|||
/// <summary>
|
|||
/// A test contributor that reverses the BLOB bytes on both save and get.
|
|||
/// </summary>
|
|||
public class FakeReversingPipelineContributor : IBlobPipelineContributor, ITransientDependency |
|||
{ |
|||
public Task<Stream> OnSaveAsync(BlobPipelineSaveArgs args) |
|||
{ |
|||
return Task.FromResult(Reverse(args.BlobStream)); |
|||
} |
|||
|
|||
public Task<Stream> OnGetAsync(BlobPipelineGetArgs args) |
|||
{ |
|||
return Task.FromResult(Reverse(args.BlobStream)); |
|||
} |
|||
|
|||
private static Stream Reverse(Stream stream) |
|||
{ |
|||
using (var memoryStream = new MemoryStream()) |
|||
{ |
|||
stream.CopyTo(memoryStream); |
|||
var bytes = memoryStream.ToArray(); |
|||
Array.Reverse(bytes); |
|||
return new MemoryStream(bytes); |
|||
} |
|||
} |
|||
} |
|||
@ -1,77 +0,0 @@ |
|||
using System; |
|||
using System.IO; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.DependencyInjection; |
|||
|
|||
namespace Volo.Abp.BlobStoring.Fakes; |
|||
|
|||
public class FakeScopeBoundPipelineContributor : IBlobPipelineContributor, IScopedDependency, IDisposable |
|||
{ |
|||
private bool _isDisposed; |
|||
|
|||
public Task<Stream> OnSaveAsync(BlobPipelineSaveArgs args) |
|||
{ |
|||
return Task.FromResult<Stream>(new ScopeBoundStream(args.BlobStream, this)); |
|||
} |
|||
|
|||
public Task<Stream> OnGetAsync(BlobPipelineGetArgs args) |
|||
{ |
|||
return Task.FromResult<Stream>(new ScopeBoundStream(args.BlobStream, this)); |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
_isDisposed = true; |
|||
} |
|||
|
|||
private sealed class ScopeBoundStream : Stream |
|||
{ |
|||
private readonly Stream _stream; |
|||
private readonly FakeScopeBoundPipelineContributor _owner; |
|||
|
|||
public ScopeBoundStream(Stream stream, FakeScopeBoundPipelineContributor owner) |
|||
{ |
|||
_stream = stream; |
|||
_owner = owner; |
|||
} |
|||
|
|||
public override bool CanRead => _stream.CanRead; |
|||
public override bool CanSeek => _stream.CanSeek; |
|||
public override bool CanWrite => false; |
|||
public override long Length => _stream.Length; |
|||
|
|||
public override long Position |
|||
{ |
|||
get => _stream.Position; |
|||
set => _stream.Position = value; |
|||
} |
|||
|
|||
public override void Flush() |
|||
{ |
|||
} |
|||
|
|||
public override int Read(byte[] buffer, int offset, int count) |
|||
{ |
|||
if (_owner._isDisposed) |
|||
{ |
|||
throw new ObjectDisposedException(nameof(FakeScopeBoundPipelineContributor)); |
|||
} |
|||
|
|||
return _stream.Read(buffer, offset, count); |
|||
} |
|||
|
|||
public override long Seek(long offset, SeekOrigin origin) => _stream.Seek(offset, origin); |
|||
public override void SetLength(long value) => throw new NotSupportedException(); |
|||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); |
|||
|
|||
protected override void Dispose(bool disposing) |
|||
{ |
|||
if (disposing) |
|||
{ |
|||
_stream.Dispose(); |
|||
} |
|||
|
|||
base.Dispose(disposing); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,64 @@ |
|||
using System; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Extensions.Options; |
|||
using Volo.Abp.MultiTenancy; |
|||
|
|||
namespace Volo.Abp.BlobStoring.Fakes; |
|||
|
|||
/// <summary>
|
|||
/// A custom key provider giving each tenant its own passphrase.
|
|||
/// </summary>
|
|||
public class FakeTenantBlobEncryptionKeyProvider : DefaultBlobEncryptionKeyProvider |
|||
{ |
|||
public const string PassPhrasePrefix = "tenant-passphrase-"; |
|||
|
|||
protected ICurrentTenant CurrentTenant { get; } |
|||
|
|||
public FakeTenantBlobEncryptionKeyProvider( |
|||
ICurrentTenant currentTenant, |
|||
IOptions<AbpBlobStoringEncryptionOptions> options) |
|||
: base(options) |
|||
{ |
|||
CurrentTenant = currentTenant; |
|||
} |
|||
|
|||
public override Task<BlobEncryptionKey> ResolveForEncryptionAsync( |
|||
BlobContainerConfiguration configuration, |
|||
CancellationToken cancellationToken = default) |
|||
{ |
|||
var containerPassPhrase = GetContainerPassPhraseOrNull(configuration); |
|||
if (string.IsNullOrWhiteSpace(containerPassPhrase) && CurrentTenant.Id.HasValue) |
|||
{ |
|||
return Task.FromResult(new BlobEncryptionKey( |
|||
BlobEncryptionKeySource.Tenant, |
|||
GetPassPhrase(CurrentTenant.Id.Value) |
|||
)); |
|||
} |
|||
|
|||
return base.ResolveForEncryptionAsync(configuration, cancellationToken); |
|||
} |
|||
|
|||
public override Task<string> ResolveForDecryptionAsync( |
|||
BlobEncryptionKeySource keySource, |
|||
BlobContainerConfiguration configuration, |
|||
CancellationToken cancellationToken = default) |
|||
{ |
|||
if (keySource == BlobEncryptionKeySource.Tenant) |
|||
{ |
|||
if (!CurrentTenant.Id.HasValue) |
|||
{ |
|||
throw new AbpException("The BLOB was encrypted with a tenant-specific passphrase, but there is no current tenant!"); |
|||
} |
|||
|
|||
return Task.FromResult(GetPassPhrase(CurrentTenant.Id.Value)); |
|||
} |
|||
|
|||
return base.ResolveForDecryptionAsync(keySource, configuration, cancellationToken); |
|||
} |
|||
|
|||
public static string GetPassPhrase(Guid tenantId) |
|||
{ |
|||
return PassPhrasePrefix + tenantId.ToString("N"); |
|||
} |
|||
} |
|||
@ -1,59 +0,0 @@ |
|||
#nullable enable |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.MultiTenancy; |
|||
using Volo.Abp.Settings; |
|||
|
|||
namespace Volo.Abp.BlobStoring.Fakes; |
|||
|
|||
/// <summary>
|
|||
/// Simulates a tenant-level setting value provider that gives each tenant
|
|||
/// its own encryption passphrase.
|
|||
/// </summary>
|
|||
public class FakeTenantPassPhraseSettingValueProvider : SettingValueProvider |
|||
{ |
|||
public const string PassPhrasePrefix = "tenant-passphrase-"; |
|||
|
|||
protected ICurrentTenant CurrentTenant { get; } |
|||
|
|||
protected ISettingEncryptionService SettingEncryptionService { get; } |
|||
|
|||
public FakeTenantPassPhraseSettingValueProvider( |
|||
ISettingStore settingStore, |
|||
ICurrentTenant currentTenant, |
|||
ISettingEncryptionService settingEncryptionService) |
|||
: base(settingStore) |
|||
{ |
|||
CurrentTenant = currentTenant; |
|||
SettingEncryptionService = settingEncryptionService; |
|||
} |
|||
|
|||
public override string Name => TenantSettingValueProvider.ProviderName; |
|||
|
|||
public override Task<string?> GetOrNullAsync(SettingDefinition setting) |
|||
{ |
|||
if (setting.Name == BlobStoringEncryptionSettings.TenantPassPhrase && CurrentTenant.Id.HasValue) |
|||
{ |
|||
return Task.FromResult( |
|||
SettingEncryptionService.Encrypt(setting, GetPassPhrase(CurrentTenant.Id.Value)) |
|||
); |
|||
} |
|||
|
|||
return Task.FromResult<string?>(null); |
|||
} |
|||
|
|||
public override Task<List<SettingValue>> GetAllAsync(SettingDefinition[] settings) |
|||
{ |
|||
return Task.FromResult( |
|||
settings |
|||
.Select(s => new SettingValue(s.Name, null)) |
|||
.ToList() |
|||
); |
|||
} |
|||
|
|||
public static string GetPassPhrase(System.Guid tenantId) |
|||
{ |
|||
return PassPhrasePrefix + tenantId.ToString("N"); |
|||
} |
|||
} |
|||
@ -0,0 +1,5 @@ |
|||
namespace Volo.Abp.BlobStoring.TestObjects; |
|||
|
|||
public class TestContainer8 |
|||
{ |
|||
} |
|||
@ -1,140 +0,0 @@ |
|||
using System; |
|||
using System.IO; |
|||
using System.Security.Cryptography; |
|||
using Shouldly; |
|||
using Volo.Abp.Testing; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Security.Encryption; |
|||
|
|||
public class ByteArrayEncryptionService_Tests : AbpIntegratedTest<AbpSecurityTestModule> |
|||
{ |
|||
private readonly IByteArrayEncryptionService _byteArrayEncryptionService; |
|||
|
|||
public ByteArrayEncryptionService_Tests() |
|||
{ |
|||
_byteArrayEncryptionService = GetRequiredService<IByteArrayEncryptionService>(); |
|||
} |
|||
|
|||
[Theory] |
|||
[InlineData(null)] |
|||
[InlineData(new byte[0])] |
|||
[InlineData(new byte[] { 1, 2, 3, 42, 255 })] |
|||
public void Should_Encrypt_And_Decrypt_With_Default_Options(byte[] plainBytes) |
|||
{ |
|||
_byteArrayEncryptionService |
|||
.Decrypt(_byteArrayEncryptionService.Encrypt(plainBytes)) |
|||
.ShouldBe(plainBytes); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Encrypt_And_Decrypt_Large_Data() |
|||
{ |
|||
var plainBytes = new byte[2 * 1024 * 1024]; // 2 MB
|
|||
new Random(42).NextBytes(plainBytes); |
|||
|
|||
var cipherBytes = _byteArrayEncryptionService.Encrypt(plainBytes); |
|||
|
|||
cipherBytes.ShouldNotBeNull(); |
|||
cipherBytes.ShouldNotBe(plainBytes); |
|||
|
|||
_byteArrayEncryptionService.Decrypt(cipherBytes).ShouldBe(plainBytes); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Encrypt_And_Decrypt_Streams() |
|||
{ |
|||
var plainBytes = new byte[2 * 1024 * 1024]; // 2 MB, spans multiple chunks
|
|||
new Random(42).NextBytes(plainBytes); |
|||
|
|||
using var plainInput = new MemoryStream(plainBytes); |
|||
using var cipherOutput = new MemoryStream(); |
|||
_byteArrayEncryptionService.Encrypt(plainInput, cipherOutput); |
|||
|
|||
cipherOutput.Position = 0; |
|||
using var plainOutput = new MemoryStream(); |
|||
_byteArrayEncryptionService.Decrypt(cipherOutput, plainOutput); |
|||
|
|||
plainOutput.ToArray().ShouldBe(plainBytes); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Produce_Different_Output_For_The_Same_Input() |
|||
{ |
|||
var plainBytes = new byte[] { 1, 2, 3, 42, 255 }; |
|||
|
|||
var cipherBytes1 = _byteArrayEncryptionService.Encrypt(plainBytes); |
|||
var cipherBytes2 = _byteArrayEncryptionService.Encrypt(plainBytes); |
|||
|
|||
cipherBytes1.ShouldNotBe(cipherBytes2); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Write_Format_Header() |
|||
{ |
|||
var cipherBytes = _byteArrayEncryptionService.Encrypt(new byte[] { 1, 2, 3 }); |
|||
|
|||
cipherBytes.ShouldNotBeNull(); |
|||
cipherBytes.Length.ShouldBeGreaterThan(14); |
|||
cipherBytes[0].ShouldBe((byte)1); // Format version
|
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Fail_To_Decrypt_Tampered_Data() |
|||
{ |
|||
var cipherBytes = _byteArrayEncryptionService.Encrypt(new byte[] { 1, 2, 3, 42, 255 }); |
|||
|
|||
cipherBytes![cipherBytes.Length - 1] ^= 0xFF; // Flip the last byte (inside the auth tag)
|
|||
|
|||
Assert.ThrowsAny<CryptographicException>(() => _byteArrayEncryptionService.Decrypt(cipherBytes)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Fail_To_Decrypt_With_Wrong_PassPhrase() |
|||
{ |
|||
var cipherBytes = _byteArrayEncryptionService.Encrypt(new byte[] { 1, 2, 3 }, "passphrase-1"); |
|||
|
|||
Assert.ThrowsAny<CryptographicException>(() => _byteArrayEncryptionService.Decrypt(cipherBytes, "passphrase-2")); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Encrypt_And_Decrypt_With_Custom_PassPhrase_And_Salt() |
|||
{ |
|||
var plainBytes = new byte[] { 1, 2, 3, 42, 255 }; |
|||
var salt = new byte[] { 9, 8, 7, 6, 5, 4, 3, 2 }; |
|||
|
|||
_byteArrayEncryptionService |
|||
.Decrypt(_byteArrayEncryptionService.Encrypt(plainBytes, "my-passphrase", salt), "my-passphrase", salt) |
|||
.ShouldBe(plainBytes); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Return_Null_For_Null_Or_Empty_CipherBytes() |
|||
{ |
|||
_byteArrayEncryptionService.Decrypt(null).ShouldBeNull(); |
|||
_byteArrayEncryptionService.Decrypt(new byte[0]).ShouldBeNull(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Fail_When_Authenticated_Terminal_Record_Is_Missing() |
|||
{ |
|||
var plainBytes = new byte[128 * 1024]; |
|||
var cipherBytes = _byteArrayEncryptionService.Encrypt(plainBytes)!; |
|||
|
|||
Array.Resize(ref cipherBytes, cipherBytes.Length - 20); // 4-byte terminal marker + 16-byte GCM tag
|
|||
|
|||
Should.Throw<AbpException>(() => _byteArrayEncryptionService.Decrypt(cipherBytes)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Reject_Oversized_Encoded_Chunk_Size_Before_Allocating() |
|||
{ |
|||
var cipherBytes = _byteArrayEncryptionService.Encrypt(new byte[] { 1 })!; |
|||
cipherBytes[2] = 0x7F; |
|||
cipherBytes[3] = 0xFF; |
|||
cipherBytes[4] = 0xFF; |
|||
cipherBytes[5] = 0xFF; |
|||
|
|||
Should.Throw<AbpException>(() => _byteArrayEncryptionService.Decrypt(cipherBytes)); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue