Merge pull request #1253 from adamhathcock/adam/fix-zip645-and-zipzstd

Fix ZIP64 stream bounding and WinZip AES read-state corruption in ZIP reader
This commit is contained in:
Adam Hathcock
2026-03-17 12:21:57 +00:00
committed by GitHub
10 changed files with 532 additions and 100 deletions

View File

@@ -11,5 +11,6 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
<NoWarn>${NoWarn};IDE0051</NoWarn>
</PropertyGroup>
</Project>

View File

@@ -86,55 +86,34 @@ internal partial class WinzipAesCryptoStream
private void ReadTransformBlocks(Span<byte> buffer, int count)
{
var posn = 0;
var last = count;
var remaining = count;
while (posn < buffer.Length && posn < last)
while (posn < buffer.Length && remaining > 0)
{
var n = ReadTransformOneBlock(buffer, posn, last);
var n = ReadTransformOneBlock(buffer, posn, remaining);
posn += n;
remaining -= n;
}
}
private int ReadTransformOneBlock(Span<byte> buffer, int offset, int last)
private int ReadTransformOneBlock(Span<byte> buffer, int offset, int remaining)
{
if (_isFinalBlock)
if (_counterOutOffset == BLOCK_SIZE_IN_BYTES)
{
throw new ArchiveOperationException();
FillCounterOut();
}
var bytesRemaining = last - offset;
var bytesToRead =
(bytesRemaining > BLOCK_SIZE_IN_BYTES) ? BLOCK_SIZE_IN_BYTES : bytesRemaining;
// update the counter
System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(_counter, _nonce++);
// Determine if this is the final block
if ((bytesToRead == bytesRemaining) && (_totalBytesLeftToRead == 0))
{
_counterOut = _transform.TransformFinalBlock(_counter, 0, BLOCK_SIZE_IN_BYTES);
_isFinalBlock = true;
}
else
{
_transform.TransformBlock(
_counter,
0, // offset
BLOCK_SIZE_IN_BYTES,
_counterOut,
0
); // offset
}
XorInPlace(buffer, offset, bytesToRead);
return bytesToRead;
var bytesToXor = Math.Min(BLOCK_SIZE_IN_BYTES - _counterOutOffset, remaining);
XorInPlace(buffer, offset, bytesToXor, _counterOutOffset);
_counterOutOffset += bytesToXor;
return bytesToXor;
}
private void XorInPlace(Span<byte> buffer, int offset, int count)
private void XorInPlace(Span<byte> buffer, int offset, int count, int counterOffset)
{
for (var i = 0; i < count; i++)
{
buffer[offset + i] = (byte)(_counterOut[i] ^ buffer[offset + i]);
buffer[offset + i] = (byte)(_counterOut[counterOffset + i] ^ buffer[offset + i]);
}
}
#endif

View File

@@ -16,7 +16,7 @@ internal partial class WinzipAesCryptoStream : Stream
private readonly ICryptoTransform _transform;
private int _nonce = 1;
private byte[] _counterOut = new byte[BLOCK_SIZE_IN_BYTES];
private bool _isFinalBlock;
private int _counterOutOffset = BLOCK_SIZE_IN_BYTES;
private long _totalBytesLeftToRead;
private bool _isDisposed;
@@ -123,58 +123,45 @@ internal partial class WinzipAesCryptoStream : Stream
return read;
}
private int ReadTransformOneBlock(byte[] buffer, int offset, int last)
private void FillCounterOut()
{
if (_isFinalBlock)
{
throw new ArchiveOperationException();
}
var bytesRemaining = last - offset;
var bytesToRead =
(bytesRemaining > BLOCK_SIZE_IN_BYTES) ? BLOCK_SIZE_IN_BYTES : bytesRemaining;
// update the counter
BinaryPrimitives.WriteInt32LittleEndian(_counter, _nonce++);
// Determine if this is the final block
if ((bytesToRead == bytesRemaining) && (_totalBytesLeftToRead == 0))
{
_counterOut = _transform.TransformFinalBlock(_counter, 0, BLOCK_SIZE_IN_BYTES);
_isFinalBlock = true;
}
else
{
_transform.TransformBlock(
_counter,
0, // offset
BLOCK_SIZE_IN_BYTES,
_counterOut,
0
); // offset
}
XorInPlace(buffer, offset, bytesToRead);
return bytesToRead;
_transform.TransformBlock(
_counter,
0, // offset
BLOCK_SIZE_IN_BYTES,
_counterOut,
0
); // offset
_counterOutOffset = 0;
}
private void XorInPlace(byte[] buffer, int offset, int count)
private void XorInPlace(byte[] buffer, int offset, int count, int counterOffset)
{
for (var i = 0; i < count; i++)
{
buffer[offset + i] = (byte)(_counterOut[i] ^ buffer[offset + i]);
buffer[offset + i] = (byte)(_counterOut[counterOffset + i] ^ buffer[offset + i]);
}
}
private void ReadTransformBlocks(byte[] buffer, int offset, int count)
{
var posn = offset;
var last = count + offset;
var remaining = count;
while (posn < buffer.Length && posn < last)
while (posn < buffer.Length && remaining > 0)
{
var n = ReadTransformOneBlock(buffer, posn, last);
posn += n;
if (_counterOutOffset == BLOCK_SIZE_IN_BYTES)
{
FillCounterOut();
}
var bytesToXor = Math.Min(BLOCK_SIZE_IN_BYTES - _counterOutOffset, remaining);
XorInPlace(buffer, posn, bytesToXor, _counterOutOffset);
_counterOutOffset += bytesToXor;
posn += bytesToXor;
remaining -= bytesToXor;
}
}

View File

@@ -48,10 +48,8 @@ internal abstract partial class ZipFilePart
}
if (
(
Header.CompressedSize == 0
&& FlagUtility.HasFlag(Header.Flags, HeaderFlags.UsePostDataDescriptor)
) || Header.IsZip64
Header.CompressedSize == 0
&& FlagUtility.HasFlag(Header.Flags, HeaderFlags.UsePostDataDescriptor)
)
{
plainStream = SharpCompressStream.CreateNonDisposing(plainStream); //make sure AES doesn't close

View File

@@ -234,10 +234,8 @@ internal abstract partial class ZipFilePart : FilePart
}
if (
(
Header.CompressedSize == 0
&& FlagUtility.HasFlag(Header.Flags, HeaderFlags.UsePostDataDescriptor)
) || Header.IsZip64
Header.CompressedSize == 0
&& FlagUtility.HasFlag(Header.Flags, HeaderFlags.UsePostDataDescriptor)
)
{
plainStream = SharpCompressStream.CreateNonDisposing(plainStream); //make sure AES doesn't close

View File

@@ -0,0 +1,257 @@
using System;
using System.Buffers.Binary;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using SharpCompress.Common.Zip;
using Xunit;
namespace SharpCompress.Test.Streams;
public class WinzipAesCryptoStreamTests
{
[Fact]
public void Read_Decrypts_Data_For_Aligned_Buffer_Size()
{
const string password = "sample-password";
byte[] plainText = Enumerable.Range(0, 64).Select(i => (byte)i).ToArray();
byte[] salt = [0x10, 0x21, 0x32, 0x43, 0x54, 0x65, 0x76, 0x87];
using var stream = CreateStream(plainText, password, salt);
byte[] actual = new byte[plainText.Length];
int bytesRead = stream.Read(actual, 0, actual.Length);
Assert.Equal(plainText.Length, bytesRead);
Assert.Equal(plainText, actual);
}
[Fact]
public void Read_Preserves_Keystream_Between_NonAligned_Reads()
{
const string password = "sample-password";
byte[] plainText = Enumerable.Range(0, 97).Select(i => (byte)i).ToArray();
byte[] salt = [0x10, 0x21, 0x32, 0x43, 0x54, 0x65, 0x76, 0x87];
using var stream = CreateStream(plainText, password, salt);
byte[] actual = ReadWithChunkPattern(
(buffer, offset, count) => stream.Read(buffer, offset, count),
plainText.Length,
[13, 5, 29, 7, 43]
);
Assert.Equal(plainText, actual);
}
[Fact]
public async Task ReadAsync_Preserves_Keystream_Between_NonAligned_Reads()
{
const string password = "sample-password";
byte[] plainText = Enumerable
.Range(0, 113)
.Select(i => unchecked((byte)(255 - i)))
.ToArray();
byte[] salt = [0x91, 0x82, 0x73, 0x64, 0x55, 0x46, 0x37, 0x28];
using var stream = CreateStream(plainText, password, salt);
byte[] actual = await ReadWithChunkPatternAsync(
(buffer, offset, count) => stream.ReadAsync(buffer, offset, count),
plainText.Length,
[11, 3, 17, 5, 41]
);
Assert.Equal(plainText, actual);
}
[Fact]
public async Task ReadAsync_Memory_Preserves_Keystream_Between_NonAligned_Reads()
{
const string password = "sample-password";
byte[] plainText = Enumerable
.Range(0, 113)
.Select(i => unchecked((byte)(255 - i)))
.ToArray();
byte[] salt = [0x91, 0x82, 0x73, 0x64, 0x55, 0x46, 0x37, 0x28];
using var stream = CreateStream(plainText, password, salt);
byte[] actual = await ReadWithChunkPatternMemoryAsync(
stream,
plainText.Length,
[11, 3, 17, 5, 41]
);
Assert.Equal(plainText, actual);
}
[Fact]
public void Read_Stops_At_Encrypted_Payload_Length()
{
const string password = "sample-password";
byte[] plainText = Enumerable.Range(0, 31).Select(i => (byte)(i * 3)).ToArray();
byte[] salt = [0xA1, 0xB2, 0xC3, 0xD4, 0x01, 0x12, 0x23, 0x34];
using var stream = CreateStream(plainText, password, salt);
byte[] actual = new byte[plainText.Length + 16];
int bytesRead = stream.Read(actual, 0, actual.Length);
int eofRead = stream.Read(actual, bytesRead, actual.Length - bytesRead);
Assert.Equal(plainText.Length, bytesRead);
Assert.Equal(0, eofRead);
Assert.Equal(plainText, actual.Take(bytesRead).ToArray());
}
private static WinzipAesCryptoStream CreateStream(
byte[] plainText,
string password,
byte[] salt
)
{
var encryptionData = CreateEncryptionData(password, salt);
byte[] cipherText = EncryptCtr(plainText, encryptionData.KeyBytes);
byte[] archiveBytes = cipherText.Concat(new byte[10]).ToArray();
return new WinzipAesCryptoStream(
new MemoryStream(archiveBytes, writable: false),
encryptionData,
cipherText.Length
);
}
[SuppressMessage(
"Security",
"CA5379:Rfc2898DeriveBytes might be using a weak hash algorithm",
Justification = "WinZip AES interop requires PBKDF2 with SHA-1."
)]
private static WinzipAesEncryptionData CreateEncryptionData(string password, byte[] salt)
{
#pragma warning disable SYSLIB0060 // Rfc2898DeriveBytes might be using a weak hash algorithm
using var deriveBytes = new Rfc2898DeriveBytes(
password,
salt,
1000,
HashAlgorithmName.SHA1
);
#pragma warning restore SYSLIB0060
deriveBytes.GetBytes(16);
deriveBytes.GetBytes(16);
byte[] passwordVerifyValue = deriveBytes.GetBytes(2);
return new WinzipAesEncryptionData(
WinzipAesKeySize.KeySize128,
salt,
passwordVerifyValue,
password
);
}
private static byte[] EncryptCtr(byte[] plainText, byte[] keyBytes)
{
using var aes = Aes.Create();
aes.BlockSize = 128;
aes.KeySize = keyBytes.Length * 8;
aes.Mode = CipherMode.ECB;
aes.Padding = PaddingMode.None;
using var encryptor = aes.CreateEncryptor(keyBytes, new byte[16]);
byte[] counter = new byte[16];
byte[] counterOut = new byte[16];
byte[] cipherText = new byte[plainText.Length];
int nonce = 1;
int offset = 0;
while (offset < plainText.Length)
{
BinaryPrimitives.WriteInt32LittleEndian(counter, nonce++);
encryptor.TransformBlock(counter, 0, counter.Length, counterOut, 0);
int blockLength = Math.Min(counterOut.Length, plainText.Length - offset);
for (int i = 0; i < blockLength; i++)
{
cipherText[offset + i] = (byte)(plainText[offset + i] ^ counterOut[i]);
}
offset += blockLength;
}
return cipherText;
}
private static byte[] ReadWithChunkPattern(
Func<byte[], int, int, int> read,
int totalLength,
int[] chunkPattern
)
{
byte[] actual = new byte[totalLength];
int offset = 0;
int chunkIndex = 0;
while (offset < totalLength)
{
int requested = Math.Min(
chunkPattern[chunkIndex % chunkPattern.Length],
totalLength - offset
);
int bytesRead = read(actual, offset, requested);
Assert.True(bytesRead > 0);
offset += bytesRead;
chunkIndex++;
}
return actual;
}
private static async Task<byte[]> ReadWithChunkPatternAsync(
Func<byte[], int, int, Task<int>> readAsync,
int totalLength,
int[] chunkPattern
)
{
byte[] actual = new byte[totalLength];
int offset = 0;
int chunkIndex = 0;
while (offset < totalLength)
{
int requested = Math.Min(
chunkPattern[chunkIndex % chunkPattern.Length],
totalLength - offset
);
int bytesRead = await readAsync(actual, offset, requested);
Assert.True(bytesRead > 0);
offset += bytesRead;
chunkIndex++;
}
return actual;
}
private static async Task<byte[]> ReadWithChunkPatternMemoryAsync(
Stream stream,
int totalLength,
int[] chunkPattern
)
{
byte[] actual = new byte[totalLength];
int offset = 0;
int chunkIndex = 0;
while (offset < totalLength)
{
int requested = Math.Min(
chunkPattern[chunkIndex % chunkPattern.Length],
totalLength - offset
);
#if NET48
int bytesRead = await stream.ReadAsync(actual, offset, requested);
#else
int bytesRead = await stream.ReadAsync(actual.AsMemory(offset, requested));
#endif
Assert.True(bytesRead > 0);
offset += bytesRead;
chunkIndex++;
}
return actual;
}
}

View File

@@ -16,10 +16,6 @@ public class TestBase : IAsyncDisposable
public static readonly string TEST_ARCHIVES_PATH;
public static readonly string ORIGINAL_FILES_PATH;
public static readonly string MISC_TEST_FILES_PATH;
private static readonly string SCRATCH_BASE_PATH;
private static readonly string SCRATCH_DIRECTORY;
private static readonly string SCRATCH2_DIRECTORY;
static TestBase()
{
@@ -33,23 +29,18 @@ public class TestBase : IAsyncDisposable
TEST_ARCHIVES_PATH = Path.Combine(SOLUTION_BASE_PATH, "TestArchives", "Archives");
ORIGINAL_FILES_PATH = Path.Combine(SOLUTION_BASE_PATH, "TestArchives", "Original");
MISC_TEST_FILES_PATH = Path.Combine(SOLUTION_BASE_PATH, "TestArchives", "MiscTest");
SCRATCH_BASE_PATH = Path.Combine(SOLUTION_BASE_PATH, "TestArchives");
SCRATCH_DIRECTORY = Path.Combine(SCRATCH_BASE_PATH, "Scratch");
SCRATCH2_DIRECTORY = Path.Combine(SCRATCH_BASE_PATH, "Scratch2");
Directory.CreateDirectory(SCRATCH_DIRECTORY);
Directory.CreateDirectory(SCRATCH2_DIRECTORY);
}
private readonly Guid _testGuid = Guid.NewGuid();
private readonly string _testTempDirectory;
protected readonly string SCRATCH_FILES_PATH;
protected readonly string SCRATCH2_FILES_PATH;
protected TestBase()
{
SCRATCH_FILES_PATH = Path.Combine(SCRATCH_DIRECTORY, _testGuid.ToString());
SCRATCH2_FILES_PATH = Path.Combine(SCRATCH2_DIRECTORY, _testGuid.ToString());
_testTempDirectory = Path.Combine(Path.GetTempPath(), $"SharpCompress.Test.{_testGuid:N}");
SCRATCH_FILES_PATH = Path.Combine(_testTempDirectory, "Scratch");
SCRATCH2_FILES_PATH = Path.Combine(_testTempDirectory, "Scratch2");
Directory.CreateDirectory(SCRATCH_FILES_PATH);
Directory.CreateDirectory(SCRATCH2_FILES_PATH);
@@ -59,22 +50,50 @@ public class TestBase : IAsyncDisposable
public async ValueTask DisposeAsync()
{
await Task.CompletedTask;
Directory.Delete(SCRATCH_FILES_PATH, true);
Directory.Delete(SCRATCH2_FILES_PATH, true);
DeleteScratchDirectory(_testTempDirectory);
}
public void CleanScratch()
{
if (Directory.Exists(SCRATCH_FILES_PATH))
ResetScratchDirectory(SCRATCH_FILES_PATH);
ResetScratchDirectory(SCRATCH2_FILES_PATH);
}
private static void ResetScratchDirectory(string path)
{
if (Directory.Exists(path))
{
Directory.Delete(SCRATCH_FILES_PATH, true);
Directory.Delete(path, true);
}
Directory.CreateDirectory(SCRATCH_FILES_PATH);
if (Directory.Exists(SCRATCH2_FILES_PATH))
Directory.CreateDirectory(path);
}
private static void DeleteScratchDirectory(string path)
{
if (!Directory.Exists(path))
{
Directory.Delete(SCRATCH2_FILES_PATH, true);
return;
}
try
{
Directory.Delete(path, true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
throw new InvalidOperationException(
$"Failed to clean up temp test directory '{path}'.",
ex
);
}
if (Directory.Exists(path))
{
throw new InvalidOperationException(
$"Temp test directory '{path}' was not cleaned up."
);
}
Directory.CreateDirectory(SCRATCH2_FILES_PATH);
}
public void VerifyFiles()

View File

@@ -15,6 +15,8 @@ namespace SharpCompress.Test.Zip;
public class ZipArchiveTests : ArchiveTests
{
private const long GeneratedZip64EntrySize = (long)uint.MaxValue + 1;
public ZipArchiveTests() => UseExtensionInsteadOfNameToVerify = true;
[Fact]
@@ -516,6 +518,53 @@ public class ZipArchiveTests : ArchiveTests
}
}
[Fact]
public void Zip_Zstandard_WinzipAES_Mixed_ArchiveFileRead()
{
using var archive = ZipArchive.OpenArchive(
Path.Combine(TEST_ARCHIVES_PATH, "Zip.zstd.WinzipAES.mixed.zip"),
new ReaderOptions { Password = "test" }
);
VerifyMixedZstandardArchive(archive);
}
[Fact]
public void Zip_Zstandard_WinzipAES_Mixed_ArchiveStreamRead()
{
using var stream = File.OpenRead(
Path.Combine(TEST_ARCHIVES_PATH, "Zip.zstd.WinzipAES.mixed.zip")
);
using var archive = ZipArchive.OpenArchive(stream, new ReaderOptions { Password = "test" });
VerifyMixedZstandardArchive(archive);
}
[Fact(Explicit = true)]
[Trait("zip64", "generated")]
public void Zip_Zip64_GeneratedArchive_StreamIsBoundedToEntryLength()
{
var zipPath = Path.Combine(SCRATCH2_FILES_PATH, "generated.zip64.large.zip");
CreateZip64Archive(zipPath);
using var archive = ZipArchive.OpenArchive(zipPath);
var entries = archive
.Entries.Where(x => !x.IsDirectory)
.OrderByDescending(x => x.Size)
.ToArray();
Assert.Equal(2, entries.Length);
Assert.Equal(GeneratedZip64EntrySize, entries[0].Size);
Assert.Equal(1, entries[1].Size);
using var firstStream = entries[0].OpenEntryStream();
Assert.Equal(entries[0].Size, CountBytes(firstStream));
using var secondStream = entries[1].OpenEntryStream();
Assert.Equal(0x42, secondStream.ReadByte());
Assert.Equal(-1, secondStream.ReadByte());
}
[Fact]
public void Zip_Pkware_CompressionType()
{
@@ -910,4 +959,89 @@ public class ZipArchiveTests : ArchiveTests
entries[0].WriteTo(outStream);
Assert.Equal(0, outStream.Length);
}
private static void VerifyMixedZstandardArchive(IArchive archive)
{
var entries = archive.Entries.Where(x => !x.IsDirectory).ToArray();
Assert.Equal(4, entries.Length);
Assert.Equal(2, entries.Count(x => x.IsEncrypted));
Assert.Equal(
[".signature", "encrypted-zstd-entry.bin", "plain-zstd-entry.bin", "tables.db"],
entries.Select(x => x.Key.NotNull()).OrderBy(x => x).ToArray()
);
Assert.All(
entries,
entry => Assert.Equal(CompressionType.ZStandard, entry.CompressionType)
);
var expectedSizes = new long[] { 160, 64 * 1024, 64 * 1024, 192 * 1024 };
Assert.Equal(expectedSizes, entries.Select(x => x.Size).OrderBy(x => x).ToArray());
foreach (var entry in entries)
{
using var entryStream = entry.OpenEntryStream();
using var target = new MemoryStream();
entryStream.CopyTo(target);
Assert.Equal(entry.Size, target.Length);
}
}
private static long CountBytes(Stream stream)
{
var buffer = new byte[8 * 1024 * 1024];
long total = 0;
int read;
while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
{
total += read;
}
return total;
}
private static void CreateZip64Archive(string path)
{
if (File.Exists(path))
{
File.Delete(path);
}
var writerOptions = new ZipWriterOptions(CompressionType.None) { UseZip64 = true };
using var fileStream = File.OpenWrite(path);
using var zipWriter = (ZipWriter)
WriterFactory.OpenWriter(fileStream, ArchiveType.Zip, writerOptions);
using (
var largeEntryStream = zipWriter.WriteToStream(
"large-entry.bin",
new ZipWriterEntryOptions()
)
)
{
WriteZeroes(largeEntryStream, GeneratedZip64EntrySize);
}
using (
var trailingEntryStream = zipWriter.WriteToStream(
"trailing-entry.bin",
new ZipWriterEntryOptions()
)
)
{
trailingEntryStream.WriteByte(0x42);
}
}
private static void WriteZeroes(Stream stream, long length)
{
byte[] buffer = new byte[8 * 1024 * 1024];
long remaining = length;
while (remaining > 0)
{
int chunk = (int)Math.Min(buffer.Length, remaining);
stream.Write(buffer, 0, chunk);
remaining -= chunk;
}
}
}

View File

@@ -0,0 +1,59 @@
using System.IO;
using SharpCompress.Common;
using SharpCompress.Common.Zip;
using SharpCompress.Common.Zip.Headers;
using SharpCompress.IO;
using SharpCompress.Providers;
using Xunit;
namespace SharpCompress.Test.Zip;
public class ZipFilePartTests
{
[Fact]
public void GetCryptoStream_Bounds_Known_Size_Zip64_Entries()
{
var header = new DirectoryEntryHeader(new ArchiveEncoding())
{
Name = "entry.bin",
CompressionMethod = ZipCompressionMethod.None,
CompressedSize = uint.MaxValue,
UncompressedSize = uint.MaxValue,
};
using var backingStream = new MemoryStream([1, 2, 3, 4, 5], writable: false);
var part = new TestZipFilePart(header, backingStream);
using var cryptoStream = part.OpenCryptoStream();
Assert.IsType<ReadOnlySubStream>(cryptoStream);
}
[Fact]
public void GetCryptoStream_Leaves_DataDescriptor_Entries_Unbounded_When_Size_Is_Unknown()
{
var header = new DirectoryEntryHeader(new ArchiveEncoding())
{
Name = "entry.bin",
CompressionMethod = ZipCompressionMethod.None,
CompressedSize = 0,
UncompressedSize = 0,
Flags = HeaderFlags.UsePostDataDescriptor,
};
using var backingStream = new MemoryStream([1, 2, 3, 4, 5], writable: false);
var part = new TestZipFilePart(header, backingStream);
using var cryptoStream = part.OpenCryptoStream();
Assert.IsNotType<ReadOnlySubStream>(cryptoStream);
}
private sealed class TestZipFilePart(ZipFileEntry header, Stream stream)
: ZipFilePart(header, stream, CompressionProviderRegistry.Default)
{
public Stream OpenCryptoStream() => GetCryptoStream(CreateBaseStream());
protected override Stream CreateBaseStream() => BaseStream;
}
}