From 527c6983106dbe18ff62755dc025428d67605d8b Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 20 Oct 2025 15:38:32 +0200 Subject: [PATCH 01/39] working zipcrypto, hardcoded password --- .../tests/ZipFile.Extract.cs | 44 ++++++ .../src/System.IO.Compression.csproj | 27 ++-- .../System/IO/Compression/ZipArchiveEntry.cs | 44 +++++- .../Compression/ZipCryptoDecryptionStream.cs | 143 ++++++++++++++++++ 4 files changed, 239 insertions(+), 19 deletions(-) create mode 100644 src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 280192ac05bdf7..acdf163535b50a 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; +using System.Linq; using System.Text; using System.Threading.Tasks; using Xunit; @@ -209,5 +210,48 @@ public async Task DirectoryEntryWithData(bool async) await DisposeZipArchive(async, archive); await Assert.ThrowsAsync(() => CallZipFileExtractToDirectory(async, archivePath, GetTestFilePath())); } + + + [Fact] + public void OpenEncryptedTxtFile_ShouldReturnPlaintext() + { + string zipPath = @"C:\Users\spahontu\Downloads\test.zip"; + using var archive = ZipFile.OpenRead(zipPath); + + var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt")); + using var stream = entry.Open(); + using var reader = new StreamReader(stream); + string content = reader.ReadToEnd(); + + Assert.Equal("Hello ZipCrypto!", content); + } + + [Fact] + public void OpenEncryptedJpeg_ShouldDecryptAndMatchOriginal() + { + // Arrange + string zipPath = @"C:\Users\spahontu\Downloads\jpg.zip"; + string originalPath = @"C:\Users\spahontu\Downloads\test.jpg"; // original JPEG for comparison + Assert.True(File.Exists(zipPath), $"Encrypted ZIP not found at {zipPath}"); + Assert.True(File.Exists(originalPath), $"Original JPEG not found at {originalPath}"); + + using var archive = ZipFile.OpenRead(zipPath); + var entry = archive.Entries.First(e => e.FullName.EndsWith("test.jpg", StringComparison.OrdinalIgnoreCase)); + + // Act: open decrypted + decompressed stream + using var stream = entry.Open(); + + // Read all bytes + using var ms = new MemoryStream(); + stream.CopyTo(ms); + byte[] actualBytes = ms.ToArray(); + + // Optional: compare with original file + byte[] expectedBytes = File.ReadAllBytes(originalPath); + Assert.Equal(expectedBytes.Length, actualBytes.Length); + Assert.Equal(expectedBytes, actualBytes); + } + + } } diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index 7510c1c1052274..829b36c96ffc0e 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -41,12 +41,9 @@ - - - + + + @@ -54,28 +51,26 @@ - + - + - - - + + + + + + diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index cb7a104ca03ac2..2da00b8253f56c 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -741,16 +741,54 @@ private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool return checkSumStream; } + private byte CalculateZipCryptoCheckByte() + { + const ushort DataDescriptorFlag = 0x0008; // GPBF bit 3 + + // If data descriptor NOT used, the check byte is the MSB of CRC32 + if (((ushort)_generalPurposeBitFlag & DataDescriptorFlag) == 0) + return (byte)((_crc32 >> 24) & 0xFF); + + // If data descriptor IS used, the check byte is the MSB of the DOS time from the *local* header + return (byte)((ZipHelper.DateTimeToDosTime(_lastModified.DateTime) >> 8) & 0xFF); + } + + private bool IsZipCryptoEncrypted() + { + const ushort EncryptionFlag = 0x0001; + return ((ushort)_generalPurposeBitFlag & EncryptionFlag) != 0; // && !UsesAes(); + } + + // TODO: Change based on specs + // private static bool UsesAes() => false; + private Stream GetDataDecompressor(Stream compressedStreamToRead) { + + Stream toDecompress = compressedStreamToRead; + if (IsZipCryptoEncrypted()) + { + // if (UsesAes()) for future + // throw new NotSupportedException("AES-encrypted ZIP entries are not supported yet."); + + ReadOnlySpan password = "123456789"; + if (password.IsEmpty) + throw new InvalidDataException("Password required for encrypted ZIP entry."); + + byte expectedCheckByte = CalculateZipCryptoCheckByte(); + + // This stream will read & validate the 12-byte header and then yield plaintext compressed bytes. + toDecompress = new ZipCryptoDecryptionStream(toDecompress, password, expectedCheckByte); + } + Stream? uncompressedStream; switch (CompressionMethod) { case CompressionMethodValues.Deflate: - uncompressedStream = new DeflateStream(compressedStreamToRead, CompressionMode.Decompress, _uncompressedSize); + uncompressedStream = new DeflateStream(toDecompress, CompressionMode.Decompress, _uncompressedSize); break; case CompressionMethodValues.Deflate64: - uncompressedStream = new DeflateManagedStream(compressedStreamToRead, CompressionMethodValues.Deflate64, _uncompressedSize); + uncompressedStream = new DeflateManagedStream(toDecompress, CompressionMethodValues.Deflate64, _uncompressedSize); break; case CompressionMethodValues.Stored: default: @@ -758,7 +796,7 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead) // IsOpenable is checked before this function is called Debug.Assert(CompressionMethod == CompressionMethodValues.Stored); - uncompressedStream = compressedStreamToRead; + uncompressedStream = toDecompress; break; } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs new file mode 100644 index 00000000000000..fa34517fb2e594 --- /dev/null +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs @@ -0,0 +1,143 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.IO.Compression +{ + // Internal read-only, non-seekable stream that: + // - Initializes ZipCrypto keys from the password + // - Reads & decrypts the 12-byte header and validates the check byte + // - Decrypts subsequent bytes on Read(...) + internal sealed class ZipCryptoDecryptionStream : Stream + { + private readonly Stream _base; + private uint _key0; + private uint _key1; + private uint _key2; + private static readonly uint[] crc2Table = CreateCrc32Table(); + + private static uint[] CreateCrc32Table() { + + var table = new uint[256]; + for (uint i = 0; i < 256; i++) + { + uint c = i; + for (int j = 0; j < 8; j++) + c = (c & 1) != 0 ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); + table[i] = c; + } + return table; + + } + + public ZipCryptoDecryptionStream(Stream baseStream, ReadOnlySpan password, byte expectedCheckByte) + { + _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); + InitKeys(password); + ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes + } + + private void InitKeys(ReadOnlySpan password) + { + _key0 = 305419896; + _key1 = 591751049; + _key2 = 878082192; + + foreach (char ch in password) + { + UpdateKeys((byte)ch); + } + } + + private void ValidateHeader(byte expectedCheckByte) + { + byte[] hdr = new byte[12]; + int read = 0; + while (read < hdr.Length) + { + int n = _base.Read(hdr.AsSpan(read)); + if (n <= 0) throw new InvalidDataException("Truncated ZipCrypto header."); + read += n; + } + + for (int i = 0; i < hdr.Length; i++) + { + hdr[i] = DecryptByte(hdr[i]); + } + + if (hdr[11] != expectedCheckByte) + throw new InvalidDataException("Invalid password for encrypted ZIP entry."); + } + + private void UpdateKeys(byte b) + { + _key0 = Crc32Update(_key0, b); + _key1 += (_key0 & 0xFF); + _key1 = _key1 * 134775813 + 1; + _key2 = Crc32Update(_key2, (byte)(_key1 >> 24)); + } + + private byte DecipherByte() + { + ushort temp = (ushort)(_key2 | 2); + return (byte)((temp * (temp ^ 1)) >> 8); + } + + private byte DecryptByte(byte ciph) + { + byte m = DecipherByte(); + byte plain = (byte)(ciph ^ m); + UpdateKeys(plain); + return plain; + } + + // ---- Stream overrides ---- + + 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) + { + ArgumentNullException.ThrowIfNull(buffer); + if ((uint)offset > buffer.Length || (uint)count > buffer.Length - offset) + throw new ArgumentOutOfRangeException(); + + int n = _base.Read(buffer, offset, count); + for (int i = 0; i < n; i++) + { + buffer[offset + i] = DecryptByte(buffer[offset + i]); + } + return n; + } + + public override int Read(Span destination) + { + int n = _base.Read(destination); + for (int i = 0; i < n; i++) + { + destination[i] = DecryptByte(destination[i]); + } + return n; + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override void Write(ReadOnlySpan buffer) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) _base.Dispose(); + base.Dispose(disposing); + } + + // TODO: replace with the runtime's internal CRC32 update routine (fast table-based). + private static uint Crc32Update(uint crc, byte b) + { + return crc2Table[(crc ^ b) & 0xFF] ^ (crc >> 8); + } + } +} From 255642bfafcde5138d93437831f6ab4667285554 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 21 Oct 2025 11:02:51 +0200 Subject: [PATCH 02/39] add tests with multiple entries with same password and entries with password and unprotected --- .../tests/ZipFile.Extract.cs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index acdf163535b50a..685b1aa778a95f 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -253,5 +253,85 @@ public void OpenEncryptedJpeg_ShouldDecryptAndMatchOriginal() } + [Fact] + public void OpenEncryptedArchive_WithMultipleEntries_ShouldDecryptBoth() + { + // Arrange + string zipPath = @"C:\Users\spahontu\Downloads\combined.zip"; + string originalJpgPath = @"C:\Users\spahontu\Downloads\test.jpg"; + Assert.True(File.Exists(zipPath), $"Encrypted ZIP not found at {zipPath}"); + Assert.True(File.Exists(originalJpgPath), $"Original JPEG not found at {originalJpgPath}"); + + // Open archive with password + using var archive = ZipFile.OpenRead(zipPath); + + // 1) Validate hello.txt + var txtEntry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); + using (var txtStream = txtEntry.Open()) + using (var reader = new StreamReader(txtStream)) + { + string content = reader.ReadToEnd(); + Assert.Equal("Hello ZipCrypto!", content); + } + + // 2) Validate test.jpg + var jpgEntry = archive.Entries.First(e => e.FullName.EndsWith("test.jpg", StringComparison.OrdinalIgnoreCase)); + using (var jpgStream = jpgEntry.Open()) + using (var ms = new MemoryStream()) + { + jpgStream.CopyTo(ms); + byte[] actualBytes = ms.ToArray(); + + // Quick sanity: JPEG SOI marker + Assert.True(actualBytes.Length > 3, "JPEG too small"); + Assert.Equal(new byte[] { 0xFF, 0xD8, 0xFF }, actualBytes.Take(3).ToArray()); + + // Full comparison with original file + byte[] expectedBytes = File.ReadAllBytes(originalJpgPath); + Assert.Equal(expectedBytes.Length, actualBytes.Length); + Assert.Equal(expectedBytes, actualBytes); + } + } + + [Fact] + public void OpenEncryptedArchive_WithMultipleEntries_DifferentPassword_ShouldDecryptBoth() + { + // Arrange + string zipPath = @"C:\Users\spahontu\Downloads\combinedpass.zip"; + string originalJpgPath = @"C:\Users\spahontu\Downloads\test.jpg"; + Assert.True(File.Exists(zipPath), $"Encrypted ZIP not found at {zipPath}"); + Assert.True(File.Exists(originalJpgPath), $"Original JPEG not found at {originalJpgPath}"); + + // Open archive with password + using var archive = ZipFile.OpenRead(zipPath); + + // 1) Validate hello.txt + var txtEntry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); + using (var txtStream = txtEntry.Open()) + using (var reader = new StreamReader(txtStream)) + { + string content = reader.ReadToEnd(); + Assert.Equal("Hello ZipCrypto!", content); + } + + // 2) Validate test.jpg + var jpgEntry = archive.Entries.First(e => e.FullName.EndsWith("test.jpg", StringComparison.OrdinalIgnoreCase)); + using (var jpgStream = jpgEntry.Open()) + using (var ms = new MemoryStream()) + { + jpgStream.CopyTo(ms); + byte[] actualBytes = ms.ToArray(); + + // Quick sanity: JPEG SOI marker + Assert.True(actualBytes.Length > 3, "JPEG too small"); + Assert.Equal(new byte[] { 0xFF, 0xD8, 0xFF }, actualBytes.Take(3).ToArray()); + + // Full comparison with original file + byte[] expectedBytes = File.ReadAllBytes(originalJpgPath); + Assert.Equal(expectedBytes.Length, actualBytes.Length); + Assert.Equal(expectedBytes, actualBytes); + } + } + } } From 968bb60525c615062748276ca28228a1219a3d73 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 21 Oct 2025 21:19:48 +0200 Subject: [PATCH 03/39] reading/extracting experimentation and tests --- .../IO/Compression/ZipTestHelper.ZipFile.cs | 4 +- .../System/IO/Compression/ZipTestHelper.cs | 2 +- .../ref/System.IO.Compression.ZipFile.cs | 4 + ...xtensions.ZipArchiveEntry.Extract.Async.cs | 26 +++ ...pFileExtensions.ZipArchiveEntry.Extract.cs | 23 +++ .../tests/ZipFile.Extract.cs | 159 +++++++++++++++++- .../ref/System.IO.Compression.cs | 2 + .../IO/Compression/ZipArchiveEntry.Async.cs | 22 ++- .../System/IO/Compression/ZipArchiveEntry.cs | 52 +++++- .../Compression/ZipCryptoDecryptionStream.cs | 4 +- 10 files changed, 280 insertions(+), 18 deletions(-) diff --git a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.ZipFile.cs b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.ZipFile.cs index f5e1db167b95b1..f6aa5ee7b095fe 100644 --- a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.ZipFile.cs +++ b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.ZipFile.cs @@ -48,7 +48,7 @@ protected Task CallExtractToFile(bool async, ZipArchiveEntry entry, string desti { if (async) { - return entry.ExtractToFileAsync(destinationFileName, overwrite: false); + return entry.ExtractToFileAsync(destinationFileName, overwrite: false, cancellationToken: default); } else { @@ -61,7 +61,7 @@ protected Task CallExtractToFile(bool async, ZipArchiveEntry entry, string desti { if (async) { - return entry.ExtractToFileAsync(destinationFileName, overwrite); + return entry.ExtractToFileAsync(destinationFileName, overwrite, cancellationToken: default); } else { diff --git a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs index a3ccd52901601e..57782feb62b8e2 100644 --- a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs +++ b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs @@ -559,7 +559,7 @@ public static async Task DisposeZipArchive(bool async, ZipArchive archive) public static async Task OpenEntryStream(bool async, ZipArchiveEntry entry) { - return async ? await entry.OpenAsync() : entry.Open(); + return async ? await entry.OpenAsync(cancellationToken: default) : entry.Open(); } public static async Task DisposeStream(bool async, Stream stream) diff --git a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs index f0948420b9eaf9..8e705b98936904 100644 --- a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs +++ b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs @@ -56,7 +56,11 @@ public static void ExtractToDirectory(this System.IO.Compression.ZipArchive sour public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName) { } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite) { } + public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, string password) { } + public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, string password) { } public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken), string password = "") { throw null; } public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken), string password = "") { throw null; } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs index 188f1542af3834..c4c571b1ca9837 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs @@ -86,6 +86,32 @@ public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string ExtractToFileFinalize(source, destinationFileName); } + public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, CancellationToken cancellationToken = default, string password = "") => + await ExtractToFileAsync(source, destinationFileName, false, cancellationToken, password).ConfigureAwait(false); + + public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, bool overwrite, CancellationToken cancellationToken = default, string password = "") + { + cancellationToken.ThrowIfCancellationRequested(); + + ExtractToFileInitialize(source, destinationFileName, overwrite, out FileStreamOptions fileStreamOptions); + + FileStream fs = new FileStream(destinationFileName, fileStreamOptions); + await using (fs) + { + Stream es; + if (password.Length > 1) + es = await source.OpenAsync(cancellationToken, password).ConfigureAwait(false); + else + es = await source.OpenAsync(cancellationToken).ConfigureAwait(false); + await using (es) + { + await es.CopyToAsync(fs, cancellationToken).ConfigureAwait(false); + } + } + + ExtractToFileFinalize(source, destinationFileName); + } + internal static async Task ExtractRelativeToDirectoryAsync(this ZipArchiveEntry source, string destinationDirectoryName, bool overwrite, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs index cbd0ebd901ba2d..fb00173c8237f8 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs @@ -73,6 +73,29 @@ public static void ExtractToFile(this ZipArchiveEntry source, string destination ExtractToFileFinalize(source, destinationFileName); } + + public static void ExtractToFile(this ZipArchiveEntry source, string destinationFileName, string password) => + ExtractToFile(source, destinationFileName, false, password); + + public static void ExtractToFile(this ZipArchiveEntry source, string destinationFileName, bool overwrite, string password) + { + ExtractToFileInitialize(source, destinationFileName, overwrite, out FileStreamOptions fileStreamOptions); + + using (FileStream fs = new FileStream(destinationFileName, fileStreamOptions)) + { + if (password.Length > 0) + { + using (Stream es = source.Open(password)) + es.CopyTo(fs); + } + else + using (Stream es = source.Open()) + es.CopyTo(fs); + } + + ExtractToFileFinalize(source, destinationFileName); + } + private static void ExtractToFileInitialize(ZipArchiveEntry source, string destinationFileName, bool overwrite, out FileStreamOptions fileStreamOptions) { ArgumentNullException.ThrowIfNull(source); diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 685b1aa778a95f..49614b6bdadcdc 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -4,8 +4,10 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; using Xunit; +using Xunit.Sdk; namespace System.IO.Compression.Tests { @@ -219,13 +221,160 @@ public void OpenEncryptedTxtFile_ShouldReturnPlaintext() using var archive = ZipFile.OpenRead(zipPath); var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt")); - using var stream = entry.Open(); + using var stream = entry.Open("123456789"); using var reader = new StreamReader(stream); string content = reader.ReadToEnd(); Assert.Equal("Hello ZipCrypto!", content); } + + [Fact] + public void ExtractEncryptedEntryToFile_ShouldCreatePlaintextFile() + { + + + string ZipPath = @"C:\Users\spahontu\Downloads\test.zip"; + string EntryName = "hello.txt"; + string CorrectPassword = "123456789"; + + string tempFile = Path.Combine(Path.GetTempPath(), "hello_extracted.txt"); + if (File.Exists(tempFile)) File.Delete(tempFile); + + using var archive = ZipFile.OpenRead(ZipPath); + var entry = archive.Entries.First(e => e.FullName.EndsWith(EntryName, StringComparison.OrdinalIgnoreCase)); + + // Act: Extract using password + entry.ExtractToFile(tempFile, overwrite: true, password: CorrectPassword); + + // Assert: File exists and content matches expected plaintext + Assert.True(File.Exists(tempFile), "Extracted file was not created."); + string content = File.ReadAllText(tempFile); + Assert.Equal("Hello ZipCrypto!", content); + + // Cleanup + File.Delete(tempFile); + } + + [Fact] + public void ExtractEncryptedEntryToFile_WithWrongPassword_ShouldThrow() + { + string ZipPath = @"C:\Users\spahontu\Downloads\test.zip"; + string EntryName = "hello.txt"; + ReadOnlyMemory CorrectPassword = "123456789".AsMemory(); + + string tempFile = Path.Combine(Path.GetTempPath(), "hello_extracted.txt"); + if (File.Exists(tempFile)) File.Delete(tempFile); + + using var archive = ZipFile.OpenRead(ZipPath); + var entry = archive.Entries.First(e => e.FullName.EndsWith(EntryName, StringComparison.OrdinalIgnoreCase)); + + Assert.Throws(() => + { + entry.ExtractToFile(tempFile, overwrite: true, password: "wrongpass"); + }); + } + + [Fact] + public void ExtractEncryptedEntryToFile_WithoutPassword_ShouldThrow() + { + string ZipPath = @"C:\Users\spahontu\Downloads\test.zip"; + string EntryName = "hello.txt"; + ReadOnlyMemory CorrectPassword = "123456789".AsMemory(); + + string tempFile = Path.Combine(Path.GetTempPath(), "hello_extracted.txt"); + if (File.Exists(tempFile)) File.Delete(tempFile); + + using var archive = ZipFile.OpenRead(ZipPath); + var entry = archive.Entries.First(e => e.FullName.EndsWith(EntryName, StringComparison.OrdinalIgnoreCase)); + + Assert.Throws(() => + { + entry.ExtractToFile(tempFile, overwrite: true); // No password passed + }); + } + + + [Fact] + public async Task ExtractToFileAsync_WithPassword_ShouldCreatePlaintextFile() + { + string zipPath = @"C:\Users\spahontu\Downloads\test.zip"; + Assert.True(File.Exists(zipPath), $"Test ZIP not found at {zipPath}"); + + string tempFile = Path.Combine(Path.GetTempPath(), "hello_async.txt"); + if (File.Exists(tempFile)) File.Delete(tempFile); + + using var archive = ZipFile.OpenRead(zipPath); + var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); + + await entry.ExtractToFileAsync(tempFile, overwrite: true, password: "123456789"); + + Assert.True(File.Exists(tempFile), "Extracted file was not created."); + string content = await File.ReadAllTextAsync(tempFile); + Assert.Equal("Hello ZipCrypto!", content); + + File.Delete(tempFile); + } + + [Fact] + public async Task ExtractToFileAsync_WithWrongPassword_ShouldThrow() + { + string zipPath = @"C:\Users\spahontu\Downloads\test.zip"; + Assert.True(File.Exists(zipPath), $"Test ZIP not found at {zipPath}"); + + string tempFile = Path.Combine(Path.GetTempPath(), "hello_async.txt"); + if (File.Exists(tempFile)) File.Delete(tempFile); + + using var archive = ZipFile.OpenRead(zipPath); + var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); + + await Assert.ThrowsAsync(async () => + { + await entry.ExtractToFileAsync(tempFile, overwrite: true, password: "wrongpass"); + }); + } + + + [Fact] + public async Task ExtractToFileAsync_WithoutPassword_ShouldThrow() + { + string zipPath = @"C:\Users\spahontu\Downloads\test.zip"; + Assert.True(File.Exists(zipPath), $"Test ZIP not found at {zipPath}"); + + string tempFile = Path.Combine(Path.GetTempPath(), "hello_async.txt"); + if (File.Exists(tempFile)) File.Delete(tempFile); + + using var archive = ZipFile.OpenRead(zipPath); + var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); + + await Assert.ThrowsAsync(async () => + { + await entry.ExtractToFileAsync(tempFile, overwrite: true, cancellationToken: default); // No password passed + }); + } + + + + [Fact] + public async Task ExtractToFileAsync_WithCancellation_ShouldCancel() + { + string zipPath = @"C:\Users\spahontu\Downloads\test.zip"; + Assert.True(File.Exists(zipPath), $"Test ZIP not found at {zipPath}"); + + string tempFile = Path.Combine(Path.GetTempPath(), "hello_async_cancel.txt"); + if (File.Exists(tempFile)) File.Delete(tempFile); + + using var archive = ZipFile.OpenRead(zipPath); + var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); // Cancel immediately + await Assert.ThrowsAsync(async () => + { + await entry.ExtractToFileAsync(tempFile, overwrite: true, cts.Token, password: "123456789"); + }); + } + [Fact] public void OpenEncryptedJpeg_ShouldDecryptAndMatchOriginal() { @@ -239,7 +388,7 @@ public void OpenEncryptedJpeg_ShouldDecryptAndMatchOriginal() var entry = archive.Entries.First(e => e.FullName.EndsWith("test.jpg", StringComparison.OrdinalIgnoreCase)); // Act: open decrypted + decompressed stream - using var stream = entry.Open(); + using var stream = entry.Open("123456789"); // Read all bytes using var ms = new MemoryStream(); @@ -267,7 +416,7 @@ public void OpenEncryptedArchive_WithMultipleEntries_ShouldDecryptBoth() // 1) Validate hello.txt var txtEntry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); - using (var txtStream = txtEntry.Open()) + using (var txtStream = txtEntry.Open("123456789")) using (var reader = new StreamReader(txtStream)) { string content = reader.ReadToEnd(); @@ -276,7 +425,7 @@ public void OpenEncryptedArchive_WithMultipleEntries_ShouldDecryptBoth() // 2) Validate test.jpg var jpgEntry = archive.Entries.First(e => e.FullName.EndsWith("test.jpg", StringComparison.OrdinalIgnoreCase)); - using (var jpgStream = jpgEntry.Open()) + using (var jpgStream = jpgEntry.Open("123456789")) using (var ms = new MemoryStream()) { jpgStream.CopyTo(ms); @@ -316,7 +465,7 @@ public void OpenEncryptedArchive_WithMultipleEntries_DifferentPassword_ShouldDec // 2) Validate test.jpg var jpgEntry = archive.Entries.First(e => e.FullName.EndsWith("test.jpg", StringComparison.OrdinalIgnoreCase)); - using (var jpgStream = jpgEntry.Open()) + using (var jpgStream = jpgEntry.Open("123456789")) using (var ms = new MemoryStream()) { jpgStream.CopyTo(ms); diff --git a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs index e0e0bd0eda52f2..cb68b33aa6ca0e 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -126,7 +126,9 @@ internal ZipArchiveEntry() { } public string Name { get { throw null; } } public void Delete() { } public System.IO.Stream Open() { throw null; } + public System.IO.Stream Open(string password) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken), string password = "") { throw null; } public override string ToString() { throw null; } } public enum ZipArchiveMode diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 030f2d1e25f635..3d77fbff6075d7 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -37,6 +37,24 @@ public async Task OpenAsync(CancellationToken cancellationToken = defaul } } + public async Task OpenAsync(CancellationToken cancellationToken = default, string password = "") + { + cancellationToken.ThrowIfCancellationRequested(); + ThrowIfInvalidArchive(); + + switch (_archive.Mode) + { + case ZipArchiveMode.Read: + return await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password.AsMemory()).ConfigureAwait(false); + case ZipArchiveMode.Create: + return OpenInWriteMode(); + case ZipArchiveMode.Update: + default: + Debug.Assert(_archive.Mode == ZipArchiveMode.Update); + return await OpenInUpdateModeAsync(cancellationToken).ConfigureAwait(false); + } + } + internal async Task GetOffsetOfCompressedDataAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -193,14 +211,14 @@ internal async Task ThrowIfNotOpenableAsync(bool needToUncompress, bool needToLo throw new InvalidDataException(message); } - private async Task OpenInReadModeAsync(bool checkOpenable, CancellationToken cancellationToken) + private async Task OpenInReadModeAsync(bool checkOpenable, CancellationToken cancellationToken, ReadOnlyMemory password = default) { cancellationToken.ThrowIfCancellationRequested(); if (checkOpenable) await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: false, cancellationToken).ConfigureAwait(false); return OpenInReadModeGetDataCompressor( - await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false)); + await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false), password); } private async Task OpenInUpdateModeAsync(CancellationToken cancellationToken) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index 2da00b8253f56c..a4000ca2233cf9 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -47,6 +47,7 @@ public partial class ZipArchiveEntry private byte[]? _lhTrailingExtraFieldData; private byte[] _fileComment; private readonly CompressionLevel _compressionLevel; + //private ReadOnlyMemory _password; // Initializes a ZipArchiveEntry instance for an existing archive entry. internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) @@ -159,6 +160,22 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName) Changes = ZipArchive.ChangeState.Unchanged; } + ///// + ///// Gets the password as a read-only memory block of characters. + ///// + ///// The password is stored in a read-only memory block to enhance security by minimizing + ///// exposure in memory. + //internal ReadOnlyMemory Password { + + // get + // { + // return _password; + // } + // set + // { + // _password = value; + // } + //} /// /// The ZipArchive that this entry belongs to. If this entry has been deleted, this will return null. @@ -362,6 +379,30 @@ public Stream Open() } } + + /// + /// Opens the entry. If the archive that the entry belongs to was opened in Read mode, the returned stream will be readable, and it may or may not be seekable. If Create mode, the returned stream will be writable and not seekable. If Update mode, the returned stream will be readable, writable, seekable, and support SetLength. + /// + /// A Stream that represents the contents of the entry. + /// The entry is already currently open for writing. -or- The entry has been deleted from the archive. -or- The archive that this entry belongs to was opened in ZipArchiveMode.Create, and this entry has already been written to once. + /// The entry is missing from the archive or is corrupt and cannot be read. -or- The entry has been compressed using a compression method that is not supported. + /// The ZipArchive that this entry belongs to has been disposed. + public Stream Open(string password) + { + ThrowIfInvalidArchive(); + switch (_archive.Mode) + { + case ZipArchiveMode.Read: + return OpenInReadMode(checkOpenable: true, password.AsMemory()); + case ZipArchiveMode.Create: + return OpenInWriteMode(); + case ZipArchiveMode.Update: + default: + Debug.Assert(_archive.Mode == ZipArchiveMode.Update); + return OpenInUpdateMode(); + } + } + /// /// Returns the FullName of the entry. /// @@ -762,7 +803,7 @@ private bool IsZipCryptoEncrypted() // TODO: Change based on specs // private static bool UsesAes() => false; - private Stream GetDataDecompressor(Stream compressedStreamToRead) + private Stream GetDataDecompressor(Stream compressedStreamToRead, ReadOnlyMemory password = default) { Stream toDecompress = compressedStreamToRead; @@ -771,7 +812,6 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead) // if (UsesAes()) for future // throw new NotSupportedException("AES-encrypted ZIP entries are not supported yet."); - ReadOnlySpan password = "123456789"; if (password.IsEmpty) throw new InvalidDataException("Password required for encrypted ZIP entry."); @@ -803,17 +843,17 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead) return uncompressedStream; } - private Stream OpenInReadMode(bool checkOpenable) + private Stream OpenInReadMode(bool checkOpenable, ReadOnlyMemory password = default) { if (checkOpenable) ThrowIfNotOpenable(needToUncompress: true, needToLoadIntoMemory: false); - return OpenInReadModeGetDataCompressor(GetOffsetOfCompressedData()); + return OpenInReadModeGetDataCompressor(GetOffsetOfCompressedData(), password); } - private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData) + private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, ReadOnlyMemory password = default) { Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offsetOfCompressedData, _compressedSize); - return GetDataDecompressor(compressedStream); + return GetDataDecompressor(compressedStream, password); } private WrappedStream OpenInWriteMode() diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs index fa34517fb2e594..f5d04128ce40fd 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs @@ -29,10 +29,10 @@ private static uint[] CreateCrc32Table() { } - public ZipCryptoDecryptionStream(Stream baseStream, ReadOnlySpan password, byte expectedCheckByte) + public ZipCryptoDecryptionStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) { _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); - InitKeys(password); + InitKeys(password.Span); ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes } From 19cbc8e28c10363474a9bfe692b8525231df7f01 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 22 Oct 2025 11:26:59 +0200 Subject: [PATCH 04/39] add enum for encryption and write methods for zipcrypto stream --- .../ref/System.IO.Compression.cs | 5 + .../src/System.IO.Compression.csproj | 2 +- .../System/IO/Compression/ZipArchiveEntry.cs | 26 ++--- ...DecryptionStream.cs => ZipCryptoStream.cs} | 97 ++++++++++++++++++- 4 files changed, 108 insertions(+), 22 deletions(-) rename src/libraries/System.IO.Compression/src/System/IO/Compression/{ZipCryptoDecryptionStream.cs => ZipCryptoStream.cs} (59%) diff --git a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs index cb68b33aa6ca0e..821f737d83d027 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -130,6 +130,11 @@ public void Delete() { } public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken), string password = "") { throw null; } public override string ToString() { throw null; } + public enum EncryptionMethod : byte + { + None = (byte)0, + ZipCrypto = (byte)1, + } } public enum ZipArchiveMode { diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index 829b36c96ffc0e..f95ead16d5292c 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -70,7 +70,7 @@ - + diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index a4000ca2233cf9..e894c5a21107c0 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -160,22 +160,6 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName) Changes = ZipArchive.ChangeState.Unchanged; } - ///// - ///// Gets the password as a read-only memory block of characters. - ///// - ///// The password is stored in a read-only memory block to enhance security by minimizing - ///// exposure in memory. - //internal ReadOnlyMemory Password { - - // get - // { - // return _password; - // } - // set - // { - // _password = value; - // } - //} /// /// The ZipArchive that this entry belongs to. If this entry has been deleted, this will return null. @@ -818,7 +802,7 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead, ReadOnlyMemory byte expectedCheckByte = CalculateZipCryptoCheckByte(); // This stream will read & validate the 12-byte header and then yield plaintext compressed bytes. - toDecompress = new ZipCryptoDecryptionStream(toDecompress, password, expectedCheckByte); + toDecompress = new ZipCryptoStream(toDecompress, password, expectedCheckByte); } Stream? uncompressedStream; @@ -1696,6 +1680,14 @@ internal enum BitFlagValues : ushort UnicodeFileNameAndComment = 0x800 } + public enum EncryptionMethod : byte + { + None = 0, + ZipCrypto = 1 + //Aes256 = 4, + } + + internal enum CompressionMethodValues : ushort { Stored = 0x0, diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs similarity index 59% rename from src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs rename to src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index f5d04128ce40fd..b71a2891453035 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoDecryptionStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -7,8 +7,9 @@ namespace System.IO.Compression // - Initializes ZipCrypto keys from the password // - Reads & decrypts the 12-byte header and validates the check byte // - Decrypts subsequent bytes on Read(...) - internal sealed class ZipCryptoDecryptionStream : Stream + internal sealed class ZipCryptoStream : Stream { + private readonly bool _encrypting; private readonly Stream _base; private uint _key0; private uint _key1; @@ -29,13 +30,66 @@ private static uint[] CreateCrc32Table() { } - public ZipCryptoDecryptionStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) + // decryption constructor + public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) { _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); InitKeys(password.Span); ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes } + public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, ushort passwordVerifierLow2Bytes, uint? crc32 = null) + { + _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); + _encrypting = true; + + InitKeys(password.Span); + CreateAndWriteHeader(passwordVerifierLow2Bytes, crc32); + } + + private void CreateAndWriteHeader(ushort verifierLow2Bytes, uint? crc32) + { + byte[] hdrPlain = new byte[12]; + + // 0..9: random + for (int i = 0; i < 10; i++) + hdrPlain[i] = 0; + + + // 10..11: check bytes + if (crc32.HasValue) + { + uint crc = crc32.Value; + hdrPlain[10] = (byte)((crc >> 16) & 0xFF); + hdrPlain[11] = (byte)((crc >> 24) & 0xFF); + } + else + { + // Fallback when CRC32 is not yet known + hdrPlain[10] = (byte)(verifierLow2Bytes & 0xFF); + hdrPlain[11] = (byte)((verifierLow2Bytes >> 8) & 0xFF); + } + + // Encrypt header and write + byte[] hdrCiph = new byte[12]; + for (int i = 0; i < 12; i++) + { + hdrCiph[i] = EncryptByte(hdrPlain[i]); // EncryptByte updates keys with PLAINTEXT + } + + _base.Write(hdrCiph, 0, hdrCiph.Length); + } + + + private byte EncryptByte(byte plain) + { + byte ks = DecipherByte(); + byte ciph = (byte)(plain ^ ks); + UpdateKeys(plain); + return ciph; + } + + private void InitKeys(ReadOnlySpan password) { _key0 = 305419896; @@ -125,8 +179,43 @@ public override int Read(Span destination) public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - public override void Write(ReadOnlySpan buffer) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + { + if (_encrypting) + { + ArgumentNullException.ThrowIfNull(buffer); + if ((uint)offset > (uint)buffer.Length || (uint)count > (uint)(buffer.Length - offset)) + throw new ArgumentOutOfRangeException(); + + // Simple temporary buffer; no ArrayPool, no async + byte[] tmp = new byte[count]; + for (int i = 0; i < count; i++) + { + tmp[i] = EncryptByte(buffer[offset + i]); + } + _base.Write(tmp, 0, count); + return; + } + throw new NotSupportedException("Stream is in decryption (read-only) mode."); + } + + public override void Write(ReadOnlySpan buffer) + { + if (_encrypting) + { + // Simple temporary buffer; no ArrayPool, no async + byte[] tmp = new byte[buffer.Length]; + for (int i = 0; i < buffer.Length; i++) + { + tmp[i] = EncryptByte(buffer[i]); + } + _base.Write(tmp, 0, tmp.Length); + return; + } + throw new NotSupportedException("Stream is in decryption (read-only) mode."); + } + protected override void Dispose(bool disposing) { From 0dec73756974edbae5bf14c4a9ddd12e4ae87f23 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 27 Oct 2025 09:55:49 +0100 Subject: [PATCH 05/39] allow encryption of entries --- .../ZipFileExtensions.ZipArchive.Create.cs | 21 +- .../tests/ZipFile.Extract.cs | 236 +++++++++- .../ref/System.IO.Compression.cs | 2 + .../src/System/IO/Compression/ZipArchive.cs | 38 +- .../System/IO/Compression/ZipArchiveEntry.cs | 276 +++++++++++- .../System/IO/Compression/ZipCryptoStream.cs | 425 ++++++++++++++---- 6 files changed, 880 insertions(+), 118 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs index a523577247fd43..3d9cbc1c180293 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs @@ -78,9 +78,9 @@ public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, DoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel); internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destination, - string sourceFileName, string entryName, CompressionLevel? compressionLevel) + string sourceFileName, string entryName, CompressionLevel? compressionLevel, string? password = null, ZipArchiveEntry.EncryptionMethod encryption = ZipArchiveEntry.EncryptionMethod.None) { - (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: true); + (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: true, password, encryption); using (fs) { @@ -93,12 +93,18 @@ internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destinatio return entry; } - private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(ZipArchive destination, string sourceFileName, string entryName, CompressionLevel? compressionLevel, bool useAsync) + private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(ZipArchive destination, string sourceFileName, string entryName, CompressionLevel? compressionLevel, bool useAsync, + string? password = null, ZipArchiveEntry.EncryptionMethod encryption = ZipArchiveEntry.EncryptionMethod.None) { ArgumentNullException.ThrowIfNull(destination); ArgumentNullException.ThrowIfNull(sourceFileName); ArgumentNullException.ThrowIfNull(entryName); + if (password != null && encryption == ZipArchiveEntry.EncryptionMethod.None) + { + throw new ArgumentException("password and encryption should both be set"); + } + // Checking of compressionLevel is passed down to DeflateStream and the IDeflater implementation // as it is a pluggable component that completely encapsulates the meaning of compressionLevel. @@ -106,9 +112,12 @@ private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(Zip FileStream fs = new FileStream(sourceFileName, FileMode.Open, FileAccess.Read, FileShare.Read, ZipFile.FileStreamBufferSize, useAsync); - ZipArchiveEntry entry = compressionLevel.HasValue ? - destination.CreateEntry(entryName, compressionLevel.Value) : - destination.CreateEntry(entryName); + ZipArchiveEntry entry = password is not null ? (compressionLevel.HasValue + ? destination.CreateEntry(entryName, compressionLevel.Value, password, encryption) + : destination.CreateEntry(entryName, password, encryption)) + : (compressionLevel.HasValue + ? destination.CreateEntry(entryName, compressionLevel.Value) + : destination.CreateEntry(entryName)); DateTime lastWrite = File.GetLastWriteTime(sourceFileName); diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 49614b6bdadcdc..d0f470a8caaa3e 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -13,6 +13,10 @@ namespace System.IO.Compression.Tests { public class ZipFile_Extract : ZipFileTestBase { + + private const string DownloadsDir = @"C:\Users\spahontu\Downloads"; + private static string NewPath(string file) => Path.Combine(DownloadsDir, file); + public static IEnumerable Get_ExtractToDirectoryNormal_Data() { foreach (bool async in _bools) @@ -482,5 +486,235 @@ public void OpenEncryptedArchive_WithMultipleEntries_DifferentPassword_ShouldDec } } + + + [Fact] + public async Task ZipCrypto_CreateEntry_ThenRead_Back_ContentMatches() + { + // Arrange + const string downloadsDir = @"C:\Users\spahontu\Downloads"; + const string zipPath = $@"{downloadsDir}\zipcrypto_test.zip"; + const string entryName = "hello.txt"; + const string password = "P@ssw0rd!"; + const string expectedContent = "hello zipcrypto"; + + // Ensure target directory exists + Directory.CreateDirectory(downloadsDir); + + // Clean up any previous file + if (File.Exists(zipPath)) + File.Delete(zipPath); + + // ACT 1: Create the archive and write one encrypted entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + // Your custom overload that sets per-entry password & ZipCrypto + var entry = za.CreateEntry(entryName, password, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + + using var writer = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + writer.Write(expectedContent); + } + + // ACT 2: Open the archive for reading and read back the content using the password + string actualContent; + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var entry = za.GetEntry(entryName); + Assert.NotNull(entry); + + // Your custom entry decryption API: Open(password) + using var reader = new StreamReader(entry!.Open(password), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + actualContent = await reader.ReadToEndAsync(); + } + + // Assert + Assert.Equal(expectedContent, actualContent); + } + + + + [Fact] + public async Task ZipCrypto_MultipleEntries_SamePassword_AllRoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_multi_samepw.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + var items = new (string Name, string Content)[] + { + ("a.txt", "alpha"), + ("b/config.json", "{\"k\":1}"), + ("c/readme.md", "# readme"), + }; + const string password = "S@m3PW!"; + const ZipArchiveEntry.EncryptionMethod enc = ZipArchiveEntry.EncryptionMethod.ZipCrypto; + + // Act 1: Create with same password for all + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + foreach (var it in items) + { + var entry = za.CreateEntry(it.Name, password, enc); + using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + await w.WriteAsync(it.Content); + } + } + + // Act 2: Read back using same password for each entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + foreach (var it in items) + { + var e = za.GetEntry(it.Name); + Assert.NotNull(e); + using var r = new StreamReader(e!.Open(password), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + string content = await r.ReadToEndAsync(); + Assert.Equal(it.Content, content); + } + } + } + + [Fact] + public async Task ZipCrypto_MultipleEntries_DifferentPasswords_AllRoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_multi_diffpw.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + var items = new (string Name, string Content, string Password)[] + { + ("d.txt", "delta", "pw-d"), + ("e/info.txt", "echo-info", "pw-e"), + ("f/sub/notes.txt", "foxtrot-notes", "pw-f"), + }; + const ZipArchiveEntry.EncryptionMethod enc = ZipArchiveEntry.EncryptionMethod.ZipCrypto; + + // Act 1: Create, each entry with its own password + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + foreach (var it in items) + { + var entry = za.CreateEntry(it.Name, it.Password, enc); + using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + await w.WriteAsync(it.Content); + } + } + + // Act 2: Read back with matching password per entry, and also verify a wrong password fails + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + foreach (var it in items) + { + var e = za.GetEntry(it.Name); + Assert.NotNull(e); + + // Correct password + using (var r = new StreamReader(e!.Open(it.Password), Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) + { + string content = await r.ReadToEndAsync(); + Assert.Equal(it.Content, content); + } + + // Wrong password should throw (ZipCrypto header check fails) + Assert.ThrowsAny(() => + { + using var _ = e.Open("WRONG-PASSWORD"); + }); + } + } + } + + [Fact] + public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_mixed.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + const string encPw = "EncOnly123!"; + const ZipArchiveEntry.EncryptionMethod enc = ZipArchiveEntry.EncryptionMethod.ZipCrypto; + + var encryptedItems = new (string Name, string Content)[] + { + ("secure/one.txt", "top-secret-1"), + ("secure/two.txt", "top-secret-2"), + }; + + var plainItems = new (string Name, string Content)[] + { + ("public/a.txt", "visible-a"), + ("public/b.txt", "visible-b"), + }; + + // Act 1: Create archive with both encrypted and plain entries + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + // Encrypted + foreach (var it in encryptedItems) + { + var entry = za.CreateEntry(it.Name, encPw, enc); + using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + await w.WriteAsync(it.Content); + } + + // Plain + foreach (var it in plainItems) + { + var entry = za.CreateEntry(it.Name); // default: no encryption + using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + await w.WriteAsync(it.Content); + } + } + + // Act 2: Read backencrypted need password, plain do not + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + // Encrypted + foreach (var it in encryptedItems) + { + var e = za.GetEntry(it.Name); + Assert.NotNull(e); + using var r = new StreamReader(e!.Open(encPw), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + string content = await r.ReadToEndAsync(); + Assert.Equal(it.Content, content); + } + + // Plain + foreach (var it in plainItems) + { + var e = za.GetEntry(it.Name); + Assert.NotNull(e); + using var r = new StreamReader(e!.Open(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + string content = await r.ReadToEndAsync(); + Assert.Equal(it.Content, content); + + // Ensure opening a plain entry with a password is rejected (or simply ignored depending on API) + Assert.ThrowsAny(() => + { + using var _ = e.Open("some-password"); + }); + } + } + } + + + //[Fact] + //public void OpenEncryptedTxtFile() + //{ + // string zipPath = @"C:\Users\spahontu\Downloads\zipcrypto_test_wr.zip"; + // using var archive = ZipFile.OpenRead(zipPath); + + // var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt")); + // using var stream = entry.Open("P@ssw0rd!"); + // using var reader = new StreamReader(stream); + // string content = reader.ReadToEnd(); + + // Assert.Equal("hello zipcrypto", content); + //} + + + } } -} diff --git a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs index 821f737d83d027..83acf40d183457 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -103,6 +103,8 @@ public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode public static System.Threading.Tasks.Task CreateAsync(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode, bool leaveOpen, System.Text.Encoding? entryNameEncoding, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName) { throw null; } public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName, System.IO.Compression.CompressionLevel compressionLevel) { throw null; } + public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName, System.IO.Compression.CompressionLevel compressionLevel, string password, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryption) { throw null; } + public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName, string password, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryption) { throw null; } public void Dispose() { } protected virtual void Dispose(bool disposing) { } public System.Threading.Tasks.ValueTask DisposeAsync() { throw null; } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs index 6e1e36fd63b8eb..c2534303c0c02b 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs @@ -9,6 +9,7 @@ using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Text; namespace System.IO.Compression @@ -267,6 +268,11 @@ public ZipArchiveEntry CreateEntry(string entryName) return DoCreateEntry(entryName, null); } + public ZipArchiveEntry CreateEntry(string entryName, string password, ZipArchiveEntry.EncryptionMethod encryption) + { + return DoCreateEntry(entryName, null, password, encryption); + } + /// /// Creates an empty entry in the Zip archive with the specified entry name. There are no restrictions on the names of entries. The last write time of the entry is set to the current time. If an entry with the specified name already exists in the archive, a second entry will be created that has an identical name. /// @@ -282,6 +288,11 @@ public ZipArchiveEntry CreateEntry(string entryName, CompressionLevel compressio return DoCreateEntry(entryName, compressionLevel); } + public ZipArchiveEntry CreateEntry(string entryName, CompressionLevel compressionLevel, string password, ZipArchiveEntry.EncryptionMethod encryption) + { + return DoCreateEntry(entryName, compressionLevel, password, encryption); + } + /// /// Releases the unmanaged resources used by ZipArchive and optionally finishes writing the archive and releases the managed resources. /// @@ -379,7 +390,8 @@ private set // New entries in the archive won't change its state. internal ChangeState Changed { get; private set; } - private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compressionLevel) + private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compressionLevel, + string? password = null, ZipArchiveEntry.EncryptionMethod encryption = ZipArchiveEntry.EncryptionMethod.None) { ArgumentException.ThrowIfNullOrEmpty(entryName); @@ -389,10 +401,26 @@ private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compre ThrowIfDisposed(); - ZipArchiveEntry entry = compressionLevel.HasValue ? - new ZipArchiveEntry(this, entryName, compressionLevel.Value) : - new ZipArchiveEntry(this, entryName); - + ZipArchiveEntry entry; + if (compressionLevel.HasValue) + { + if (password != null) { + entry = new ZipArchiveEntry(this, entryName, compressionLevel.Value, password, encryption); + } else + { + entry = new ZipArchiveEntry(this, entryName, compressionLevel.Value); + } + } + else + { + if (password != null) + { + entry = new ZipArchiveEntry(this, entryName, password, encryption); + } + else { + entry = new ZipArchiveEntry(this, entryName); + } + } AddEntry(entry); return entry; diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index e894c5a21107c0..fac6862748e8f9 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -47,7 +48,8 @@ public partial class ZipArchiveEntry private byte[]? _lhTrailingExtraFieldData; private byte[] _fileComment; private readonly CompressionLevel _compressionLevel; - //private ReadOnlyMemory _password; + private string? _password; + private EncryptionMethod _encryptionMethod; // Initializes a ZipArchiveEntry instance for an existing archive entry. internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) @@ -161,6 +163,58 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName) Changes = ZipArchive.ChangeState.Unchanged; } + internal ZipArchiveEntry(ZipArchive archive, string entryName, CompressionLevel compressionLevel, string? password, EncryptionMethod encryptionMethod = EncryptionMethod.ZipCrypto) + : this(archive, entryName, compressionLevel) + { + _password = password; + _encryptionMethod = encryptionMethod; + _generalPurposeBitFlag = 0; + + if (!string.IsNullOrEmpty(_password) && _encryptionMethod != EncryptionMethod.None) + { + _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; + _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; + _isEncrypted = true; + } + else + { + _generalPurposeBitFlag &= ~BitFlagValues.IsEncrypted; + _isEncrypted = false; + } + } + + internal ZipArchiveEntry(ZipArchive archive, string entryName, string? password, EncryptionMethod encryptionMethod = EncryptionMethod.ZipCrypto) + : this(archive, entryName) + { + _password = password; + _encryptionMethod = encryptionMethod; + _generalPurposeBitFlag = 0; + + if (!string.IsNullOrEmpty(_password) && _encryptionMethod != EncryptionMethod.None) + { + _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; + _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; + _isEncrypted = true; + } + else + { + _generalPurposeBitFlag &= ~BitFlagValues.IsEncrypted; + _isEncrypted = false; + } + } + + internal string Password + { + get => _password ?? string.Empty; + set => _password = value; + } + + internal EncryptionMethod Encryption + { + get => _encryptionMethod; + set => _encryptionMethod = value; + } + /// /// The ZipArchive that this entry belongs to. If this entry has been deleted, this will return null. /// @@ -377,6 +431,9 @@ public Stream Open(string password) switch (_archive.Mode) { case ZipArchiveMode.Read: + if (!_isEncrypted) { + throw new InvalidDataException("Entry is not encrypted"); + } return OpenInReadMode(checkOpenable: true, password.AsMemory()); case ZipArchiveMode.Create: return OpenInWriteMode(); @@ -723,32 +780,179 @@ private void DetectEntryNameVersion() } } - private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) + //private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) + //{ + // // stream stack: backingStream -> DeflateStream -> CheckSumWriteStream + + // // By default we compress with deflate, except if compression level is set to NoCompression then stored is used. + // // Stored is also used for empty files, but we don't actually call through this function for that - we just write the stored value in the header + // // Deflate64 is not supported on all platforms + // Debug.Assert(CompressionMethod == CompressionMethodValues.Deflate + // || CompressionMethod == CompressionMethodValues.Stored); + + // bool isIntermediateStream = true; + // Stream compressorStream; + // switch (CompressionMethod) + // { + // case CompressionMethodValues.Stored: + // compressorStream = backingStream; + // isIntermediateStream = false; + // break; + // case CompressionMethodValues.Deflate: + // case CompressionMethodValues.Deflate64: + // default: + // compressorStream = new DeflateStream(backingStream, _compressionLevel, leaveBackingStreamOpen); + // break; + + // } + // bool leaveCompressorStreamOpenOnClose = leaveBackingStreamOpen && !isIntermediateStream; + // var checkSumStream = new CheckSumAndSizeWriteStream( + // compressorStream, + // backingStream, + // leaveCompressorStreamOpenOnClose, + // this, + // onClose, + // (long initialPosition, long currentPosition, uint checkSum, Stream backing, ZipArchiveEntry thisRef, EventHandler? closeHandler) => + // { + // thisRef._crc32 = checkSum; + // thisRef._uncompressedSize = currentPosition; + // thisRef._compressedSize = backing.Position - initialPosition; + // closeHandler?.Invoke(thisRef, EventArgs.Empty); + // }); + + // return checkSumStream; + //} + //private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) + //{ + // // final chain: backingStream <- ZipCrypto? <- Deflate/Stored <- CheckSumAndSizeWriteStream + + // Debug.Assert(CompressionMethod == CompressionMethodValues.Deflate + // || CompressionMethod == CompressionMethodValues.Stored + // || CompressionMethod == CompressionMethodValues.Deflate64); + + // bool isEncrypted = Encryption == _encryptionMethod; + // string? pwd = _password; + + + // // (A) Insert encrypting stream eagerly (ZipCrypto header is written NOW, before initialPosition capture) + // Stream targetSink = backingStream; + // if (isEncrypted) + // { + // if (string.IsNullOrEmpty(pwd)) + // throw new InvalidOperationException("Encrypted entry requires a non-empty password."); + + // // With streaming (GPBF bit 3), use DOS time low word for ZipCrypto header check bytes + // ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); + + // // Constructor writes 12-byte ZipCrypto header immediately + // targetSink = new ZipCryptoStream( + // baseStream: backingStream, + // password: pwd.AsMemory(), + // passwordVerifierLow2Bytes: verifierLow2Bytes, + // crc32: null); + // } + + // Stream compressorStream; + // bool isIntermediateStream = true; + + // switch (CompressionMethod) + // { + // case CompressionMethodValues.Stored: + // compressorStream = targetSink; + // // If not encrypted, there is no intermediate layer; otherwise ZipCrypto is an intermediate + // isIntermediateStream = isEncrypted; + // break; + + // case CompressionMethodValues.Deflate: + // case CompressionMethodValues.Deflate64: + // default: + // // NOTE: DeflateStream uses leaveBackingStreamOpen for its own inner stream, + // // which here is targetSink (possibly encrypting stream) + // compressorStream = new DeflateStream(targetSink, _compressionLevel, leaveBackingStreamOpen); + // isIntermediateStream = true; + // break; + // } + + // bool leaveCompressorStreamOpenOnClose = leaveBackingStreamOpen && !isIntermediateStream; + + // // (C) Return the checksum/size wrapper; add encryption overhead (12 for ZipCrypto) to compressed size + // var checkSumStream = new CheckSumAndSizeWriteStream( + // compressorStream, + // backingStream, + // leaveCompressorStreamOpenOnClose, + // this, + // onClose, + // (long initialPosition, long currentPosition, uint checkSum, Stream backing, ZipArchiveEntry thisRef, EventHandler? closeHandler) => + // { + // // CRC is over plaintext (as your CheckSumAndSizeWriteStream computes) + // thisRef._crc32 = checkSum; + // thisRef._uncompressedSize = currentPosition; + + // long rawCompressed = backing.Position - initialPosition; + + // // Because ZipCrypto header was written BEFORE initialPosition was captured, + // // we must add the overhead explicitly. + // if (thisRef.Encryption == EncryptionMethod.ZipCrypto) + // { + // rawCompressed += 12; // 12 for ZipCrypto + // } + + // thisRef._compressedSize = rawCompressed; + // closeHandler?.Invoke(thisRef, EventArgs.Empty); + // }); + + // return checkSumStream; + //} + private CheckSumAndSizeWriteStream GetDataCompressor( + Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose, string? password = null, EncryptionMethod encryption = EncryptionMethod.None) { - // stream stack: backingStream -> DeflateStream -> CheckSumWriteStream + // final chain: backingStream <- ZipCrypto? <- Deflate/Stored <- CheckSumAndSizeWriteStream - // By default we compress with deflate, except if compression level is set to NoCompression then stored is used. - // Stored is also used for empty files, but we don't actually call through this function for that - we just write the stored value in the header - // Deflate64 is not supported on all platforms Debug.Assert(CompressionMethod == CompressionMethodValues.Deflate - || CompressionMethod == CompressionMethodValues.Stored); + || CompressionMethod == CompressionMethodValues.Stored + || CompressionMethod == CompressionMethodValues.Deflate64); + + bool isEncrypted = Encryption == encryption; // your internal property + string? pwd = password; + + // Build target sink (encrypting layer if needed). Header will be emitted on the first write. + Stream targetSink = backingStream; + if (IsZipCryptoEncrypted()) + { + if (string.IsNullOrEmpty(pwd)) + throw new InvalidOperationException("Encrypted entry requires a non-empty password."); + + // For streaming (GPBF bit 3 set in LOCAL HEADER), verifier = DOS time low word of that header + ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); + + targetSink = new ZipCryptoStream( + baseStream: backingStream, + password: pwd.AsMemory(), + passwordVerifierLow2Bytes: verifierLow2Bytes, + crc32: null, + leaveOpen: leaveBackingStreamOpen); // honor leaveOpen semantics + } - bool isIntermediateStream = true; Stream compressorStream; + bool isIntermediateStream = true; + switch (CompressionMethod) { case CompressionMethodValues.Stored: - compressorStream = backingStream; - isIntermediateStream = false; + compressorStream = targetSink; + isIntermediateStream = isEncrypted; // only intermediate if we added encryption break; + case CompressionMethodValues.Deflate: case CompressionMethodValues.Deflate64: default: - compressorStream = new DeflateStream(backingStream, _compressionLevel, leaveBackingStreamOpen); + compressorStream = new DeflateStream(targetSink, _compressionLevel, leaveBackingStreamOpen); + isIntermediateStream = true; break; - } + bool leaveCompressorStreamOpenOnClose = leaveBackingStreamOpen && !isIntermediateStream; + var checkSumStream = new CheckSumAndSizeWriteStream( compressorStream, backingStream, @@ -757,9 +961,14 @@ private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool onClose, (long initialPosition, long currentPosition, uint checkSum, Stream backing, ZipArchiveEntry thisRef, EventHandler? closeHandler) => { + // CRC over plaintext: thisRef._crc32 = checkSum; thisRef._uncompressedSize = currentPosition; - thisRef._compressedSize = backing.Position - initialPosition; + + // No +12 needed anymore: the header was written after initialPosition was captured. + long rawCompressed = backing.Position - initialPosition; + + thisRef._compressedSize = rawCompressed; closeHandler?.Invoke(thisRef, EventArgs.Empty); }); @@ -768,10 +977,8 @@ private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool private byte CalculateZipCryptoCheckByte() { - const ushort DataDescriptorFlag = 0x0008; // GPBF bit 3 - // If data descriptor NOT used, the check byte is the MSB of CRC32 - if (((ushort)_generalPurposeBitFlag & DataDescriptorFlag) == 0) + if ((_generalPurposeBitFlag & BitFlagValues.DataDescriptor) == 0) return (byte)((_crc32 >> 24) & 0xFF); // If data descriptor IS used, the check byte is the MSB of the DOS time from the *local* header @@ -862,6 +1069,31 @@ private WrappedStream OpenInWriteMode() return new WrappedStream(baseStream: _outstandingWriteStream, closeBaseStream: true); } + private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod encryption = EncryptionMethod.None) + { + if (_everOpenedForWrite) + throw new IOException(SR.CreateModeWriteOnceAndOneEntryAtATime); + + // we assume that if another entry grabbed the archive stream, that it set this entry's _everOpenedForWrite property to true by calling WriteLocalFileHeaderAndDataIfNeeded + _archive.DebugAssertIsStillArchiveStreamOwner(this); + + _everOpenedForWrite = true; + Changes |= ZipArchive.ChangeState.StoredData; + CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor(_archive.ArchiveStream, true, (object? o, EventArgs e) => + { + // release the archive stream + var entry = (ZipArchiveEntry)o!; + entry._archive.ReleaseArchiveStream(entry); + entry._outstandingWriteStream = null; + }, + password, + encryption + ); + _outstandingWriteStream = new DirectToArchiveWriterStream(crcSizeStream, this); + + return new WrappedStream(baseStream: _outstandingWriteStream, closeBaseStream: true); + } + private WrappedStream OpenInUpdateMode() { if (_currentlyOpenForWrite) @@ -1043,9 +1275,19 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o } else { + + if (Encryption == EncryptionMethod.ZipCrypto) + { + // Streaming mode for encryption: sizes and CRC unknown upfront + _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; + _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; + compressedSizeTruncated = 0; + uncompressedSizeTruncated = 0; + Debug.Assert(_crc32 == 0); + } // if we have a non-seekable stream, don't worry about sizes at all, and just set the right bit // if we are using the data descriptor, then sizes and crc should be set to 0 in the header - if (_archive.Mode == ZipArchiveMode.Create && !_archive.ArchiveStream.CanSeek) + else if (_archive.Mode == ZipArchiveMode.Create && !_archive.ArchiveStream.CanSeek) { _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; compressedSizeTruncated = 0; diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index b71a2891453035..a3e62d5cde9654 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -1,23 +1,258 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +//namespace System.IO.Compression +//{ +// // Internal read-only, non-seekable stream that: +// // - Initializes ZipCrypto keys from the password +// // - Reads & decrypts the 12-byte header and validates the check byte +// // - Decrypts subsequent bytes on Read(...) +// internal sealed class ZipCryptoStream : Stream +// { +// private readonly bool _encrypting; +// private readonly Stream _base; +// private uint _key0; +// private uint _key1; +// private uint _key2; +// private static readonly uint[] crc2Table = CreateCrc32Table(); + +// private static uint[] CreateCrc32Table() { + +// var table = new uint[256]; +// for (uint i = 0; i < 256; i++) +// { +// uint c = i; +// for (int j = 0; j < 8; j++) +// c = (c & 1) != 0 ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); +// table[i] = c; +// } +// return table; + +// } + +// // decryption constructor +// public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) +// { +// _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); +// InitKeys(password.Span); +// _encrypting = false; +// ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes +// } + +// public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, ushort passwordVerifierLow2Bytes, uint? crc32 = null) +// { +// _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); +// _encrypting = true; + +// InitKeys(password.Span); +// CreateAndWriteHeader(passwordVerifierLow2Bytes, crc32); +// } + +// private void CreateAndWriteHeader(ushort verifierLow2Bytes, uint? crc32) +// { +// byte[] hdrPlain = new byte[12]; + +// // 0..9: random +// for (int i = 0; i < 10; i++) +// hdrPlain[i] = 0; + + +// // 10..11: check bytes +// if (crc32.HasValue) +// { +// uint crc = crc32.Value; +// hdrPlain[10] = (byte)((crc >> 16) & 0xFF); +// hdrPlain[11] = (byte)((crc >> 24) & 0xFF); +// } +// else +// { +// // Fallback when CRC32 is not yet known +// hdrPlain[10] = (byte)(verifierLow2Bytes & 0xFF); +// hdrPlain[11] = (byte)((verifierLow2Bytes >> 8) & 0xFF); +// } + +// // Encrypt header and write +// byte[] hdrCiph = new byte[12]; +// for (int i = 0; i < 12; i++) +// { +// hdrCiph[i] = EncryptByte(hdrPlain[i]); // EncryptByte updates keys with PLAINTEXT +// } + +// _base.Write(hdrCiph, 0, hdrCiph.Length); +// } + + +// private byte EncryptByte(byte plain) +// { +// byte ks = DecipherByte(); +// byte ciph = (byte)(plain ^ ks); +// UpdateKeys(plain); +// return ciph; +// } + + +// private void InitKeys(ReadOnlySpan password) +// { +// _key0 = 305419896; +// _key1 = 591751049; +// _key2 = 878082192; + +// foreach (char ch in password) +// { +// UpdateKeys((byte)ch); +// } +// } + +// private void ValidateHeader(byte expectedCheckByte) +// { +// byte[] hdr = new byte[12]; +// int read = 0; +// while (read < hdr.Length) +// { +// int n = _base.Read(hdr.AsSpan(read)); +// if (n <= 0) throw new InvalidDataException("Truncated ZipCrypto header."); +// read += n; +// } + +// for (int i = 0; i < hdr.Length; i++) +// { +// hdr[i] = DecryptByte(hdr[i]); +// } + +// if (hdr[11] != expectedCheckByte) +// throw new InvalidDataException("Invalid password for encrypted ZIP entry."); +// } + +// private void UpdateKeys(byte b) +// { +// _key0 = Crc32Update(_key0, b); +// _key1 += (_key0 & 0xFF); +// _key1 = _key1 * 134775813 + 1; +// _key2 = Crc32Update(_key2, (byte)(_key1 >> 24)); +// } + +// private byte DecipherByte() +// { +// ushort temp = (ushort)(_key2 | 2); +// return (byte)((temp * (temp ^ 1)) >> 8); +// } + +// private byte DecryptByte(byte ciph) +// { +// byte m = DecipherByte(); +// byte plain = (byte)(ciph ^ m); +// UpdateKeys(plain); +// return plain; +// } + +// // ---- Stream overrides ---- + +// public override bool CanRead => !_encrypting; +// public override bool CanSeek => false; +// public override bool CanWrite => _encrypting; +// public override long Length => throw new NotSupportedException(); +// public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } +// public override void Flush() => _base.Flush(); + +// public override int Read(byte[] buffer, int offset, int count) +// { +// ArgumentNullException.ThrowIfNull(buffer); +// if ((uint)offset > buffer.Length || (uint)count > buffer.Length - offset) +// throw new ArgumentOutOfRangeException(); + +// int n = _base.Read(buffer, offset, count); +// for (int i = 0; i < n; i++) +// { +// buffer[offset + i] = DecryptByte(buffer[offset + i]); +// } +// return n; +// } + +// public override int Read(Span destination) +// { +// int n = _base.Read(destination); +// for (int i = 0; i < n; i++) +// { +// destination[i] = DecryptByte(destination[i]); +// } +// return n; +// } + +// 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) +// { +// if (_encrypting) +// { +// ArgumentNullException.ThrowIfNull(buffer); +// if ((uint)offset > (uint)buffer.Length || (uint)count > (uint)(buffer.Length - offset)) +// throw new ArgumentOutOfRangeException(); + +// // Simple temporary buffer; no ArrayPool, no async +// byte[] tmp = new byte[count]; +// for (int i = 0; i < count; i++) +// { +// tmp[i] = EncryptByte(buffer[offset + i]); +// } +// _base.Write(tmp, 0, count); +// return; +// } +// throw new NotSupportedException("Stream is in decryption (read-only) mode."); +// } + +// public override void Write(ReadOnlySpan buffer) +// { +// if (_encrypting) +// { +// // Simple temporary buffer; no ArrayPool, no async +// byte[] tmp = new byte[buffer.Length]; +// for (int i = 0; i < buffer.Length; i++) +// { +// tmp[i] = EncryptByte(buffer[i]); +// } +// _base.Write(tmp, 0, tmp.Length); +// return; +// } +// throw new NotSupportedException("Stream is in decryption (read-only) mode."); +// } + + +// protected override void Dispose(bool disposing) +// { +// if (disposing) _base.Dispose(); +// base.Dispose(disposing); +// } + +// // TODO: replace with the runtime's internal CRC32 update routine (fast table-based). +// private static uint Crc32Update(uint crc, byte b) +// { +// return crc2Table[(crc ^ b) & 0xFF] ^ (crc >> 8); +// } +// } +//} +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + namespace System.IO.Compression { - // Internal read-only, non-seekable stream that: - // - Initializes ZipCrypto keys from the password - // - Reads & decrypts the 12-byte header and validates the check byte - // - Decrypts subsequent bytes on Read(...) internal sealed class ZipCryptoStream : Stream { private readonly bool _encrypting; private readonly Stream _base; + private readonly bool _leaveOpen; // NEW + private bool _headerWritten; // NEW + private bool _everWrotePayload; // NEW + private readonly ushort _verifierLow2Bytes; // NEW (DOS time low word when streaming) + private readonly uint? _crc32ForHeader; // NEW (CRC-based header when not streaming) + private uint _key0; private uint _key1; private uint _key2; private static readonly uint[] crc2Table = CreateCrc32Table(); - private static uint[] CreateCrc32Table() { - + private static uint[] CreateCrc32Table() + { var table = new uint[256]; for (uint i = 0; i < 256; i++) { @@ -27,79 +262,81 @@ private static uint[] CreateCrc32Table() { table[i] = c; } return table; - } - // decryption constructor + // Decryption constructor (unchanged semantics) public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) { _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); - InitKeys(password.Span); + InitKeysFromBytes(password.Span); + _encrypting = false; ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes } - public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, ushort passwordVerifierLow2Bytes, uint? crc32 = null) + // ENCRYPTION constructor (header is now deferred to first write) + public ZipCryptoStream(Stream baseStream, + ReadOnlyMemory password, + ushort passwordVerifierLow2Bytes, + uint? crc32 = null, + bool leaveOpen = false) // NEW { _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); _encrypting = true; + _leaveOpen = leaveOpen; + _verifierLow2Bytes = passwordVerifierLow2Bytes; + _crc32ForHeader = crc32; - InitKeys(password.Span); - CreateAndWriteHeader(passwordVerifierLow2Bytes, crc32); + InitKeysFromBytes(password.Span); + // NOTE: Do NOT write the 12-byte header here anymore. } - private void CreateAndWriteHeader(ushort verifierLow2Bytes, uint? crc32) + private void EnsureHeader() // NEW { - byte[] hdrPlain = new byte[12]; + if (!_encrypting || _headerWritten) return; + + Span hdrPlain = stackalloc byte[12]; - // 0..9: random + // bytes 0..9: random for (int i = 0; i < 10; i++) hdrPlain[i] = 0; - - // 10..11: check bytes - if (crc32.HasValue) + // bytes 10..11: check bytes (CRC-based if crc32 provided; else DOS time low word) + if (_crc32ForHeader.HasValue) { - uint crc = crc32.Value; + uint crc = _crc32ForHeader.Value; hdrPlain[10] = (byte)((crc >> 16) & 0xFF); hdrPlain[11] = (byte)((crc >> 24) & 0xFF); } else { - // Fallback when CRC32 is not yet known - hdrPlain[10] = (byte)(verifierLow2Bytes & 0xFF); - hdrPlain[11] = (byte)((verifierLow2Bytes >> 8) & 0xFF); + hdrPlain[10] = (byte)(_verifierLow2Bytes & 0xFF); + hdrPlain[11] = (byte)((_verifierLow2Bytes >> 8) & 0xFF); } - // Encrypt header and write + // Encrypt & write; update keys with PLAINTEXT per spec byte[] hdrCiph = new byte[12]; for (int i = 0; i < 12; i++) { - hdrCiph[i] = EncryptByte(hdrPlain[i]); // EncryptByte updates keys with PLAINTEXT + byte ks = DecipherByte(); + byte p = hdrPlain[i]; + hdrCiph[i] = (byte)(p ^ ks); + UpdateKeys(p); } - _base.Write(hdrCiph, 0, hdrCiph.Length); - } - - - private byte EncryptByte(byte plain) - { - byte ks = DecipherByte(); - byte ciph = (byte)(plain ^ ks); - UpdateKeys(plain); - return ciph; + _base.Write(hdrCiph, 0, 12); + _headerWritten = true; } - - private void InitKeys(ReadOnlySpan password) + private void InitKeysFromBytes(ReadOnlySpan password) // NEW (byte-based init) { _key0 = 305419896; _key1 = 591751049; _key2 = 878082192; - foreach (char ch in password) - { - UpdateKeys((byte)ch); - } + // ZipCrypto uses raw bytes; ASCII is the most interoperable (UTF8 also acceptable). + var bytes = System.Text.Encoding.ASCII.GetBytes(password.ToString()); + foreach (byte b in bytes) + UpdateKeys(b); } private void ValidateHeader(byte expectedCheckByte) @@ -108,15 +345,13 @@ private void ValidateHeader(byte expectedCheckByte) int read = 0; while (read < hdr.Length) { - int n = _base.Read(hdr.AsSpan(read)); + int n = _base.Read(hdr, read, hdr.Length - read); if (n <= 0) throw new InvalidDataException("Truncated ZipCrypto header."); read += n; } for (int i = 0; i < hdr.Length; i++) - { hdr[i] = DecryptByte(hdr[i]); - } if (hdr[11] != expectedCheckByte) throw new InvalidDataException("Invalid password for encrypted ZIP entry."); @@ -132,7 +367,7 @@ private void UpdateKeys(byte b) private byte DecipherByte() { - ushort temp = (ushort)(_key2 | 2); + uint temp = _key2 | 2; // use uint to avoid narrowing issues return (byte)((temp * (temp ^ 1)) >> 8); } @@ -146,35 +381,39 @@ private byte DecryptByte(byte ciph) // ---- Stream overrides ---- - public override bool CanRead => true; + public override bool CanRead => !_encrypting; public override bool CanSeek => false; - public override bool CanWrite => false; + public override bool CanWrite => _encrypting; 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 void Flush() => _base.Flush(); public override int Read(byte[] buffer, int offset, int count) { - ArgumentNullException.ThrowIfNull(buffer); - if ((uint)offset > buffer.Length || (uint)count > buffer.Length - offset) - throw new ArgumentOutOfRangeException(); - - int n = _base.Read(buffer, offset, count); - for (int i = 0; i < n; i++) + if (!_encrypting) { - buffer[offset + i] = DecryptByte(buffer[offset + i]); + ArgumentNullException.ThrowIfNull(buffer); + if ((uint)offset > buffer.Length || (uint)count > buffer.Length - offset) + throw new ArgumentOutOfRangeException(); + + int n = _base.Read(buffer, offset, count); + for (int i = 0; i < n; i++) + buffer[offset + i] = DecryptByte(buffer[offset + i]); + return n; } - return n; + throw new NotSupportedException("Stream is in encryption (write-only) mode."); } public override int Read(Span destination) { - int n = _base.Read(destination); - for (int i = 0; i < n; i++) + if (!_encrypting) { - destination[i] = DecryptByte(destination[i]); + int n = _base.Read(destination); + for (int i = 0; i < n; i++) + destination[i] = DecryptByte(destination[i]); + return n; } - return n; + throw new NotSupportedException("Stream is in encryption (write-only) mode."); } public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); @@ -182,51 +421,59 @@ public override int Read(Span destination) public override void Write(byte[] buffer, int offset, int count) { - if (_encrypting) - { - ArgumentNullException.ThrowIfNull(buffer); - if ((uint)offset > (uint)buffer.Length || (uint)count > (uint)(buffer.Length - offset)) - throw new ArgumentOutOfRangeException(); + if (!_encrypting) throw new NotSupportedException("Stream is in decryption (read-only) mode."); + ArgumentNullException.ThrowIfNull(buffer); + if ((uint)offset > (uint)buffer.Length || (uint)count > (uint)(buffer.Length - offset)) + throw new ArgumentOutOfRangeException(); + + EnsureHeader(); // NEW + _everWrotePayload = _everWrotePayload || (count > 0); - // Simple temporary buffer; no ArrayPool, no async - byte[] tmp = new byte[count]; - for (int i = 0; i < count; i++) - { - tmp[i] = EncryptByte(buffer[offset + i]); - } - _base.Write(tmp, 0, count); - return; + // Simple temp buffer; optimize with ArrayPool if desired + byte[] tmp = new byte[count]; + for (int i = 0; i < count; i++) + { + byte ks = DecipherByte(); + byte p = buffer[offset + i]; + tmp[i] = (byte)(p ^ ks); + UpdateKeys(p); } - throw new NotSupportedException("Stream is in decryption (read-only) mode."); + _base.Write(tmp, 0, count); } public override void Write(ReadOnlySpan buffer) { - if (_encrypting) + if (!_encrypting) throw new NotSupportedException("Stream is in decryption (read-only) mode."); + + EnsureHeader(); // NEW + _everWrotePayload = _everWrotePayload || (buffer.Length > 0); + + byte[] tmp = new byte[buffer.Length]; + for (int i = 0; i < buffer.Length; i++) { - // Simple temporary buffer; no ArrayPool, no async - byte[] tmp = new byte[buffer.Length]; - for (int i = 0; i < buffer.Length; i++) - { - tmp[i] = EncryptByte(buffer[i]); - } - _base.Write(tmp, 0, tmp.Length); - return; + byte ks = DecipherByte(); + byte p = buffer[i]; + tmp[i] = (byte)(p ^ ks); + UpdateKeys(p); } - throw new NotSupportedException("Stream is in decryption (read-only) mode."); + _base.Write(tmp, 0, tmp.Length); } - protected override void Dispose(bool disposing) { - if (disposing) _base.Dispose(); + if (disposing) + { + // If encrypted empty entry (no payload written), still must emit 12-byte header: + if (_encrypting && !_headerWritten) + EnsureHeader(); + + if (!_leaveOpen) + _base.Dispose(); + } base.Dispose(disposing); } - // TODO: replace with the runtime's internal CRC32 update routine (fast table-based). private static uint Crc32Update(uint crc, byte b) - { - return crc2Table[(crc ^ b) & 0xFF] ^ (crc >> 8); - } + => crc2Table[(crc ^ b) & 0xFF] ^ (crc >> 8); } } From f30fbfb81c3306d86f452be677e4c89af8001ea0 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 29 Oct 2025 11:29:36 +0100 Subject: [PATCH 06/39] add more read/write tests for update mode and small refactors --- .../ref/System.IO.Compression.ZipFile.cs | 1 + .../ZipFileExtensions.ZipArchive.Create.cs | 4 + .../tests/ZipFile.Extract.cs | 526 +++++++++++++++++- .../src/System/IO/Compression/ZipArchive.cs | 2 + .../System/IO/Compression/ZipArchiveEntry.cs | 182 +----- .../System/IO/Compression/ZipCryptoStream.cs | 263 +-------- 6 files changed, 562 insertions(+), 416 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs index 8e705b98936904..479ca0180cce98 100644 --- a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs +++ b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs @@ -48,6 +48,7 @@ public static partial class ZipFileExtensions { public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName) { throw null; } public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.IO.Compression.CompressionLevel compressionLevel) { throw null; } + public static System.IO.Compression.ZipArchiveEntry CreateEntryFromFile(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.IO.Compression.CompressionLevel compressionLevel, string password, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryption) { throw null; } public static System.Threading.Tasks.Task CreateEntryFromFileAsync(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.IO.Compression.CompressionLevel compressionLevel, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task CreateEntryFromFileAsync(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName) { } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs index 3d9cbc1c180293..3e70aa9e4daded 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs @@ -77,6 +77,10 @@ public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, CompressionLevel compressionLevel) => DoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel); + public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, + string sourceFileName, string entryName, CompressionLevel compressionLevel, string password, ZipArchiveEntry.EncryptionMethod encryption) => + DoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, password, encryption); + internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, CompressionLevel? compressionLevel, string? password = null, ZipArchiveEntry.EncryptionMethod encryption = ZipArchiveEntry.EncryptionMethod.None) { diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index d0f470a8caaa3e..9de81530caf6c1 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -701,20 +701,524 @@ public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() } - //[Fact] - //public void OpenEncryptedTxtFile() - //{ - // string zipPath = @"C:\Users\spahontu\Downloads\zipcrypto_test_wr.zip"; - // using var archive = ZipFile.OpenRead(zipPath); - // var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt")); - // using var stream = entry.Open("P@ssw0rd!"); - // using var reader = new StreamReader(stream); - // string content = reader.ReadToEnd(); + [Fact] + public async Task Update_AddEncryptedEntry_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("update_add.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Create initial archive with one plain entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntry("plain.txt"); + using var w = new StreamWriter(e.Open(), Encoding.UTF8); + await w.WriteAsync("plain content"); + } + + // Act: Open in Update mode and add encrypted entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var encEntry = za.CreateEntry("secure/new.txt", "pw123", ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(encEntry.Open(), Encoding.UTF8); + await w.WriteAsync("secret data"); + } + + // Assert: Verify both entries exist and encrypted one decrypts correctly + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var plain = za.GetEntry("plain.txt"); + Assert.NotNull(plain); + using (var r = new StreamReader(plain!.Open(), Encoding.UTF8)) + Assert.Equal("plain content", await r.ReadToEndAsync()); + + var secure = za.GetEntry("secure/new.txt"); + Assert.NotNull(secure); + using (var r = new StreamReader(secure!.Open("pw123"), Encoding.UTF8)) + Assert.Equal("secret data", await r.ReadToEndAsync()); + } + } + + [Fact] + public async Task Update_DeleteEncryptedEntry_RemovesSuccessfully() + { + // Arrange + string zipPath = NewPath("update_delete.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntry("secure/delete.txt", "delpw", ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(e.Open(), Encoding.UTF8); + await w.WriteAsync("to be deleted"); + } + + // Act: Delete the encrypted entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var e = za.GetEntry("secure/delete.txt"); + Assert.NotNull(e); + e!.Delete(); + } + + // Assert: Entry should not exist + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + Assert.Null(za.GetEntry("secure/delete.txt")); + } + } + + [Fact] + public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip() + { + // Arrange + string zipPath = NewPath("update_copy.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); - // Assert.Equal("hello zipcrypto", content); - //} + const string pw = "copy-pw"; + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntry("secure/original.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(e.Open(), Encoding.UTF8); + w.Write("original content"); + } + + // Act: Copy encrypted entry to new name + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var src = za.GetEntry("secure/original.txt"); + Assert.NotNull(src); + + // Read original + string content; + using (var r = new StreamReader(src!.Open(pw), Encoding.UTF8)) + content = r.ReadToEnd(); + + // Create new entry with same password + var dst = za.CreateEntry("secure/copy.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(dst.Open(), Encoding.UTF8); + w.Write(content); + } + // Assert: Both entries exist and decrypt correctly + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var orig = za.GetEntry("secure/original.txt"); + var copy = za.GetEntry("secure/copy.txt"); + Assert.NotNull(orig); + Assert.NotNull(copy); + using (var r1 = new StreamReader(orig!.Open(pw), Encoding.UTF8)) + Assert.Equal("original content", await r1.ReadToEndAsync()); + + using (var r2 = new StreamReader(copy!.Open(pw), Encoding.UTF8)) + Assert.Equal("original content", await r2.ReadToEndAsync()); + } + } + + + [Fact] + public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip_2() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("update_copy.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + const string pw = "copy-pw"; + const string originalName = "secure/original.txt"; + const string copyName = "secure/copy.txt"; + const string payload = "original content"; + + // Create archive and a single encrypted entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntry(originalName, pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(e.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + await w.WriteAsync(payload); + } + + // Act: Open in Update mode and copy encrypted entry to a new name + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var src = za.GetEntry(originalName); + Assert.NotNull(src); + + // READ-ONLY decrypt in Update mode (Option A): Open(password) returns a readable stream, + // does NOT mark the entry as modified, and does NOT materialize to an edit buffer. + string content; + using (var r = new StreamReader(src!.Open(pw), Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) + content = await r.ReadToEndAsync(); + + // Optional: wrong password should fail early + Assert.ThrowsAny(() => + { + using var _ = src.Open("WRONG"); + }); + + // Create the destination entry with the same password and write the copied content. + var dst = za.CreateEntry(copyName, pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(dst.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + await w.WriteAsync(content); + } + + // Assert: Both entries exist and decrypt to the expected content + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var orig = za.GetEntry(originalName); + var copy = za.GetEntry(copyName); + Assert.NotNull(orig); + Assert.NotNull(copy); + + using (var r1 = new StreamReader(orig!.Open(pw), Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) + { + var text = await r1.ReadToEndAsync(); + Assert.Equal(payload, text); + } + + using (var r2 = new StreamReader(copy!.Open(pw), Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) + { + var text = await r2.ReadToEndAsync(); + Assert.Equal(payload, text); + } + } + } + + + [Fact] + public void Update_OpenEncryptedEntry_WrongPassword_Throws() + { + string zipPath = NewPath("update_wrong_pw.zip"); + const string pw = "correct-pw"; + + if (File.Exists(zipPath)) File.Delete(zipPath); + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntry("secure/file.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(e.Open(), Encoding.UTF8); + w.Write("secret"); + } + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var e = za.GetEntry("secure/file.txt"); + Assert.NotNull(e); + Assert.ThrowsAny(() => + { + using var _ = e.Open("wrong-pw"); + }); + } + } + + + [Fact] + public async Task Update_EditPlainEntry_RoundTrip() + { + string zipPath = NewPath("update_edit_plain.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Create plain entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntry("plain.txt"); + using var w = new StreamWriter(e.Open(), Encoding.UTF8); + await w.WriteAsync("original"); + } + + // Edit in Update mode + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var e = za.GetEntry("plain.txt"); + Assert.NotNull(e); + + using var w = new StreamWriter(e.Open(), Encoding.UTF8); + await w.WriteAsync("modified"); + } + + // Verify updated content + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var e = za.GetEntry("plain.txt"); + using var r = new StreamReader(e.Open(), Encoding.UTF8); + Assert.Equal("modified", await r.ReadToEndAsync()); + } + } + + + + [Fact] + public void Update_EditEncryptedEntryWithoutPassword_Throws() + { + string zipPath = NewPath("update_edit_encrypted.zip"); + const string pw = "edit-pw"; + + if (File.Exists(zipPath)) File.Delete(zipPath); + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntry("secure/edit.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(e.Open(), Encoding.UTF8); + w.Write("secret"); + } + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var e = za.GetEntry("secure/edit.txt"); + Assert.NotNull(e); + + // Should throw because edit-in-place for encrypted entries is not supported + Assert.Throws(() => + { + using var _ = e.Open(); // no password + }); + } + } + + + [Fact] + public async Task Update_MixedEntries_ReadEncrypted_EditPlain() + { + string zipPath = NewPath("update_mixed.zip"); + const string pw = "mixed-pw"; + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Create initial zip with encrypted and plain entries + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var encEntry = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using (var w = new StreamWriter(encEntry.Open(), Encoding.UTF8)) + await w.WriteAsync("encrypted"); + + var plainEntry = za.CreateEntry("plain.txt"); + using (var w = new StreamWriter(plainEntry.Open(), Encoding.UTF8)) + await w.WriteAsync("original"); + } + + // First update: read encrypted, modify plain + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var enc = za.GetEntry("secure/data.txt"); + Assert.NotNull(enc); + + string encryptedContent; + using (var r = new StreamReader(enc.Open(pw), Encoding.UTF8)) + encryptedContent = await r.ReadToEndAsync(); + + var plain = za.GetEntry("plain.txt"); + using var w = new StreamWriter(plain.Open(), Encoding.UTF8); + await w.WriteAsync("modified"); + } + + // Second update: verify encrypted, re-modify plain + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var enc = za.GetEntry("secure/data.txt"); + using (var r = new StreamReader(enc.Open(pw), Encoding.UTF8)) + Assert.Equal("encrypted", await r.ReadToEndAsync()); + + var plain = za.GetEntry("plain.txt"); + using var w = new StreamWriter(plain.Open(), Encoding.UTF8); + await w.WriteAsync("modified"); + } + + // Final read: verify both entries + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + using (var r1 = new StreamReader(za.GetEntry("secure/data.txt").Open(pw), Encoding.UTF8)) + Assert.Equal("encrypted", await r1.ReadToEndAsync()); + + using (var r2 = new StreamReader(za.GetEntry("plain.txt").Open(), Encoding.UTF8)) + Assert.Equal("modified", await r2.ReadToEndAsync()); + } + } + + + + [Fact] + public async Task Update_ModifySameEncryptedEntryMultipleTimes() + { + string zipPath = NewPath("update_modify_multiple.zip"); + const string pw = "multi-pw"; + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Create initial encrypted entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(e.Open(), Encoding.UTF8); + await w.WriteAsync("version1"); + } + + // Modify entry multiple times + for (int i = 2; i <= 3; i++) + { + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var e = za.GetEntry("secure/data.txt"); + Assert.NotNull(e); + + string oldContent; + using (var r = new StreamReader(e!.Open(pw), Encoding.UTF8)) + oldContent = await r.ReadToEndAsync(); + + e.Delete(); // remove old entry + + var newEntry = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(newEntry.Open(), Encoding.UTF8); + await w.WriteAsync($"{oldContent}-version{i}"); + } + } + + // Assert final content + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var e = za.GetEntry("secure/data.txt"); + Assert.NotNull(e); + using var r = new StreamReader(e!.Open(pw), Encoding.UTF8); + var text = await r.ReadToEndAsync(); + Assert.Equal("version1-version2-version3", text); + } + } + + + [Fact] + public async Task Update_CopyEncryptedEntryToPlainEntry() + { + string zipPath = NewPath("update_copy_to_plain.zip"); + const string pw = "plain-copy"; + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Create encrypted entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntry("secure/original.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var w = new StreamWriter(e.Open(), Encoding.UTF8); + await w.WriteAsync("secret content"); + } + + // Copy encrypted content to a plain entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + { + var src = za.GetEntry("secure/original.txt"); + Assert.NotNull(src); + + string content; + using (var r = new StreamReader(src!.Open(pw), Encoding.UTF8)) + content = await r.ReadToEndAsync(); + + var plainEntry = za.CreateEntry("public/copy.txt"); // no encryption + using var w = new StreamWriter(plainEntry.Open(), Encoding.UTF8); + await w.WriteAsync(content); + } + + // Assert both entries exist and content matches + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var enc = za.GetEntry("secure/original.txt"); + var plain = za.GetEntry("public/copy.txt"); + Assert.NotNull(enc); + Assert.NotNull(plain); + + using (var r1 = new StreamReader(enc!.Open(pw), Encoding.UTF8)) + Assert.Equal("secret content", await r1.ReadToEndAsync()); + + using (var r2 = new StreamReader(plain!.Open(), Encoding.UTF8)) + Assert.Equal("secret content", await r2.ReadToEndAsync()); + } + } + + + + [Fact] + public void CreateEntryFromFile_WithPassword_WrongPassword_Throws() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string srcPath = NewPath("source_wrong_pw.txt"); + string zipPath = NewPath("create_from_file_encrypted_wrongpw.zip"); + const string entryName = "secure/wrong.txt"; + const string correctPassword = "correct!"; + const string badPassword = "wrong!"; + const string payload = "secret data"; + + if (File.Exists(srcPath)) File.Delete(srcPath); + if (File.Exists(zipPath)) File.Delete(zipPath); + + File.WriteAllText(srcPath, payload, new UTF8Encoding(false)); + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntryFromFile( + sourceFileName: srcPath, + entryName: entryName, + compressionLevel: CompressionLevel.Optimal, + password: correctPassword, + encryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto); + } + + // Act & Assert + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var e = za.GetEntry(entryName); + Assert.NotNull(e); + + Assert.ThrowsAny(() => + { + using var _ = e!.Open(badPassword); + }); + } + } + + + [Fact] + public async Task CreateEntryFromFile_WithEncryption_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string srcPath = NewPath("source_plain.txt"); + string zipPath = NewPath("create_from_file_plain.zip"); + const string entryName = "plain/copy.txt"; + const string payload = "this is plain"; + const string pwd = "anything"; + + if (File.Exists(srcPath)) File.Delete(srcPath); + if (File.Exists(zipPath)) File.Delete(zipPath); + + await File.WriteAllTextAsync(srcPath, payload, new UTF8Encoding(false)); + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var e = za.CreateEntryFromFile( + sourceFileName: srcPath, + entryName: entryName, + compressionLevel: CompressionLevel.Optimal, + password: pwd, + encryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto); + } + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var e = za.GetEntry(entryName); + Assert.NotNull(e); + + using var r = new StreamReader(e!.Open(pwd), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + string text = await r.ReadToEndAsync(); + Assert.Equal(payload, text); + + // Opening a plain entry with a password should throw + Assert.ThrowsAny(() => + { + using var _ = e.Open("some-password"); + }); + } } } + + +} diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs index c2534303c0c02b..3bbe7343b27098 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs @@ -32,6 +32,8 @@ public partial class ZipArchive : IDisposable, IAsyncDisposable private byte[] _archiveComment; private Encoding? _entryNameAndCommentEncoding; private long _firstDeletedEntryOffset; + //private string _defaultPassword = ""; + //private ZipArchiveEntry.EncryptionMethod _defaultEncryption = ZipArchiveEntry.EncryptionMethod.None; #if DEBUG_FORCE_ZIP64 public bool _forceZip64; diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index fac6862748e8f9..05809ab56ebb2a 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -412,7 +412,6 @@ public Stream Open() return OpenInWriteMode(); case ZipArchiveMode.Update: default: - Debug.Assert(_archive.Mode == ZipArchiveMode.Update); return OpenInUpdateMode(); } } @@ -440,7 +439,10 @@ public Stream Open(string password) case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - return OpenInUpdateMode(); + + if (!_isEncrypted) throw new InvalidDataException("Entry is not encrypted."); + return OpenInReadMode(checkOpenable: true, password.AsMemory()); + } } @@ -486,7 +488,7 @@ internal long GetOffsetOfCompressedData() return _storedOffsetOfCompressedData.Value; } - private MemoryStream GetUncompressedData() + private MemoryStream GetUncompressedData(string? password = null) { if (_storedUncompressedData == null) { @@ -498,7 +500,23 @@ private MemoryStream GetUncompressedData() if (_originallyInArchive) { - using (Stream decompressor = OpenInReadMode(false)) + + + if (_isEncrypted) + { + // We dont support edit-in-place for encrypted entries without an explicit password flow. + // Tell the caller to do the safe pattern: read with Open(password), then delete+recreate. + _storedUncompressedData.Dispose(); + _storedUncompressedData = null; + _currentlyOpenForWrite = false; + _everOpenedForWrite = false; + throw new InvalidOperationException( + "Editing an encrypted entry in-place is not supported. " + + "Read it with Open(password), then delete and recreate the entry with CreateEntry(..., password, ...)."); + } + + + using (Stream decompressor = OpenInReadMode(false, password.AsMemory())) { try { @@ -780,131 +798,8 @@ private void DetectEntryNameVersion() } } - //private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) - //{ - // // stream stack: backingStream -> DeflateStream -> CheckSumWriteStream - - // // By default we compress with deflate, except if compression level is set to NoCompression then stored is used. - // // Stored is also used for empty files, but we don't actually call through this function for that - we just write the stored value in the header - // // Deflate64 is not supported on all platforms - // Debug.Assert(CompressionMethod == CompressionMethodValues.Deflate - // || CompressionMethod == CompressionMethodValues.Stored); - - // bool isIntermediateStream = true; - // Stream compressorStream; - // switch (CompressionMethod) - // { - // case CompressionMethodValues.Stored: - // compressorStream = backingStream; - // isIntermediateStream = false; - // break; - // case CompressionMethodValues.Deflate: - // case CompressionMethodValues.Deflate64: - // default: - // compressorStream = new DeflateStream(backingStream, _compressionLevel, leaveBackingStreamOpen); - // break; - - // } - // bool leaveCompressorStreamOpenOnClose = leaveBackingStreamOpen && !isIntermediateStream; - // var checkSumStream = new CheckSumAndSizeWriteStream( - // compressorStream, - // backingStream, - // leaveCompressorStreamOpenOnClose, - // this, - // onClose, - // (long initialPosition, long currentPosition, uint checkSum, Stream backing, ZipArchiveEntry thisRef, EventHandler? closeHandler) => - // { - // thisRef._crc32 = checkSum; - // thisRef._uncompressedSize = currentPosition; - // thisRef._compressedSize = backing.Position - initialPosition; - // closeHandler?.Invoke(thisRef, EventArgs.Empty); - // }); - - // return checkSumStream; - //} - //private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) - //{ - // // final chain: backingStream <- ZipCrypto? <- Deflate/Stored <- CheckSumAndSizeWriteStream - - // Debug.Assert(CompressionMethod == CompressionMethodValues.Deflate - // || CompressionMethod == CompressionMethodValues.Stored - // || CompressionMethod == CompressionMethodValues.Deflate64); - - // bool isEncrypted = Encryption == _encryptionMethod; - // string? pwd = _password; - - - // // (A) Insert encrypting stream eagerly (ZipCrypto header is written NOW, before initialPosition capture) - // Stream targetSink = backingStream; - // if (isEncrypted) - // { - // if (string.IsNullOrEmpty(pwd)) - // throw new InvalidOperationException("Encrypted entry requires a non-empty password."); - - // // With streaming (GPBF bit 3), use DOS time low word for ZipCrypto header check bytes - // ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); - - // // Constructor writes 12-byte ZipCrypto header immediately - // targetSink = new ZipCryptoStream( - // baseStream: backingStream, - // password: pwd.AsMemory(), - // passwordVerifierLow2Bytes: verifierLow2Bytes, - // crc32: null); - // } - - // Stream compressorStream; - // bool isIntermediateStream = true; - - // switch (CompressionMethod) - // { - // case CompressionMethodValues.Stored: - // compressorStream = targetSink; - // // If not encrypted, there is no intermediate layer; otherwise ZipCrypto is an intermediate - // isIntermediateStream = isEncrypted; - // break; - - // case CompressionMethodValues.Deflate: - // case CompressionMethodValues.Deflate64: - // default: - // // NOTE: DeflateStream uses leaveBackingStreamOpen for its own inner stream, - // // which here is targetSink (possibly encrypting stream) - // compressorStream = new DeflateStream(targetSink, _compressionLevel, leaveBackingStreamOpen); - // isIntermediateStream = true; - // break; - // } - - // bool leaveCompressorStreamOpenOnClose = leaveBackingStreamOpen && !isIntermediateStream; - - // // (C) Return the checksum/size wrapper; add encryption overhead (12 for ZipCrypto) to compressed size - // var checkSumStream = new CheckSumAndSizeWriteStream( - // compressorStream, - // backingStream, - // leaveCompressorStreamOpenOnClose, - // this, - // onClose, - // (long initialPosition, long currentPosition, uint checkSum, Stream backing, ZipArchiveEntry thisRef, EventHandler? closeHandler) => - // { - // // CRC is over plaintext (as your CheckSumAndSizeWriteStream computes) - // thisRef._crc32 = checkSum; - // thisRef._uncompressedSize = currentPosition; - - // long rawCompressed = backing.Position - initialPosition; - - // // Because ZipCrypto header was written BEFORE initialPosition was captured, - // // we must add the overhead explicitly. - // if (thisRef.Encryption == EncryptionMethod.ZipCrypto) - // { - // rawCompressed += 12; // 12 for ZipCrypto - // } - - // thisRef._compressedSize = rawCompressed; - // closeHandler?.Invoke(thisRef, EventArgs.Empty); - // }); - - // return checkSumStream; - //} private CheckSumAndSizeWriteStream GetDataCompressor( - Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose, string? password = null, EncryptionMethod encryption = EncryptionMethod.None) + Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) { // final chain: backingStream <- ZipCrypto? <- Deflate/Stored <- CheckSumAndSizeWriteStream @@ -912,8 +807,7 @@ private CheckSumAndSizeWriteStream GetDataCompressor( || CompressionMethod == CompressionMethodValues.Stored || CompressionMethod == CompressionMethodValues.Deflate64); - bool isEncrypted = Encryption == encryption; // your internal property - string? pwd = password; + string? pwd = _password; // Build target sink (encrypting layer if needed). Header will be emitted on the first write. Stream targetSink = backingStream; @@ -940,7 +834,7 @@ private CheckSumAndSizeWriteStream GetDataCompressor( { case CompressionMethodValues.Stored: compressorStream = targetSink; - isIntermediateStream = isEncrypted; // only intermediate if we added encryption + isIntermediateStream = IsEncrypted; // only intermediate if we added encryption break; case CompressionMethodValues.Deflate: @@ -1068,32 +962,6 @@ private WrappedStream OpenInWriteMode() return new WrappedStream(baseStream: _outstandingWriteStream, closeBaseStream: true); } - - private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod encryption = EncryptionMethod.None) - { - if (_everOpenedForWrite) - throw new IOException(SR.CreateModeWriteOnceAndOneEntryAtATime); - - // we assume that if another entry grabbed the archive stream, that it set this entry's _everOpenedForWrite property to true by calling WriteLocalFileHeaderAndDataIfNeeded - _archive.DebugAssertIsStillArchiveStreamOwner(this); - - _everOpenedForWrite = true; - Changes |= ZipArchive.ChangeState.StoredData; - CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor(_archive.ArchiveStream, true, (object? o, EventArgs e) => - { - // release the archive stream - var entry = (ZipArchiveEntry)o!; - entry._archive.ReleaseArchiveStream(entry); - entry._outstandingWriteStream = null; - }, - password, - encryption - ); - _outstandingWriteStream = new DirectToArchiveWriterStream(crcSizeStream, this); - - return new WrappedStream(baseStream: _outstandingWriteStream, closeBaseStream: true); - } - private WrappedStream OpenInUpdateMode() { if (_currentlyOpenForWrite) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index a3e62d5cde9654..4c9df6ea95ea4b 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -1,250 +1,17 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -//namespace System.IO.Compression -//{ -// // Internal read-only, non-seekable stream that: -// // - Initializes ZipCrypto keys from the password -// // - Reads & decrypts the 12-byte header and validates the check byte -// // - Decrypts subsequent bytes on Read(...) -// internal sealed class ZipCryptoStream : Stream -// { -// private readonly bool _encrypting; -// private readonly Stream _base; -// private uint _key0; -// private uint _key1; -// private uint _key2; -// private static readonly uint[] crc2Table = CreateCrc32Table(); - -// private static uint[] CreateCrc32Table() { - -// var table = new uint[256]; -// for (uint i = 0; i < 256; i++) -// { -// uint c = i; -// for (int j = 0; j < 8; j++) -// c = (c & 1) != 0 ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); -// table[i] = c; -// } -// return table; - -// } - -// // decryption constructor -// public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) -// { -// _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); -// InitKeys(password.Span); -// _encrypting = false; -// ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes -// } - -// public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, ushort passwordVerifierLow2Bytes, uint? crc32 = null) -// { -// _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); -// _encrypting = true; - -// InitKeys(password.Span); -// CreateAndWriteHeader(passwordVerifierLow2Bytes, crc32); -// } - -// private void CreateAndWriteHeader(ushort verifierLow2Bytes, uint? crc32) -// { -// byte[] hdrPlain = new byte[12]; - -// // 0..9: random -// for (int i = 0; i < 10; i++) -// hdrPlain[i] = 0; - - -// // 10..11: check bytes -// if (crc32.HasValue) -// { -// uint crc = crc32.Value; -// hdrPlain[10] = (byte)((crc >> 16) & 0xFF); -// hdrPlain[11] = (byte)((crc >> 24) & 0xFF); -// } -// else -// { -// // Fallback when CRC32 is not yet known -// hdrPlain[10] = (byte)(verifierLow2Bytes & 0xFF); -// hdrPlain[11] = (byte)((verifierLow2Bytes >> 8) & 0xFF); -// } - -// // Encrypt header and write -// byte[] hdrCiph = new byte[12]; -// for (int i = 0; i < 12; i++) -// { -// hdrCiph[i] = EncryptByte(hdrPlain[i]); // EncryptByte updates keys with PLAINTEXT -// } - -// _base.Write(hdrCiph, 0, hdrCiph.Length); -// } - - -// private byte EncryptByte(byte plain) -// { -// byte ks = DecipherByte(); -// byte ciph = (byte)(plain ^ ks); -// UpdateKeys(plain); -// return ciph; -// } - - -// private void InitKeys(ReadOnlySpan password) -// { -// _key0 = 305419896; -// _key1 = 591751049; -// _key2 = 878082192; - -// foreach (char ch in password) -// { -// UpdateKeys((byte)ch); -// } -// } - -// private void ValidateHeader(byte expectedCheckByte) -// { -// byte[] hdr = new byte[12]; -// int read = 0; -// while (read < hdr.Length) -// { -// int n = _base.Read(hdr.AsSpan(read)); -// if (n <= 0) throw new InvalidDataException("Truncated ZipCrypto header."); -// read += n; -// } - -// for (int i = 0; i < hdr.Length; i++) -// { -// hdr[i] = DecryptByte(hdr[i]); -// } - -// if (hdr[11] != expectedCheckByte) -// throw new InvalidDataException("Invalid password for encrypted ZIP entry."); -// } - -// private void UpdateKeys(byte b) -// { -// _key0 = Crc32Update(_key0, b); -// _key1 += (_key0 & 0xFF); -// _key1 = _key1 * 134775813 + 1; -// _key2 = Crc32Update(_key2, (byte)(_key1 >> 24)); -// } - -// private byte DecipherByte() -// { -// ushort temp = (ushort)(_key2 | 2); -// return (byte)((temp * (temp ^ 1)) >> 8); -// } - -// private byte DecryptByte(byte ciph) -// { -// byte m = DecipherByte(); -// byte plain = (byte)(ciph ^ m); -// UpdateKeys(plain); -// return plain; -// } - -// // ---- Stream overrides ---- - -// public override bool CanRead => !_encrypting; -// public override bool CanSeek => false; -// public override bool CanWrite => _encrypting; -// public override long Length => throw new NotSupportedException(); -// public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } -// public override void Flush() => _base.Flush(); - -// public override int Read(byte[] buffer, int offset, int count) -// { -// ArgumentNullException.ThrowIfNull(buffer); -// if ((uint)offset > buffer.Length || (uint)count > buffer.Length - offset) -// throw new ArgumentOutOfRangeException(); - -// int n = _base.Read(buffer, offset, count); -// for (int i = 0; i < n; i++) -// { -// buffer[offset + i] = DecryptByte(buffer[offset + i]); -// } -// return n; -// } - -// public override int Read(Span destination) -// { -// int n = _base.Read(destination); -// for (int i = 0; i < n; i++) -// { -// destination[i] = DecryptByte(destination[i]); -// } -// return n; -// } - -// 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) -// { -// if (_encrypting) -// { -// ArgumentNullException.ThrowIfNull(buffer); -// if ((uint)offset > (uint)buffer.Length || (uint)count > (uint)(buffer.Length - offset)) -// throw new ArgumentOutOfRangeException(); - -// // Simple temporary buffer; no ArrayPool, no async -// byte[] tmp = new byte[count]; -// for (int i = 0; i < count; i++) -// { -// tmp[i] = EncryptByte(buffer[offset + i]); -// } -// _base.Write(tmp, 0, count); -// return; -// } -// throw new NotSupportedException("Stream is in decryption (read-only) mode."); -// } - -// public override void Write(ReadOnlySpan buffer) -// { -// if (_encrypting) -// { -// // Simple temporary buffer; no ArrayPool, no async -// byte[] tmp = new byte[buffer.Length]; -// for (int i = 0; i < buffer.Length; i++) -// { -// tmp[i] = EncryptByte(buffer[i]); -// } -// _base.Write(tmp, 0, tmp.Length); -// return; -// } -// throw new NotSupportedException("Stream is in decryption (read-only) mode."); -// } - - -// protected override void Dispose(bool disposing) -// { -// if (disposing) _base.Dispose(); -// base.Dispose(disposing); -// } - -// // TODO: replace with the runtime's internal CRC32 update routine (fast table-based). -// private static uint Crc32Update(uint crc, byte b) -// { -// return crc2Table[(crc ^ b) & 0xFF] ^ (crc >> 8); -// } -// } -//} -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - namespace System.IO.Compression { internal sealed class ZipCryptoStream : Stream { private readonly bool _encrypting; private readonly Stream _base; - private readonly bool _leaveOpen; // NEW - private bool _headerWritten; // NEW - private bool _everWrotePayload; // NEW - private readonly ushort _verifierLow2Bytes; // NEW (DOS time low word when streaming) - private readonly uint? _crc32ForHeader; // NEW (CRC-based header when not streaming) + private readonly bool _leaveOpen; + private bool _headerWritten; + private bool _everWrotePayload; + private readonly ushort _verifierLow2Bytes; // (DOS time low word when streaming) + private readonly uint? _crc32ForHeader; // (CRC-based header when not streaming) private uint _key0; private uint _key1; @@ -264,7 +31,7 @@ private static uint[] CreateCrc32Table() return table; } - // Decryption constructor (unchanged semantics) + // Decryption constructor public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) { _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); @@ -278,7 +45,7 @@ public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, ushort passwordVerifierLow2Bytes, uint? crc32 = null, - bool leaveOpen = false) // NEW + bool leaveOpen = false) { _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); _encrypting = true; @@ -287,16 +54,16 @@ public ZipCryptoStream(Stream baseStream, _crc32ForHeader = crc32; InitKeysFromBytes(password.Span); - // NOTE: Do NOT write the 12-byte header here anymore. } - private void EnsureHeader() // NEW + private void EnsureHeader() { if (!_encrypting || _headerWritten) return; Span hdrPlain = stackalloc byte[12]; - // bytes 0..9: random + // bytes 0..9 are random + // TODO: change to actual random data later for (int i = 0; i < 10; i++) hdrPlain[i] = 0; @@ -327,14 +94,14 @@ private void EnsureHeader() // NEW _headerWritten = true; } - private void InitKeysFromBytes(ReadOnlySpan password) // NEW (byte-based init) + private void InitKeysFromBytes(ReadOnlySpan password) { _key0 = 305419896; _key1 = 591751049; _key2 = 878082192; // ZipCrypto uses raw bytes; ASCII is the most interoperable (UTF8 also acceptable). - var bytes = System.Text.Encoding.ASCII.GetBytes(password.ToString()); + var bytes = password.ToArray(); foreach (byte b in bytes) UpdateKeys(b); } @@ -426,10 +193,10 @@ public override void Write(byte[] buffer, int offset, int count) if ((uint)offset > (uint)buffer.Length || (uint)count > (uint)(buffer.Length - offset)) throw new ArgumentOutOfRangeException(); - EnsureHeader(); // NEW + EnsureHeader(); _everWrotePayload = _everWrotePayload || (count > 0); - // Simple temp buffer; optimize with ArrayPool if desired + // Simple buffer; optimize with ArrayPool if needed later byte[] tmp = new byte[count]; for (int i = 0; i < count; i++) { @@ -445,7 +212,7 @@ public override void Write(ReadOnlySpan buffer) { if (!_encrypting) throw new NotSupportedException("Stream is in decryption (read-only) mode."); - EnsureHeader(); // NEW + EnsureHeader(); _everWrotePayload = _everWrotePayload || (buffer.Length > 0); byte[] tmp = new byte[buffer.Length]; From aac140b1ae79d471c53c0afad51f0747e72d3a5e Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 29 Oct 2025 14:27:53 +0100 Subject: [PATCH 07/39] add default password and encryption method to ziparchive --- .../tests/ZipFile.Extract.cs | 131 +++++++++++++++++- .../ref/System.IO.Compression.cs | 1 + .../src/System/IO/Compression/ZipArchive.cs | 94 ++++++++++++- .../System/IO/Compression/ZipArchiveEntry.cs | 24 ++-- 4 files changed, 235 insertions(+), 15 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 9de81530caf6c1..873b3f07e61bab 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; @@ -669,7 +669,7 @@ public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() } } - // Act 2: Read backencrypted need password, plain do not + // Act 2: Read back—encrypted need password, plain do not using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) { // Encrypted @@ -1218,6 +1218,133 @@ public async Task CreateEntryFromFile_WithEncryption_RoundTrip() }); } } + + + + + [Fact] + public void CreateEntry_UsesArchiveDefaults_WhenNotOverridden() + { + Directory.CreateDirectory(DownloadsDir); + var zipPath = NewPath("defaults_apply.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + const string defaultPassword = "archive-pw"; + const string payload = "default encryption content"; + const string entryName = "secure/default.txt"; + + using (var zipFs = File.Create(zipPath)) + using (var za = new ZipArchive(zipFs, + ZipArchiveMode.Create, + leaveOpen: false, + entryNameEncoding: Encoding.UTF8, + defaultPassword: defaultPassword, + defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) + { + var e = za.CreateEntry(entryName); + + // OPEN → WRITE → DISPOSE (single scope) + using (var es = e.Open()) + { + var bytes = Encoding.UTF8.GetBytes(payload); + es.Write(bytes, 0, bytes.Length); + } + // no other entry opened while this one was open + } + + // Verify with the archive default password + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var e = za.GetEntry(entryName); + Assert.NotNull(e); + using var r = new StreamReader(e!.Open(defaultPassword), Encoding.UTF8); + Assert.Equal(payload, r.ReadToEnd()); + } + } + + [Fact] + public async Task CreateMode_DefaultPassword_AppliesToMultipleEntries() + { + string zipPath = NewPath("defaults_multiple.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + const string defaultPassword = "archive-pw"; + + using (var zipFs = File.Create(zipPath)) + using (var za = new ZipArchive(zipFs, + ZipArchiveMode.Create, + leaveOpen: false, + entryNameEncoding: Encoding.UTF8, + defaultPassword: defaultPassword, + defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) + { + var e1 = za.CreateEntry("secure/one.txt"); + using (var s1 = e1.Open()) + { + var b = Encoding.UTF8.GetBytes("ONE"); + s1.Write(b, 0, b.Length); + } + + var e2 = za.CreateEntry("secure/two.txt"); + using (var s2 = e2.Open()) + { + var b = Encoding.UTF8.GetBytes("TWO"); + s2.Write(b, 0, b.Length); + } + } + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + using (var r1 = new StreamReader(za.GetEntry("secure/one.txt")!.Open(defaultPassword), Encoding.UTF8)) + Assert.Equal("ONE", await r1.ReadToEndAsync()); + + using (var r2 = new StreamReader(za.GetEntry("secure/two.txt")!.Open(defaultPassword), Encoding.UTF8)) + Assert.Equal("TWO", await r2.ReadToEndAsync()); + } + } + + [Fact] + public async Task CreateEntry_WithExplicitPassword_OverridesDefaultPassword() + { + string zipPath = NewPath("override_default.zip"); + if (File.Exists(zipPath)) File.Delete(zipPath); + + const string archivePassword = "archive-pw"; + const string entryPassword = "entry-pw"; + + using (var zipFs = File.Create(zipPath)) + using (var za = new ZipArchive(zipFs, + ZipArchiveMode.Create, + leaveOpen: false, + entryNameEncoding: Encoding.UTF8, + defaultPassword: archivePassword, + defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) + { + var e = za.CreateEntry("secure/override.txt", entryPassword, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using (var s = e.Open()) + { + var b = Encoding.UTF8.GetBytes("OVERRIDE"); + s.Write(b, 0, b.Length); + } + } + + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var e = za.GetEntry("secure/override.txt"); + Assert.NotNull(e); + + // Should succeed with entry password + using (var rOk = new StreamReader(e!.Open(entryPassword), Encoding.UTF8)) + Assert.Equal("OVERRIDE", await rOk.ReadToEndAsync()); + + // Wrong: using archive default should fail + Assert.ThrowsAny(() => + { + using var _ = e.Open(archivePassword); + }); + } + } + } diff --git a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs index 83acf40d183457..9194f4aaf13d40 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -96,6 +96,7 @@ public ZipArchive(System.IO.Stream stream) { } public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode) { } public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode, bool leaveOpen) { } public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode, bool leaveOpen, System.Text.Encoding? entryNameEncoding) { } + public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode, bool leaveOpen, System.Text.Encoding? entryNameEncoding, string? defaultPassword, System.IO.Compression.ZipArchiveEntry.EncryptionMethod defaultEncryption) { } [System.Diagnostics.CodeAnalysis.AllowNullAttribute] public string Comment { get { throw null; } set { } } public System.Collections.ObjectModel.ReadOnlyCollection Entries { get { throw null; } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs index 3bbe7343b27098..5cd5ef5121d3ec 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs @@ -32,8 +32,10 @@ public partial class ZipArchive : IDisposable, IAsyncDisposable private byte[] _archiveComment; private Encoding? _entryNameAndCommentEncoding; private long _firstDeletedEntryOffset; - //private string _defaultPassword = ""; - //private ZipArchiveEntry.EncryptionMethod _defaultEncryption = ZipArchiveEntry.EncryptionMethod.None; + private readonly string? _defaultPassword; + private readonly ZipArchiveEntry.EncryptionMethod _defaultEncryption; + + #if DEBUG_FORCE_ZIP64 public bool _forceZip64; @@ -178,6 +180,66 @@ public ZipArchive(Stream stream, ZipArchiveMode mode, bool leaveOpen, Encoding? } } + + public ZipArchive(Stream stream, ZipArchiveMode mode, bool leaveOpen, Encoding? entryNameEncoding, string? defaultPassword, ZipArchiveEntry.EncryptionMethod defaultEncryption) + : this(mode, leaveOpen, entryNameEncoding, backingStream: null, archiveStream: DecideArchiveStream(mode, stream), defaultPassword: defaultPassword, defaultEncryption: defaultEncryption) + { + ArgumentNullException.ThrowIfNull(stream); + + Stream? extraTempStream = null; + + try + { + _backingStream = null; + + if (ValidateMode(mode, stream)) + { + _backingStream = stream; + extraTempStream = stream = new MemoryStream(); + _backingStream.CopyTo(stream); + stream.Seek(0, SeekOrigin.Begin); + } + + _archiveStream = DecideArchiveStream(mode, stream); + + switch (mode) + { + case ZipArchiveMode.Create: + _readEntries = true; + break; + + case ZipArchiveMode.Read: + ReadEndOfCentralDirectory(); + break; + + case ZipArchiveMode.Update: + default: + Debug.Assert(mode == ZipArchiveMode.Update); + if (_archiveStream.Length == 0) + { + _readEntries = true; + } + else + { + ReadEndOfCentralDirectory(); + EnsureCentralDirectoryRead(); + + foreach (ZipArchiveEntry entry in _entries) + { + entry.ThrowIfNotOpenable(needToUncompress: false, needToLoadIntoMemory: true); + } + } + break; + } + } + catch + { + extraTempStream?.Dispose(); + throw; + } + } + + /// Helper constructor that initializes some of the essential ZipArchive /// information that other constructors initialize the same way. /// Validations, checks and entry collection need to be done outside this constructor. @@ -201,6 +263,22 @@ private ZipArchive(ZipArchiveMode mode, bool leaveOpen, Encoding? entryNameEncod _firstDeletedEntryOffset = long.MaxValue; } + + private ZipArchive(ZipArchiveMode mode, bool leaveOpen, Encoding? entryNameEncoding, Stream? backingStream, Stream archiveStream, string? defaultPassword, ZipArchiveEntry.EncryptionMethod defaultEncryption) + : this(mode, leaveOpen, entryNameEncoding, backingStream, archiveStream) + { + _defaultPassword = string.IsNullOrEmpty(defaultPassword) ? null : defaultPassword; + _defaultEncryption = defaultEncryption; + + // Optional guardrails: if user gives a default password but sets encryption None, allow it (password is simply unused); + // if encryption is ZipCrypto but password is null/empty, you can either throw here or defer to CreateEntry validation. + if (_defaultEncryption == ZipArchiveEntry.EncryptionMethod.ZipCrypto && _defaultPassword is null) + { + throw new ArgumentException("A default password must be provided when defaultEncryption is ZipCrypto.", nameof(defaultPassword)); + } + } + + /// /// Gets or sets the optional archive comment. /// @@ -356,6 +434,9 @@ protected virtual void Dispose(bool disposing) internal uint NumberOfThisDisk => _numberOfThisDisk; + internal string? DefaultPassword => _defaultPassword; + internal ZipArchiveEntry.EncryptionMethod DefaultEncryption => _defaultEncryption; + internal Encoding? EntryNameAndCommentEncoding { get => _entryNameAndCommentEncoding; @@ -406,9 +487,11 @@ private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compre ZipArchiveEntry entry; if (compressionLevel.HasValue) { - if (password != null) { + if (password != null) + { entry = new ZipArchiveEntry(this, entryName, compressionLevel.Value, password, encryption); - } else + } + else { entry = new ZipArchiveEntry(this, entryName, compressionLevel.Value); } @@ -419,7 +502,8 @@ private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compre { entry = new ZipArchiveEntry(this, entryName, password, encryption); } - else { + else + { entry = new ZipArchiveEntry(this, entryName); } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index 05809ab56ebb2a..94e0cde6285974 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -98,6 +98,9 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) _fileComment = cd.FileComment; _compressionLevel = MapCompressionLevel(_generalPurposeBitFlag, CompressionMethod); + + _password = archive.DefaultPassword; + _encryptionMethod = archive.DefaultEncryption; } // Initializes a ZipArchiveEntry instance for a new archive entry with a specified compression level. @@ -110,6 +113,8 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName, CompressionLevel CompressionMethod = CompressionMethodValues.Stored; } _generalPurposeBitFlag = MapDeflateCompressionOption(_generalPurposeBitFlag, _compressionLevel, CompressionMethod); + _password = archive.DefaultPassword; + _encryptionMethod = archive.DefaultEncryption; } // Initializes a ZipArchiveEntry instance for a new archive entry. @@ -161,6 +166,9 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName) } Changes = ZipArchive.ChangeState.Unchanged; + + _password = archive.DefaultPassword; + _encryptionMethod = archive.DefaultEncryption; } internal ZipArchiveEntry(ZipArchive archive, string entryName, CompressionLevel compressionLevel, string? password, EncryptionMethod encryptionMethod = EncryptionMethod.ZipCrypto) @@ -404,10 +412,12 @@ public Stream Open() { ThrowIfInvalidArchive(); + bool isEncrypted = !string.IsNullOrEmpty(_archive.DefaultPassword) && _archive.DefaultEncryption != EncryptionMethod.None; + switch (_archive.Mode) { case ZipArchiveMode.Read: - return OpenInReadMode(checkOpenable: true); + return isEncrypted ? OpenInReadMode(checkOpenable: true, _archive.DefaultPassword.AsMemory()) : OpenInReadMode(checkOpenable: true); case ZipArchiveMode.Create: return OpenInWriteMode(); case ZipArchiveMode.Update: @@ -801,17 +811,17 @@ private void DetectEntryNameVersion() private CheckSumAndSizeWriteStream GetDataCompressor( Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) { - // final chain: backingStream <- ZipCrypto? <- Deflate/Stored <- CheckSumAndSizeWriteStream + // final chain: backingStream <- Encryption <- Deflate/Stored <- CheckSumAndSizeWriteStream Debug.Assert(CompressionMethod == CompressionMethodValues.Deflate || CompressionMethod == CompressionMethodValues.Stored || CompressionMethod == CompressionMethodValues.Deflate64); - string? pwd = _password; + string? pwd = string.IsNullOrEmpty(_password) ? _archive.DefaultPassword : _password; - // Build target sink (encrypting layer if needed). Header will be emitted on the first write. + // Build encrypting layer if needed. Header will be emitted on the first write. Stream targetSink = backingStream; - if (IsZipCryptoEncrypted()) + if (IsZipCryptoEncrypted() || _archive.DefaultEncryption == EncryptionMethod.ZipCrypto) { if (string.IsNullOrEmpty(pwd)) throw new InvalidOperationException("Encrypted entry requires a non-empty password."); @@ -824,7 +834,7 @@ private CheckSumAndSizeWriteStream GetDataCompressor( password: pwd.AsMemory(), passwordVerifierLow2Bytes: verifierLow2Bytes, crc32: null, - leaveOpen: leaveBackingStreamOpen); // honor leaveOpen semantics + leaveOpen: leaveBackingStreamOpen); } Stream compressorStream; @@ -859,7 +869,6 @@ private CheckSumAndSizeWriteStream GetDataCompressor( thisRef._crc32 = checkSum; thisRef._uncompressedSize = currentPosition; - // No +12 needed anymore: the header was written after initialPosition was captured. long rawCompressed = backing.Position - initialPosition; thisRef._compressedSize = rawCompressed; @@ -902,7 +911,6 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead, ReadOnlyMemory byte expectedCheckByte = CalculateZipCryptoCheckByte(); - // This stream will read & validate the 12-byte header and then yield plaintext compressed bytes. toDecompress = new ZipCryptoStream(toDecompress, password, expectedCheckByte); } From eb1a46079fca3fae6c69304432f9a453ec4ea578 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 29 Oct 2025 15:06:24 +0100 Subject: [PATCH 08/39] avoid ambigous calls --- .../ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs | 4 ++-- .../System.IO.Compression/ref/System.IO.Compression.cs | 2 +- .../src/System/IO/Compression/ZipArchiveEntry.Async.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs index c4c571b1ca9837..0fb8710db3160c 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs @@ -99,8 +99,8 @@ public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string await using (fs) { Stream es; - if (password.Length > 1) - es = await source.OpenAsync(cancellationToken, password).ConfigureAwait(false); + if (!string.IsNullOrEmpty(password)) + es = await source.OpenAsync(password, cancellationToken).ConfigureAwait(false); else es = await source.OpenAsync(cancellationToken).ConfigureAwait(false); await using (es) diff --git a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs index 9194f4aaf13d40..6628fe68080cd2 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -130,8 +130,8 @@ internal ZipArchiveEntry() { } public void Delete() { } public System.IO.Stream Open() { throw null; } public System.IO.Stream Open(string password) { throw null; } + public System.Threading.Tasks.Task OpenAsync(string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken), string password = "") { throw null; } public override string ToString() { throw null; } public enum EncryptionMethod : byte { diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 3d77fbff6075d7..48b946bd1f9669 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -37,7 +37,7 @@ public async Task OpenAsync(CancellationToken cancellationToken = defaul } } - public async Task OpenAsync(CancellationToken cancellationToken = default, string password = "") + public async Task OpenAsync(string password, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); From 0a7c885412a4a3616161c410bd148d7f6a03a325 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 29 Oct 2025 15:36:37 +0100 Subject: [PATCH 09/39] avoid ambigous calls and fi some nitpicks --- .../System/IO/Compression/ZipTestHelper.cs | 2 +- .../ref/System.IO.Compression.ZipFile.cs | 4 ++-- .../ZipFileExtensions.ZipArchive.Create.cs | 2 +- ...xtensions.ZipArchiveEntry.Extract.Async.cs | 6 ++--- ...pFileExtensions.ZipArchiveEntry.Extract.cs | 2 +- .../tests/ZipFile.Extract.cs | 2 +- .../src/System/IO/Compression/ZipArchive.cs | 24 ++++++------------- 7 files changed, 16 insertions(+), 26 deletions(-) diff --git a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs index 57782feb62b8e2..a3ccd52901601e 100644 --- a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs +++ b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs @@ -559,7 +559,7 @@ public static async Task DisposeZipArchive(bool async, ZipArchive archive) public static async Task OpenEntryStream(bool async, ZipArchiveEntry entry) { - return async ? await entry.OpenAsync(cancellationToken: default) : entry.Open(); + return async ? await entry.OpenAsync() : entry.Open(); } public static async Task DisposeStream(bool async, Stream stream) diff --git a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs index 479ca0180cce98..b678c368782f50 100644 --- a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs +++ b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs @@ -59,9 +59,9 @@ public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry sour public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite) { } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, string password) { } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, string password) { } + public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, string? password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken), string password = "") { throw null; } + public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, string? password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken), string password = "") { throw null; } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs index 3e70aa9e4daded..1fe78d9e53f21b 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs @@ -104,7 +104,7 @@ private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(Zip ArgumentNullException.ThrowIfNull(sourceFileName); ArgumentNullException.ThrowIfNull(entryName); - if (password != null && encryption == ZipArchiveEntry.EncryptionMethod.None) + if (!string.IsNullOrEmpty(password) && encryption == ZipArchiveEntry.EncryptionMethod.None) { throw new ArgumentException("password and encryption should both be set"); } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs index 0fb8710db3160c..5e4a4604db53aa 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs @@ -86,10 +86,10 @@ public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string ExtractToFileFinalize(source, destinationFileName); } - public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, CancellationToken cancellationToken = default, string password = "") => - await ExtractToFileAsync(source, destinationFileName, false, cancellationToken, password).ConfigureAwait(false); + public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, string? password, CancellationToken cancellationToken = default) => + await ExtractToFileAsync(source, destinationFileName, false, password, cancellationToken).ConfigureAwait(false); - public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, bool overwrite, CancellationToken cancellationToken = default, string password = "") + public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, bool overwrite, string? password, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs index fb00173c8237f8..8d252fc6a0b89b 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs @@ -83,7 +83,7 @@ public static void ExtractToFile(this ZipArchiveEntry source, string destination using (FileStream fs = new FileStream(destinationFileName, fileStreamOptions)) { - if (password.Length > 0) + if (!string.IsNullOrEmpty(password)) { using (Stream es = source.Open(password)) es.CopyTo(fs); diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 873b3f07e61bab..5598097bcfeca6 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -375,7 +375,7 @@ public async Task ExtractToFileAsync_WithCancellation_ShouldCancel() cts.Cancel(); // Cancel immediately await Assert.ThrowsAsync(async () => { - await entry.ExtractToFileAsync(tempFile, overwrite: true, cts.Token, password: "123456789"); + await entry.ExtractToFileAsync(tempFile, overwrite: true, password: "123456789", cts.Token); }); } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs index 5cd5ef5121d3ec..458e7913129b11 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs @@ -270,7 +270,7 @@ private ZipArchive(ZipArchiveMode mode, bool leaveOpen, Encoding? entryNameEncod _defaultPassword = string.IsNullOrEmpty(defaultPassword) ? null : defaultPassword; _defaultEncryption = defaultEncryption; - // Optional guardrails: if user gives a default password but sets encryption None, allow it (password is simply unused); + // Optional guardrails: if user gives a default password but sets encryption None, allow it (password is simply unused) ?? // if encryption is ZipCrypto but password is null/empty, you can either throw here or defer to CreateEntry validation. if (_defaultEncryption == ZipArchiveEntry.EncryptionMethod.ZipCrypto && _defaultPassword is null) { @@ -487,25 +487,15 @@ private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compre ZipArchiveEntry entry; if (compressionLevel.HasValue) { - if (password != null) - { - entry = new ZipArchiveEntry(this, entryName, compressionLevel.Value, password, encryption); - } - else - { - entry = new ZipArchiveEntry(this, entryName, compressionLevel.Value); - } + entry = !string.IsNullOrEmpty(password) + ? new ZipArchiveEntry(this, entryName, compressionLevel.Value, password, encryption) + : new ZipArchiveEntry(this, entryName, compressionLevel.Value); } else { - if (password != null) - { - entry = new ZipArchiveEntry(this, entryName, password, encryption); - } - else - { - entry = new ZipArchiveEntry(this, entryName); - } + entry = !string.IsNullOrEmpty(password) + ? new ZipArchiveEntry(this, entryName, password, encryption) + : new ZipArchiveEntry(this, entryName); } AddEntry(entry); From 817165cd110a29ef1033f59c4ebe4ff308247b7c Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 5 Nov 2025 15:09:39 +0100 Subject: [PATCH 10/39] recognize aes encrypted files + build fais due to circular dependency with cryptography lib --- .../tests/ZipFile.Extract.cs | 22 +- .../ref/System.IO.Compression.cs | 3 + .../src/System.IO.Compression.csproj | 5 +- .../src/System/IO/Compression/AesStream.cs | 316 ++++++++++++++++++ .../System/IO/Compression/ZipArchiveEntry.cs | 127 ++++++- .../src/System/IO/Compression/ZipBlocks.cs | 52 +++ .../System/IO/Compression/ZipCryptoStream.cs | 2 +- 7 files changed, 500 insertions(+), 27 deletions(-) create mode 100644 src/libraries/System.IO.Compression/src/System/IO/Compression/AesStream.cs diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 5598097bcfeca6..0233394122f3e0 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -265,8 +265,7 @@ public void ExtractEncryptedEntryToFile_WithWrongPassword_ShouldThrow() { string ZipPath = @"C:\Users\spahontu\Downloads\test.zip"; string EntryName = "hello.txt"; - ReadOnlyMemory CorrectPassword = "123456789".AsMemory(); - + string tempFile = Path.Combine(Path.GetTempPath(), "hello_extracted.txt"); if (File.Exists(tempFile)) File.Delete(tempFile); @@ -391,15 +390,12 @@ public void OpenEncryptedJpeg_ShouldDecryptAndMatchOriginal() using var archive = ZipFile.OpenRead(zipPath); var entry = archive.Entries.First(e => e.FullName.EndsWith("test.jpg", StringComparison.OrdinalIgnoreCase)); - // Act: open decrypted + decompressed stream using var stream = entry.Open("123456789"); - // Read all bytes using var ms = new MemoryStream(); stream.CopyTo(ms); byte[] actualBytes = ms.ToArray(); - // Optional: compare with original file byte[] expectedBytes = File.ReadAllBytes(originalPath); Assert.Equal(expectedBytes.Length, actualBytes.Length); Assert.Equal(expectedBytes, actualBytes); @@ -1243,13 +1239,11 @@ public void CreateEntry_UsesArchiveDefaults_WhenNotOverridden() { var e = za.CreateEntry(entryName); - // OPEN → WRITE → DISPOSE (single scope) using (var es = e.Open()) { var bytes = Encoding.UTF8.GetBytes(payload); es.Write(bytes, 0, bytes.Length); } - // no other entry opened while this one was open } // Verify with the archive default password @@ -1345,6 +1339,20 @@ public async Task CreateEntry_WithExplicitPassword_OverridesDefaultPassword() } } + [Fact] + public void OpenAESEncryptedTxtFile_ShouldReturnPlaintext() + { + string zipPath = Path.Join(DownloadsDir, "plainwr.zip"); + using var archive = ZipFile.OpenRead(zipPath); + + var entry = archive.Entries.First(e => e.FullName.EndsWith("source_plain.txt")); + using var stream = entry.Open("123456789"); + using var reader = new StreamReader(stream); + string content = reader.ReadToEnd(); + + Assert.Equal("this is plain", content); + } + } diff --git a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs index 6628fe68080cd2..fe1ffe0e3030ef 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -137,6 +137,9 @@ public enum EncryptionMethod : byte { None = (byte)0, ZipCrypto = (byte)1, + Aes128 = (byte)2, + Aes192 = (byte)3, + Aes256 = (byte)4, } } public enum ZipArchiveMode diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index f95ead16d5292c..4ff73a2bf41a18 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -1,4 +1,4 @@ - + $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-unix;$(NetCoreAppCurrent)-browser;$(NetCoreAppCurrent)-wasi;$(NetCoreAppCurrent) @@ -71,9 +71,10 @@ + - + diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/AesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/AesStream.cs new file mode 100644 index 00000000000000..271eeb4e5a8b09 --- /dev/null +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/AesStream.cs @@ -0,0 +1,316 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography; + +namespace System.IO.Compression +{ + internal sealed class AesStream : Stream + { + private readonly Stream _baseStream; + private readonly bool _encrypting; + private readonly int _keySizeBits; + private readonly bool _ae2; + private readonly uint? _crc32ForHeader; + private readonly Aes _aes; + private ICryptoTransform? _aesEncryptor; +#pragma warning disable CA1416 // HMACSHA1 is available on all platforms + private readonly HMACSHA1 _hmac; +#pragma warning restore CA1416 + private readonly byte[] _counterBlock = new byte[16]; + private byte[]? _key; + private byte[]? _hmacKey; + private byte[]? _salt; + private byte[]? _passwordVerifier; + private bool _headerWritten; + private bool _headerRead; + private long _position; + private readonly ReadOnlyMemory _password; + private bool _disposed; + + public AesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null) + { + ArgumentNullException.ThrowIfNull(baseStream); + + + _baseStream = baseStream; + _password = password; + _encrypting = encrypting; + _keySizeBits = keySizeBits; + _ae2 = ae2; + _crc32ForHeader = crc32; +#pragma warning disable CA1416 // HMACSHA1 is available on all platforms + _aes = Aes.Create(); +#pragma warning restore CA1416 + _aes.Mode = CipherMode.ECB; + _aes.Padding = PaddingMode.None; + +#pragma warning disable CA1416 // HMACSHA1 available on all platforms ? +#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms ? + _hmac = new HMACSHA1(); +#pragma warning restore CA5350 // Do Not Use Weak Cryptographic Algorithms +#pragma warning restore CA1416 + + if (_encrypting) + { + GenerateKeys(); + InitCipher(); + } + } + + private void GenerateKeys() + { + int saltSize = _keySizeBits / 16; // 8 for AES-128, 12 for AES-192, 16 for AES-256 + _salt = new byte[saltSize]; + RandomNumberGenerator.Fill(_salt); + + // WinZip AES uses SHA1 for PBKDF2 + byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2(_password.Span, _salt, 1000, HashAlgorithmName.SHA1, (_keySizeBits / 8) + 32 + 2); + + _key = new byte[_keySizeBits / 8]; + _hmacKey = new byte[32]; + _passwordVerifier = new byte[2]; + + Buffer.BlockCopy(derivedKey, 0, _key, 0, _key.Length); + Buffer.BlockCopy(derivedKey, _key.Length, _hmacKey, 0, _hmacKey.Length); + Buffer.BlockCopy(derivedKey, _key.Length + _hmacKey.Length, _passwordVerifier, 0, _passwordVerifier.Length); + + _hmac.Key = _hmacKey; + } + + private void InitCipher() + { + if (_key is null) + throw new InvalidOperationException("Keys have not been generated."); + + _aes.Key = _key; + _aesEncryptor = _aes.CreateEncryptor(); + } + + private void WriteHeader() + { + if (_headerWritten) return; + + if (_salt is null || _passwordVerifier is null) + throw new InvalidOperationException("Keys have not been generated."); + + _baseStream.Write(_salt); + _baseStream.Write(_passwordVerifier); + + if (_ae2 && _crc32ForHeader.HasValue) + { + Span crcBytes = stackalloc byte[4]; + BitConverter.TryWriteBytes(crcBytes, _crc32ForHeader.Value); + _baseStream.Write(crcBytes); + } + + _headerWritten = true; + } + + private void ReadHeader() + { + if (_headerRead) return; + + int saltSize = _keySizeBits / 16; + _salt = new byte[saltSize]; + _baseStream.ReadExactly(_salt); + + byte[] verifier = new byte[2]; + _baseStream.ReadExactly(verifier); + + // WinZip AES uses SHA1 for PBKDF2 + byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2(_password.Span, _salt, 1000, HashAlgorithmName.SHA1, (_keySizeBits / 8) + 32 + 2); + + _key = new byte[_keySizeBits / 8]; + _hmacKey = new byte[32]; + _passwordVerifier = new byte[2]; + + Buffer.BlockCopy(derivedKey, 0, _key, 0, _key.Length); + Buffer.BlockCopy(derivedKey, _key.Length, _hmacKey, 0, _hmacKey.Length); + Buffer.BlockCopy(derivedKey, _key.Length + _hmacKey.Length, _passwordVerifier, 0, _passwordVerifier.Length); + + if (!verifier.AsSpan().SequenceEqual(_passwordVerifier)) + throw new InvalidDataException("Invalid password."); + + _hmac.Key = _hmacKey; + InitCipher(); + + if (_ae2) + { + byte[] crcBytes = new byte[4]; + _baseStream.ReadExactly(crcBytes); + // CRC can be validated later if needed + } + + _headerRead = true; + } + + private void ProcessBlock(byte[] buffer, int offset, int count) + { + if (_aesEncryptor is null) + throw new InvalidOperationException("Cipher has not been initialized."); + + int processed = 0; + while (processed < count) + { + IncrementCounter(); + byte[] keystream = new byte[16]; + _aesEncryptor.TransformBlock(_counterBlock, 0, 16, keystream, 0); + + int blockSize = Math.Min(16, count - processed); + for (int i = 0; i < blockSize; i++) + { + buffer[offset + processed + i] ^= keystream[i]; + } + + _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); + processed += blockSize; + } + } + + private void IncrementCounter() + { + for (int i = 15; i >= 0; i--) + { + if (++_counterBlock[i] != 0) break; + } + } + + public override void Write(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!_encrypting) + throw new NotSupportedException("Stream is in decryption mode."); + + WriteHeader(); + byte[] tmp = new byte[count]; + Buffer.BlockCopy(buffer, offset, tmp, 0, count); + ProcessBlock(tmp, 0, count); + _baseStream.Write(tmp, 0, count); + _position += count; + } + + public override int Read(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_encrypting) + throw new NotSupportedException("Stream is in encryption mode."); + + if (!_headerRead) + ReadHeader(); + + int n = _baseStream.Read(buffer, offset, count); + if (n > 0) + { + ProcessBlock(buffer, offset, n); + _position += n; + } + + return n; + } + + public override void Write(ReadOnlySpan buffer) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!_encrypting) + throw new NotSupportedException("Stream is in decryption mode."); + + WriteHeader(); + byte[] tmp = buffer.ToArray(); + ProcessBlock(tmp, 0, tmp.Length); + _baseStream.Write(tmp); + _position += buffer.Length; + } + + public override int Read(Span buffer) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_encrypting) + throw new NotSupportedException("Stream is in encryption mode."); + + if (!_headerRead) + ReadHeader(); + + int n = _baseStream.Read(buffer); + if (n > 0) + { + byte[] tmp = buffer[..n].ToArray(); + ProcessBlock(tmp, 0, n); + tmp.CopyTo(buffer); + _position += n; + } + + return n; + } + + protected override void Dispose(bool disposing) + { + if (_disposed) + return; + + if (disposing) + { + try + { + if (_headerWritten || _headerRead) + { + _hmac.TransformFinalBlock(Array.Empty(), 0, 0); + byte[]? authCode = _hmac.Hash; + + if (authCode is not null) + { + if (_encrypting) + { + _baseStream.Write(authCode); + } + else + { + // For decryption, read and validate footer + byte[] storedAuth = new byte[authCode.Length]; + _baseStream.ReadExactly(storedAuth); + if (!storedAuth.AsSpan().SequenceEqual(authCode)) + throw new InvalidDataException("Authentication code mismatch."); + } + } + } + + _baseStream.Flush(); + } + finally + { + _aesEncryptor?.Dispose(); + _aes.Dispose(); + _hmac.Dispose(); + } + } + + _disposed = true; + base.Dispose(disposing); + } + + public override bool CanRead => !_encrypting && !_disposed; + public override bool CanSeek => false; + public override bool CanWrite => _encrypting && !_disposed; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + public override void Flush() + { + ObjectDisposedException.ThrowIf(_disposed, this); + _baseStream.Flush(); + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + } +} diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index 94e0cde6285974..f830b0ba8a498e 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -440,7 +440,8 @@ public Stream Open(string password) switch (_archive.Mode) { case ZipArchiveMode.Read: - if (!_isEncrypted) { + if (!IsEncrypted) + { throw new InvalidDataException("Entry is not encrypted"); } return OpenInReadMode(checkOpenable: true, password.AsMemory()); @@ -488,13 +489,42 @@ internal long GetOffsetOfCompressedData() { if (_storedOffsetOfCompressedData == null) { + // Seek to local header _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - // by calling this, we are using local header _storedEntryNameBytes.Length and extraFieldLength - // to find start of data, but still using central directory size information - if (!ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveStream)) - throw new InvalidDataException(SR.LocalFileHeaderCorrupt); - _storedOffsetOfCompressedData = _archive.ArchiveStream.Position; + + long baseOffset; + + if (!IsEncrypted || IsZipCryptoEncrypted()) + { + // Non-AES case: just skip the local header + if (!ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveStream)) + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + + baseOffset = _archive.ArchiveStream.Position; + } + else + { + // AES case + if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _, out _)) + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + + baseOffset = _archive.ArchiveStream.Position; + + // Adjust for AES salt + password verifier using _encryptionMethod + int saltSize = _encryptionMethod switch + { + EncryptionMethod.Aes128 => 8, + EncryptionMethod.Aes192 => 12, + EncryptionMethod.Aes256 => 16, + _ => throw new InvalidDataException("Unknown AES encryption method") + }; + + baseOffset += saltSize + 2; // salt + password verifier + } + + _storedOffsetOfCompressedData = baseOffset; } + return _storedOffsetOfCompressedData.Value; } @@ -891,11 +921,14 @@ private byte CalculateZipCryptoCheckByte() private bool IsZipCryptoEncrypted() { const ushort EncryptionFlag = 0x0001; - return ((ushort)_generalPurposeBitFlag & EncryptionFlag) != 0; // && !UsesAes(); + return ((ushort)_generalPurposeBitFlag & EncryptionFlag) != 0 && !IsAesEncrypted(); } - // TODO: Change based on specs - // private static bool UsesAes() => false; + private bool IsAesEncrypted() + { + // Compression method 99 indicates AES encryption + return _storedCompressionMethod == CompressionMethodValues.Aes; + } private Stream GetDataDecompressor(Stream compressedStreamToRead, ReadOnlyMemory password = default) { @@ -903,9 +936,6 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead, ReadOnlyMemory Stream toDecompress = compressedStreamToRead; if (IsZipCryptoEncrypted()) { - // if (UsesAes()) for future - // throw new NotSupportedException("AES-encrypted ZIP entries are not supported yet."); - if (password.IsEmpty) throw new InvalidDataException("Password required for encrypted ZIP entry."); @@ -913,6 +943,28 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead, ReadOnlyMemory toDecompress = new ZipCryptoStream(toDecompress, password, expectedCheckByte); } + else if (IsAesEncrypted()) + { + if (password.IsEmpty) + throw new InvalidDataException("Password required for AES-encrypted ZIP entry."); + + // Determine key size based on encryption method + int keySizeBits = _encryptionMethod switch + { + EncryptionMethod.Aes128 => 128, + EncryptionMethod.Aes192 => 192, + EncryptionMethod.Aes256 => 256, + _ => throw new InvalidDataException($"Invalid AES encryption method: {_encryptionMethod}") + }; + + // For AES in ZIP, AE-2 format includes CRC-32 in the AES extra field + // The _crc32 field should contain the CRC value for AE-2 + bool isAe2 = (_generalPurposeBitFlag & BitFlagValues.DataDescriptor) == 0; + + // Read and parse the AES extra field to get necessary parameters + toDecompress = new AesStream(toDecompress, password, false, keySizeBits, isAe2, isAe2 ? _crc32 : null); + } + Stream? uncompressedStream; switch (CompressionMethod) @@ -993,6 +1045,7 @@ private WrappedStream OpenInUpdateMode() }); } + private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out string? message) { message = null; @@ -1003,12 +1056,41 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st { return false; } - if (!ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveStream)) + if (!IsEncrypted && !ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveStream)) { message = SR.LocalFileHeaderCorrupt; return false; } + else if (IsEncrypted && CompressionMethod == CompressionMethodValues.Aes) + { + _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); + + byte? aesStrength; + ushort? originalCompressionMethod; + + if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out aesStrength, out originalCompressionMethod)) + { + message = SR.LocalFileHeaderCorrupt; + return false; + } + + // Save encryption info for later use + if (aesStrength.HasValue) + { + _encryptionMethod = aesStrength switch + { + 1 => EncryptionMethod.Aes128, + 2 => EncryptionMethod.Aes192, + 3 => EncryptionMethod.Aes256, + _ => throw new InvalidDataException("Unknown AES strength") + }; + } + if (originalCompressionMethod.HasValue) + { + _storedCompressionMethod = (CompressionMethodValues)originalCompressionMethod.Value; + } + } // when this property gets called, some duplicated work long offsetOfCompressedData = GetOffsetOfCompressedData(); if (!IsOpenableFinalVerifications(needToLoadIntoMemory, offsetOfCompressedData, out message)) @@ -1027,7 +1109,8 @@ private bool IsOpenableInitialVerifications(bool needToUncompress, out string? m message = null; if (needToUncompress) { - if (CompressionMethod != CompressionMethodValues.Stored && + if (!IsEncrypted && + CompressionMethod != CompressionMethodValues.Stored && CompressionMethod != CompressionMethodValues.Deflate && CompressionMethod != CompressionMethodValues.Deflate64) { @@ -1038,6 +1121,13 @@ private bool IsOpenableInitialVerifications(bool needToUncompress, out string? m }; return false; } + else + { + if (IsEncrypted && CompressionMethod == CompressionMethodValues.Aes) + { + return true; + } + } } if (_diskNumberStart != _archive.NumberOfThisDisk) { @@ -1801,8 +1891,10 @@ internal enum BitFlagValues : ushort public enum EncryptionMethod : byte { None = 0, - ZipCrypto = 1 - //Aes256 = 4, + ZipCrypto = 1, + Aes128 = 2, + Aes192 = 3, + Aes256 = 4 } @@ -1812,7 +1904,8 @@ internal enum CompressionMethodValues : ushort Deflate = 0x8, Deflate64 = 0x9, BZip2 = 0xC, - LZMA = 0xE + LZMA = 0xE, + Aes = 99 } internal sealed class LocalHeaderOffsetComparer : Comparer diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs index 5e70cf29fc5eaa..e6afca04182ca0 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs @@ -670,6 +670,58 @@ public static bool TrySkipBlock(Stream stream) bytesRead = stream.ReadAtLeast(blockBytes, blockBytes.Length, throwOnEndOfStream: false); return TrySkipBlockFinalize(stream, blockBytes, bytesRead); } + + public static bool TrySkipBlockAESAware(Stream stream, out byte? aesStrength, out ushort? originalCompressionMethod) + { + aesStrength = null; + originalCompressionMethod = null; + + BinaryReader reader = new BinaryReader(stream); + + // Read the first 4 bytes (local file header signature) + byte[] signatureBytes = reader.ReadBytes(4); + if (!signatureBytes.AsSpan().SequenceEqual(ZipLocalFileHeader.SignatureConstantBytes)) + { + return false; // Not a valid local file header + } + // Read fixed-size fields after signature + // Local file header layout: + // signature (4) + version (2) + flags (2) + compression (2) + + // mod time (2) + mod date (2) + CRC32 (4) + compressed size (4) + + // uncompressed size (4) + name length (2) + extra length (2) + reader.ReadBytes(22); // Skip version through sizes + ushort nameLength = reader.ReadUInt16(); + ushort extraLength = reader.ReadUInt16(); + + // Skip file name + stream.Seek(nameLength, SeekOrigin.Current); + + // Parse extra fields + long extraStart = stream.Position; + long extraEnd = extraStart + extraLength; + while (stream.Position < extraEnd) + { + ushort headerId = reader.ReadUInt16(); + ushort dataSize = reader.ReadUInt16(); + + if (headerId == 0x9901) // AES extra field + { + // AES extra field structure: + // Vendor version (2) + Vendor ID (2) + AES strength (1) + Original compression (2) + reader.ReadBytes(2); + reader.ReadBytes(2); // Vendor ID + aesStrength = reader.ReadByte(); // 1, 2, or 3 + originalCompressionMethod = reader.ReadUInt16(); + } + else + { + stream.Seek(dataSize, SeekOrigin.Current); // Skip unknown extra field + } + } + + return true; + } + } internal sealed partial class ZipCentralDirectoryFileHeader diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index 4c9df6ea95ea4b..a3589e210ebec0 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -40,7 +40,7 @@ public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte ex ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes } - // ENCRYPTION constructor (header is now deferred to first write) + // Encryption constructor public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, ushort passwordVerifierLow2Bytes, From 797921db80b20cc810fff8fbeabd8e0d76134329 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 11 Nov 2025 16:13:09 +0100 Subject: [PATCH 11/39] fixed some comments --- .../IO/Compression/ZipTestHelper.ZipFile.cs | 4 +- .../src/System.IO.Compression.csproj | 2 +- .../{AesStream.cs => WinZipAesStream.cs} | 223 ++++++++++++------ .../src/System/IO/Compression/ZipArchive.cs | 74 +----- .../System/IO/Compression/ZipArchiveEntry.cs | 24 +- 5 files changed, 176 insertions(+), 151 deletions(-) rename src/libraries/System.IO.Compression/src/System/IO/Compression/{AesStream.cs => WinZipAesStream.cs} (55%) diff --git a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.ZipFile.cs b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.ZipFile.cs index f6aa5ee7b095fe..f5e1db167b95b1 100644 --- a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.ZipFile.cs +++ b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.ZipFile.cs @@ -48,7 +48,7 @@ protected Task CallExtractToFile(bool async, ZipArchiveEntry entry, string desti { if (async) { - return entry.ExtractToFileAsync(destinationFileName, overwrite: false, cancellationToken: default); + return entry.ExtractToFileAsync(destinationFileName, overwrite: false); } else { @@ -61,7 +61,7 @@ protected Task CallExtractToFile(bool async, ZipArchiveEntry entry, string desti { if (async) { - return entry.ExtractToFileAsync(destinationFileName, overwrite, cancellationToken: default); + return entry.ExtractToFileAsync(destinationFileName, overwrite); } else { diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index 4ff73a2bf41a18..64799e955ab637 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -71,7 +71,7 @@ - + diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/AesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs similarity index 55% rename from src/libraries/System.IO.Compression/src/System/IO/Compression/AesStream.cs rename to src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 271eeb4e5a8b09..122dc298f01725 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/AesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -1,11 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; namespace System.IO.Compression { - internal sealed class AesStream : Stream + internal sealed class WinZipAesStream : Stream { private readonly Stream _baseStream; private readonly bool _encrypting; @@ -27,8 +30,11 @@ internal sealed class AesStream : Stream private long _position; private readonly ReadOnlyMemory _password; private bool _disposed; + private bool _authCodeValidated; + private readonly byte[] _authCodeBuffer = new byte[20]; // HMACSHA1 is 20 bytes + private int _authCodeBufferCount; - public AesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null) + public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null) { ArgumentNullException.ThrowIfNull(baseStream); @@ -58,14 +64,12 @@ public AesStream(Stream baseStream, ReadOnlyMemory password, bool encrypti } } - private void GenerateKeys() + private void DeriveKeysFromPassword() { - int saltSize = _keySizeBits / 16; // 8 for AES-128, 12 for AES-192, 16 for AES-256 - _salt = new byte[saltSize]; - RandomNumberGenerator.Fill(_salt); + Debug.Assert(_salt is not null, "Salt must be initialized before deriving keys"); - // WinZip AES uses SHA1 for PBKDF2 - byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2(_password.Span, _salt, 1000, HashAlgorithmName.SHA1, (_keySizeBits / 8) + 32 + 2); + // WinZip AES uses SHA1 for PBKDF2 with 1000 iterations per spec + byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2(_password.Span, _salt!, 1000, HashAlgorithmName.SHA1, (_keySizeBits / 8) + 32 + 2); _key = new byte[_keySizeBits / 8]; _hmacKey = new byte[32]; @@ -74,16 +78,26 @@ private void GenerateKeys() Buffer.BlockCopy(derivedKey, 0, _key, 0, _key.Length); Buffer.BlockCopy(derivedKey, _key.Length, _hmacKey, 0, _hmacKey.Length); Buffer.BlockCopy(derivedKey, _key.Length + _hmacKey.Length, _passwordVerifier, 0, _passwordVerifier.Length); + } + + private void GenerateKeys() + { + // 8 for AES-128, 12 for AES-192, 16 for AES-256 + int saltSize = _keySizeBits / 16; + _salt = new byte[saltSize]; + RandomNumberGenerator.Fill(_salt); + + DeriveKeysFromPassword(); - _hmac.Key = _hmacKey; + Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); + _hmac.Key = _hmacKey!; } private void InitCipher() { - if (_key is null) - throw new InvalidOperationException("Keys have not been generated."); + Debug.Assert(_key is not null, "_key is not null"); - _aes.Key = _key; + _aes.Key = _key!; _aesEncryptor = _aes.CreateEncryptor(); } @@ -91,8 +105,7 @@ private void WriteHeader() { if (_headerWritten) return; - if (_salt is null || _passwordVerifier is null) - throw new InvalidOperationException("Keys have not been generated."); + Debug.Assert(_salt is not null && _passwordVerifier is not null, "Keys should have been generated before writing header"); _baseStream.Write(_salt); _baseStream.Write(_passwordVerifier); @@ -118,21 +131,14 @@ private void ReadHeader() byte[] verifier = new byte[2]; _baseStream.ReadExactly(verifier); - // WinZip AES uses SHA1 for PBKDF2 - byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2(_password.Span, _salt, 1000, HashAlgorithmName.SHA1, (_keySizeBits / 8) + 32 + 2); + DeriveKeysFromPassword(); - _key = new byte[_keySizeBits / 8]; - _hmacKey = new byte[32]; - _passwordVerifier = new byte[2]; - - Buffer.BlockCopy(derivedKey, 0, _key, 0, _key.Length); - Buffer.BlockCopy(derivedKey, _key.Length, _hmacKey, 0, _hmacKey.Length); - Buffer.BlockCopy(derivedKey, _key.Length + _hmacKey.Length, _passwordVerifier, 0, _passwordVerifier.Length); - - if (!verifier.AsSpan().SequenceEqual(_passwordVerifier)) + Debug.Assert(_passwordVerifier is not null, "Password verifier should be derived"); + if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) throw new InvalidDataException("Invalid password."); - _hmac.Key = _hmacKey; + Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); + _hmac.Key = _hmacKey!; InitCipher(); if (_ae2) @@ -147,17 +153,22 @@ private void ReadHeader() private void ProcessBlock(byte[] buffer, int offset, int count) { - if (_aesEncryptor is null) - throw new InvalidOperationException("Cipher has not been initialized."); + Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized before processing blocks"); int processed = 0; + byte[] keystream = new byte[16]; while (processed < count) { IncrementCounter(); - byte[] keystream = new byte[16]; _aesEncryptor.TransformBlock(_counterBlock, 0, 16, keystream, 0); + // For the last block, we may use less than 16 bytes of the keystream + // This is correct CTR mode behavior - we only use as many bytes as needed int blockSize = Math.Min(16, count - processed); + + // XOR the data with the keystream + // Note: If blockSize < 16, we only use the first 'blockSize' bytes of keystream + // The unused bytes are discarded, which is the expected for (int i = 0; i < blockSize; i++) { buffer[offset + processed + i] ^= keystream[i]; @@ -176,25 +187,62 @@ private void IncrementCounter() } } - public override void Write(byte[] buffer, int offset, int count) + private void WriteAuthCode() + { + if (!_encrypting || _authCodeValidated) + return; + + _hmac.TransformFinalBlock(Array.Empty(), 0, 0); + byte[]? authCode = _hmac.Hash; + + if (authCode is not null) + { + _baseStream.Write(authCode); + } + + _authCodeValidated = true; + } + + private void ValidateAuthCode() + { + if (_encrypting || _authCodeValidated) + return; + + // Finalize HMAC computation + _hmac.TransformFinalBlock(Array.Empty(), 0, 0); + byte[]? expectedAuth = _hmac.Hash; + + if (expectedAuth is not null) + { + // Read the stored authentication code from the stream + byte[] storedAuth = new byte[expectedAuth.Length]; + _baseStream.ReadExactly(storedAuth); + + if (!storedAuth.AsSpan().SequenceEqual(expectedAuth)) + throw new InvalidDataException("Authentication code mismatch."); + } + + _authCodeValidated = true; + } + + private void WriteCore(ReadOnlySpan buffer) { - ValidateBufferArguments(buffer, offset, count); ObjectDisposedException.ThrowIf(_disposed, this); if (!_encrypting) throw new NotSupportedException("Stream is in decryption mode."); WriteHeader(); - byte[] tmp = new byte[count]; - Buffer.BlockCopy(buffer, offset, tmp, 0, count); - ProcessBlock(tmp, 0, count); - _baseStream.Write(tmp, 0, count); - _position += count; + + // We need to copy the data since ProcessBlock modifies it in place + byte[] tmp = buffer.ToArray(); + ProcessBlock(tmp, 0, tmp.Length); + _baseStream.Write(tmp); + _position += buffer.Length; } - public override int Read(byte[] buffer, int offset, int count) + private int ReadCore(Span buffer) { - ValidateBufferArguments(buffer, offset, count); ObjectDisposedException.ThrowIf(_disposed, this); if (_encrypting) @@ -203,31 +251,85 @@ public override int Read(byte[] buffer, int offset, int count) if (!_headerRead) ReadHeader(); - int n = _baseStream.Read(buffer, offset, count); + int n = _baseStream.Read(buffer); + + // Check if we reached the end of the stream + if (n == 0 && !_authCodeValidated) + { + ValidateAuthCode(); + return 0; + } + if (n > 0) { - ProcessBlock(buffer, offset, n); + // Process the data in-place for reads (it's already in the buffer) + // We need to temporarily copy to array for HMAC processing + byte[] temp = buffer.Slice(0, n).ToArray(); + ProcessBlock(temp, 0, n); + temp.CopyTo(buffer); _position += n; } return n; } + // All Write overloads redirect to Write(ReadOnlySpan) + public override void Write(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + Write(new ReadOnlySpan(buffer, offset, count)); + } + public override void Write(ReadOnlySpan buffer) + { + WriteCore(buffer); + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + await WriteAsync(new ReadOnlyMemory(buffer, offset, count), cancellationToken).ConfigureAwait(false); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(_disposed, this); if (!_encrypting) throw new NotSupportedException("Stream is in decryption mode."); - WriteHeader(); - byte[] tmp = buffer.ToArray(); - ProcessBlock(tmp, 0, tmp.Length); - _baseStream.Write(tmp); - _position += buffer.Length; + return Core(buffer, cancellationToken); + + async ValueTask Core(ReadOnlyMemory buffer, CancellationToken cancellationToken) + { + WriteHeader(); + + // We need to copy the data since ProcessBlock modifies it in place + byte[] tmp = buffer.ToArray(); + ProcessBlock(tmp, 0, tmp.Length); + await _baseStream.WriteAsync(tmp, cancellationToken).ConfigureAwait(false); + _position += buffer.Length; + } + } + + public override int Read(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + return ReadCore(new Span(buffer, offset, count)); } public override int Read(Span buffer) + { + return ReadCore(buffer); + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + return await ReadAsync(new Memory(buffer, offset, count), cancellationToken).ConfigureAwait(false); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -237,12 +339,13 @@ public override int Read(Span buffer) if (!_headerRead) ReadHeader(); - int n = _baseStream.Read(buffer); + int n = await _baseStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); if (n > 0) { - byte[] tmp = buffer[..n].ToArray(); - ProcessBlock(tmp, 0, n); - tmp.CopyTo(buffer); + // Process the data - work with the Memory span + byte[] temp = buffer.Slice(0, n).ToArray(); + ProcessBlock(temp, 0, n); + temp.CopyTo(buffer.Span); _position += n; } @@ -258,26 +361,10 @@ protected override void Dispose(bool disposing) { try { - if (_headerWritten || _headerRead) + // For encryption, write the auth code when closing + if (_encrypting && _headerWritten && !_authCodeValidated) { - _hmac.TransformFinalBlock(Array.Empty(), 0, 0); - byte[]? authCode = _hmac.Hash; - - if (authCode is not null) - { - if (_encrypting) - { - _baseStream.Write(authCode); - } - else - { - // For decryption, read and validate footer - byte[] storedAuth = new byte[authCode.Length]; - _baseStream.ReadExactly(storedAuth); - if (!storedAuth.AsSpan().SequenceEqual(authCode)) - throw new InvalidDataException("Authentication code mismatch."); - } - } + WriteAuthCode(); } _baseStream.Flush(); diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs index 458e7913129b11..bc16f2119bbf88 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs @@ -180,62 +180,16 @@ public ZipArchive(Stream stream, ZipArchiveMode mode, bool leaveOpen, Encoding? } } - public ZipArchive(Stream stream, ZipArchiveMode mode, bool leaveOpen, Encoding? entryNameEncoding, string? defaultPassword, ZipArchiveEntry.EncryptionMethod defaultEncryption) - : this(mode, leaveOpen, entryNameEncoding, backingStream: null, archiveStream: DecideArchiveStream(mode, stream), defaultPassword: defaultPassword, defaultEncryption: defaultEncryption) + : this(stream, mode, leaveOpen, entryNameEncoding) { - ArgumentNullException.ThrowIfNull(stream); - - Stream? extraTempStream = null; - - try - { - _backingStream = null; - - if (ValidateMode(mode, stream)) - { - _backingStream = stream; - extraTempStream = stream = new MemoryStream(); - _backingStream.CopyTo(stream); - stream.Seek(0, SeekOrigin.Begin); - } - - _archiveStream = DecideArchiveStream(mode, stream); - - switch (mode) - { - case ZipArchiveMode.Create: - _readEntries = true; - break; - - case ZipArchiveMode.Read: - ReadEndOfCentralDirectory(); - break; - - case ZipArchiveMode.Update: - default: - Debug.Assert(mode == ZipArchiveMode.Update); - if (_archiveStream.Length == 0) - { - _readEntries = true; - } - else - { - ReadEndOfCentralDirectory(); - EnsureCentralDirectoryRead(); + _defaultPassword = string.IsNullOrEmpty(defaultPassword) ? null : defaultPassword; + _defaultEncryption = defaultEncryption; - foreach (ZipArchiveEntry entry in _entries) - { - entry.ThrowIfNotOpenable(needToUncompress: false, needToLoadIntoMemory: true); - } - } - break; - } - } - catch + // Validate that if encryption is specified, a password is provided, what to do otherwise? + if (_defaultEncryption != ZipArchiveEntry.EncryptionMethod.None && _defaultPassword is null) { - extraTempStream?.Dispose(); - throw; + throw new ArgumentException("A password must be provided when encryption is specified.", nameof(defaultPassword)); } } @@ -263,22 +217,6 @@ private ZipArchive(ZipArchiveMode mode, bool leaveOpen, Encoding? entryNameEncod _firstDeletedEntryOffset = long.MaxValue; } - - private ZipArchive(ZipArchiveMode mode, bool leaveOpen, Encoding? entryNameEncoding, Stream? backingStream, Stream archiveStream, string? defaultPassword, ZipArchiveEntry.EncryptionMethod defaultEncryption) - : this(mode, leaveOpen, entryNameEncoding, backingStream, archiveStream) - { - _defaultPassword = string.IsNullOrEmpty(defaultPassword) ? null : defaultPassword; - _defaultEncryption = defaultEncryption; - - // Optional guardrails: if user gives a default password but sets encryption None, allow it (password is simply unused) ?? - // if encryption is ZipCrypto but password is null/empty, you can either throw here or defer to CreateEntry validation. - if (_defaultEncryption == ZipArchiveEntry.EncryptionMethod.ZipCrypto && _defaultPassword is null) - { - throw new ArgumentException("A default password must be provided when defaultEncryption is ZipCrypto.", nameof(defaultPassword)); - } - } - - /// /// Gets or sets the optional archive comment. /// diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index f830b0ba8a498e..be1c8a3c7e4932 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -949,20 +949,20 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead, ReadOnlyMemory throw new InvalidDataException("Password required for AES-encrypted ZIP entry."); // Determine key size based on encryption method - int keySizeBits = _encryptionMethod switch - { - EncryptionMethod.Aes128 => 128, - EncryptionMethod.Aes192 => 192, - EncryptionMethod.Aes256 => 256, - _ => throw new InvalidDataException($"Invalid AES encryption method: {_encryptionMethod}") - }; - - // For AES in ZIP, AE-2 format includes CRC-32 in the AES extra field - // The _crc32 field should contain the CRC value for AE-2 - bool isAe2 = (_generalPurposeBitFlag & BitFlagValues.DataDescriptor) == 0; + //int keySizeBits = _encryptionMethod switch + //{ + // EncryptionMethod.Aes128 => 128, + // EncryptionMethod.Aes192 => 192, + // EncryptionMethod.Aes256 => 256, + // _ => throw new InvalidDataException($"Invalid AES encryption method: {_encryptionMethod}") + //}; + + //// For AES in ZIP, AE-2 format includes CRC-32 in the AES extra field + //// The _crc32 field should contain the CRC value for AE-2 + //bool isAe2 = (_generalPurposeBitFlag & BitFlagValues.DataDescriptor) == 0; // Read and parse the AES extra field to get necessary parameters - toDecompress = new AesStream(toDecompress, password, false, keySizeBits, isAe2, isAe2 ? _crc32 : null); + // toDecompress = new WinZipAesStream(toDecompress, password, false, keySizeBits, isAe2, isAe2 ? _crc32 : null); } From 623f238b473cb981bcaff016ba159cce86424d8c Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Thu, 13 Nov 2025 15:48:46 +0100 Subject: [PATCH 12/39] remove storing password fields --- .../ZipFileExtensions.ZipArchive.Create.cs | 24 +- .../tests/ZipFile.Extract.cs | 952 +++++++++--------- .../ref/System.IO.Compression.cs | 5 +- .../src/System.IO.Compression.csproj | 1 - .../System/IO/Compression/WinZipAesStream.cs | 782 +++++++------- .../src/System/IO/Compression/ZipArchive.cs | 51 +- .../System/IO/Compression/ZipArchiveEntry.cs | 238 ++--- .../System/IO/Compression/ZipCryptoStream.cs | 18 +- 8 files changed, 970 insertions(+), 1101 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs index 1fe78d9e53f21b..f9a79d83be7df7 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs @@ -79,12 +79,12 @@ public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, public static ZipArchiveEntry CreateEntryFromFile(this ZipArchive destination, string sourceFileName, string entryName, CompressionLevel compressionLevel, string password, ZipArchiveEntry.EncryptionMethod encryption) => - DoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, password, encryption); + DoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel); internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destination, - string sourceFileName, string entryName, CompressionLevel? compressionLevel, string? password = null, ZipArchiveEntry.EncryptionMethod encryption = ZipArchiveEntry.EncryptionMethod.None) + string sourceFileName, string entryName, CompressionLevel? compressionLevel) { - (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: true, password, encryption); + (FileStream fs, ZipArchiveEntry entry) = InitializeDoCreateEntryFromFile(destination, sourceFileName, entryName, compressionLevel, useAsync: true); using (fs) { @@ -97,18 +97,11 @@ internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destinatio return entry; } - private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(ZipArchive destination, string sourceFileName, string entryName, CompressionLevel? compressionLevel, bool useAsync, - string? password = null, ZipArchiveEntry.EncryptionMethod encryption = ZipArchiveEntry.EncryptionMethod.None) + private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(ZipArchive destination, string sourceFileName, string entryName, CompressionLevel? compressionLevel, bool useAsync) { ArgumentNullException.ThrowIfNull(destination); ArgumentNullException.ThrowIfNull(sourceFileName); ArgumentNullException.ThrowIfNull(entryName); - - if (!string.IsNullOrEmpty(password) && encryption == ZipArchiveEntry.EncryptionMethod.None) - { - throw new ArgumentException("password and encryption should both be set"); - } - // Checking of compressionLevel is passed down to DeflateStream and the IDeflater implementation // as it is a pluggable component that completely encapsulates the meaning of compressionLevel. @@ -116,12 +109,9 @@ private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(Zip FileStream fs = new FileStream(sourceFileName, FileMode.Open, FileAccess.Read, FileShare.Read, ZipFile.FileStreamBufferSize, useAsync); - ZipArchiveEntry entry = password is not null ? (compressionLevel.HasValue - ? destination.CreateEntry(entryName, compressionLevel.Value, password, encryption) - : destination.CreateEntry(entryName, password, encryption)) - : (compressionLevel.HasValue - ? destination.CreateEntry(entryName, compressionLevel.Value) - : destination.CreateEntry(entryName)); + ZipArchiveEntry entry = compressionLevel.HasValue ? + destination.CreateEntry(entryName, compressionLevel.Value) + : destination.CreateEntry(entryName); DateTime lastWrite = File.GetLastWriteTime(sourceFileName); diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 0233394122f3e0..5e5b3e6a878c41 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -356,8 +356,6 @@ await Assert.ThrowsAsync(async () => }); } - - [Fact] public async Task ExtractToFileAsync_WithCancellation_ShouldCancel() { @@ -505,9 +503,9 @@ public async Task ZipCrypto_CreateEntry_ThenRead_Back_ContentMatches() using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) { // Your custom overload that sets per-entry password & ZipCrypto - var entry = za.CreateEntry(entryName, password, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + var entry = za.CreateEntry(entryName); - using var writer = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + using var writer = new StreamWriter(entry.Open(password, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); writer.Write(expectedContent); } @@ -551,8 +549,8 @@ public async Task ZipCrypto_MultipleEntries_SamePassword_AllRoundTrip() { foreach (var it in items) { - var entry = za.CreateEntry(it.Name, password, enc); - using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + var entry = za.CreateEntry(it.Name); + using var w = new StreamWriter(entry.Open(password, enc), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); await w.WriteAsync(it.Content); } } @@ -592,8 +590,8 @@ public async Task ZipCrypto_MultipleEntries_DifferentPasswords_AllRoundTrip() { foreach (var it in items) { - var entry = za.CreateEntry(it.Name, it.Password, enc); - using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + var entry = za.CreateEntry(it.Name); + using var w = new StreamWriter(entry.Open(it.Password, enc), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); await w.WriteAsync(it.Content); } } @@ -651,9 +649,9 @@ public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() // Encrypted foreach (var it in encryptedItems) { - var entry = za.CreateEntry(it.Name, encPw, enc); - using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); - await w.WriteAsync(it.Content); + var entry = za.CreateEntry(it.Name); + using var w = new StreamWriter(entry.Open(encPw, enc), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + w.Write(it.Content); } // Plain @@ -661,7 +659,7 @@ public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() { var entry = za.CreateEntry(it.Name); // default: no encryption using var w = new StreamWriter(entry.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); - await w.WriteAsync(it.Content); + w.Write(it.Content); } } @@ -717,8 +715,8 @@ public async Task Update_AddEncryptedEntry_RoundTrip() // Act: Open in Update mode and add encrypted entry using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) { - var encEntry = za.CreateEntry("secure/new.txt", "pw123", ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(encEntry.Open(), Encoding.UTF8); + var encEntry = za.CreateEntry("secure/new.txt"); + using var w = new StreamWriter(encEntry.Open("pw123", ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); await w.WriteAsync("secret data"); } @@ -746,8 +744,8 @@ public async Task Update_DeleteEncryptedEntry_RemovesSuccessfully() using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) { - var e = za.CreateEntry("secure/delete.txt", "delpw", ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(e.Open(), Encoding.UTF8); + var e = za.CreateEntry("secure/delete.txt"); + using var w = new StreamWriter(e.Open("delpw", ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); await w.WriteAsync("to be deleted"); } @@ -776,8 +774,8 @@ public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip() const string pw = "copy-pw"; using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) { - var e = za.CreateEntry("secure/original.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(e.Open(), Encoding.UTF8); + var e = za.CreateEntry("secure/original.txt"); + using var w = new StreamWriter(e.Open(pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); w.Write("original content"); } @@ -793,8 +791,8 @@ public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip() content = r.ReadToEnd(); // Create new entry with same password - var dst = za.CreateEntry("secure/copy.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(dst.Open(), Encoding.UTF8); + var dst = za.CreateEntry("secure/copy.txt"); + using var w = new StreamWriter(dst.Open(pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); w.Write(content); } @@ -831,8 +829,8 @@ public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip_2() // Create archive and a single encrypted entry using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) { - var e = za.CreateEntry(originalName, pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(e.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + var e = za.CreateEntry(originalName); + using var w = new StreamWriter(e.Open(pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); await w.WriteAsync(payload); } @@ -855,8 +853,8 @@ public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip_2() }); // Create the destination entry with the same password and write the copied content. - var dst = za.CreateEntry(copyName, pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(dst.Open(), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); + var dst = za.CreateEntry(copyName); + using var w = new StreamWriter(dst.Open(pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); await w.WriteAsync(content); } @@ -883,461 +881,457 @@ public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip_2() } - [Fact] - public void Update_OpenEncryptedEntry_WrongPassword_Throws() - { - string zipPath = NewPath("update_wrong_pw.zip"); - const string pw = "correct-pw"; - - if (File.Exists(zipPath)) File.Delete(zipPath); - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntry("secure/file.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(e.Open(), Encoding.UTF8); - w.Write("secret"); - } - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var e = za.GetEntry("secure/file.txt"); - Assert.NotNull(e); - Assert.ThrowsAny(() => - { - using var _ = e.Open("wrong-pw"); - }); - } - } - - - [Fact] - public async Task Update_EditPlainEntry_RoundTrip() - { - string zipPath = NewPath("update_edit_plain.zip"); - if (File.Exists(zipPath)) File.Delete(zipPath); - - // Create plain entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntry("plain.txt"); - using var w = new StreamWriter(e.Open(), Encoding.UTF8); - await w.WriteAsync("original"); - } - - // Edit in Update mode - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var e = za.GetEntry("plain.txt"); - Assert.NotNull(e); - - using var w = new StreamWriter(e.Open(), Encoding.UTF8); - await w.WriteAsync("modified"); - } - - // Verify updated content - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var e = za.GetEntry("plain.txt"); - using var r = new StreamReader(e.Open(), Encoding.UTF8); - Assert.Equal("modified", await r.ReadToEndAsync()); - } - } - - - - [Fact] - public void Update_EditEncryptedEntryWithoutPassword_Throws() - { - string zipPath = NewPath("update_edit_encrypted.zip"); - const string pw = "edit-pw"; - - if (File.Exists(zipPath)) File.Delete(zipPath); - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntry("secure/edit.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(e.Open(), Encoding.UTF8); - w.Write("secret"); - } - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var e = za.GetEntry("secure/edit.txt"); - Assert.NotNull(e); - - // Should throw because edit-in-place for encrypted entries is not supported - Assert.Throws(() => - { - using var _ = e.Open(); // no password - }); - } - } - - - [Fact] - public async Task Update_MixedEntries_ReadEncrypted_EditPlain() - { - string zipPath = NewPath("update_mixed.zip"); - const string pw = "mixed-pw"; - - if (File.Exists(zipPath)) File.Delete(zipPath); - - // Create initial zip with encrypted and plain entries - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var encEntry = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using (var w = new StreamWriter(encEntry.Open(), Encoding.UTF8)) - await w.WriteAsync("encrypted"); - - var plainEntry = za.CreateEntry("plain.txt"); - using (var w = new StreamWriter(plainEntry.Open(), Encoding.UTF8)) - await w.WriteAsync("original"); - } - - // First update: read encrypted, modify plain - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var enc = za.GetEntry("secure/data.txt"); - Assert.NotNull(enc); - - string encryptedContent; - using (var r = new StreamReader(enc.Open(pw), Encoding.UTF8)) - encryptedContent = await r.ReadToEndAsync(); - - var plain = za.GetEntry("plain.txt"); - using var w = new StreamWriter(plain.Open(), Encoding.UTF8); - await w.WriteAsync("modified"); - } - - // Second update: verify encrypted, re-modify plain - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var enc = za.GetEntry("secure/data.txt"); - using (var r = new StreamReader(enc.Open(pw), Encoding.UTF8)) - Assert.Equal("encrypted", await r.ReadToEndAsync()); - - var plain = za.GetEntry("plain.txt"); - using var w = new StreamWriter(plain.Open(), Encoding.UTF8); - await w.WriteAsync("modified"); - } - - // Final read: verify both entries - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - using (var r1 = new StreamReader(za.GetEntry("secure/data.txt").Open(pw), Encoding.UTF8)) - Assert.Equal("encrypted", await r1.ReadToEndAsync()); - - using (var r2 = new StreamReader(za.GetEntry("plain.txt").Open(), Encoding.UTF8)) - Assert.Equal("modified", await r2.ReadToEndAsync()); - } - } - - - - [Fact] - public async Task Update_ModifySameEncryptedEntryMultipleTimes() - { - string zipPath = NewPath("update_modify_multiple.zip"); - const string pw = "multi-pw"; - - if (File.Exists(zipPath)) File.Delete(zipPath); - - // Create initial encrypted entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(e.Open(), Encoding.UTF8); - await w.WriteAsync("version1"); - } - - // Modify entry multiple times - for (int i = 2; i <= 3; i++) - { - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var e = za.GetEntry("secure/data.txt"); - Assert.NotNull(e); - - string oldContent; - using (var r = new StreamReader(e!.Open(pw), Encoding.UTF8)) - oldContent = await r.ReadToEndAsync(); - - e.Delete(); // remove old entry - - var newEntry = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(newEntry.Open(), Encoding.UTF8); - await w.WriteAsync($"{oldContent}-version{i}"); - } - } - - // Assert final content - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var e = za.GetEntry("secure/data.txt"); - Assert.NotNull(e); - using var r = new StreamReader(e!.Open(pw), Encoding.UTF8); - var text = await r.ReadToEndAsync(); - Assert.Equal("version1-version2-version3", text); - } - } - - - [Fact] - public async Task Update_CopyEncryptedEntryToPlainEntry() - { - string zipPath = NewPath("update_copy_to_plain.zip"); - const string pw = "plain-copy"; - - if (File.Exists(zipPath)) File.Delete(zipPath); - - // Create encrypted entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntry("secure/original.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using var w = new StreamWriter(e.Open(), Encoding.UTF8); - await w.WriteAsync("secret content"); - } - - // Copy encrypted content to a plain entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var src = za.GetEntry("secure/original.txt"); - Assert.NotNull(src); - - string content; - using (var r = new StreamReader(src!.Open(pw), Encoding.UTF8)) - content = await r.ReadToEndAsync(); - - var plainEntry = za.CreateEntry("public/copy.txt"); // no encryption - using var w = new StreamWriter(plainEntry.Open(), Encoding.UTF8); - await w.WriteAsync(content); - } - - // Assert both entries exist and content matches - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var enc = za.GetEntry("secure/original.txt"); - var plain = za.GetEntry("public/copy.txt"); - Assert.NotNull(enc); - Assert.NotNull(plain); - - using (var r1 = new StreamReader(enc!.Open(pw), Encoding.UTF8)) - Assert.Equal("secret content", await r1.ReadToEndAsync()); - - using (var r2 = new StreamReader(plain!.Open(), Encoding.UTF8)) - Assert.Equal("secret content", await r2.ReadToEndAsync()); - } - } - - - - [Fact] - public void CreateEntryFromFile_WithPassword_WrongPassword_Throws() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string srcPath = NewPath("source_wrong_pw.txt"); - string zipPath = NewPath("create_from_file_encrypted_wrongpw.zip"); - const string entryName = "secure/wrong.txt"; - const string correctPassword = "correct!"; - const string badPassword = "wrong!"; - const string payload = "secret data"; - - if (File.Exists(srcPath)) File.Delete(srcPath); - if (File.Exists(zipPath)) File.Delete(zipPath); - - File.WriteAllText(srcPath, payload, new UTF8Encoding(false)); - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntryFromFile( - sourceFileName: srcPath, - entryName: entryName, - compressionLevel: CompressionLevel.Optimal, - password: correctPassword, - encryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto); - } - - // Act & Assert - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var e = za.GetEntry(entryName); - Assert.NotNull(e); - - Assert.ThrowsAny(() => - { - using var _ = e!.Open(badPassword); - }); - } - } - - - [Fact] - public async Task CreateEntryFromFile_WithEncryption_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string srcPath = NewPath("source_plain.txt"); - string zipPath = NewPath("create_from_file_plain.zip"); - const string entryName = "plain/copy.txt"; - const string payload = "this is plain"; - const string pwd = "anything"; - - if (File.Exists(srcPath)) File.Delete(srcPath); - if (File.Exists(zipPath)) File.Delete(zipPath); - - await File.WriteAllTextAsync(srcPath, payload, new UTF8Encoding(false)); - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntryFromFile( - sourceFileName: srcPath, - entryName: entryName, - compressionLevel: CompressionLevel.Optimal, - password: pwd, - encryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto); - } - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var e = za.GetEntry(entryName); - Assert.NotNull(e); - - using var r = new StreamReader(e!.Open(pwd), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - string text = await r.ReadToEndAsync(); - Assert.Equal(payload, text); - - // Opening a plain entry with a password should throw - Assert.ThrowsAny(() => - { - using var _ = e.Open("some-password"); - }); - } - } - - - - - [Fact] - public void CreateEntry_UsesArchiveDefaults_WhenNotOverridden() - { - Directory.CreateDirectory(DownloadsDir); - var zipPath = NewPath("defaults_apply.zip"); - if (File.Exists(zipPath)) File.Delete(zipPath); - - const string defaultPassword = "archive-pw"; - const string payload = "default encryption content"; - const string entryName = "secure/default.txt"; - - using (var zipFs = File.Create(zipPath)) - using (var za = new ZipArchive(zipFs, - ZipArchiveMode.Create, - leaveOpen: false, - entryNameEncoding: Encoding.UTF8, - defaultPassword: defaultPassword, - defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) - { - var e = za.CreateEntry(entryName); - - using (var es = e.Open()) - { - var bytes = Encoding.UTF8.GetBytes(payload); - es.Write(bytes, 0, bytes.Length); - } - } - - // Verify with the archive default password - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var e = za.GetEntry(entryName); - Assert.NotNull(e); - using var r = new StreamReader(e!.Open(defaultPassword), Encoding.UTF8); - Assert.Equal(payload, r.ReadToEnd()); - } - } - - [Fact] - public async Task CreateMode_DefaultPassword_AppliesToMultipleEntries() - { - string zipPath = NewPath("defaults_multiple.zip"); - if (File.Exists(zipPath)) File.Delete(zipPath); - - const string defaultPassword = "archive-pw"; - - using (var zipFs = File.Create(zipPath)) - using (var za = new ZipArchive(zipFs, - ZipArchiveMode.Create, - leaveOpen: false, - entryNameEncoding: Encoding.UTF8, - defaultPassword: defaultPassword, - defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) - { - var e1 = za.CreateEntry("secure/one.txt"); - using (var s1 = e1.Open()) - { - var b = Encoding.UTF8.GetBytes("ONE"); - s1.Write(b, 0, b.Length); - } - - var e2 = za.CreateEntry("secure/two.txt"); - using (var s2 = e2.Open()) - { - var b = Encoding.UTF8.GetBytes("TWO"); - s2.Write(b, 0, b.Length); - } - } - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - using (var r1 = new StreamReader(za.GetEntry("secure/one.txt")!.Open(defaultPassword), Encoding.UTF8)) - Assert.Equal("ONE", await r1.ReadToEndAsync()); - - using (var r2 = new StreamReader(za.GetEntry("secure/two.txt")!.Open(defaultPassword), Encoding.UTF8)) - Assert.Equal("TWO", await r2.ReadToEndAsync()); - } - } - - [Fact] - public async Task CreateEntry_WithExplicitPassword_OverridesDefaultPassword() - { - string zipPath = NewPath("override_default.zip"); - if (File.Exists(zipPath)) File.Delete(zipPath); - - const string archivePassword = "archive-pw"; - const string entryPassword = "entry-pw"; - - using (var zipFs = File.Create(zipPath)) - using (var za = new ZipArchive(zipFs, - ZipArchiveMode.Create, - leaveOpen: false, - entryNameEncoding: Encoding.UTF8, - defaultPassword: archivePassword, - defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) - { - var e = za.CreateEntry("secure/override.txt", entryPassword, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - using (var s = e.Open()) - { - var b = Encoding.UTF8.GetBytes("OVERRIDE"); - s.Write(b, 0, b.Length); - } - } - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var e = za.GetEntry("secure/override.txt"); - Assert.NotNull(e); - - // Should succeed with entry password - using (var rOk = new StreamReader(e!.Open(entryPassword), Encoding.UTF8)) - Assert.Equal("OVERRIDE", await rOk.ReadToEndAsync()); - - // Wrong: using archive default should fail - Assert.ThrowsAny(() => - { - using var _ = e.Open(archivePassword); - }); - } - } + //[Fact] + //public void Update_OpenEncryptedEntry_WrongPassword_Throws() + //{ + // string zipPath = NewPath("update_wrong_pw.zip"); + // const string pw = "correct-pw"; + + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + // { + // var e = za.CreateEntry("secure/file.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + // using var w = new StreamWriter(e.Open(), Encoding.UTF8); + // w.Write("secret"); + // } + + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + // { + // var e = za.GetEntry("secure/file.txt"); + // Assert.NotNull(e); + // Assert.ThrowsAny(() => + // { + // using var _ = e.Open("wrong-pw"); + // }); + // } + //} + + + //[Fact] + //public async Task Update_EditPlainEntry_RoundTrip() + //{ + // string zipPath = NewPath("update_edit_plain.zip"); + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // // Create plain entry + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + // { + // var e = za.CreateEntry("plain.txt"); + // using var w = new StreamWriter(e.Open(), Encoding.UTF8); + // await w.WriteAsync("original"); + // } + + // // Edit in Update mode + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + // { + // var e = za.GetEntry("plain.txt"); + // Assert.NotNull(e); + + // using var w = new StreamWriter(e.Open(), Encoding.UTF8); + // await w.WriteAsync("modified"); + // } + + // // Verify updated content + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + // { + // var e = za.GetEntry("plain.txt"); + // using var r = new StreamReader(e.Open(), Encoding.UTF8); + // Assert.Equal("modified", await r.ReadToEndAsync()); + // } + //} + + + + //[Fact] + //public void Update_EditEncryptedEntryWithoutPassword_Throws() + //{ + // string zipPath = NewPath("update_edit_encrypted.zip"); + // const string pw = "edit-pw"; + + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + // { + // var e = za.CreateEntry("secure/edit.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + // using var w = new StreamWriter(e.Open(), Encoding.UTF8); + // w.Write("secret"); + // } + + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + // { + // var e = za.GetEntry("secure/edit.txt"); + // Assert.NotNull(e); + + // // Should throw because edit-in-place for encrypted entries is not supported + // Assert.Throws(() => + // { + // using var _ = e.Open(); // no password + // }); + // } + //} + + + //[Fact] + //public async Task Update_MixedEntries_ReadEncrypted_EditPlain() + //{ + // string zipPath = NewPath("update_mixed.zip"); + // const string pw = "mixed-pw"; + + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // // Create initial zip with encrypted and plain entries + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + // { + // var encEntry = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + // using (var w = new StreamWriter(encEntry.Open(), Encoding.UTF8)) + // await w.WriteAsync("encrypted"); + + // var plainEntry = za.CreateEntry("plain.txt"); + // using (var w = new StreamWriter(plainEntry.Open(), Encoding.UTF8)) + // await w.WriteAsync("original"); + // } + + // // First update: read encrypted, modify plain + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + // { + // var enc = za.GetEntry("secure/data.txt"); + // Assert.NotNull(enc); + + // string encryptedContent; + // using (var r = new StreamReader(enc.Open(pw), Encoding.UTF8)) + // encryptedContent = await r.ReadToEndAsync(); + + // var plain = za.GetEntry("plain.txt"); + // using var w = new StreamWriter(plain.Open(), Encoding.UTF8); + // await w.WriteAsync("modified"); + // } + + // // Second update: verify encrypted, re-modify plain + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + // { + // var enc = za.GetEntry("secure/data.txt"); + // using (var r = new StreamReader(enc.Open(pw), Encoding.UTF8)) + // Assert.Equal("encrypted", await r.ReadToEndAsync()); + + // var plain = za.GetEntry("plain.txt"); + // using var w = new StreamWriter(plain.Open(), Encoding.UTF8); + // await w.WriteAsync("modified"); + // } + + // // Final read: verify both entries + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + // { + // using (var r1 = new StreamReader(za.GetEntry("secure/data.txt").Open(pw), Encoding.UTF8)) + // Assert.Equal("encrypted", await r1.ReadToEndAsync()); + + // using (var r2 = new StreamReader(za.GetEntry("plain.txt").Open(), Encoding.UTF8)) + // Assert.Equal("modified", await r2.ReadToEndAsync()); + // } + //} + + + + //[Fact] + //public async Task Update_ModifySameEncryptedEntryMultipleTimes() + //{ + // string zipPath = NewPath("update_modify_multiple.zip"); + // const string pw = "multi-pw"; + + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // // Create initial encrypted entry + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + // { + // var e = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + // using var w = new StreamWriter(e.Open(), Encoding.UTF8); + // await w.WriteAsync("version1"); + // } + + // // Modify entry multiple times + // for (int i = 2; i <= 3; i++) + // { + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + // { + // var e = za.GetEntry("secure/data.txt"); + // Assert.NotNull(e); + + // string oldContent; + // using (var r = new StreamReader(e!.Open(pw), Encoding.UTF8)) + // oldContent = await r.ReadToEndAsync(); + + // e.Delete(); // remove old entry + + // var newEntry = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + // using var w = new StreamWriter(newEntry.Open(), Encoding.UTF8); + // await w.WriteAsync($"{oldContent}-version{i}"); + // } + // } + + // // Assert final content + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + // { + // var e = za.GetEntry("secure/data.txt"); + // Assert.NotNull(e); + // using var r = new StreamReader(e!.Open(pw), Encoding.UTF8); + // var text = await r.ReadToEndAsync(); + // Assert.Equal("version1-version2-version3", text); + // } + //} + + + //[Fact] + //public async Task Update_CopyEncryptedEntryToPlainEntry() + //{ + // string zipPath = NewPath("update_copy_to_plain.zip"); + // const string pw = "plain-copy"; + + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // // Create encrypted entry + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + // { + // var e = za.CreateEntry("secure/original.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + // using var w = new StreamWriter(e.Open(), Encoding.UTF8); + // await w.WriteAsync("secret content"); + // } + + // // Copy encrypted content to a plain entry + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) + // { + // var src = za.GetEntry("secure/original.txt"); + // Assert.NotNull(src); + + // string content; + // using (var r = new StreamReader(src!.Open(pw), Encoding.UTF8)) + // content = await r.ReadToEndAsync(); + + // var plainEntry = za.CreateEntry("public/copy.txt"); // no encryption + // using var w = new StreamWriter(plainEntry.Open(), Encoding.UTF8); + // await w.WriteAsync(content); + // } + + // // Assert both entries exist and content matches + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + // { + // var enc = za.GetEntry("secure/original.txt"); + // var plain = za.GetEntry("public/copy.txt"); + // Assert.NotNull(enc); + // Assert.NotNull(plain); + + // using (var r1 = new StreamReader(enc!.Open(pw), Encoding.UTF8)) + // Assert.Equal("secret content", await r1.ReadToEndAsync()); + + // using (var r2 = new StreamReader(plain!.Open(), Encoding.UTF8)) + // Assert.Equal("secret content", await r2.ReadToEndAsync()); + // } + //} + + + //[Fact] + //public void CreateEntryFromFile_WithPassword_WrongPassword_Throws() + //{ + // // Arrange + // Directory.CreateDirectory(DownloadsDir); + // string srcPath = NewPath("source_wrong_pw.txt"); + // string zipPath = NewPath("create_from_file_encrypted_wrongpw.zip"); + // const string entryName = "secure/wrong.txt"; + // const string correctPassword = "correct!"; + // const string badPassword = "wrong!"; + // const string payload = "secret data"; + + // if (File.Exists(srcPath)) File.Delete(srcPath); + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // File.WriteAllText(srcPath, payload, new UTF8Encoding(false)); + + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + // { + // var e = za.CreateEntryFromFile( + // sourceFileName: srcPath, + // entryName: entryName, + // compressionLevel: CompressionLevel.Optimal, + // password: correctPassword, + // encryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto); + // } + + // // Act & Assert + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + // { + // var e = za.GetEntry(entryName); + // Assert.NotNull(e); + + // Assert.ThrowsAny(() => + // { + // using var _ = e!.Open(badPassword); + // }); + // } + //} + + + //[Fact] + //public async Task CreateEntryFromFile_WithEncryption_RoundTrip() + //{ + // // Arrange + // Directory.CreateDirectory(DownloadsDir); + // string srcPath = NewPath("source_plain.txt"); + // string zipPath = NewPath("create_from_file_plain.zip"); + // const string entryName = "plain/copy.txt"; + // const string payload = "this is plain"; + // const string pwd = "anything"; + + // if (File.Exists(srcPath)) File.Delete(srcPath); + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // await File.WriteAllTextAsync(srcPath, payload, new UTF8Encoding(false)); + + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + // { + // var e = za.CreateEntryFromFile( + // sourceFileName: srcPath, + // entryName: entryName, + // compressionLevel: CompressionLevel.Optimal, + // password: pwd, + // encryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto); + // } + + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + // { + // var e = za.GetEntry(entryName); + // Assert.NotNull(e); + + // using var r = new StreamReader(e!.Open(pwd), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + // string text = await r.ReadToEndAsync(); + // Assert.Equal(payload, text); + + // // Opening a plain entry with a password should throw + // Assert.ThrowsAny(() => + // { + // using var _ = e.Open("some-password"); + // }); + // } + //} + + //[Fact] + //public void CreateEntry_UsesArchiveDefaults_WhenNotOverridden() + //{ + // Directory.CreateDirectory(DownloadsDir); + // var zipPath = NewPath("defaults_apply.zip"); + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // const string defaultPassword = "archive-pw"; + // const string payload = "default encryption content"; + // const string entryName = "secure/default.txt"; + + // using (var zipFs = File.Create(zipPath)) + // using (var za = new ZipArchive(zipFs, + // ZipArchiveMode.Create, + // leaveOpen: false, + // entryNameEncoding: Encoding.UTF8, + // defaultPassword: defaultPassword, + // defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) + // { + // var e = za.CreateEntry(entryName); + + // using (var es = e.Open()) + // { + // var bytes = Encoding.UTF8.GetBytes(payload); + // es.Write(bytes, 0, bytes.Length); + // } + // } + + // // Verify with the archive default password + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + // { + // var e = za.GetEntry(entryName); + // Assert.NotNull(e); + // using var r = new StreamReader(e!.Open(defaultPassword), Encoding.UTF8); + // Assert.Equal(payload, r.ReadToEnd()); + // } + //} + + //[Fact] + //public async Task CreateMode_DefaultPassword_AppliesToMultipleEntries() + //{ + // string zipPath = NewPath("defaults_multiple.zip"); + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // const string defaultPassword = "archive-pw"; + + // using (var zipFs = File.Create(zipPath)) + // using (var za = new ZipArchive(zipFs, + // ZipArchiveMode.Create, + // leaveOpen: false, + // entryNameEncoding: Encoding.UTF8, + // defaultPassword: defaultPassword, + // defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) + // { + // var e1 = za.CreateEntry("secure/one.txt"); + // using (var s1 = e1.Open()) + // { + // var b = Encoding.UTF8.GetBytes("ONE"); + // s1.Write(b, 0, b.Length); + // } + + // var e2 = za.CreateEntry("secure/two.txt"); + // using (var s2 = e2.Open()) + // { + // var b = Encoding.UTF8.GetBytes("TWO"); + // s2.Write(b, 0, b.Length); + // } + // } + + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + // { + // using (var r1 = new StreamReader(za.GetEntry("secure/one.txt")!.Open(defaultPassword), Encoding.UTF8)) + // Assert.Equal("ONE", await r1.ReadToEndAsync()); + + // using (var r2 = new StreamReader(za.GetEntry("secure/two.txt")!.Open(defaultPassword), Encoding.UTF8)) + // Assert.Equal("TWO", await r2.ReadToEndAsync()); + // } + //} + + //[Fact] + //public async Task CreateEntry_WithExplicitPassword_OverridesDefaultPassword() + //{ + // string zipPath = NewPath("override_default.zip"); + // if (File.Exists(zipPath)) File.Delete(zipPath); + + // const string archivePassword = "archive-pw"; + // const string entryPassword = "entry-pw"; + + // using (var zipFs = File.Create(zipPath)) + // using (var za = new ZipArchive(zipFs, + // ZipArchiveMode.Create, + // leaveOpen: false, + // entryNameEncoding: Encoding.UTF8, + // defaultPassword: archivePassword, + // defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) + // { + // var e = za.CreateEntry("secure/override.txt", entryPassword, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + // using (var s = e.Open()) + // { + // var b = Encoding.UTF8.GetBytes("OVERRIDE"); + // s.Write(b, 0, b.Length); + // } + // } + + // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + // { + // var e = za.GetEntry("secure/override.txt"); + // Assert.NotNull(e); + + // // Should succeed with entry password + // using (var rOk = new StreamReader(e!.Open(entryPassword), Encoding.UTF8)) + // Assert.Equal("OVERRIDE", await rOk.ReadToEndAsync()); + + // // Wrong: using archive default should fail + // Assert.ThrowsAny(() => + // { + // using var _ = e.Open(archivePassword); + // }); + // } + //} [Fact] public void OpenAESEncryptedTxtFile_ShouldReturnPlaintext() diff --git a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs index fe1ffe0e3030ef..c1a6360b72c4e0 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -96,7 +96,6 @@ public ZipArchive(System.IO.Stream stream) { } public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode) { } public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode, bool leaveOpen) { } public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode, bool leaveOpen, System.Text.Encoding? entryNameEncoding) { } - public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode, bool leaveOpen, System.Text.Encoding? entryNameEncoding, string? defaultPassword, System.IO.Compression.ZipArchiveEntry.EncryptionMethod defaultEncryption) { } [System.Diagnostics.CodeAnalysis.AllowNullAttribute] public string Comment { get { throw null; } set { } } public System.Collections.ObjectModel.ReadOnlyCollection Entries { get { throw null; } } @@ -104,8 +103,6 @@ public ZipArchive(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode public static System.Threading.Tasks.Task CreateAsync(System.IO.Stream stream, System.IO.Compression.ZipArchiveMode mode, bool leaveOpen, System.Text.Encoding? entryNameEncoding, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName) { throw null; } public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName, System.IO.Compression.CompressionLevel compressionLevel) { throw null; } - public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName, System.IO.Compression.CompressionLevel compressionLevel, string password, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryption) { throw null; } - public System.IO.Compression.ZipArchiveEntry CreateEntry(string entryName, string password, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryption) { throw null; } public void Dispose() { } protected virtual void Dispose(bool disposing) { } public System.Threading.Tasks.ValueTask DisposeAsync() { throw null; } @@ -129,7 +126,7 @@ internal ZipArchiveEntry() { } public string Name { get { throw null; } } public void Delete() { } public System.IO.Stream Open() { throw null; } - public System.IO.Stream Open(string password) { throw null; } + public System.IO.Stream Open(string? password = null, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod = System.IO.Compression.ZipArchiveEntry.EncryptionMethod.ZipCrypto) { throw null; } public System.Threading.Tasks.Task OpenAsync(string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public override string ToString() { throw null; } diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index 64799e955ab637..2c6ff2fa3a63b5 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -74,7 +74,6 @@ - diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 122dc298f01725..266d64921d4519 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -8,396 +8,394 @@ namespace System.IO.Compression { - internal sealed class WinZipAesStream : Stream - { - private readonly Stream _baseStream; - private readonly bool _encrypting; - private readonly int _keySizeBits; - private readonly bool _ae2; - private readonly uint? _crc32ForHeader; - private readonly Aes _aes; - private ICryptoTransform? _aesEncryptor; -#pragma warning disable CA1416 // HMACSHA1 is available on all platforms - private readonly HMACSHA1 _hmac; -#pragma warning restore CA1416 - private readonly byte[] _counterBlock = new byte[16]; - private byte[]? _key; - private byte[]? _hmacKey; - private byte[]? _salt; - private byte[]? _passwordVerifier; - private bool _headerWritten; - private bool _headerRead; - private long _position; - private readonly ReadOnlyMemory _password; - private bool _disposed; - private bool _authCodeValidated; - private readonly byte[] _authCodeBuffer = new byte[20]; // HMACSHA1 is 20 bytes - private int _authCodeBufferCount; - - public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null) - { - ArgumentNullException.ThrowIfNull(baseStream); - - - _baseStream = baseStream; - _password = password; - _encrypting = encrypting; - _keySizeBits = keySizeBits; - _ae2 = ae2; - _crc32ForHeader = crc32; -#pragma warning disable CA1416 // HMACSHA1 is available on all platforms - _aes = Aes.Create(); -#pragma warning restore CA1416 - _aes.Mode = CipherMode.ECB; - _aes.Padding = PaddingMode.None; - -#pragma warning disable CA1416 // HMACSHA1 available on all platforms ? -#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms ? - _hmac = new HMACSHA1(); -#pragma warning restore CA5350 // Do Not Use Weak Cryptographic Algorithms -#pragma warning restore CA1416 - - if (_encrypting) - { - GenerateKeys(); - InitCipher(); - } - } - - private void DeriveKeysFromPassword() - { - Debug.Assert(_salt is not null, "Salt must be initialized before deriving keys"); - - // WinZip AES uses SHA1 for PBKDF2 with 1000 iterations per spec - byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2(_password.Span, _salt!, 1000, HashAlgorithmName.SHA1, (_keySizeBits / 8) + 32 + 2); - - _key = new byte[_keySizeBits / 8]; - _hmacKey = new byte[32]; - _passwordVerifier = new byte[2]; - - Buffer.BlockCopy(derivedKey, 0, _key, 0, _key.Length); - Buffer.BlockCopy(derivedKey, _key.Length, _hmacKey, 0, _hmacKey.Length); - Buffer.BlockCopy(derivedKey, _key.Length + _hmacKey.Length, _passwordVerifier, 0, _passwordVerifier.Length); - } - - private void GenerateKeys() - { - // 8 for AES-128, 12 for AES-192, 16 for AES-256 - int saltSize = _keySizeBits / 16; - _salt = new byte[saltSize]; - RandomNumberGenerator.Fill(_salt); - - DeriveKeysFromPassword(); - - Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); - _hmac.Key = _hmacKey!; - } - - private void InitCipher() - { - Debug.Assert(_key is not null, "_key is not null"); - - _aes.Key = _key!; - _aesEncryptor = _aes.CreateEncryptor(); - } - - private void WriteHeader() - { - if (_headerWritten) return; - - Debug.Assert(_salt is not null && _passwordVerifier is not null, "Keys should have been generated before writing header"); - - _baseStream.Write(_salt); - _baseStream.Write(_passwordVerifier); - - if (_ae2 && _crc32ForHeader.HasValue) - { - Span crcBytes = stackalloc byte[4]; - BitConverter.TryWriteBytes(crcBytes, _crc32ForHeader.Value); - _baseStream.Write(crcBytes); - } - - _headerWritten = true; - } - - private void ReadHeader() - { - if (_headerRead) return; - - int saltSize = _keySizeBits / 16; - _salt = new byte[saltSize]; - _baseStream.ReadExactly(_salt); - - byte[] verifier = new byte[2]; - _baseStream.ReadExactly(verifier); - - DeriveKeysFromPassword(); - - Debug.Assert(_passwordVerifier is not null, "Password verifier should be derived"); - if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) - throw new InvalidDataException("Invalid password."); - - Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); - _hmac.Key = _hmacKey!; - InitCipher(); - - if (_ae2) - { - byte[] crcBytes = new byte[4]; - _baseStream.ReadExactly(crcBytes); - // CRC can be validated later if needed - } - - _headerRead = true; - } - - private void ProcessBlock(byte[] buffer, int offset, int count) - { - Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized before processing blocks"); - - int processed = 0; - byte[] keystream = new byte[16]; - while (processed < count) - { - IncrementCounter(); - _aesEncryptor.TransformBlock(_counterBlock, 0, 16, keystream, 0); - - // For the last block, we may use less than 16 bytes of the keystream - // This is correct CTR mode behavior - we only use as many bytes as needed - int blockSize = Math.Min(16, count - processed); - - // XOR the data with the keystream - // Note: If blockSize < 16, we only use the first 'blockSize' bytes of keystream - // The unused bytes are discarded, which is the expected - for (int i = 0; i < blockSize; i++) - { - buffer[offset + processed + i] ^= keystream[i]; - } - - _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); - processed += blockSize; - } - } - - private void IncrementCounter() - { - for (int i = 15; i >= 0; i--) - { - if (++_counterBlock[i] != 0) break; - } - } - - private void WriteAuthCode() - { - if (!_encrypting || _authCodeValidated) - return; - - _hmac.TransformFinalBlock(Array.Empty(), 0, 0); - byte[]? authCode = _hmac.Hash; - - if (authCode is not null) - { - _baseStream.Write(authCode); - } - - _authCodeValidated = true; - } - - private void ValidateAuthCode() - { - if (_encrypting || _authCodeValidated) - return; - - // Finalize HMAC computation - _hmac.TransformFinalBlock(Array.Empty(), 0, 0); - byte[]? expectedAuth = _hmac.Hash; - - if (expectedAuth is not null) - { - // Read the stored authentication code from the stream - byte[] storedAuth = new byte[expectedAuth.Length]; - _baseStream.ReadExactly(storedAuth); - - if (!storedAuth.AsSpan().SequenceEqual(expectedAuth)) - throw new InvalidDataException("Authentication code mismatch."); - } - - _authCodeValidated = true; - } - - private void WriteCore(ReadOnlySpan buffer) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - if (!_encrypting) - throw new NotSupportedException("Stream is in decryption mode."); - - WriteHeader(); - - // We need to copy the data since ProcessBlock modifies it in place - byte[] tmp = buffer.ToArray(); - ProcessBlock(tmp, 0, tmp.Length); - _baseStream.Write(tmp); - _position += buffer.Length; - } - - private int ReadCore(Span buffer) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - if (_encrypting) - throw new NotSupportedException("Stream is in encryption mode."); - - if (!_headerRead) - ReadHeader(); - - int n = _baseStream.Read(buffer); - - // Check if we reached the end of the stream - if (n == 0 && !_authCodeValidated) - { - ValidateAuthCode(); - return 0; - } - - if (n > 0) - { - // Process the data in-place for reads (it's already in the buffer) - // We need to temporarily copy to array for HMAC processing - byte[] temp = buffer.Slice(0, n).ToArray(); - ProcessBlock(temp, 0, n); - temp.CopyTo(buffer); - _position += n; - } - - return n; - } - - // All Write overloads redirect to Write(ReadOnlySpan) - public override void Write(byte[] buffer, int offset, int count) - { - ValidateBufferArguments(buffer, offset, count); - Write(new ReadOnlySpan(buffer, offset, count)); - } - - public override void Write(ReadOnlySpan buffer) - { - WriteCore(buffer); - } - - public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - ValidateBufferArguments(buffer, offset, count); - await WriteAsync(new ReadOnlyMemory(buffer, offset, count), cancellationToken).ConfigureAwait(false); - } - - public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - if (!_encrypting) - throw new NotSupportedException("Stream is in decryption mode."); - - return Core(buffer, cancellationToken); - - async ValueTask Core(ReadOnlyMemory buffer, CancellationToken cancellationToken) - { - WriteHeader(); - - // We need to copy the data since ProcessBlock modifies it in place - byte[] tmp = buffer.ToArray(); - ProcessBlock(tmp, 0, tmp.Length); - await _baseStream.WriteAsync(tmp, cancellationToken).ConfigureAwait(false); - _position += buffer.Length; - } - } - - public override int Read(byte[] buffer, int offset, int count) - { - ValidateBufferArguments(buffer, offset, count); - return ReadCore(new Span(buffer, offset, count)); - } - - public override int Read(Span buffer) - { - return ReadCore(buffer); - } - - public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - ValidateBufferArguments(buffer, offset, count); - return await ReadAsync(new Memory(buffer, offset, count), cancellationToken).ConfigureAwait(false); - } - - public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - if (_encrypting) - throw new NotSupportedException("Stream is in encryption mode."); - - if (!_headerRead) - ReadHeader(); - - int n = await _baseStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - if (n > 0) - { - // Process the data - work with the Memory span - byte[] temp = buffer.Slice(0, n).ToArray(); - ProcessBlock(temp, 0, n); - temp.CopyTo(buffer.Span); - _position += n; - } - - return n; - } - - protected override void Dispose(bool disposing) - { - if (_disposed) - return; - - if (disposing) - { - try - { - // For encryption, write the auth code when closing - if (_encrypting && _headerWritten && !_authCodeValidated) - { - WriteAuthCode(); - } - - _baseStream.Flush(); - } - finally - { - _aesEncryptor?.Dispose(); - _aes.Dispose(); - _hmac.Dispose(); - } - } - - _disposed = true; - base.Dispose(disposing); - } - - public override bool CanRead => !_encrypting && !_disposed; - public override bool CanSeek => false; - public override bool CanWrite => _encrypting && !_disposed; - public override long Length => throw new NotSupportedException(); - public override long Position - { - get => _position; - set => throw new NotSupportedException(); - } - - public override void Flush() - { - ObjectDisposedException.ThrowIf(_disposed, this); - _baseStream.Flush(); - } - - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - public override void SetLength(long value) => throw new NotSupportedException(); - } +// internal sealed class WinZipAesStream : Stream +// { +// private readonly Stream _baseStream; +// private readonly bool _encrypting; +// private readonly int _keySizeBits; +// private readonly bool _ae2; +// private readonly uint? _crc32ForHeader; +// private readonly Aes _aes; +// private ICryptoTransform? _aesEncryptor; +//#pragma warning disable CA1416 // HMACSHA1 is available on all platforms +// private readonly HMACSHA1 _hmac; +//#pragma warning restore CA1416 +// private readonly byte[] _counterBlock = new byte[16]; +// private byte[]? _key; +// private byte[]? _hmacKey; +// private byte[]? _salt; +// private byte[]? _passwordVerifier; +// private bool _headerWritten; +// private bool _headerRead; +// private long _position; +// private readonly ReadOnlyMemory _password; +// private bool _disposed; +// private bool _authCodeValidated; + +// public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null) +// { +// ArgumentNullException.ThrowIfNull(baseStream); + + +// _baseStream = baseStream; +// _password = password; +// _encrypting = encrypting; +// _keySizeBits = keySizeBits; +// _ae2 = ae2; +// _crc32ForHeader = crc32; +//#pragma warning disable CA1416 // HMACSHA1 is available on all platforms +// _aes = Aes.Create(); +//#pragma warning restore CA1416 +// _aes.Mode = CipherMode.ECB; +// _aes.Padding = PaddingMode.None; + +//#pragma warning disable CA1416 // HMACSHA1 available on all platforms ? +//#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms ? +// _hmac = new HMACSHA1(); +//#pragma warning restore CA5350 // Do Not Use Weak Cryptographic Algorithms +//#pragma warning restore CA1416 + +// if (_encrypting) +// { +// GenerateKeys(); +// InitCipher(); +// } +// } + +// private void DeriveKeysFromPassword() +// { +// Debug.Assert(_salt is not null, "Salt must be initialized before deriving keys"); + +// // WinZip AES uses SHA1 for PBKDF2 with 1000 iterations per spec +// byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2(_password.Span, _salt!, 1000, HashAlgorithmName.SHA1, (_keySizeBits / 8) + 32 + 2); + +// _key = new byte[_keySizeBits / 8]; +// _hmacKey = new byte[32]; +// _passwordVerifier = new byte[2]; + +// Buffer.BlockCopy(derivedKey, 0, _key, 0, _key.Length); +// Buffer.BlockCopy(derivedKey, _key.Length, _hmacKey, 0, _hmacKey.Length); +// Buffer.BlockCopy(derivedKey, _key.Length + _hmacKey.Length, _passwordVerifier, 0, _passwordVerifier.Length); +// } + +// private void GenerateKeys() +// { +// // 8 for AES-128, 12 for AES-192, 16 for AES-256 +// int saltSize = _keySizeBits / 16; +// _salt = new byte[saltSize]; +// RandomNumberGenerator.Fill(_salt); + +// DeriveKeysFromPassword(); + +// Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); +// _hmac.Key = _hmacKey!; +// } + +// private void InitCipher() +// { +// Debug.Assert(_key is not null, "_key is not null"); + +// _aes.Key = _key!; +// _aesEncryptor = _aes.CreateEncryptor(); +// } + +// private void WriteHeader() +// { +// if (_headerWritten) return; + +// Debug.Assert(_salt is not null && _passwordVerifier is not null, "Keys should have been generated before writing header"); + +// _baseStream.Write(_salt); +// _baseStream.Write(_passwordVerifier); + +// if (_ae2 && _crc32ForHeader.HasValue) +// { +// Span crcBytes = stackalloc byte[4]; +// BitConverter.TryWriteBytes(crcBytes, _crc32ForHeader.Value); +// _baseStream.Write(crcBytes); +// } + +// _headerWritten = true; +// } + +// private void ReadHeader() +// { +// if (_headerRead) return; + +// int saltSize = _keySizeBits / 16; +// _salt = new byte[saltSize]; +// _baseStream.ReadExactly(_salt); + +// byte[] verifier = new byte[2]; +// _baseStream.ReadExactly(verifier); + +// DeriveKeysFromPassword(); + +// Debug.Assert(_passwordVerifier is not null, "Password verifier should be derived"); +// if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) +// throw new InvalidDataException("Invalid password."); + +// Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); +// _hmac.Key = _hmacKey!; +// InitCipher(); + +// if (_ae2) +// { +// byte[] crcBytes = new byte[4]; +// _baseStream.ReadExactly(crcBytes); +// // CRC can be validated later if needed +// } + +// _headerRead = true; +// } + +// private void ProcessBlock(byte[] buffer, int offset, int count) +// { +// Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized before processing blocks"); + +// int processed = 0; +// byte[] keystream = new byte[16]; +// while (processed < count) +// { +// IncrementCounter(); +// _aesEncryptor.TransformBlock(_counterBlock, 0, 16, keystream, 0); + +// // For the last block, we may use less than 16 bytes of the keystream +// // This is correct CTR mode behavior - we only use as many bytes as needed +// int blockSize = Math.Min(16, count - processed); + +// // XOR the data with the keystream +// // Note: If blockSize < 16, we only use the first 'blockSize' bytes of keystream +// // The unused bytes are discarded, which is the expected +// for (int i = 0; i < blockSize; i++) +// { +// buffer[offset + processed + i] ^= keystream[i]; +// } + +// _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); +// processed += blockSize; +// } +// } + +// private void IncrementCounter() +// { +// for (int i = 15; i >= 0; i--) +// { +// if (++_counterBlock[i] != 0) break; +// } +// } + +// private void WriteAuthCode() +// { +// if (!_encrypting || _authCodeValidated) +// return; + +// _hmac.TransformFinalBlock(Array.Empty(), 0, 0); +// byte[]? authCode = _hmac.Hash; + +// if (authCode is not null) +// { +// _baseStream.Write(authCode); +// } + +// _authCodeValidated = true; +// } + +// private void ValidateAuthCode() +// { +// if (_encrypting || _authCodeValidated) +// return; + +// // Finalize HMAC computation +// _hmac.TransformFinalBlock(Array.Empty(), 0, 0); +// byte[]? expectedAuth = _hmac.Hash; + +// if (expectedAuth is not null) +// { +// // Read the stored authentication code from the stream +// byte[] storedAuth = new byte[expectedAuth.Length]; +// _baseStream.ReadExactly(storedAuth); + +// if (!storedAuth.AsSpan().SequenceEqual(expectedAuth)) +// throw new InvalidDataException("Authentication code mismatch."); +// } + +// _authCodeValidated = true; +// } + +// private void WriteCore(ReadOnlySpan buffer) +// { +// ObjectDisposedException.ThrowIf(_disposed, this); + +// if (!_encrypting) +// throw new NotSupportedException("Stream is in decryption mode."); + +// WriteHeader(); + +// // We need to copy the data since ProcessBlock modifies it in place +// byte[] tmp = buffer.ToArray(); +// ProcessBlock(tmp, 0, tmp.Length); +// _baseStream.Write(tmp); +// _position += buffer.Length; +// } + +// private int ReadCore(Span buffer) +// { +// ObjectDisposedException.ThrowIf(_disposed, this); + +// if (_encrypting) +// throw new NotSupportedException("Stream is in encryption mode."); + +// if (!_headerRead) +// ReadHeader(); + +// int n = _baseStream.Read(buffer); + +// // Check if we reached the end of the stream +// if (n == 0 && !_authCodeValidated) +// { +// ValidateAuthCode(); +// return 0; +// } + +// if (n > 0) +// { +// // Process the data in-place for reads (it's already in the buffer) +// // We need to temporarily copy to array for HMAC processing +// byte[] temp = buffer.Slice(0, n).ToArray(); +// ProcessBlock(temp, 0, n); +// temp.CopyTo(buffer); +// _position += n; +// } + +// return n; +// } + +// // All Write overloads redirect to Write(ReadOnlySpan) +// public override void Write(byte[] buffer, int offset, int count) +// { +// ValidateBufferArguments(buffer, offset, count); +// Write(new ReadOnlySpan(buffer, offset, count)); +// } + +// public override void Write(ReadOnlySpan buffer) +// { +// WriteCore(buffer); +// } + +// public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) +// { +// ValidateBufferArguments(buffer, offset, count); +// await WriteAsync(new ReadOnlyMemory(buffer, offset, count), cancellationToken).ConfigureAwait(false); +// } + +// public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) +// { +// ObjectDisposedException.ThrowIf(_disposed, this); + +// if (!_encrypting) +// throw new NotSupportedException("Stream is in decryption mode."); + +// return Core(buffer, cancellationToken); + +// async ValueTask Core(ReadOnlyMemory buffer, CancellationToken cancellationToken) +// { +// WriteHeader(); + +// // We need to copy the data since ProcessBlock modifies it in place +// byte[] tmp = buffer.ToArray(); +// ProcessBlock(tmp, 0, tmp.Length); +// await _baseStream.WriteAsync(tmp, cancellationToken).ConfigureAwait(false); +// _position += buffer.Length; +// } +// } + +// public override int Read(byte[] buffer, int offset, int count) +// { +// ValidateBufferArguments(buffer, offset, count); +// return ReadCore(new Span(buffer, offset, count)); +// } + +// public override int Read(Span buffer) +// { +// return ReadCore(buffer); +// } + +// public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) +// { +// ValidateBufferArguments(buffer, offset, count); +// return await ReadAsync(new Memory(buffer, offset, count), cancellationToken).ConfigureAwait(false); +// } + +// public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) +// { +// ObjectDisposedException.ThrowIf(_disposed, this); + +// if (_encrypting) +// throw new NotSupportedException("Stream is in encryption mode."); + +// if (!_headerRead) +// ReadHeader(); + +// int n = await _baseStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); +// if (n > 0) +// { +// // Process the data - work with the Memory span +// byte[] temp = buffer.Slice(0, n).ToArray(); +// ProcessBlock(temp, 0, n); +// temp.CopyTo(buffer.Span); +// _position += n; +// } + +// return n; +// } + +// protected override void Dispose(bool disposing) +// { +// if (_disposed) +// return; + +// if (disposing) +// { +// try +// { +// // For encryption, write the auth code when closing +// if (_encrypting && _headerWritten && !_authCodeValidated) +// { +// WriteAuthCode(); +// } + +// _baseStream.Flush(); +// } +// finally +// { +// _aesEncryptor?.Dispose(); +// _aes.Dispose(); +// _hmac.Dispose(); +// } +// } + +// _disposed = true; +// base.Dispose(disposing); +// } + +// public override bool CanRead => !_encrypting && !_disposed; +// public override bool CanSeek => false; +// public override bool CanWrite => _encrypting && !_disposed; +// public override long Length => throw new NotSupportedException(); +// public override long Position +// { +// get => _position; +// set => throw new NotSupportedException(); +// } + +// public override void Flush() +// { +// ObjectDisposedException.ThrowIf(_disposed, this); +// _baseStream.Flush(); +// } + +// public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); +// public override void SetLength(long value) => throw new NotSupportedException(); +// } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs index bc16f2119bbf88..1133482cd6f446 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs @@ -9,7 +9,6 @@ using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Globalization; using System.Text; namespace System.IO.Compression @@ -32,10 +31,6 @@ public partial class ZipArchive : IDisposable, IAsyncDisposable private byte[] _archiveComment; private Encoding? _entryNameAndCommentEncoding; private long _firstDeletedEntryOffset; - private readonly string? _defaultPassword; - private readonly ZipArchiveEntry.EncryptionMethod _defaultEncryption; - - #if DEBUG_FORCE_ZIP64 public bool _forceZip64; @@ -180,20 +175,6 @@ public ZipArchive(Stream stream, ZipArchiveMode mode, bool leaveOpen, Encoding? } } - public ZipArchive(Stream stream, ZipArchiveMode mode, bool leaveOpen, Encoding? entryNameEncoding, string? defaultPassword, ZipArchiveEntry.EncryptionMethod defaultEncryption) - : this(stream, mode, leaveOpen, entryNameEncoding) - { - _defaultPassword = string.IsNullOrEmpty(defaultPassword) ? null : defaultPassword; - _defaultEncryption = defaultEncryption; - - // Validate that if encryption is specified, a password is provided, what to do otherwise? - if (_defaultEncryption != ZipArchiveEntry.EncryptionMethod.None && _defaultPassword is null) - { - throw new ArgumentException("A password must be provided when encryption is specified.", nameof(defaultPassword)); - } - } - - /// Helper constructor that initializes some of the essential ZipArchive /// information that other constructors initialize the same way. /// Validations, checks and entry collection need to be done outside this constructor. @@ -286,11 +267,6 @@ public ZipArchiveEntry CreateEntry(string entryName) return DoCreateEntry(entryName, null); } - public ZipArchiveEntry CreateEntry(string entryName, string password, ZipArchiveEntry.EncryptionMethod encryption) - { - return DoCreateEntry(entryName, null, password, encryption); - } - /// /// Creates an empty entry in the Zip archive with the specified entry name. There are no restrictions on the names of entries. The last write time of the entry is set to the current time. If an entry with the specified name already exists in the archive, a second entry will be created that has an identical name. /// @@ -306,11 +282,6 @@ public ZipArchiveEntry CreateEntry(string entryName, CompressionLevel compressio return DoCreateEntry(entryName, compressionLevel); } - public ZipArchiveEntry CreateEntry(string entryName, CompressionLevel compressionLevel, string password, ZipArchiveEntry.EncryptionMethod encryption) - { - return DoCreateEntry(entryName, compressionLevel, password, encryption); - } - /// /// Releases the unmanaged resources used by ZipArchive and optionally finishes writing the archive and releases the managed resources. /// @@ -372,9 +343,6 @@ protected virtual void Dispose(bool disposing) internal uint NumberOfThisDisk => _numberOfThisDisk; - internal string? DefaultPassword => _defaultPassword; - internal ZipArchiveEntry.EncryptionMethod DefaultEncryption => _defaultEncryption; - internal Encoding? EntryNameAndCommentEncoding { get => _entryNameAndCommentEncoding; @@ -411,8 +379,7 @@ private set // New entries in the archive won't change its state. internal ChangeState Changed { get; private set; } - private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compressionLevel, - string? password = null, ZipArchiveEntry.EncryptionMethod encryption = ZipArchiveEntry.EncryptionMethod.None) + private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compressionLevel) { ArgumentException.ThrowIfNullOrEmpty(entryName); @@ -421,20 +388,10 @@ private ZipArchiveEntry DoCreateEntry(string entryName, CompressionLevel? compre ThrowIfDisposed(); + ZipArchiveEntry entry = compressionLevel.HasValue ? + new ZipArchiveEntry(this, entryName, compressionLevel.Value) : + new ZipArchiveEntry(this, entryName); - ZipArchiveEntry entry; - if (compressionLevel.HasValue) - { - entry = !string.IsNullOrEmpty(password) - ? new ZipArchiveEntry(this, entryName, compressionLevel.Value, password, encryption) - : new ZipArchiveEntry(this, entryName, compressionLevel.Value); - } - else - { - entry = !string.IsNullOrEmpty(password) - ? new ZipArchiveEntry(this, entryName, password, encryption) - : new ZipArchiveEntry(this, entryName); - } AddEntry(entry); return entry; diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index be1c8a3c7e4932..dc9493e35bdf25 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -47,9 +47,8 @@ public partial class ZipArchiveEntry private List? _lhUnknownExtraFields; private byte[]? _lhTrailingExtraFieldData; private byte[] _fileComment; - private readonly CompressionLevel _compressionLevel; - private string? _password; private EncryptionMethod _encryptionMethod; + private readonly CompressionLevel _compressionLevel; // Initializes a ZipArchiveEntry instance for an existing archive entry. internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) @@ -99,8 +98,6 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) _compressionLevel = MapCompressionLevel(_generalPurposeBitFlag, CompressionMethod); - _password = archive.DefaultPassword; - _encryptionMethod = archive.DefaultEncryption; } // Initializes a ZipArchiveEntry instance for a new archive entry with a specified compression level. @@ -113,8 +110,6 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName, CompressionLevel CompressionMethod = CompressionMethodValues.Stored; } _generalPurposeBitFlag = MapDeflateCompressionOption(_generalPurposeBitFlag, _compressionLevel, CompressionMethod); - _password = archive.DefaultPassword; - _encryptionMethod = archive.DefaultEncryption; } // Initializes a ZipArchiveEntry instance for a new archive entry. @@ -167,60 +162,6 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName) Changes = ZipArchive.ChangeState.Unchanged; - _password = archive.DefaultPassword; - _encryptionMethod = archive.DefaultEncryption; - } - - internal ZipArchiveEntry(ZipArchive archive, string entryName, CompressionLevel compressionLevel, string? password, EncryptionMethod encryptionMethod = EncryptionMethod.ZipCrypto) - : this(archive, entryName, compressionLevel) - { - _password = password; - _encryptionMethod = encryptionMethod; - _generalPurposeBitFlag = 0; - - if (!string.IsNullOrEmpty(_password) && _encryptionMethod != EncryptionMethod.None) - { - _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; - _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; - _isEncrypted = true; - } - else - { - _generalPurposeBitFlag &= ~BitFlagValues.IsEncrypted; - _isEncrypted = false; - } - } - - internal ZipArchiveEntry(ZipArchive archive, string entryName, string? password, EncryptionMethod encryptionMethod = EncryptionMethod.ZipCrypto) - : this(archive, entryName) - { - _password = password; - _encryptionMethod = encryptionMethod; - _generalPurposeBitFlag = 0; - - if (!string.IsNullOrEmpty(_password) && _encryptionMethod != EncryptionMethod.None) - { - _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; - _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; - _isEncrypted = true; - } - else - { - _generalPurposeBitFlag &= ~BitFlagValues.IsEncrypted; - _isEncrypted = false; - } - } - - internal string Password - { - get => _password ?? string.Empty; - set => _password = value; - } - - internal EncryptionMethod Encryption - { - get => _encryptionMethod; - set => _encryptionMethod = value; } /// @@ -412,12 +353,10 @@ public Stream Open() { ThrowIfInvalidArchive(); - bool isEncrypted = !string.IsNullOrEmpty(_archive.DefaultPassword) && _archive.DefaultEncryption != EncryptionMethod.None; - switch (_archive.Mode) { case ZipArchiveMode.Read: - return isEncrypted ? OpenInReadMode(checkOpenable: true, _archive.DefaultPassword.AsMemory()) : OpenInReadMode(checkOpenable: true); + return OpenInReadMode(checkOpenable: true); case ZipArchiveMode.Create: return OpenInWriteMode(); case ZipArchiveMode.Update: @@ -434,7 +373,7 @@ public Stream Open() /// The entry is already currently open for writing. -or- The entry has been deleted from the archive. -or- The archive that this entry belongs to was opened in ZipArchiveMode.Create, and this entry has already been written to once. /// The entry is missing from the archive or is corrupt and cannot be read. -or- The entry has been compressed using a compression method that is not supported. /// The ZipArchive that this entry belongs to has been disposed. - public Stream Open(string password) + public Stream Open(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.ZipCrypto) { ThrowIfInvalidArchive(); switch (_archive.Mode) @@ -446,7 +385,7 @@ public Stream Open(string password) } return OpenInReadMode(checkOpenable: true, password.AsMemory()); case ZipArchiveMode.Create: - return OpenInWriteMode(); + return OpenInWriteMode(password, encryptionMethod); case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); @@ -485,7 +424,7 @@ private string DecodeEntryString(byte[] entryStringBytes) internal bool EverOpenedForWrite => _everOpenedForWrite; - internal long GetOffsetOfCompressedData() + internal long GetOffsetOfCompressedData(EncryptionMethod encryptionMethod = EncryptionMethod.None) { if (_storedOffsetOfCompressedData == null) { @@ -511,7 +450,7 @@ internal long GetOffsetOfCompressedData() baseOffset = _archive.ArchiveStream.Position; // Adjust for AES salt + password verifier using _encryptionMethod - int saltSize = _encryptionMethod switch + int saltSize = encryptionMethod switch { EncryptionMethod.Aes128 => 8, EncryptionMethod.Aes192 => 12, @@ -838,55 +777,34 @@ private void DetectEntryNameVersion() } } - private CheckSumAndSizeWriteStream GetDataCompressor( - Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) + + private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) { - // final chain: backingStream <- Encryption <- Deflate/Stored <- CheckSumAndSizeWriteStream + // stream stack: backingStream -> DeflateStream -> CheckSumWriteStream + // By default we compress with deflate, except if compression level is set to NoCompression then stored is used. + // Stored is also used for empty files, but we don't actually call through this function for that - we just write the stored value in the header + // Deflate64 is not supported on all platforms Debug.Assert(CompressionMethod == CompressionMethodValues.Deflate - || CompressionMethod == CompressionMethodValues.Stored - || CompressionMethod == CompressionMethodValues.Deflate64); + || CompressionMethod == CompressionMethodValues.Stored); - string? pwd = string.IsNullOrEmpty(_password) ? _archive.DefaultPassword : _password; - - // Build encrypting layer if needed. Header will be emitted on the first write. - Stream targetSink = backingStream; - if (IsZipCryptoEncrypted() || _archive.DefaultEncryption == EncryptionMethod.ZipCrypto) - { - if (string.IsNullOrEmpty(pwd)) - throw new InvalidOperationException("Encrypted entry requires a non-empty password."); - - // For streaming (GPBF bit 3 set in LOCAL HEADER), verifier = DOS time low word of that header - ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); - - targetSink = new ZipCryptoStream( - baseStream: backingStream, - password: pwd.AsMemory(), - passwordVerifierLow2Bytes: verifierLow2Bytes, - crc32: null, - leaveOpen: leaveBackingStreamOpen); - } - Stream compressorStream; bool isIntermediateStream = true; - + Stream compressorStream; switch (CompressionMethod) { case CompressionMethodValues.Stored: - compressorStream = targetSink; - isIntermediateStream = IsEncrypted; // only intermediate if we added encryption + compressorStream = backingStream; + isIntermediateStream = false; break; - case CompressionMethodValues.Deflate: case CompressionMethodValues.Deflate64: default: - compressorStream = new DeflateStream(targetSink, _compressionLevel, leaveBackingStreamOpen); - isIntermediateStream = true; + compressorStream = new DeflateStream(backingStream, _compressionLevel, leaveBackingStreamOpen); break; - } + } bool leaveCompressorStreamOpenOnClose = leaveBackingStreamOpen && !isIntermediateStream; - var checkSumStream = new CheckSumAndSizeWriteStream( compressorStream, backingStream, @@ -895,13 +813,9 @@ private CheckSumAndSizeWriteStream GetDataCompressor( onClose, (long initialPosition, long currentPosition, uint checkSum, Stream backing, ZipArchiveEntry thisRef, EventHandler? closeHandler) => { - // CRC over plaintext: thisRef._crc32 = checkSum; thisRef._uncompressedSize = currentPosition; - - long rawCompressed = backing.Position - initialPosition; - - thisRef._compressedSize = rawCompressed; + thisRef._compressedSize = backing.Position - initialPosition; closeHandler?.Invoke(thisRef, EventArgs.Empty); }); @@ -930,50 +844,16 @@ private bool IsAesEncrypted() return _storedCompressionMethod == CompressionMethodValues.Aes; } - private Stream GetDataDecompressor(Stream compressedStreamToRead, ReadOnlyMemory password = default) + private Stream GetDataDecompressor(Stream compressedStreamToRead) { - - Stream toDecompress = compressedStreamToRead; - if (IsZipCryptoEncrypted()) - { - if (password.IsEmpty) - throw new InvalidDataException("Password required for encrypted ZIP entry."); - - byte expectedCheckByte = CalculateZipCryptoCheckByte(); - - toDecompress = new ZipCryptoStream(toDecompress, password, expectedCheckByte); - } - else if (IsAesEncrypted()) - { - if (password.IsEmpty) - throw new InvalidDataException("Password required for AES-encrypted ZIP entry."); - - // Determine key size based on encryption method - //int keySizeBits = _encryptionMethod switch - //{ - // EncryptionMethod.Aes128 => 128, - // EncryptionMethod.Aes192 => 192, - // EncryptionMethod.Aes256 => 256, - // _ => throw new InvalidDataException($"Invalid AES encryption method: {_encryptionMethod}") - //}; - - //// For AES in ZIP, AE-2 format includes CRC-32 in the AES extra field - //// The _crc32 field should contain the CRC value for AE-2 - //bool isAe2 = (_generalPurposeBitFlag & BitFlagValues.DataDescriptor) == 0; - - // Read and parse the AES extra field to get necessary parameters - // toDecompress = new WinZipAesStream(toDecompress, password, false, keySizeBits, isAe2, isAe2 ? _crc32 : null); - } - - Stream? uncompressedStream; switch (CompressionMethod) { case CompressionMethodValues.Deflate: - uncompressedStream = new DeflateStream(toDecompress, CompressionMode.Decompress, _uncompressedSize); + uncompressedStream = new DeflateStream(compressedStreamToRead, CompressionMode.Decompress, _uncompressedSize); break; case CompressionMethodValues.Deflate64: - uncompressedStream = new DeflateManagedStream(toDecompress, CompressionMethodValues.Deflate64, _uncompressedSize); + uncompressedStream = new DeflateManagedStream(compressedStreamToRead, CompressionMethodValues.Deflate64, _uncompressedSize); break; case CompressionMethodValues.Stored: default: @@ -981,7 +861,7 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead, ReadOnlyMemory // IsOpenable is checked before this function is called Debug.Assert(CompressionMethod == CompressionMethodValues.Stored); - uncompressedStream = toDecompress; + uncompressedStream = compressedStreamToRead; break; } @@ -998,10 +878,29 @@ private Stream OpenInReadMode(bool checkOpenable, ReadOnlyMemory password private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, ReadOnlyMemory password = default) { Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offsetOfCompressedData, _compressedSize); - return GetDataDecompressor(compressedStream, password); + Stream streamToDecompress = compressedStream; + + if (IsZipCryptoEncrypted()) + { + if (password.IsEmpty) + throw new InvalidDataException("Password required for encrypted ZIP entry."); + + byte expectedCheckByte = CalculateZipCryptoCheckByte(); + streamToDecompress = new ZipCryptoStream(compressedStream, password, expectedCheckByte); + } + else if (IsAesEncrypted()) + { + if (password.IsEmpty) + throw new InvalidDataException("Password required for AES-encrypted ZIP entry."); + + // AES implementation placeholder as indicated in the original code + // When AES is implemented, create the appropriate decryption stream here + throw new NotSupportedException("AES encryption is not yet supported."); + } + return GetDataDecompressor(streamToDecompress); } - private WrappedStream OpenInWriteMode() + private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.None) { if (_everOpenedForWrite) throw new IOException(SR.CreateModeWriteOnceAndOneEntryAtATime); @@ -1011,17 +910,43 @@ private WrappedStream OpenInWriteMode() _everOpenedForWrite = true; Changes |= ZipArchive.ChangeState.StoredData; - CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor(_archive.ArchiveStream, true, (object? o, EventArgs e) => + + // Build the stream stack with encryption if needed + Stream targetStream = _archive.ArchiveStream; + + if (encryptionMethod == EncryptionMethod.ZipCrypto) { - // release the archive stream - var entry = (ZipArchiveEntry)o!; - entry._archive.ReleaseArchiveStream(entry); - entry._outstandingWriteStream = null; - }); - _outstandingWriteStream = new DirectToArchiveWriterStream(crcSizeStream, this); + if (string.IsNullOrEmpty(password)) + throw new InvalidOperationException("Password is required for encryption."); + + Encryption = encryptionMethod; + + // For ZipCrypto with streaming (bit 3 set), verifier = DOS time low word + ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); + + targetStream = new ZipCryptoStream( + baseStream: _archive.ArchiveStream, + password: password.AsMemory(), + passwordVerifierLow2Bytes: verifierLow2Bytes, + crc32: null, + leaveOpen: true); + } + CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor( + targetStream, + true, + (object? o, EventArgs e) => + { + // release the archive stream + var entry = (ZipArchiveEntry)o!; + entry._archive.ReleaseArchiveStream(entry); + entry._outstandingWriteStream = null; + }); + + _outstandingWriteStream = new DirectToArchiveWriterStream(crcSizeStream, this, encryptionMethod); return new WrappedStream(baseStream: _outstandingWriteStream, closeBaseStream: true); } + private WrappedStream OpenInUpdateMode() { if (_currentlyOpenForWrite) @@ -1045,7 +970,6 @@ private WrappedStream OpenInUpdateMode() }); } - private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out string? message) { message = null; @@ -1074,10 +998,9 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st return false; } - // Save encryption info for later use if (aesStrength.HasValue) { - _encryptionMethod = aesStrength switch + _ = aesStrength switch { 1 => EncryptionMethod.Aes128, 2 => EncryptionMethod.Aes192, @@ -1216,6 +1139,7 @@ private static BitFlagValues MapDeflateCompressionOption(BitFlagValues generalPu private bool IsOffsetTooLarge => _offsetOfLocalHeader > uint.MaxValue; private bool ShouldUseZIP64 => AreSizesTooLarge || IsOffsetTooLarge; + internal EncryptionMethod Encryption { get => _encryptionMethod; set => _encryptionMethod = value; } private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, out Zip64ExtraField? zip64ExtraField, out uint compressedSizeTruncated, out uint uncompressedSizeTruncated, out ushort extraFieldLength) { @@ -1655,10 +1579,11 @@ private sealed class DirectToArchiveWriterStream : Stream private readonly ZipArchiveEntry _entry; private bool _usedZip64inLH; private bool _canWrite; + private readonly EncryptionMethod _encryption; // makes the assumption that somewhere down the line, crcSizeStream is eventually writing directly to the archive // this class calls other functions on ZipArchiveEntry that write directly to the archive - public DirectToArchiveWriterStream(CheckSumAndSizeWriteStream crcSizeStream, ZipArchiveEntry entry) + public DirectToArchiveWriterStream(CheckSumAndSizeWriteStream crcSizeStream, ZipArchiveEntry entry, EncryptionMethod encryptionMethod = EncryptionMethod.None) { _position = 0; _crcSizeStream = crcSizeStream; @@ -1667,6 +1592,7 @@ public DirectToArchiveWriterStream(CheckSumAndSizeWriteStream crcSizeStream, Zip _entry = entry; _usedZip64inLH = false; _canWrite = true; + _encryption = encryptionMethod; } public override long Length diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index a3589e210ebec0..990fe702247a61 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -9,9 +9,9 @@ internal sealed class ZipCryptoStream : Stream private readonly Stream _base; private readonly bool _leaveOpen; private bool _headerWritten; - private bool _everWrotePayload; private readonly ushort _verifierLow2Bytes; // (DOS time low word when streaming) private readonly uint? _crc32ForHeader; // (CRC-based header when not streaming) + private int _position; private uint _key0; private uint _key1; @@ -38,6 +38,7 @@ public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte ex InitKeysFromBytes(password.Span); _encrypting = false; ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes + _position = 0; } // Encryption constructor @@ -52,7 +53,7 @@ public ZipCryptoStream(Stream baseStream, _leaveOpen = leaveOpen; _verifierLow2Bytes = passwordVerifierLow2Bytes; _crc32ForHeader = crc32; - + _position = 0; InitKeysFromBytes(password.Span); } @@ -92,6 +93,7 @@ private void EnsureHeader() _base.Write(hdrCiph, 0, 12); _headerWritten = true; + _position += 12; } private void InitKeysFromBytes(ReadOnlySpan password) @@ -152,7 +154,11 @@ private byte DecryptByte(byte ciph) public override bool CanSeek => false; public override bool CanWrite => _encrypting; public override long Length => throw new NotSupportedException(); - public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override long Position + { + get => _position; + set => throw new NotSupportedException("ZipCryptoStream does not support seeking."); + } public override void Flush() => _base.Flush(); public override int Read(byte[] buffer, int offset, int count) @@ -166,6 +172,7 @@ public override int Read(byte[] buffer, int offset, int count) int n = _base.Read(buffer, offset, count); for (int i = 0; i < n; i++) buffer[offset + i] = DecryptByte(buffer[offset + i]); + _position += n; return n; } throw new NotSupportedException("Stream is in encryption (write-only) mode."); @@ -178,6 +185,7 @@ public override int Read(Span destination) int n = _base.Read(destination); for (int i = 0; i < n; i++) destination[i] = DecryptByte(destination[i]); + _position += n; return n; } throw new NotSupportedException("Stream is in encryption (write-only) mode."); @@ -194,7 +202,6 @@ public override void Write(byte[] buffer, int offset, int count) throw new ArgumentOutOfRangeException(); EnsureHeader(); - _everWrotePayload = _everWrotePayload || (count > 0); // Simple buffer; optimize with ArrayPool if needed later byte[] tmp = new byte[count]; @@ -206,6 +213,7 @@ public override void Write(byte[] buffer, int offset, int count) UpdateKeys(p); } _base.Write(tmp, 0, count); + _position += count; } public override void Write(ReadOnlySpan buffer) @@ -213,7 +221,6 @@ public override void Write(ReadOnlySpan buffer) if (!_encrypting) throw new NotSupportedException("Stream is in decryption (read-only) mode."); EnsureHeader(); - _everWrotePayload = _everWrotePayload || (buffer.Length > 0); byte[] tmp = new byte[buffer.Length]; for (int i = 0; i < buffer.Length; i++) @@ -224,6 +231,7 @@ public override void Write(ReadOnlySpan buffer) UpdateKeys(p); } _base.Write(tmp, 0, tmp.Length); + _position += tmp.Length; } protected override void Dispose(bool disposing) From b61b37c501b017791eab7faf7c93da139860df2d Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Thu, 20 Nov 2025 09:36:40 +0100 Subject: [PATCH 13/39] initial reading of winzip encrypted files --- .../src/System.IO.Compression.ZipFile.csproj | 27 +- .../tests/ZipFile.Extract.cs | 57 +- .../src/System.IO.Compression.csproj | 1 + .../System/IO/Compression/WinZipAesStream.cs | 961 +++++++++++------- .../System/IO/Compression/ZipArchiveEntry.cs | 71 +- .../src/System/IO/Compression/ZipBlocks.cs | 6 +- .../src/System.Security.Cryptography.csproj | 1 - 7 files changed, 687 insertions(+), 437 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System.IO.Compression.ZipFile.csproj b/src/libraries/System.IO.Compression.ZipFile/src/System.IO.Compression.ZipFile.csproj index 1709589cc4d076..beeaedeba67337 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System.IO.Compression.ZipFile.csproj +++ b/src/libraries/System.IO.Compression.ZipFile/src/System.IO.Compression.ZipFile.csproj @@ -17,18 +17,14 @@ - - - + + + - + @@ -37,16 +33,11 @@ - - - - - + + + + + diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 5e5b3e6a878c41..c8e445f4988816 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -1334,9 +1334,9 @@ public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip_2() //} [Fact] - public void OpenAESEncryptedTxtFile_ShouldReturnPlaintext() + public void OpenAESEncryptedTxtFile_ShouldReturnPlaintextWinZip() { - string zipPath = Path.Join(DownloadsDir, "plainwr.zip"); + string zipPath = Path.Join(DownloadsDir, "source_plain_winzip.zip"); using var archive = ZipFile.OpenRead(zipPath); var entry = archive.Entries.First(e => e.FullName.EndsWith("source_plain.txt")); @@ -1347,6 +1347,59 @@ public void OpenAESEncryptedTxtFile_ShouldReturnPlaintext() Assert.Equal("this is plain", content); } + [Fact] + public void OpenAESEncryptedTxtFile_ShouldReturnPlaintextWinRar() + { + string zipPath = Path.Join(DownloadsDir, "source_plain.zip"); + using var archive = ZipFile.OpenRead(zipPath); + + var entry = archive.Entries.First(e => e.FullName.EndsWith("source_plain.txt")); + using var stream = entry.Open("123456789"); + using var reader = new StreamReader(stream); + string content = reader.ReadToEnd(); + + Assert.Equal("this is plain", content); + } + + [Fact] + public void OpenAESEncryptedTxtFile_ShouldReturnPlaintext7zip() + { + string zipPath = Path.Join(DownloadsDir, "source_plain7z.zip"); + using var archive = ZipFile.OpenRead(zipPath); + + var entry = archive.Entries.First(e => e.FullName.EndsWith("source_plain.txt")); + using var stream = entry.Open("123456789"); + using var reader = new StreamReader(stream); + string content = reader.ReadToEnd(); + + Assert.Equal("this is plain", content); + } + + [Fact] + public void OpenMultipleAESEncryptedEntries_ShouldReturnCorrectContent() + { + string zipPath = Path.Join(DownloadsDir, "multiple_entries_aes.zip"); + using var archive = ZipFile.OpenRead(zipPath); + + // Test first entry + var entry1 = archive.Entries.First(e => e.FullName.EndsWith("source_plain.txt")); + using (var stream1 = entry1.Open("123456789")) + using (var reader1 = new StreamReader(stream1)) + { + string content1 = reader1.ReadToEnd(); + Assert.Equal("this is plain", content1); + } + + // Test second entry + var entry2 = archive.Entries.First(e => e.FullName.EndsWith("source_plain_2.txt")); + using (var stream2 = entry2.Open("123456789")) + using (var reader2 = new StreamReader(stream2)) + { + string content2 = reader2.ReadToEnd(); + Assert.Equal("this is plain", content2); + } + } + } diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index 2c6ff2fa3a63b5..6715dc201493bc 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -76,6 +76,7 @@ + diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 266d64921d4519..7b6876fb972e14 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -3,399 +3,580 @@ using System.Diagnostics; using System.Security.Cryptography; +using System.Text; using System.Threading; using System.Threading.Tasks; namespace System.IO.Compression { -// internal sealed class WinZipAesStream : Stream -// { -// private readonly Stream _baseStream; -// private readonly bool _encrypting; -// private readonly int _keySizeBits; -// private readonly bool _ae2; -// private readonly uint? _crc32ForHeader; -// private readonly Aes _aes; -// private ICryptoTransform? _aesEncryptor; -//#pragma warning disable CA1416 // HMACSHA1 is available on all platforms -// private readonly HMACSHA1 _hmac; -//#pragma warning restore CA1416 -// private readonly byte[] _counterBlock = new byte[16]; -// private byte[]? _key; -// private byte[]? _hmacKey; -// private byte[]? _salt; -// private byte[]? _passwordVerifier; -// private bool _headerWritten; -// private bool _headerRead; -// private long _position; -// private readonly ReadOnlyMemory _password; -// private bool _disposed; -// private bool _authCodeValidated; - -// public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null) -// { -// ArgumentNullException.ThrowIfNull(baseStream); - - -// _baseStream = baseStream; -// _password = password; -// _encrypting = encrypting; -// _keySizeBits = keySizeBits; -// _ae2 = ae2; -// _crc32ForHeader = crc32; -//#pragma warning disable CA1416 // HMACSHA1 is available on all platforms -// _aes = Aes.Create(); -//#pragma warning restore CA1416 -// _aes.Mode = CipherMode.ECB; -// _aes.Padding = PaddingMode.None; - -//#pragma warning disable CA1416 // HMACSHA1 available on all platforms ? -//#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms ? -// _hmac = new HMACSHA1(); -//#pragma warning restore CA5350 // Do Not Use Weak Cryptographic Algorithms -//#pragma warning restore CA1416 - -// if (_encrypting) -// { -// GenerateKeys(); -// InitCipher(); -// } -// } - -// private void DeriveKeysFromPassword() -// { -// Debug.Assert(_salt is not null, "Salt must be initialized before deriving keys"); - -// // WinZip AES uses SHA1 for PBKDF2 with 1000 iterations per spec -// byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2(_password.Span, _salt!, 1000, HashAlgorithmName.SHA1, (_keySizeBits / 8) + 32 + 2); - -// _key = new byte[_keySizeBits / 8]; -// _hmacKey = new byte[32]; -// _passwordVerifier = new byte[2]; - -// Buffer.BlockCopy(derivedKey, 0, _key, 0, _key.Length); -// Buffer.BlockCopy(derivedKey, _key.Length, _hmacKey, 0, _hmacKey.Length); -// Buffer.BlockCopy(derivedKey, _key.Length + _hmacKey.Length, _passwordVerifier, 0, _passwordVerifier.Length); -// } - -// private void GenerateKeys() -// { -// // 8 for AES-128, 12 for AES-192, 16 for AES-256 -// int saltSize = _keySizeBits / 16; -// _salt = new byte[saltSize]; -// RandomNumberGenerator.Fill(_salt); - -// DeriveKeysFromPassword(); - -// Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); -// _hmac.Key = _hmacKey!; -// } - -// private void InitCipher() -// { -// Debug.Assert(_key is not null, "_key is not null"); - -// _aes.Key = _key!; -// _aesEncryptor = _aes.CreateEncryptor(); -// } - -// private void WriteHeader() -// { -// if (_headerWritten) return; - -// Debug.Assert(_salt is not null && _passwordVerifier is not null, "Keys should have been generated before writing header"); - -// _baseStream.Write(_salt); -// _baseStream.Write(_passwordVerifier); - -// if (_ae2 && _crc32ForHeader.HasValue) -// { -// Span crcBytes = stackalloc byte[4]; -// BitConverter.TryWriteBytes(crcBytes, _crc32ForHeader.Value); -// _baseStream.Write(crcBytes); -// } - -// _headerWritten = true; -// } - -// private void ReadHeader() -// { -// if (_headerRead) return; - -// int saltSize = _keySizeBits / 16; -// _salt = new byte[saltSize]; -// _baseStream.ReadExactly(_salt); - -// byte[] verifier = new byte[2]; -// _baseStream.ReadExactly(verifier); - -// DeriveKeysFromPassword(); - -// Debug.Assert(_passwordVerifier is not null, "Password verifier should be derived"); -// if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) -// throw new InvalidDataException("Invalid password."); - -// Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); -// _hmac.Key = _hmacKey!; -// InitCipher(); - -// if (_ae2) -// { -// byte[] crcBytes = new byte[4]; -// _baseStream.ReadExactly(crcBytes); -// // CRC can be validated later if needed -// } - -// _headerRead = true; -// } - -// private void ProcessBlock(byte[] buffer, int offset, int count) -// { -// Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized before processing blocks"); - -// int processed = 0; -// byte[] keystream = new byte[16]; -// while (processed < count) -// { -// IncrementCounter(); -// _aesEncryptor.TransformBlock(_counterBlock, 0, 16, keystream, 0); - -// // For the last block, we may use less than 16 bytes of the keystream -// // This is correct CTR mode behavior - we only use as many bytes as needed -// int blockSize = Math.Min(16, count - processed); - -// // XOR the data with the keystream -// // Note: If blockSize < 16, we only use the first 'blockSize' bytes of keystream -// // The unused bytes are discarded, which is the expected -// for (int i = 0; i < blockSize; i++) -// { -// buffer[offset + processed + i] ^= keystream[i]; -// } - -// _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); -// processed += blockSize; -// } -// } - -// private void IncrementCounter() -// { -// for (int i = 15; i >= 0; i--) -// { -// if (++_counterBlock[i] != 0) break; -// } -// } - -// private void WriteAuthCode() -// { -// if (!_encrypting || _authCodeValidated) -// return; - -// _hmac.TransformFinalBlock(Array.Empty(), 0, 0); -// byte[]? authCode = _hmac.Hash; - -// if (authCode is not null) -// { -// _baseStream.Write(authCode); -// } - -// _authCodeValidated = true; -// } - -// private void ValidateAuthCode() -// { -// if (_encrypting || _authCodeValidated) -// return; - -// // Finalize HMAC computation -// _hmac.TransformFinalBlock(Array.Empty(), 0, 0); -// byte[]? expectedAuth = _hmac.Hash; - -// if (expectedAuth is not null) -// { -// // Read the stored authentication code from the stream -// byte[] storedAuth = new byte[expectedAuth.Length]; -// _baseStream.ReadExactly(storedAuth); - -// if (!storedAuth.AsSpan().SequenceEqual(expectedAuth)) -// throw new InvalidDataException("Authentication code mismatch."); -// } - -// _authCodeValidated = true; -// } - -// private void WriteCore(ReadOnlySpan buffer) -// { -// ObjectDisposedException.ThrowIf(_disposed, this); - -// if (!_encrypting) -// throw new NotSupportedException("Stream is in decryption mode."); - -// WriteHeader(); - -// // We need to copy the data since ProcessBlock modifies it in place -// byte[] tmp = buffer.ToArray(); -// ProcessBlock(tmp, 0, tmp.Length); -// _baseStream.Write(tmp); -// _position += buffer.Length; -// } - -// private int ReadCore(Span buffer) -// { -// ObjectDisposedException.ThrowIf(_disposed, this); - -// if (_encrypting) -// throw new NotSupportedException("Stream is in encryption mode."); - -// if (!_headerRead) -// ReadHeader(); - -// int n = _baseStream.Read(buffer); - -// // Check if we reached the end of the stream -// if (n == 0 && !_authCodeValidated) -// { -// ValidateAuthCode(); -// return 0; -// } - -// if (n > 0) -// { -// // Process the data in-place for reads (it's already in the buffer) -// // We need to temporarily copy to array for HMAC processing -// byte[] temp = buffer.Slice(0, n).ToArray(); -// ProcessBlock(temp, 0, n); -// temp.CopyTo(buffer); -// _position += n; -// } - -// return n; -// } - -// // All Write overloads redirect to Write(ReadOnlySpan) -// public override void Write(byte[] buffer, int offset, int count) -// { -// ValidateBufferArguments(buffer, offset, count); -// Write(new ReadOnlySpan(buffer, offset, count)); -// } - -// public override void Write(ReadOnlySpan buffer) -// { -// WriteCore(buffer); -// } - -// public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) -// { -// ValidateBufferArguments(buffer, offset, count); -// await WriteAsync(new ReadOnlyMemory(buffer, offset, count), cancellationToken).ConfigureAwait(false); -// } - -// public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) -// { -// ObjectDisposedException.ThrowIf(_disposed, this); - -// if (!_encrypting) -// throw new NotSupportedException("Stream is in decryption mode."); - -// return Core(buffer, cancellationToken); - -// async ValueTask Core(ReadOnlyMemory buffer, CancellationToken cancellationToken) -// { -// WriteHeader(); - -// // We need to copy the data since ProcessBlock modifies it in place -// byte[] tmp = buffer.ToArray(); -// ProcessBlock(tmp, 0, tmp.Length); -// await _baseStream.WriteAsync(tmp, cancellationToken).ConfigureAwait(false); -// _position += buffer.Length; -// } -// } - -// public override int Read(byte[] buffer, int offset, int count) -// { -// ValidateBufferArguments(buffer, offset, count); -// return ReadCore(new Span(buffer, offset, count)); -// } - -// public override int Read(Span buffer) -// { -// return ReadCore(buffer); -// } - -// public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) -// { -// ValidateBufferArguments(buffer, offset, count); -// return await ReadAsync(new Memory(buffer, offset, count), cancellationToken).ConfigureAwait(false); -// } - -// public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) -// { -// ObjectDisposedException.ThrowIf(_disposed, this); - -// if (_encrypting) -// throw new NotSupportedException("Stream is in encryption mode."); - -// if (!_headerRead) -// ReadHeader(); - -// int n = await _baseStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); -// if (n > 0) -// { -// // Process the data - work with the Memory span -// byte[] temp = buffer.Slice(0, n).ToArray(); -// ProcessBlock(temp, 0, n); -// temp.CopyTo(buffer.Span); -// _position += n; -// } - -// return n; -// } - -// protected override void Dispose(bool disposing) -// { -// if (_disposed) -// return; - -// if (disposing) -// { -// try -// { -// // For encryption, write the auth code when closing -// if (_encrypting && _headerWritten && !_authCodeValidated) -// { -// WriteAuthCode(); -// } - -// _baseStream.Flush(); -// } -// finally -// { -// _aesEncryptor?.Dispose(); -// _aes.Dispose(); -// _hmac.Dispose(); -// } -// } - -// _disposed = true; -// base.Dispose(disposing); -// } - -// public override bool CanRead => !_encrypting && !_disposed; -// public override bool CanSeek => false; -// public override bool CanWrite => _encrypting && !_disposed; -// public override long Length => throw new NotSupportedException(); -// public override long Position -// { -// get => _position; -// set => throw new NotSupportedException(); -// } - -// public override void Flush() -// { -// ObjectDisposedException.ThrowIf(_disposed, this); -// _baseStream.Flush(); -// } - -// public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); -// public override void SetLength(long value) => throw new NotSupportedException(); -// } + internal sealed class WinZipAesStream : Stream + { + private readonly Stream _baseStream; + private readonly bool _encrypting; + private readonly int _keySizeBits; + private readonly bool _ae2; + private readonly uint? _crc32ForHeader; + private readonly Aes _aes; + private ICryptoTransform? _aesEncryptor; +#pragma warning disable CA1416 // HMACSHA1 is available on all platforms + private readonly HMACSHA1 _hmac; +#pragma warning restore CA1416 + private readonly byte[] _counterBlock = new byte[16]; + private byte[]? _key; + private byte[]? _hmacKey; + private byte[]? _salt; + private byte[]? _passwordVerifier; + private bool _headerWritten; + private bool _headerRead; + private long _position; + private readonly ReadOnlyMemory _password; + private bool _disposed; + private bool _authCodeValidated; + private readonly long _totalStreamSize; + private long _bytesReadFromBase; + + public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null, long totalStreamSize = -1) + { + ArgumentNullException.ThrowIfNull(baseStream); + + + _baseStream = baseStream; + _password = password; + _encrypting = encrypting; + _keySizeBits = keySizeBits; + _ae2 = ae2; + _crc32ForHeader = crc32; + _totalStreamSize = totalStreamSize; // Store the total size + _bytesReadFromBase = 0; +#pragma warning disable CA1416 // HMACSHA1 is available on all platforms + _aes = Aes.Create(); +#pragma warning restore CA1416 + _aes.Mode = CipherMode.ECB; + _aes.Padding = PaddingMode.None; + +#pragma warning disable CA1416 // HMACSHA1 available on all platforms ? +#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms ? + _hmac = new HMACSHA1(); +#pragma warning restore CA5350 // Do Not Use Weak Cryptographic Algorithms +#pragma warning restore CA1416 + + Array.Clear(_counterBlock, 0, 16); + _counterBlock[0] = 1; + + if (_encrypting) + { + GenerateKeys(); + InitCipher(); + } + } + + private void DeriveKeysFromPassword() + { + Debug.Assert(_salt is not null, "Salt must be initialized before deriving keys"); + + byte[] passwordBytes = Encoding.UTF8.GetBytes(_password.ToArray()); + + try + { + // AES key size + HMAC key size (same as AES key) + password verifier (2 bytes) + int keySizeInBytes = _keySizeBits / 8; + int totalKeySize = keySizeInBytes + keySizeInBytes + 2; + + // WinZip AES uses SHA1 for PBKDF2 with 1000 iterations per spec + byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2( + passwordBytes, + _salt!, + 1000, + HashAlgorithmName.SHA1, + totalKeySize); + + // Split the derived key material + _key = new byte[keySizeInBytes]; + _hmacKey = new byte[keySizeInBytes]; + _passwordVerifier = new byte[2]; + + // Copy the key material in the correct order + int offset = 0; + + // First: AES encryption key + Buffer.BlockCopy(derivedKey, offset, _key, 0, _key.Length); + offset += _key.Length; + + // Second: HMAC authentication key (same size as encryption key) + Buffer.BlockCopy(derivedKey, offset, _hmacKey, 0, _hmacKey.Length); + offset += _hmacKey.Length; + + // Third: Password verification value (2 bytes) + Buffer.BlockCopy(derivedKey, offset, _passwordVerifier, 0, _passwordVerifier.Length); + + // Clear the derived key from memory + Array.Clear(derivedKey, 0, derivedKey.Length); + } + finally + { + // Clear the password bytes from memory + Array.Clear(passwordBytes, 0, passwordBytes.Length); + } + } + + private void ValidateAuthCode() + { + if (_encrypting || _authCodeValidated) + return; + + // Finalize HMAC computation + _hmac.TransformFinalBlock(Array.Empty(), 0, 0); + byte[]? expectedAuth = _hmac.Hash; + + if (expectedAuth is not null) + { + // Read the 10-byte stored authentication code from the stream + byte[] storedAuth = new byte[10]; + _baseStream.ReadExactly(storedAuth); + + // Compare the first 10 bytes of the expected hash + if (!storedAuth.AsSpan().SequenceEqual(expectedAuth.AsSpan(0, 10))) + throw new InvalidDataException("Authentication code mismatch."); + } + + _authCodeValidated = true; + } + + private void GenerateKeys() + { + // 8 for AES-128, 12 for AES-192, 16 for AES-256 + int saltSize = _keySizeBits / 16; + _salt = new byte[saltSize]; + RandomNumberGenerator.Fill(_salt); + + DeriveKeysFromPassword(); + + Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); + _hmac.Key = _hmacKey!; + } + + private void InitCipher() + { + Debug.Assert(_key is not null, "_key is not null"); + + _aes.Key = _key!; + _aesEncryptor = _aes.CreateEncryptor(); + } + + private void WriteHeader() + { + if (_headerWritten) return; + + Debug.Assert(_salt is not null && _passwordVerifier is not null, "Keys should have been generated before writing header"); + + _baseStream.Write(_salt); + _baseStream.Write(_passwordVerifier); + + if (_ae2 && _crc32ForHeader.HasValue) + { + Span crcBytes = stackalloc byte[4]; + BitConverter.TryWriteBytes(crcBytes, _crc32ForHeader.Value); + _baseStream.Write(crcBytes); + } + + _headerWritten = true; + } + + private void ReadHeader() + { + if (_headerRead) return; + + // Salt size depends on AES strength: 8 for AES-128, 12 for AES-192, 16 for AES-256 + int saltSize = _keySizeBits / 16; + _salt = new byte[saltSize]; + _baseStream.ReadExactly(_salt); + + // Debug: Log the salt + Debug.WriteLine($"Salt ({saltSize} bytes): {BitConverter.ToString(_salt)}"); + + // Read the 2-byte password verifier + byte[] verifier = new byte[2]; + _baseStream.ReadExactly(verifier); + + // Debug: Log the verifier + Debug.WriteLine($"Password verifier: {BitConverter.ToString(verifier)}"); + + // Derive keys from password and salt + DeriveKeysFromPassword(); + + // Verify the password + Debug.Assert(_passwordVerifier is not null, "Password verifier should be derived"); + + // Debug: Log derived verifier + Debug.WriteLine($"Derived verifier: {BitConverter.ToString(_passwordVerifier!)}"); + + if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) + { + throw new InvalidDataException($"Invalid password. Expected verifier: {BitConverter.ToString(_passwordVerifier!)}, Got: {BitConverter.ToString(verifier)}"); + } + + Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); + _hmac.Key = _hmacKey!; + InitCipher(); + + int headerSize = saltSize + 2; // Salt + Password Verifier + _bytesReadFromBase += headerSize; + + Array.Clear(_counterBlock, 0, 16); + _counterBlock[0] = 1; + + _headerRead = true; + } + + private void ProcessBlock(byte[] buffer, int offset, int count) + { + Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized before processing blocks"); + + int processed = 0; + byte[] keystream = new byte[16]; + + // Log initial counter state + Debug.WriteLine($"=== ProcessBlock Debug ==="); + Debug.WriteLine($"Processing {count} bytes at offset {offset}"); + Debug.WriteLine($"Initial counter: {BitConverter.ToString(_counterBlock)}"); + + while (processed < count) + { + _aesEncryptor.TransformBlock(_counterBlock, 0, 16, keystream, 0); + + // Log keystream for first block + if (processed == 0) + { + Debug.WriteLine($"First keystream block: {BitConverter.ToString(keystream)}"); + } + + IncrementCounter(); + + int blockSize = Math.Min(16, count - processed); + + // For decryption: HMAC is computed on ciphertext BEFORE decryption + if (!_encrypting) + { + _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); + } + + // XOR the data with the keystream + for (int i = 0; i < blockSize; i++) + { + buffer[offset + processed + i] ^= keystream[i]; + } + + // For encryption: HMAC is computed on ciphertext AFTER encryption + if (_encrypting) + { + _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); + } + + processed += blockSize; + } + + Debug.WriteLine($"Final counter after processing: {BitConverter.ToString(_counterBlock)}"); + } + + private void IncrementCounter() + { + // WinZip AES treats the entire 16-byte block as a little-endian 128-bit integer + for (int i = 0; i < 16; i++) + { + if (++_counterBlock[i] != 0) + break; + } + } + + private void WriteAuthCode() + { + if (!_encrypting || _authCodeValidated) + return; + + _hmac.TransformFinalBlock(Array.Empty(), 0, 0); + byte[]? authCode = _hmac.Hash; + + if (authCode is not null) + { + // WinZip AES spec requires only the first 10 bytes of the HMAC + _baseStream.Write(authCode, 0, 10); + } + + _authCodeValidated = true; + } + + private void WriteCore(ReadOnlySpan buffer) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!_encrypting) + throw new NotSupportedException("Stream is in decryption mode."); + + WriteHeader(); + + // We need to copy the data since ProcessBlock modifies it in place + byte[] tmp = buffer.ToArray(); + ProcessBlock(tmp, 0, tmp.Length); + _baseStream.Write(tmp); + _position += buffer.Length; + } + + private int ReadCore(Span buffer) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_encrypting) + throw new NotSupportedException("Stream is in encryption mode."); + + if (!_headerRead) + ReadHeader(); + + int bytesToRead = buffer.Length; + + // If we know the total size, ensure we don't read into the HMAC + if (_totalStreamSize > 0) + { + const int hmacSize = 10; // Correct 10-byte HMAC size + long remainingData = _totalStreamSize - _bytesReadFromBase - hmacSize; + + Debug.WriteLine($"=== ReadCore Debug ==="); + Debug.WriteLine($"Total stream size: {_totalStreamSize}"); + Debug.WriteLine($"Bytes read from base: {_bytesReadFromBase}"); + Debug.WriteLine($"Remaining data: {remainingData}"); + Debug.WriteLine($"Buffer length requested: {buffer.Length}"); + + if (remainingData <= 0) + { + if (!_authCodeValidated) + { + ValidateAuthCode(); + } + return 0; + } + + bytesToRead = (int)Math.Min(bytesToRead, remainingData); + } + + if (bytesToRead == 0) + { + if (!_authCodeValidated && _totalStreamSize > 0) + { + ValidateAuthCode(); + } + return 0; + } + + int n = _baseStream.Read(buffer.Slice(0, bytesToRead)); + + Debug.WriteLine($"Read {n} bytes from base stream"); + + if (n > 0) + { + _bytesReadFromBase += n; + + // Log the ciphertext before decryption + Debug.WriteLine($"Ciphertext (hex): {BitConverter.ToString(buffer.Slice(0, n).ToArray())}"); + + // The buffer now contains the ciphertext. + // We need to pass an array to ProcessBlock. + byte[] temp = buffer.Slice(0, n).ToArray(); + + // ProcessBlock will now correctly: + // 1. Update the HMAC with the ciphertext from `temp`. + // 2. Decrypt `temp` in-place. + ProcessBlock(temp, 0, n); + + // Log the plaintext after decryption + Debug.WriteLine($"Plaintext (hex): {BitConverter.ToString(temp)}"); + Debug.WriteLine($"Plaintext (ASCII): {System.Text.Encoding.ASCII.GetString(temp)}"); + + // Copy the decrypted data from `temp` back to the original buffer. + temp.CopyTo(buffer); + + _position += n; + } + else // n == 0, meaning end of stream + { + if (!_authCodeValidated) + { + ValidateAuthCode(); + } + } + + return n; + } + + // All Write overloads redirect to Write(ReadOnlySpan) + public override void Write(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + Write(new ReadOnlySpan(buffer, offset, count)); + } + + public override void Write(ReadOnlySpan buffer) + { + WriteCore(buffer); + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + await WriteAsync(new ReadOnlyMemory(buffer, offset, count), cancellationToken).ConfigureAwait(false); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!_encrypting) + throw new NotSupportedException("Stream is in decryption mode."); + + return Core(buffer, cancellationToken); + + async ValueTask Core(ReadOnlyMemory buffer, CancellationToken cancellationToken) + { + WriteHeader(); + + // We need to copy the data since ProcessBlock modifies it in place + byte[] tmp = buffer.ToArray(); + ProcessBlock(tmp, 0, tmp.Length); + await _baseStream.WriteAsync(tmp, cancellationToken).ConfigureAwait(false); + _position += buffer.Length; + } + } + + public override int Read(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + return ReadCore(new Span(buffer, offset, count)); + } + + public override int Read(Span buffer) + { + return ReadCore(buffer); + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + return await ReadAsync(new Memory(buffer, offset, count), cancellationToken).ConfigureAwait(false); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_encrypting) + throw new NotSupportedException("Stream is in encryption mode."); + + if (!_headerRead) + ReadHeader(); + + // Apply the same boundary logic as ReadCore + int bytesToRead = buffer.Length; + + if (_totalStreamSize > 0) + { + const int hmacSize = 10; + long remainingData = _totalStreamSize - _bytesReadFromBase - hmacSize; + + if (remainingData <= 0) + { + if (!_authCodeValidated) + { + ValidateAuthCode(); + } + return 0; + } + + bytesToRead = (int)Math.Min(bytesToRead, remainingData); + } + + if (bytesToRead == 0) + { + if (!_authCodeValidated && _totalStreamSize > 0) + { + ValidateAuthCode(); + } + return 0; + } + + int n = await _baseStream.ReadAsync(buffer.Slice(0, bytesToRead), cancellationToken).ConfigureAwait(false); + + if (n > 0) + { + _bytesReadFromBase += n; // This was missing - crucial for boundary tracking! + + // Process the data + byte[] temp = buffer.Slice(0, n).ToArray(); + ProcessBlock(temp, 0, n); + temp.CopyTo(buffer.Span); + _position += n; + } + else if (!_authCodeValidated) + { + ValidateAuthCode(); + } + + return n; + } + + protected override void Dispose(bool disposing) + { + if (_disposed) + return; + + if (disposing) + { + try + { + // For encryption, write the auth code when closing + if (_encrypting && _headerWritten && !_authCodeValidated) + { + WriteAuthCode(); + } + + // Only flush if the base stream supports writing + // SubReadStream (used for reading compressed data) doesn't support Flush() + if (_baseStream.CanWrite) + { + _baseStream.Flush(); + } + } + finally + { + _aesEncryptor?.Dispose(); + _aes.Dispose(); + _hmac.Dispose(); + } + } + + _disposed = true; + base.Dispose(disposing); + } + + public override bool CanRead => !_encrypting && !_disposed; + public override bool CanSeek => false; + public override bool CanWrite => _encrypting && !_disposed; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + public override void Flush() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + // Only flush if the base stream supports writing + if (_baseStream.CanWrite) + { + _baseStream.Flush(); + } + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index dc9493e35bdf25..a347500bbbf1bf 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -49,6 +49,8 @@ public partial class ZipArchiveEntry private byte[] _fileComment; private EncryptionMethod _encryptionMethod; private readonly CompressionLevel _compressionLevel; + private CompressionMethodValues _aesCompressionLevel; + private ushort? _aeVersion; // Initializes a ZipArchiveEntry instance for an existing archive entry. internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) @@ -424,7 +426,7 @@ private string DecodeEntryString(byte[] entryStringBytes) internal bool EverOpenedForWrite => _everOpenedForWrite; - internal long GetOffsetOfCompressedData(EncryptionMethod encryptionMethod = EncryptionMethod.None) + internal long GetOffsetOfCompressedData() { if (_storedOffsetOfCompressedData == null) { @@ -444,21 +446,11 @@ internal long GetOffsetOfCompressedData(EncryptionMethod encryptionMethod = Encr else { // AES case - if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _, out _)) + if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _, out _, out _)) throw new InvalidDataException(SR.LocalFileHeaderCorrupt); baseOffset = _archive.ArchiveStream.Position; - // Adjust for AES salt + password verifier using _encryptionMethod - int saltSize = encryptionMethod switch - { - EncryptionMethod.Aes128 => 8, - EncryptionMethod.Aes192 => 12, - EncryptionMethod.Aes256 => 16, - _ => throw new InvalidDataException("Unknown AES encryption method") - }; - - baseOffset += saltSize + 2; // salt + password verifier } _storedOffsetOfCompressedData = baseOffset; @@ -841,7 +833,7 @@ private bool IsZipCryptoEncrypted() private bool IsAesEncrypted() { // Compression method 99 indicates AES encryption - return _storedCompressionMethod == CompressionMethodValues.Aes; + return _aesCompressionLevel == CompressionMethodValues.Aes; } private Stream GetDataDecompressor(Stream compressedStreamToRead) @@ -856,11 +848,15 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead) uncompressedStream = new DeflateManagedStream(compressedStreamToRead, CompressionMethodValues.Deflate64, _uncompressedSize); break; case CompressionMethodValues.Stored: + uncompressedStream = compressedStreamToRead; + break; default: - // we can assume that only deflate/deflate64/stored are allowed because we assume that - // IsOpenable is checked before this function is called - Debug.Assert(CompressionMethod == CompressionMethodValues.Stored); + // We should not get here with Aes as CompressionMethod anymore + // as it should have been replaced with the actual compression method + Debug.Assert(CompressionMethod != CompressionMethodValues.Aes, + "AES compression method should have been replaced with actual compression method"); + // Fallback to stored if we somehow get here uncompressedStream = compressedStreamToRead; break; } @@ -893,9 +889,24 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read if (password.IsEmpty) throw new InvalidDataException("Password required for AES-encrypted ZIP entry."); + int keySizeBits = _encryptionMethod switch + { + EncryptionMethod.Aes128 => 128, + EncryptionMethod.Aes192 => 192, + EncryptionMethod.Aes256 => 256, + _ => 256 // Default to AES-256 + }; + // AES implementation placeholder as indicated in the original code // When AES is implemented, create the appropriate decryption stream here - throw new NotSupportedException("AES encryption is not yet supported."); + streamToDecompress = new WinZipAesStream( + baseStream: compressedStream, + password: password, + encrypting: false, // false for decryption + keySizeBits: keySizeBits, + ae2: _aeVersion == 2, // AE-2 format (standard) + crc32: null, + totalStreamSize: _compressedSize); } return GetDataDecompressor(streamToDecompress); } @@ -980,6 +991,7 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st { return false; } + if (!IsEncrypted && !ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveStream)) { message = SR.LocalFileHeaderCorrupt; @@ -988,11 +1000,11 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st else if (IsEncrypted && CompressionMethod == CompressionMethodValues.Aes) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - + _aesCompressionLevel = CompressionMethodValues.Aes; byte? aesStrength; ushort? originalCompressionMethod; - - if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out aesStrength, out originalCompressionMethod)) + ushort? aeVersion; + if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out aesStrength, out originalCompressionMethod, out aeVersion)) { message = SR.LocalFileHeaderCorrupt; return false; @@ -1000,22 +1012,35 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st if (aesStrength.HasValue) { - _ = aesStrength switch + EncryptionMethod detectedEncryption = aesStrength switch { 1 => EncryptionMethod.Aes128, 2 => EncryptionMethod.Aes192, 3 => EncryptionMethod.Aes256, _ => throw new InvalidDataException("Unknown AES strength") }; + + // Store the detected encryption method + _encryptionMethod = detectedEncryption; + } + if (aeVersion.HasValue) + { + _aeVersion = aeVersion.Value; } + // CRITICAL: Store the actual compression method that will be used after decryption + // This is needed for GetDataDecompressor to work correctly if (originalCompressionMethod.HasValue) { - _storedCompressionMethod = (CompressionMethodValues)originalCompressionMethod.Value; + // Temporarily set the compression method to the actual method for decompression + // Note: We're modifying _storedCompressionMethod, not the property + CompressionMethod = (CompressionMethodValues)originalCompressionMethod.Value; } } - // when this property gets called, some duplicated work + + // Pass the detected encryption method to GetOffsetOfCompressedData long offsetOfCompressedData = GetOffsetOfCompressedData(); + if (!IsOpenableFinalVerifications(needToLoadIntoMemory, offsetOfCompressedData, out message)) { return false; diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs index e6afca04182ca0..21d2a58da47238 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs @@ -671,11 +671,11 @@ public static bool TrySkipBlock(Stream stream) return TrySkipBlockFinalize(stream, blockBytes, bytesRead); } - public static bool TrySkipBlockAESAware(Stream stream, out byte? aesStrength, out ushort? originalCompressionMethod) + public static bool TrySkipBlockAESAware(Stream stream, out byte? aesStrength, out ushort? originalCompressionMethod, out ushort? aesVersion) { aesStrength = null; originalCompressionMethod = null; - + aesVersion = null; BinaryReader reader = new BinaryReader(stream); // Read the first 4 bytes (local file header signature) @@ -708,7 +708,7 @@ public static bool TrySkipBlockAESAware(Stream stream, out byte? aesStrength, ou { // AES extra field structure: // Vendor version (2) + Vendor ID (2) + AES strength (1) + Original compression (2) - reader.ReadBytes(2); + aesVersion = reader.ReadUInt16(); reader.ReadBytes(2); // Vendor ID aesStrength = reader.ReadByte(); // 1, 2, or 3 originalCompressionMethod = reader.ReadUInt16(); diff --git a/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj b/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj index 8e4bbc548e2abe..b5c0d08d79f39f 100644 --- a/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj +++ b/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj @@ -2046,7 +2046,6 @@ - From 122f85a72aa4d5eb132ae0d3a66a535b79f06084 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Thu, 20 Nov 2025 10:15:44 +0100 Subject: [PATCH 14/39] address zipcrypto comments --- .../System/IO/Compression/ZipCryptoStream.cs | 213 +++++++++++++++--- 1 file changed, 177 insertions(+), 36 deletions(-) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index 990fe702247a61..c2dc0b3653a429 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -1,6 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; + namespace System.IO.Compression { internal sealed class ZipCryptoStream : Stream @@ -31,7 +35,18 @@ private static uint[] CreateCrc32Table() return table; } - // Decryption constructor + // Private constructor for async factory method + private ZipCryptoStream(Stream baseStream, bool encrypting, bool leaveOpen = false, ushort verifierLow2Bytes = 0, uint? crc32ForHeader = null) + { + _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); + _encrypting = encrypting; + _leaveOpen = leaveOpen; + _verifierLow2Bytes = verifierLow2Bytes; + _crc32ForHeader = crc32ForHeader; + _position = 0; + } + + // Synchronous decryption constructor (existing) public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) { _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); @@ -41,7 +56,7 @@ public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte ex _position = 0; } - // Encryption constructor + // Synchronous encryption constructor (existing) public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, ushort passwordVerifierLow2Bytes, @@ -57,6 +72,40 @@ public ZipCryptoStream(Stream baseStream, InitKeysFromBytes(password.Span); } + // Async factory method for decryption + public static async Task CreateForDecryptionAsync( + Stream baseStream, + ReadOnlyMemory password, + byte expectedCheckByte, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(baseStream); + + var stream = new ZipCryptoStream(baseStream, encrypting: false); + stream.InitKeysFromBytes(password.Span); + await stream.ValidateHeaderAsync(expectedCheckByte, cancellationToken).ConfigureAwait(false); + return stream; + } + + // Async factory method for encryption + public static Task CreateForEncryptionAsync( + Stream baseStream, + ReadOnlyMemory password, + ushort passwordVerifierLow2Bytes, + uint? crc32 = null, + bool leaveOpen = false, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(baseStream); + cancellationToken.ThrowIfCancellationRequested(); + + var stream = new ZipCryptoStream(baseStream, encrypting: true, leaveOpen, passwordVerifierLow2Bytes, crc32); + stream.InitKeysFromBytes(password.Span); + + // No async work needed for encryption constructor + return Task.FromResult(stream); + } + private void EnsureHeader() { if (!_encrypting || _headerWritten) return; @@ -64,9 +113,7 @@ private void EnsureHeader() Span hdrPlain = stackalloc byte[12]; // bytes 0..9 are random - // TODO: change to actual random data later - for (int i = 0; i < 10; i++) - hdrPlain[i] = 0; + RandomNumberGenerator.Fill(hdrPlain.Slice(0, 10)); // bytes 10..11: check bytes (CRC-based if crc32 provided; else DOS time low word) if (_crc32ForHeader.HasValue) @@ -96,6 +143,43 @@ private void EnsureHeader() _position += 12; } + private async ValueTask EnsureHeaderAsync(CancellationToken cancellationToken) + { + if (!_encrypting || _headerWritten) return; + + byte[] hdrPlain = new byte[12]; + + // bytes 0..9 are random + RandomNumberGenerator.Fill(hdrPlain.AsSpan(0, 10)); + + // bytes 10..11: check bytes (CRC-based if crc32 provided; else DOS time low word) + if (_crc32ForHeader.HasValue) + { + uint crc = _crc32ForHeader.Value; + hdrPlain[10] = (byte)((crc >> 16) & 0xFF); + hdrPlain[11] = (byte)((crc >> 24) & 0xFF); + } + else + { + hdrPlain[10] = (byte)(_verifierLow2Bytes & 0xFF); + hdrPlain[11] = (byte)((_verifierLow2Bytes >> 8) & 0xFF); + } + + // Encrypt & write; update keys with PLAINTEXT per spec + byte[] hdrCiph = new byte[12]; + for (int i = 0; i < 12; i++) + { + byte ks = DecipherByte(); + byte p = hdrPlain[i]; + hdrCiph[i] = (byte)(p ^ ks); + UpdateKeys(p); + } + + await _base.WriteAsync(hdrCiph.AsMemory(0, 12), cancellationToken).ConfigureAwait(false); + _headerWritten = true; + _position += 12; + } + private void InitKeysFromBytes(ReadOnlySpan password) { _key0 = 305419896; @@ -126,6 +210,24 @@ private void ValidateHeader(byte expectedCheckByte) throw new InvalidDataException("Invalid password for encrypted ZIP entry."); } + private async ValueTask ValidateHeaderAsync(byte expectedCheckByte, CancellationToken cancellationToken) + { + byte[] hdr = new byte[12]; + int read = 0; + while (read < hdr.Length) + { + int n = await _base.ReadAsync(hdr.AsMemory(read, hdr.Length - read), cancellationToken).ConfigureAwait(false); + if (n <= 0) throw new InvalidDataException("Truncated ZipCrypto header."); + read += n; + } + + for (int i = 0; i < hdr.Length; i++) + hdr[i] = DecryptByte(hdr[i]); + + if (hdr[11] != expectedCheckByte) + throw new InvalidDataException("Invalid password for encrypted ZIP entry."); + } + private void UpdateKeys(byte b) { _key0 = Crc32Update(_key0, b); @@ -163,19 +265,8 @@ public override long Position public override int Read(byte[] buffer, int offset, int count) { - if (!_encrypting) - { - ArgumentNullException.ThrowIfNull(buffer); - if ((uint)offset > buffer.Length || (uint)count > buffer.Length - offset) - throw new ArgumentOutOfRangeException(); - - int n = _base.Read(buffer, offset, count); - for (int i = 0; i < n; i++) - buffer[offset + i] = DecryptByte(buffer[offset + i]); - _position += n; - return n; - } - throw new NotSupportedException("Stream is in encryption (write-only) mode."); + ValidateBufferArguments(buffer, offset, count); + return Read(buffer.AsSpan(offset, count)); } public override int Read(Span destination) @@ -196,24 +287,7 @@ public override int Read(Span destination) public override void Write(byte[] buffer, int offset, int count) { - if (!_encrypting) throw new NotSupportedException("Stream is in decryption (read-only) mode."); - ArgumentNullException.ThrowIfNull(buffer); - if ((uint)offset > (uint)buffer.Length || (uint)count > (uint)(buffer.Length - offset)) - throw new ArgumentOutOfRangeException(); - - EnsureHeader(); - - // Simple buffer; optimize with ArrayPool if needed later - byte[] tmp = new byte[count]; - for (int i = 0; i < count; i++) - { - byte ks = DecipherByte(); - byte p = buffer[offset + i]; - tmp[i] = (byte)(p ^ ks); - UpdateKeys(p); - } - _base.Write(tmp, 0, count); - _position += count; + Write(buffer.AsSpan(offset, count)); } public override void Write(ReadOnlySpan buffer) @@ -248,6 +322,73 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (!_encrypting) + { + cancellationToken.ThrowIfCancellationRequested(); + + int n = await _base.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + Span span = buffer.Span; + for (int i = 0; i < n; i++) + span[i] = DecryptByte(span[i]); + _position += n; + return n; + } + throw new NotSupportedException("Stream is in encryption (write-only) mode."); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + return WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (!_encrypting) throw new NotSupportedException("Stream is in decryption (read-only) mode."); + + cancellationToken.ThrowIfCancellationRequested(); + + EnsureHeader(); + + byte[] tmp = new byte[buffer.Length]; + ReadOnlySpan span = buffer.Span; + for (int i = 0; i < buffer.Length; i++) + { + byte ks = DecipherByte(); + byte p = span[i]; + tmp[i] = (byte)(p ^ ks); + UpdateKeys(p); + } + + await _base.WriteAsync(tmp, cancellationToken).ConfigureAwait(false); + _position += tmp.Length; + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + return _base.FlushAsync(cancellationToken); + } + + public override async ValueTask DisposeAsync() + { + // If encrypted empty entry (no payload written), still must emit 12-byte header: + if (_encrypting && !_headerWritten) + EnsureHeader(); + + if (!_leaveOpen) + await _base.DisposeAsync().ConfigureAwait(false); + + await base.DisposeAsync().ConfigureAwait(false); + } + private static uint Crc32Update(uint crc, byte b) => crc2Table[(crc ^ b) & 0xFF] ^ (crc >> 8); } From 55c0613920fcf435cb8ca8fdc7ff803de3927613 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 24 Nov 2025 20:49:09 +0100 Subject: [PATCH 15/39] initial writing test works --- .../tests/ZipFile.Extract.cs | 75 ++++++ .../System/IO/Compression/WinZipAesStream.cs | 225 +++++++++++++----- .../System/IO/Compression/ZipArchiveEntry.cs | 176 +++++++++++++- 3 files changed, 408 insertions(+), 68 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index c8e445f4988816..7b396fe954bee4 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -1400,6 +1400,81 @@ public void OpenMultipleAESEncryptedEntries_ShouldReturnCorrectContent() } } + [Fact] + public async Task CreateAndReadAES256EncryptedEntry_RoundTrip() + { + // Arrange + string tempPath = Path.Join(DownloadsDir, "source_plain_mine.zip"); + const string entryName = "source_plain.txt"; + const string password = "123456789"; + const string expectedContent = "this is plain"; + + // Act 1: Create ZIP with AES-256 encrypted entry + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes256); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(expectedContent); + } + + // Act 2: Read back the encrypted entry + string actualContent; + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + var entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var reader = new StreamReader(entryStream, Encoding.UTF8); + actualContent = await reader.ReadToEndAsync(); + } + + // Assert + Assert.Equal(expectedContent, actualContent); + + } + + + [Fact] + public void CreateBasicArchive() + { + // Arrange + string tempPath = Path.Join(DownloadsDir, "test_simple.zip"); + const string entryName = "test.txt"; + const string expectedContent = "this is plain"; + + // Act 1: Create ZIP with AES-256 encrypted entry + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(expectedContent); + } + + // Act 2: Read back the encrypted entry + string actualContent; + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + var entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(); + using var reader = new StreamReader(entryStream, Encoding.UTF8); + actualContent = reader.ReadToEnd(); + } + + // Assert + Assert.Equal(expectedContent, actualContent); + } + + + } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 7b6876fb972e14..5d8fe6c0b0a352 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -34,8 +34,11 @@ internal sealed class WinZipAesStream : Stream private bool _authCodeValidated; private readonly long _totalStreamSize; private long _bytesReadFromBase; + private readonly bool _leaveOpen; + private readonly MemoryStream? _encryptionBuffer; - public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null, long totalStreamSize = -1) + + public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null, long totalStreamSize = -1, bool leaveOpen = false) { ArgumentNullException.ThrowIfNull(baseStream); @@ -48,6 +51,7 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en _crc32ForHeader = crc32; _totalStreamSize = totalStreamSize; // Store the total size _bytesReadFromBase = 0; + _leaveOpen = leaveOpen; #pragma warning disable CA1416 // HMACSHA1 is available on all platforms _aes = Aes.Create(); #pragma warning restore CA1416 @@ -65,6 +69,7 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en if (_encrypting) { + _encryptionBuffer = new MemoryStream(); GenerateKeys(); InitCipher(); } @@ -84,11 +89,11 @@ private void DeriveKeysFromPassword() // WinZip AES uses SHA1 for PBKDF2 with 1000 iterations per spec byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2( - passwordBytes, - _salt!, - 1000, - HashAlgorithmName.SHA1, - totalKeySize); + passwordBytes, + _salt!, + 1000, + HashAlgorithmName.SHA1, + totalKeySize); // Split the derived key material _key = new byte[keySizeInBytes]; @@ -171,13 +176,9 @@ private void WriteHeader() _baseStream.Write(_salt); _baseStream.Write(_passwordVerifier); - - if (_ae2 && _crc32ForHeader.HasValue) - { - Span crcBytes = stackalloc byte[4]; - BitConverter.TryWriteBytes(crcBytes, _crc32ForHeader.Value); - _baseStream.Write(crcBytes); - } + // output to debug log + Debug.WriteLine($"Wrote salt: {BitConverter.ToString(_salt)}"); + Debug.WriteLine($"Wrote password verifier: {BitConverter.ToString(_passwordVerifier)}"); _headerWritten = true; } @@ -219,9 +220,6 @@ private void ReadHeader() _hmac.Key = _hmacKey!; InitCipher(); - int headerSize = saltSize + 2; // Salt + Password Verifier - _bytesReadFromBase += headerSize; - Array.Clear(_counterBlock, 0, 16); _counterBlock[0] = 1; @@ -254,22 +252,27 @@ private void ProcessBlock(byte[] buffer, int offset, int count) int blockSize = Math.Min(16, count - processed); - // For decryption: HMAC is computed on ciphertext BEFORE decryption - if (!_encrypting) + // For encryption: XOR first, then HMAC the ciphertext + if (_encrypting) { + // XOR the data with the keystream to create ciphertext + for (int i = 0; i < blockSize; i++) + { + buffer[offset + processed + i] ^= keystream[i]; + } + // HMAC is computed on the ciphertext (after XOR) _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); } - - // XOR the data with the keystream - for (int i = 0; i < blockSize; i++) - { - buffer[offset + processed + i] ^= keystream[i]; - } - - // For encryption: HMAC is computed on ciphertext AFTER encryption - if (_encrypting) + // For decryption: HMAC first (on ciphertext), then XOR + else { + // HMAC is computed on the ciphertext (before XOR) _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); + // XOR the ciphertext with the keystream to recover plaintext + for (int i = 0; i < blockSize; i++) + { + buffer[offset + processed + i] ^= keystream[i]; + } } processed += blockSize; @@ -278,6 +281,7 @@ private void ProcessBlock(byte[] buffer, int offset, int count) Debug.WriteLine($"Final counter after processing: {BitConverter.ToString(_counterBlock)}"); } + private void IncrementCounter() { // WinZip AES treats the entire 16-byte block as a little-endian 128-bit integer @@ -300,6 +304,7 @@ private void WriteAuthCode() { // WinZip AES spec requires only the first 10 bytes of the HMAC _baseStream.Write(authCode, 0, 10); + Debug.WriteLine($"Wrote authentication code: {BitConverter.ToString(authCode, 0, 10)}"); } _authCodeValidated = true; @@ -315,10 +320,25 @@ private void WriteCore(ReadOnlySpan buffer) WriteHeader(); // We need to copy the data since ProcessBlock modifies it in place - byte[] tmp = buffer.ToArray(); - ProcessBlock(tmp, 0, tmp.Length); - _baseStream.Write(tmp); + //byte[] tmp = buffer.ToArray(); + //ProcessBlock(tmp, 0, tmp.Length); + //_baseStream.Write(tmp); + _encryptionBuffer?.Write(buffer); _position += buffer.Length; + //output tmp to debug log + Debug.WriteLine($"Wrote {buffer.Length} bytes of ciphertext: {BitConverter.ToString(buffer.ToArray())}"); + } + + // Flush all buffered data, encrypt it, and write to base stream + private void FlushEncryptionBuffer() + { + if (_encryptionBuffer != null && _encryptionBuffer.Length > 0) + { + byte[] data = _encryptionBuffer.ToArray(); + ProcessBlock(data, 0, data.Length); + _baseStream.Write(data); + _encryptionBuffer.SetLength(0); // Clear the buffer + } } private int ReadCore(Span buffer) @@ -336,14 +356,14 @@ private int ReadCore(Span buffer) // If we know the total size, ensure we don't read into the HMAC if (_totalStreamSize > 0) { - const int hmacSize = 10; // Correct 10-byte HMAC size - long remainingData = _totalStreamSize - _bytesReadFromBase - hmacSize; + // Calculate the size of the AES overhead + int saltSize = _keySizeBits / 16; + int headerSize = saltSize + 2; // Salt + Password Verifier + const int hmacSize = 10; // 10-byte HMAC - Debug.WriteLine($"=== ReadCore Debug ==="); - Debug.WriteLine($"Total stream size: {_totalStreamSize}"); - Debug.WriteLine($"Bytes read from base: {_bytesReadFromBase}"); - Debug.WriteLine($"Remaining data: {remainingData}"); - Debug.WriteLine($"Buffer length requested: {buffer.Length}"); + // The actual encrypted data size is the total minus header and HMAC + long encryptedDataSize = _totalStreamSize - headerSize - hmacSize; + long remainingData = encryptedDataSize - _bytesReadFromBase; if (remainingData <= 0) { @@ -378,18 +398,12 @@ private int ReadCore(Span buffer) Debug.WriteLine($"Ciphertext (hex): {BitConverter.ToString(buffer.Slice(0, n).ToArray())}"); // The buffer now contains the ciphertext. - // We need to pass an array to ProcessBlock. byte[] temp = buffer.Slice(0, n).ToArray(); - // ProcessBlock will now correctly: // 1. Update the HMAC with the ciphertext from `temp`. // 2. Decrypt `temp` in-place. ProcessBlock(temp, 0, n); - // Log the plaintext after decryption - Debug.WriteLine($"Plaintext (hex): {BitConverter.ToString(temp)}"); - Debug.WriteLine($"Plaintext (ASCII): {System.Text.Encoding.ASCII.GetString(temp)}"); - // Copy the decrypted data from `temp` back to the original buffer. temp.CopyTo(buffer); @@ -406,7 +420,6 @@ private int ReadCore(Span buffer) return n; } - // All Write overloads redirect to Write(ReadOnlySpan) public override void Write(byte[] buffer, int offset, int count) { ValidateBufferArguments(buffer, offset, count); @@ -477,8 +490,14 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation if (_totalStreamSize > 0) { + // Calculate the size of the AES overhead + int saltSize = _keySizeBits / 16; + int headerSize = saltSize + 2; // Salt + Password Verifier const int hmacSize = 10; - long remainingData = _totalStreamSize - _bytesReadFromBase - hmacSize; + + // The actual encrypted data size is the total minus header and HMAC + long encryptedDataSize = _totalStreamSize - headerSize - hmacSize; + long remainingData = encryptedDataSize - _bytesReadFromBase; if (remainingData <= 0) { @@ -505,7 +524,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation if (n > 0) { - _bytesReadFromBase += n; // This was missing - crucial for boundary tracking! + _bytesReadFromBase += n; // Process the data byte[] temp = buffer.Slice(0, n).ToArray(); @@ -530,17 +549,22 @@ protected override void Dispose(bool disposing) { try { - // For encryption, write the auth code when closing - if (_encrypting && _headerWritten && !_authCodeValidated) + if (_encrypting && !_authCodeValidated) { + // CRITICAL: Flush the base stream BEFORE calculating auth code + // This ensures all encrypted data is written to the stream + if (_baseStream.CanWrite) + { + _baseStream.Flush(); + } + WriteAuthCode(); - } - // Only flush if the base stream supports writing - // SubReadStream (used for reading compressed data) doesn't support Flush() - if (_baseStream.CanWrite) - { - _baseStream.Flush(); + // Flush again after writing auth code + if (_baseStream.CanWrite) + { + _baseStream.Flush(); + } } } finally @@ -548,6 +572,12 @@ protected override void Dispose(bool disposing) _aesEncryptor?.Dispose(); _aes.Dispose(); _hmac.Dispose(); + + // Only dispose the base stream if we don't leave it open + if (!_leaveOpen) + { + _baseStream.Dispose(); + } } } @@ -555,13 +585,66 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + public override async ValueTask DisposeAsync() + { + if (_disposed) + return; + + try + { + if (_encrypting && !_authCodeValidated) + { + FlushEncryptionBuffer(); + } + + _encryptionBuffer?.Dispose(); + + } + finally + { + _aesEncryptor?.Dispose(); + _aes.Dispose(); + _hmac.Dispose(); + + // Only dispose the base stream if we don't leave it open + if (!_leaveOpen) + { + await _baseStream.DisposeAsync().ConfigureAwait(false); + } + } + + _disposed = true; + GC.SuppressFinalize(this); + } + public override bool CanRead => !_encrypting && !_disposed; public override bool CanSeek => false; public override bool CanWrite => _encrypting && !_disposed; public override long Length => throw new NotSupportedException(); public override long Position { - get => _position; + get + { + // Calculate the actual position including all metadata + long position = _position; + + // Add header size if it has been written/read + if (_headerWritten || _headerRead) + { + int saltSize = _keySizeBits / 16; + int headerSize = saltSize + 2; // Salt + Password Verifier + position += headerSize; + } + + // Add auth code size if it has been written/validated + if (_authCodeValidated) + { + const int authCodeSize = 10; + position += authCodeSize; + } + + return position; + } set => throw new NotSupportedException(); } @@ -576,6 +659,38 @@ public override void Flush() } } + public override void Close() + { + if (!_disposed) + { + if (_encrypting && !_authCodeValidated && _headerWritten) + { + // Encrypt all buffered data first + FlushEncryptionBuffer(); + + // Flush any pending data + if (_baseStream.CanWrite) + { + _baseStream.Flush(); + } + + // Write the authentication code + WriteAuthCode(); + + // Flush again to ensure auth code is written + if (_baseStream.CanWrite) + { + _baseStream.Flush(); + } + } + } + + // Call base.Close() which will call Dispose(true) + base.Close(); + } + + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index a347500bbbf1bf..3b9dd1da3c3ddc 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -609,14 +609,37 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 } + WinZipAesExtraField? aesExtraField = null; + int aesExtraFieldSize = 0; + + if (Encryption == EncryptionMethod.Aes128 || Encryption == EncryptionMethod.Aes192 || Encryption == EncryptionMethod.Aes256) + { + aesExtraField = new WinZipAesExtraField + { + VendorVersion = 2, // AE-2 + AesStrength = Encryption switch + { + EncryptionMethod.Aes128 => (byte)1, + EncryptionMethod.Aes192 => (byte)2, + EncryptionMethod.Aes256 => (byte)3, + _ => (byte)3 + }, + CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? + (ushort)CompressionMethodValues.Stored : + (ushort)CompressionMethodValues.Deflate + }; + aesExtraFieldSize = WinZipAesExtraField.TotalSize; + } + // determine if we can fit zip64 extra field and original extra fields all in int currExtraFieldDataLength = ZipGenericExtraField.TotalSize(_cdUnknownExtraFields, _cdTrailingExtraFieldData?.Length ?? 0); int bigExtraFieldLength = (zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) + + aesExtraFieldSize // Add this line + currExtraFieldDataLength; if (bigExtraFieldLength > ushort.MaxValue) { - extraFieldLength = (ushort)(zip64ExtraField != null ? zip64ExtraField.TotalSize : 0); + extraFieldLength = (ushort)((zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) + aesExtraFieldSize); // Modified line _cdUnknownExtraFields = null; } else @@ -668,7 +691,8 @@ private void WriteCentralDirectoryFileHeaderPrepare(Span cdStaticHeader, u BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.GeneralPurposeBitFlags..], (ushort)_generalPurposeBitFlag); BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressionMethod..], (ushort)CompressionMethod); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); - BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.Crc32..], _crc32); + uint crcToWrite = ForAesEncryption() ? 0 : _crc32; + BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.Crc32..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressedSize..], compressedSizeTruncated); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.UncompressedSize..], uncompressedSizeTruncated); BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.FilenameLength..], (ushort)_storedEntryNameBytes.Length); @@ -694,6 +718,26 @@ internal void WriteCentralDirectoryFileHeader(bool forceWrite) // only write zip64ExtraField if we decided we need it (it's not null) zip64ExtraField?.WriteBlock(_archive.ArchiveStream); + // Write AES extra field if using AES encryption (add this block) + if (Encryption == EncryptionMethod.Aes128 || Encryption == EncryptionMethod.Aes192 || Encryption == EncryptionMethod.Aes256) + { + var aesExtraField = new WinZipAesExtraField + { + VendorVersion = 2, + AesStrength = Encryption switch + { + EncryptionMethod.Aes128 => (byte)1, + EncryptionMethod.Aes192 => (byte)2, + EncryptionMethod.Aes256 => (byte)3, + _ => (byte)3 + }, + CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? + (ushort)CompressionMethodValues.Stored : + (ushort)CompressionMethodValues.Deflate + }; + aesExtraField.WriteBlock(_archive.ArchiveStream); + } + // write extra fields (and any malformed trailing data). ZipGenericExtraField.WriteAllBlocks(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream); @@ -794,7 +838,6 @@ private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool default: compressorStream = new DeflateStream(backingStream, _compressionLevel, leaveBackingStreamOpen); break; - } bool leaveCompressorStreamOpenOnClose = leaveBackingStreamOpen && !isIntermediateStream; var checkSumStream = new CheckSumAndSizeWriteStream( @@ -836,6 +879,13 @@ private bool IsAesEncrypted() return _aesCompressionLevel == CompressionMethodValues.Aes; } + private bool ForAesEncryption() + { + return _encryptionMethod == EncryptionMethod.Aes128 || + _encryptionMethod == EncryptionMethod.Aes192 || + _encryptionMethod == EncryptionMethod.Aes256; + } + private Stream GetDataDecompressor(Stream compressedStreamToRead) { Stream? uncompressedStream; @@ -942,9 +992,26 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod crc32: null, leaveOpen: true); } + else if (encryptionMethod == EncryptionMethod.Aes256) + { + if (string.IsNullOrEmpty(password)) + throw new InvalidOperationException("Password is required for encryption."); + + Encryption = encryptionMethod; + + // targetstream should be new winzipaesstream for wrting, ae2 + targetStream = new WinZipAesStream( + baseStream: _archive.ArchiveStream, + password: password.AsMemory(), + encrypting: true, + keySizeBits: 256, + ae2: true, + leaveOpen: true); + } + CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor( targetStream, - true, + encryptionMethod == EncryptionMethod.Aes256 ? false : true, (object? o, EventArgs e) => { // release the archive stream @@ -1166,6 +1233,36 @@ private static BitFlagValues MapDeflateCompressionOption(BitFlagValues generalPu private bool ShouldUseZIP64 => AreSizesTooLarge || IsOffsetTooLarge; internal EncryptionMethod Encryption { get => _encryptionMethod; set => _encryptionMethod = value; } + internal sealed class WinZipAesExtraField + { + public const ushort HeaderId = 0x9901; + + public ushort VendorVersion { get; set; } = 2; // AE-2 + public byte AesStrength { get; set; } // 1=128bit, 2=192bit, 3=256bit + public ushort CompressionMethod { get; set; } // Original compression method + + public static int TotalSize => 11; // 2 (header) + 2 (size) + 7 (data) + + public void WriteBlock(Stream stream) + { + Span buffer = stackalloc byte[TotalSize]; + WriteBlockCore(buffer); + stream.Write(buffer); + } + + private void WriteBlockCore(Span buffer) + { + BinaryPrimitives.WriteUInt16LittleEndian(buffer[0..], HeaderId); + BinaryPrimitives.WriteUInt16LittleEndian(buffer[2..], 7); // Data size + BinaryPrimitives.WriteUInt16LittleEndian(buffer[4..], VendorVersion); + // Write "AE" as two ASCII bytes, vendor ID + buffer[6] = (byte)'A'; // 0x41 + buffer[7] = (byte)'E'; // 0x45 + buffer[8] = AesStrength; + BinaryPrimitives.WriteUInt16LittleEndian(buffer[9..], CompressionMethod); + } + } + private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, out Zip64ExtraField? zip64ExtraField, out uint compressedSizeTruncated, out uint uncompressedSizeTruncated, out ushort extraFieldLength) { // _entryname only gets set when we read in or call moveTo. MoveTo does a check, and @@ -1178,6 +1275,10 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o // save offset _offsetOfLocalHeader = _archive.ArchiveStream.Position; + // for extra winzip aes header + WinZipAesExtraField? aesExtraField = null; + int aesExtraFieldSize = 0; + // if we already know that we have an empty file don't worry about anything, just do a straight shot of the header if (isEmptyFile) { @@ -1190,7 +1291,6 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o } else { - if (Encryption == EncryptionMethod.ZipCrypto) { // Streaming mode for encryption: sizes and CRC unknown upfront @@ -1200,6 +1300,31 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o uncompressedSizeTruncated = 0; Debug.Assert(_crc32 == 0); } + else if (ForAesEncryption()) + { + _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; + _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; + + // Set compression method to 99 (AES indicator) in the header + CompressionMethod = CompressionMethodValues.Aes; + compressedSizeTruncated = 0; + uncompressedSizeTruncated = 0; + aesExtraField = new WinZipAesExtraField + { + VendorVersion = 2, // AE-2 + AesStrength = Encryption switch + { + EncryptionMethod.Aes128 => (byte)1, + EncryptionMethod.Aes192 => (byte)2, + EncryptionMethod.Aes256 => (byte)3, + _ => (byte)3 + }, + CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? + (ushort)CompressionMethodValues.Stored : + (ushort)CompressionMethodValues.Deflate + }; + aesExtraFieldSize = 11; + } // if we have a non-seekable stream, don't worry about sizes at all, and just set the right bit // if we are using the data descriptor, then sizes and crc should be set to 0 in the header else if (_archive.Mode == ZipArchiveMode.Create && !_archive.ArchiveStream.CanSeek) @@ -1216,7 +1341,7 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o _generalPurposeBitFlag &= ~BitFlagValues.DataDescriptor; if (ShouldUseZIP64 #if DEBUG_FORCE_ZIP64 - || (_archive._forceZip64 && _archive.Mode == ZipArchiveMode.Update) + || (_archive._forceZip64 && _archive.Mode == ZipArchiveMode.Update) #endif ) { @@ -1246,11 +1371,12 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o // calculate extra field. if zip64 stuff + original extraField aren't going to fit, dump the original extraField, because this is more important int currExtraFieldDataLength = ZipGenericExtraField.TotalSize(_lhUnknownExtraFields, _lhTrailingExtraFieldData?.Length ?? 0); int bigExtraFieldLength = (zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) + + aesExtraFieldSize + currExtraFieldDataLength; if (bigExtraFieldLength > ushort.MaxValue) { - extraFieldLength = (ushort)(zip64ExtraField != null ? zip64ExtraField.TotalSize : 0); + extraFieldLength = (ushort)((zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) + aesExtraFieldSize); _lhUnknownExtraFields = null; } else @@ -1284,7 +1410,8 @@ private void WriteLocalFileHeaderPrepare(Span lfStaticHeader, uint compres BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.GeneralPurposeBitFlags..], (ushort)_generalPurposeBitFlag); BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressionMethod..], (ushort)CompressionMethod); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); - BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.Crc32..], _crc32); + uint crcToWrite = ForAesEncryption() ? 0 : _crc32; + BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.Crc32..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressedSize..], compressedSizeTruncated); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.UncompressedSize..], uncompressedSizeTruncated); BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.FilenameLength..], (ushort)_storedEntryNameBytes.Length); @@ -1303,9 +1430,30 @@ private bool WriteLocalFileHeader(bool isEmptyFile, bool forceWrite) _archive.ArchiveStream.Write(lfStaticHeader); _archive.ArchiveStream.Write(_storedEntryNameBytes); - // Only when handling zip64 + // Write Zip64 extra field if needed zip64ExtraField?.WriteBlock(_archive.ArchiveStream); + // Write AES extra field if using AES encryption + if (Encryption == EncryptionMethod.Aes128 || Encryption == EncryptionMethod.Aes192 || Encryption == EncryptionMethod.Aes256) + { + var aesExtraField = new WinZipAesExtraField + { + VendorVersion = 2, + AesStrength = Encryption switch + { + EncryptionMethod.Aes128 => (byte)1, + EncryptionMethod.Aes192 => (byte)2, + EncryptionMethod.Aes256 => (byte)3, + _ => (byte)3 + }, + CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? + (ushort)CompressionMethodValues.Stored : + (ushort)CompressionMethodValues.Deflate + }; + aesExtraField.WriteBlock(_archive.ArchiveStream); + } + + // Write other extra fields ZipGenericExtraField.WriteAllBlocks(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream); } @@ -1465,8 +1613,8 @@ private void WriteCrcAndSizesInLocalHeaderPrepareFor32bitValuesWriting(bool pret int relativeCrc32Location = ZipLocalFileHeader.FieldLocations.Crc32 - ZipLocalFileHeader.FieldLocations.Crc32; int relativeCompressedSizeLocation = ZipLocalFileHeader.FieldLocations.CompressedSize - ZipLocalFileHeader.FieldLocations.Crc32; int relativeUncompressedSizeLocation = ZipLocalFileHeader.FieldLocations.UncompressedSize - ZipLocalFileHeader.FieldLocations.Crc32; - - BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeCrc32Location..], _crc32); + uint crcToWrite = ForAesEncryption() ? 0 : _crc32; + BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeCrc32Location..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeCompressedSizeLocation..], compressedSizeTruncated); BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeUncompressedSizeLocation..], uncompressedSizeTruncated); } @@ -1494,7 +1642,8 @@ private void WriteCrcAndSizesInLocalHeaderPrepareForWritingDataDescriptor(Span dataDescriptor) int bytesToWrite; ZipLocalFileHeader.DataDescriptorSignatureConstantBytes.CopyTo(dataDescriptor[ZipLocalFileHeader.ZipDataDescriptor.FieldLocations.Signature..]); - BinaryPrimitives.WriteUInt32LittleEndian(dataDescriptor[ZipLocalFileHeader.ZipDataDescriptor.FieldLocations.Crc32..], _crc32); + uint crcToWrite = ForAesEncryption() ? 0 : _crc32; + BinaryPrimitives.WriteUInt32LittleEndian(dataDescriptor[ZipLocalFileHeader.ZipDataDescriptor.FieldLocations.Crc32..], crcToWrite); if (AreSizesTooLarge) { From cc6d30d09450c1db41a3d4a7b9c823b4689664b9 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 26 Nov 2025 20:51:59 +0100 Subject: [PATCH 16/39] add more tests for winzipaesstream and work on async methods --- .../tests/ZipFile.Extract.cs | 838 +++++++++++++++++- .../System/IO/Compression/WinZipAesStream.cs | 150 ++-- .../IO/Compression/ZipArchiveEntry.Async.cs | 47 +- .../System/IO/Compression/ZipArchiveEntry.cs | 37 +- 4 files changed, 995 insertions(+), 77 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 7b396fe954bee4..253fca3c664901 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -265,7 +265,7 @@ public void ExtractEncryptedEntryToFile_WithWrongPassword_ShouldThrow() { string ZipPath = @"C:\Users\spahontu\Downloads\test.zip"; string EntryName = "hello.txt"; - + string tempFile = Path.Combine(Path.GetTempPath(), "hello_extracted.txt"); if (File.Exists(tempFile)) File.Delete(tempFile); @@ -1437,23 +1437,197 @@ public async Task CreateAndReadAES256EncryptedEntry_RoundTrip() } + [Fact] + public async Task CreateAndReadMultipleAES256EncryptedEntries_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "multiple_aes256_entries.zip"); + const string password = "123456789"; + + var entries = new (string Name, string Content)[] + { + ("entry1.txt", "First encrypted entry"), + ("folder/entry2.txt", "Second encrypted entry in folder"), + ("folder/subfolder/entry3.md", "# Third Entry\nMarkdown content"), + ("data.json", "{\"key\": \"value\", \"encrypted\": true}"), + ("readme.txt", "This is AES-256 encrypted content") + }; + + // Act 1: Create ZIP with multiple AES-256 encrypted entries + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + foreach (var (name, content) in entries) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes256); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + await writer.WriteAsync(content); + } + } + + // Act 2: Read back all encrypted entries + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + foreach (var (name, expectedContent) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var reader = new StreamReader(entryStream, Encoding.UTF8); + string actualContent = await reader.ReadToEndAsync(); + + // Assert each entry's content matches + Assert.Equal(expectedContent, actualContent); + } + + // Verify wrong password fails + var firstEntry = archive.GetEntry(entries[0].Name); + Assert.NotNull(firstEntry); + } + } [Fact] - public void CreateBasicArchive() + public async Task CreateAndReadAES256EntriesWithDifferentPasswords_RoundTrip() { // Arrange - string tempPath = Path.Join(DownloadsDir, "test_simple.zip"); - const string entryName = "test.txt"; - const string expectedContent = "this is plain"; + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "multiple_aes256_diff_passwords.zip"); - // Act 1: Create ZIP with AES-256 encrypted entry + var entries = new (string Name, string Content, string Password)[] + { + ("secure1.txt", "Content with password1", "password1"), + ("secure2.txt", "Content with password2", "password2"), + ("folder/secure3.txt", "Content with password3", "password3") + }; + + // Act 1: Create ZIP with AES-256 encrypted entries using different passwords + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + foreach (var (name, content, pwd) in entries) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(pwd, ZipArchiveEntry.EncryptionMethod.Aes256); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + await writer.WriteAsync(content); + } + } + + // Act 2: Read back entries with their respective passwords + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + foreach (var (name, expectedContent, pwd) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + + // Correct password should work + using (var entryStream = entry!.Open(pwd)) + using (var reader = new StreamReader(entryStream, Encoding.UTF8)) + { + string actualContent = await reader.ReadToEndAsync(); + Assert.Equal(expectedContent, actualContent); + } + } + } + } + + [Fact] + public async Task CreateMixedPlainAndAES256EncryptedEntries_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "mixed_plain_aes256.zip"); + const string password = "securePassword123"; + + var encryptedEntries = new (string Name, string Content)[] + { + ("secure/credentials.txt", "username=admin\npassword=secret"), + ("secure/data.json", "{\"sensitive\": true}") + }; + + var plainEntries = new (string Name, string Content)[] + { + ("readme.txt", "This archive contains both encrypted and plain files"), + ("public/info.txt", "This is public information") + }; + + // Act 1: Create ZIP with both plain and AES-256 encrypted entries + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + // Add encrypted entries + foreach (var (name, content) in encryptedEntries) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes256); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + await writer.WriteAsync(content); + } + + // Add plain entries + foreach (var (name, content) in plainEntries) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + await writer.WriteAsync(content); + } + } + + // Act 2: Read back both encrypted and plain entries + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + // Read encrypted entries with password + foreach (var (name, expectedContent) in encryptedEntries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var reader = new StreamReader(entryStream, Encoding.UTF8); + string actualContent = await reader.ReadToEndAsync(); + Assert.Equal(expectedContent, actualContent); + } + + // Read plain entries without password + foreach (var (name, expectedContent) in plainEntries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(); + using var reader = new StreamReader(entryStream, Encoding.UTF8); + string actualContent = await reader.ReadToEndAsync(); + Assert.Equal(expectedContent, actualContent); + } + } + } + + [Fact] + public async Task CreateAndReadAES128EncryptedEntry_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes128_single.zip"); + const string entryName = "test_aes128.txt"; + const string password = "Test123!@#"; + const string expectedContent = "This content is encrypted with AES-128"; + + // Act 1: Create ZIP with AES-128 encrypted entry using (var createStream = File.Create(tempPath)) using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) { var entry = archive.CreateEntry(entryName); - using var entryStream = entry.Open(); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes128); using var writer = new StreamWriter(entryStream, Encoding.UTF8); - writer.Write(expectedContent); + await writer.WriteAsync(expectedContent); } // Act 2: Read back the encrypted entry @@ -1464,16 +1638,660 @@ public void CreateBasicArchive() var entry = archive.GetEntry(entryName); Assert.NotNull(entry); - using var entryStream = entry!.Open(); + using var entryStream = entry!.Open(password); + using var reader = new StreamReader(entryStream, Encoding.UTF8); + actualContent = await reader.ReadToEndAsync(); + } + + // Assert + Assert.Equal(expectedContent, actualContent); + + } + + [Fact] + public async Task CreateAndReadAES192EncryptedEntry_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes192_single.zip"); + const string entryName = "test_aes192.txt"; + const string password = "SecurePass456$%^"; + const string expectedContent = "This content is protected with AES-192 encryption"; + + // Act 1: Create ZIP with AES-192 encrypted entry + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes192); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + await writer.WriteAsync(expectedContent); + } + + // Act 2: Read back the encrypted entry + string actualContent; + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + var entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var reader = new StreamReader(entryStream, Encoding.UTF8); + actualContent = await reader.ReadToEndAsync(); + } + + // Assert + Assert.Equal(expectedContent, actualContent); + } + + [Fact] + public void CreateAndReadMultipleEntriesWithDifferentAESLevels_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "mixed_aes_levels.zip"); + + var entries = new (string Name, string Content, string Password, ZipArchiveEntry.EncryptionMethod Encryption)[] + { + ("aes128/file1.txt", "AES-128 encrypted content", "password128", ZipArchiveEntry.EncryptionMethod.Aes128), + ("aes192/file2.txt", "AES-192 encrypted content", "password192", ZipArchiveEntry.EncryptionMethod.Aes192), + ("aes256/file3.txt", "AES-256 encrypted content", "password256", ZipArchiveEntry.EncryptionMethod.Aes256), + ("mixed/doc1.json", "{\"encryption\": \"AES-128\"}", "jsonPass128", ZipArchiveEntry.EncryptionMethod.Aes128), + ("mixed/doc2.xml", "AES-192", "xmlPass192", ZipArchiveEntry.EncryptionMethod.Aes192) + }; + + // Act 1: Create ZIP with entries using different AES encryption levels + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + foreach (var (name, content, pwd, encryption) in entries) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(pwd, encryption); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(content); + } + } + + // Act 2: Read back all encrypted entries with their respective passwords + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + foreach (var (name, expectedContent, pwd, _) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + + // Correct password should work + using (var entryStream = entry!.Open(pwd)) + using (var reader = new StreamReader(entryStream, Encoding.UTF8)) + { + string actualContent = reader.ReadToEnd(); + Assert.Equal(expectedContent, actualContent); + } + + } + } + } + + [Fact] + public void CreateLargeFileWithAES128_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes128_large2.zip"); + const string entryName = "large_file.bin"; + const string password = "LargeFilePass123!"; + + // Create a larger content + var random = new Random(42); // Seed for reproducibility + var largeContent = new byte[1024 * 1024]; + random.NextBytes(largeContent); + + // Act 1: Create ZIP with AES-128 encrypted large entry + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes128); + entryStream.Write(largeContent); + } + + // Act 2: Read back and verify the large encrypted entry + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + var entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var ms = new MemoryStream(); + entryStream.CopyTo(ms); + var actualContent = ms.ToArray(); + + // Assert + Assert.Equal(largeContent.Length, actualContent.Length); + Assert.Equal(largeContent, actualContent); + } + } + + [Fact] + public async Task CreateCompressedAndAES192Encrypted_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes192_compressed.zip"); + const string password = "CompressedPass!"; + + // Create highly compressible content + string repeatedContent = string.Join("\n", Enumerable.Repeat("This line is repeated many times to test compression with AES-192.", 100)); + + var entries = new (string Name, string Content, CompressionLevel Level)[] + { + ("optimal.txt", repeatedContent, CompressionLevel.Optimal), + ("fastest.txt", repeatedContent, CompressionLevel.Fastest), + ("smallest.txt", repeatedContent, CompressionLevel.SmallestSize), + ("nocompression.txt", repeatedContent, CompressionLevel.NoCompression) + }; + + // Act 1: Create ZIP with AES-192 encrypted entries at different compression levels + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + foreach (var (name, content, level) in entries) + { + var entry = archive.CreateEntry(name, level); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes192); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(content); + } + } + + // Act 2: Read back all entries + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + foreach (var (name, expectedContent, _) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var reader = new StreamReader(entryStream, Encoding.UTF8); + string actualContent = await reader.ReadToEndAsync(); + + Assert.Equal(expectedContent, actualContent); + } + } + + // Verify file sizes are different due to compression levels + var fileInfo = new FileInfo(tempPath); + Assert.True(fileInfo.Exists); + Assert.True(fileInfo.Length > 0); + } + + [Fact] + public async Task MixAllEncryptionTypes_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "all_encryption_types.zip"); + + var entries = new (string Name, string Content, string? Password, ZipArchiveEntry.EncryptionMethod? Encryption)[] + { + // Plain entry + ("plain/readme.txt", "This is a plain unencrypted file", null, null), + + // ZipCrypto + ("zipcrypto/secret.txt", "ZipCrypto encrypted content", "zipPass", ZipArchiveEntry.EncryptionMethod.ZipCrypto), + + // AES-128 + ("aes128/data.txt", "AES-128 encrypted data", "aes128Pass", ZipArchiveEntry.EncryptionMethod.Aes128), + + // AES-192 + ("aes192/config.json", "{\"level\": \"AES-192\"}", "aes192Pass", ZipArchiveEntry.EncryptionMethod.Aes192), + + // AES-256 + ("aes256/secure.xml", "AES-256 secured", "aes256Pass", ZipArchiveEntry.EncryptionMethod.Aes256) + }; + + // Act 1: Create ZIP with all encryption types + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + foreach (var (name, content, pwd, encryption) in entries) + { + var entry = archive.CreateEntry(name); + Stream entryStream = pwd != null && encryption.HasValue + ? entry.Open(pwd, encryption.Value) + : entry.Open(); + + using (entryStream) + using (var writer = new StreamWriter(entryStream, Encoding.UTF8)) + { + await writer.WriteAsync(content); + } + } + } + + // Act 2: Read back all entries + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + foreach (var (name, expectedContent, pwd, _) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + + Stream entryStream = pwd != null + ? entry!.Open(pwd) + : entry!.Open(); + + using (entryStream) + using (var reader = new StreamReader(entryStream, Encoding.UTF8)) + { + string actualContent = await reader.ReadToEndAsync(); + Assert.Equal(expectedContent, actualContent); + } + } + } + } + + [Fact] + public async Task AES128WithSpecialCharacters_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes128_special_chars.zip"); + const string password = "パスワード123!@#"; // Japanese characters in password + + var entries = new (string Name, string Content)[] + { + ("unicode/chinese.txt", "你好世界 - Hello World in Chinese"), + ("unicode/arabic.txt", "مرحبا بالعالم - Hello World in Arabic"), + ("unicode/emoji.txt", "Hello 👋 World 🌍 with emojis! 🎉"), + ("unicode/mixed.txt", "Ñiño José façade naïve Zürich") + }; + + // Act 1: Create ZIP with AES-128 encrypted Unicode content + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + foreach (var (name, content) in entries) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes128); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + await writer.WriteAsync(content); + } + } + + // Act 2: Read back and verify Unicode content + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + foreach (var (name, expectedContent) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var reader = new StreamReader(entryStream, Encoding.UTF8); + string actualContent = await reader.ReadToEndAsync(); + + Assert.Equal(expectedContent, actualContent); + } + } + } + + [Fact] + public async Task CreateAndReadAES256WithAsyncOperations_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes256_async_operations.zip"); + const string entryName = "async_test.txt"; + const string password = "AsyncPass123!"; + const string expectedContent = "This content was written and read asynchronously with AES-256"; + + // Act 1: Create ZIP with AES-256 encrypted entry using async write + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes256); + + // Use async write operations + byte[] contentBytes = Encoding.UTF8.GetBytes(expectedContent); + await entryStream.WriteAsync(contentBytes, 0, contentBytes.Length); + await entryStream.FlushAsync(); + } + + // Act 2: Read back using async operations + string actualContent; + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + var entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); using var reader = new StreamReader(entryStream, Encoding.UTF8); - actualContent = reader.ReadToEnd(); + actualContent = await reader.ReadToEndAsync(); } // Assert Assert.Equal(expectedContent, actualContent); } + [Fact] + public async Task CreateMultipleAESEntriesWithAsyncWrites_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "multiple_aes_async_writes.zip"); + + var entries = new (string Name, byte[] Content, string Password, ZipArchiveEntry.EncryptionMethod Encryption)[] + { + ("async128.bin", Encoding.UTF8.GetBytes("AES-128 async content"), "pass128", ZipArchiveEntry.EncryptionMethod.Aes128), + ("async192.bin", Encoding.UTF8.GetBytes("AES-192 async content"), "pass192", ZipArchiveEntry.EncryptionMethod.Aes192), + ("async256.bin", Encoding.UTF8.GetBytes("AES-256 async content"), "pass256", ZipArchiveEntry.EncryptionMethod.Aes256) + }; + + // Act 1: Create entries with async writes + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + foreach (var (name, content, pwd, encryption) in entries) + { + var entry = archive.CreateEntry(name); + using var entryStream = entry.Open(pwd, encryption); + + // Write asynchronously + await entryStream.WriteAsync(content, 0, content.Length); + await entryStream.FlushAsync(); + } + } + + // Act 2: Read back with async operations + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + foreach (var (name, expectedContent, pwd, _) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(pwd); + + // Read asynchronously + var buffer = new byte[expectedContent.Length]; + int totalRead = 0; + while (totalRead < buffer.Length) + { + int bytesRead = await entryStream.ReadAsync(buffer, totalRead, buffer.Length - totalRead); + if (bytesRead == 0) break; + totalRead += bytesRead; + } + + Assert.Equal(expectedContent, buffer); + } + } + } + + [Fact] + public async Task CreateLargeBinaryDataWithAES128Async_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes128_large_async.zip"); + const string entryName = "large_async.bin"; + const string password = "LargeAsync!@#"; + + // Create larger test data + var random = new Random(123); + var largeData = new byte[256 * 1024]; // 256KB + random.NextBytes(largeData); + + // Act 1: Write large data asynchronously with AES-128 + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes128); + + // Write in chunks asynchronously + const int chunkSize = 8192; + for (int offset = 0; offset < largeData.Length; offset += chunkSize) + { + int writeSize = Math.Min(chunkSize, largeData.Length - offset); + await entryStream.WriteAsync(largeData, offset, writeSize); + } + await entryStream.FlushAsync(); + } + + // Act 2: Read back asynchronously + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + var entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var ms = new MemoryStream(); + + // Read in chunks asynchronously + await entryStream.CopyToAsync(ms, bufferSize: 8192); + var actualData = ms.ToArray(); + // Assert + Assert.Equal(largeData.Length, actualData.Length); + Assert.Equal(largeData, actualData); + } + } + + [Fact] + public async Task StreamCopyAsyncWithAES192_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes192_stream_copy.zip"); + const string entryName = "stream_copy.dat"; + const string password = "StreamCopy192!"; + + // Create test data + var testData = new byte[64 * 1024]; // 64KB + new Random(456).NextBytes(testData); + + // Act 1: Use CopyToAsync to write + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes192); + using var sourceStream = new MemoryStream(testData); + + await sourceStream.CopyToAsync(entryStream); + } + + // Act 2: Use CopyToAsync to read + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + var entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var destStream = new MemoryStream(); + + await entryStream.CopyToAsync(destStream); + var actualData = destStream.ToArray(); + + // Assert + Assert.Equal(testData, actualData); + } + } + + [Fact] + public async Task MultipleAsyncWritesInSingleEntry_AES256_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes256_multiple_writes.zip"); + const string entryName = "multi_write.txt"; + const string password = "MultiWrite256"; + + var parts = new[] + { + "First part of content\n", + "Second part of content\n", + "Third part of content\n", + "Final part of content" + }; + string expectedContent = string.Join("", parts); + + // Act 1: Write multiple times to same entry + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes256); + + // Write each part asynchronously + foreach (var part in parts) + { + byte[] partBytes = Encoding.UTF8.GetBytes(part); + await entryStream.WriteAsync(partBytes, 0, partBytes.Length); + } + await entryStream.FlushAsync(); + } + + // Act 2: Read back all content + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + var entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var reader = new StreamReader(entryStream); + string actualContent = await reader.ReadToEndAsync(); + + // Assert + Assert.Equal(expectedContent, actualContent); + } + } + + [Fact] + public async Task AsyncReadInChunks_AES128_VerifyContent() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes128_chunked_read.zip"); + const string entryName = "chunked.bin"; + const string password = "ChunkedRead128"; + + // Create recognizable pattern + var pattern = new byte[1024]; + for (int i = 0; i < pattern.Length; i++) + { + pattern[i] = (byte)(i % 256); + } + + // Act 1: Write pattern + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + var entry = archive.CreateEntry(entryName); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes128); + await entryStream.WriteAsync(pattern, 0, pattern.Length); + } + + // Act 2: Read in small chunks and verify + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + var entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + + // Read in 100-byte chunks + const int chunkSize = 16; + var readBuffer = new byte[chunkSize]; + var allData = new List(); + + int bytesRead; + while ((bytesRead = await entryStream.ReadAsync(readBuffer, 0, chunkSize)) > 0) + { + allData.AddRange(readBuffer.Take(bytesRead)); + } + + // Assert + Assert.Equal(pattern, allData.ToArray()); + } + } + + [Fact] + public async Task MixedSyncAsyncOperations_AES192_RoundTrip() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string tempPath = Path.Join(DownloadsDir, "aes192_mixed_ops.zip"); + + var entries = new[] + { + ("sync_write.txt", "Written synchronously", "syncPass"), + ("async_write.txt", "Written asynchronously", "asyncPass") + }; + + // Act 1: Mix sync and async writes + using (var createStream = File.Create(tempPath)) + using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) + { + // Synchronous write + var syncEntry = archive.CreateEntry(entries[0].Item1); + using (var syncStream = syncEntry.Open(entries[0].Item3, ZipArchiveEntry.EncryptionMethod.Aes192)) + { + byte[] syncBytes = Encoding.UTF8.GetBytes(entries[0].Item2); + syncStream.Write(syncBytes, 0, syncBytes.Length); + } + + // Asynchronous write + var asyncEntry = archive.CreateEntry(entries[1].Item1); + using (var asyncStream = asyncEntry.Open(entries[1].Item3, ZipArchiveEntry.EncryptionMethod.Aes192)) + { + byte[] asyncBytes = Encoding.UTF8.GetBytes(entries[1].Item2); + await asyncStream.WriteAsync(asyncBytes, 0, asyncBytes.Length); + } + } + + // Act 2: Read back with mixed operations + using (var readStream = File.OpenRead(tempPath)) + using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) + { + // Read first entry asynchronously + var entry1 = archive.GetEntry(entries[0].Item1); + Assert.NotNull(entry1); + using (var stream1 = entry1!.Open(entries[0].Item3)) + using (var reader1 = new StreamReader(stream1)) + { + string content1 = await reader1.ReadToEndAsync(); + Assert.Equal(entries[0].Item2, content1); + } + + // Read second entry synchronously + var entry2 = archive.GetEntry(entries[1].Item1); + Assert.NotNull(entry2); + using (var stream2 = entry2!.Open(entries[1].Item3)) + using (var reader2 = new StreamReader(stream2)) + { + string content2 = reader2.ReadToEnd(); + Assert.Equal(entries[1].Item2, content2); + } + } + } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 5d8fe6c0b0a352..28ab01fdb7de7b 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -183,6 +183,18 @@ private void WriteHeader() _headerWritten = true; } + private async Task WriteHeaderAsync(CancellationToken cancellationToken) + { + if (_headerWritten) return; + Debug.Assert(_salt is not null && _passwordVerifier is not null, "Keys should have been generated before writing header"); + await _baseStream.WriteAsync(_salt, cancellationToken).ConfigureAwait(false); + await _baseStream.WriteAsync(_passwordVerifier, cancellationToken).ConfigureAwait(false); + // output to debug log + Debug.WriteLine($"Wrote salt: {BitConverter.ToString(_salt)}"); + Debug.WriteLine($"Wrote password verifier: {BitConverter.ToString(_passwordVerifier)}"); + _headerWritten = true; + } + private void ReadHeader() { if (_headerRead) return; @@ -310,6 +322,24 @@ private void WriteAuthCode() _authCodeValidated = true; } + private async Task WriteAuthCodeAsync(CancellationToken cancellationToken) + { + if (!_encrypting || _authCodeValidated) + return; + + _hmac.TransformFinalBlock(Array.Empty(), 0, 0); + byte[]? authCode = _hmac.Hash; + + if (authCode is not null) + { + // WinZip AES spec requires only the first 10 bytes of the HMAC + await _baseStream.WriteAsync(authCode.AsMemory(0, 10), cancellationToken).ConfigureAwait(false); + Debug.WriteLine($"Wrote authentication code: {BitConverter.ToString(authCode, 0, 10)}"); + } + + _authCodeValidated = true; + } + private void WriteCore(ReadOnlySpan buffer) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -319,16 +349,13 @@ private void WriteCore(ReadOnlySpan buffer) WriteHeader(); - // We need to copy the data since ProcessBlock modifies it in place - //byte[] tmp = buffer.ToArray(); - //ProcessBlock(tmp, 0, tmp.Length); - //_baseStream.Write(tmp); + // Buffer the data - don't process it yet _encryptionBuffer?.Write(buffer); - _position += buffer.Length; - //output tmp to debug log - Debug.WriteLine($"Wrote {buffer.Length} bytes of ciphertext: {BitConverter.ToString(buffer.ToArray())}"); + + Debug.WriteLine($"Buffered {buffer.Length} bytes for encryption"); } + // Flush all buffered data, encrypt it, and write to base stream private void FlushEncryptionBuffer() { @@ -337,6 +364,20 @@ private void FlushEncryptionBuffer() byte[] data = _encryptionBuffer.ToArray(); ProcessBlock(data, 0, data.Length); _baseStream.Write(data); + _position += data.Length; + _encryptionBuffer.SetLength(0); // Clear the buffer + } + } + + // Replace the FlushEncryptionBufferAsync method with this version: + private async Task FlushEncryptionBufferAsync(CancellationToken cancellationToken) + { + if (_encryptionBuffer != null && _encryptionBuffer.Length > 0) + { + byte[] data = _encryptionBuffer.ToArray(); + ProcessBlock(data, 0, data.Length); + await _baseStream.WriteAsync(data, cancellationToken).ConfigureAwait(false); + _position += data.Length; _encryptionBuffer.SetLength(0); // Clear the buffer } } @@ -437,25 +478,21 @@ public override async Task WriteAsync(byte[] buffer, int offset, int count, Canc await WriteAsync(new ReadOnlyMemory(buffer, offset, count), cancellationToken).ConfigureAwait(false); } - public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + // Replace the WriteAsync method with this: + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(_disposed, this); if (!_encrypting) throw new NotSupportedException("Stream is in decryption mode."); - return Core(buffer, cancellationToken); + await WriteHeaderAsync(cancellationToken).ConfigureAwait(false); - async ValueTask Core(ReadOnlyMemory buffer, CancellationToken cancellationToken) - { - WriteHeader(); + // Just buffer the data + // The async version should match the sync version logic + await _encryptionBuffer!.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); - // We need to copy the data since ProcessBlock modifies it in place - byte[] tmp = buffer.ToArray(); - ProcessBlock(tmp, 0, tmp.Length); - await _baseStream.WriteAsync(tmp, cancellationToken).ConfigureAwait(false); - _position += buffer.Length; - } + Debug.WriteLine($"Buffered {buffer.Length} bytes for encryption"); } public override int Read(byte[] buffer, int offset, int count) @@ -549,18 +586,21 @@ protected override void Dispose(bool disposing) { try { - if (_encrypting && !_authCodeValidated) + if (_encrypting && !_authCodeValidated && _headerWritten) { - // CRITICAL: Flush the base stream BEFORE calculating auth code - // This ensures all encrypted data is written to the stream + // Encrypt all buffered data first + FlushEncryptionBuffer(); + + // Flush any pending data if (_baseStream.CanWrite) { _baseStream.Flush(); } + // Write the authentication code WriteAuthCode(); - // Flush again after writing auth code + // Flush again to ensure auth code is written if (_baseStream.CanWrite) { _baseStream.Flush(); @@ -572,6 +612,7 @@ protected override void Dispose(bool disposing) _aesEncryptor?.Dispose(); _aes.Dispose(); _hmac.Dispose(); + _encryptionBuffer?.Dispose(); // Only dispose the base stream if we don't leave it open if (!_leaveOpen) @@ -585,6 +626,7 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + // Replace DisposeAsync with this version: public override async ValueTask DisposeAsync() { if (_disposed) @@ -592,19 +634,36 @@ public override async ValueTask DisposeAsync() try { - if (_encrypting && !_authCodeValidated) + if (_encrypting && !_authCodeValidated && _headerWritten) { - FlushEncryptionBuffer(); - } + // make sure header is flushed + await _baseStream.FlushAsync(CancellationToken.None).ConfigureAwait(false); - _encryptionBuffer?.Dispose(); + // Encrypt all buffered data first + await FlushEncryptionBufferAsync(CancellationToken.None).ConfigureAwait(false); + + // Flush any pending data + if (_baseStream.CanWrite) + { + await _baseStream.FlushAsync(CancellationToken.None).ConfigureAwait(false); + } + // Write the authentication code (this is still sync, which is fine) + await WriteAuthCodeAsync(CancellationToken.None).ConfigureAwait(false); + + // Flush again to ensure auth code is written + if (_baseStream.CanWrite) + { + await _baseStream.FlushAsync(CancellationToken.None).ConfigureAwait(false); + } + } } finally { _aesEncryptor?.Dispose(); _aes.Dispose(); _hmac.Dispose(); + _encryptionBuffer?.Dispose(); // Only dispose the base stream if we don't leave it open if (!_leaveOpen) @@ -616,7 +675,6 @@ public override async ValueTask DisposeAsync() _disposed = true; GC.SuppressFinalize(this); } - public override bool CanRead => !_encrypting && !_disposed; public override bool CanSeek => false; public override bool CanWrite => _encrypting && !_disposed; @@ -658,39 +716,27 @@ public override void Flush() _baseStream.Flush(); } } - - public override void Close() + public override async Task FlushAsync(CancellationToken cancellationToken) { - if (!_disposed) + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_encrypting) { - if (_encrypting && !_authCodeValidated && _headerWritten) + // First flush base stream to ensure header is written + if (_baseStream.CanWrite) { - // Encrypt all buffered data first - FlushEncryptionBuffer(); - - // Flush any pending data - if (_baseStream.CanWrite) - { - _baseStream.Flush(); - } - - // Write the authentication code - WriteAuthCode(); - - // Flush again to ensure auth code is written - if (_baseStream.CanWrite) - { - _baseStream.Flush(); - } + await _baseStream.FlushAsync(cancellationToken).ConfigureAwait(false); } + } - // Call base.Close() which will call Dispose(true) - base.Close(); + // Finally flush base stream to ensure encrypted data is written + if (_baseStream.CanWrite) + { + await _baseStream.FlushAsync(cancellationToken).ConfigureAwait(false); + } } - - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 48b946bd1f9669..75d5f099c13863 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -146,12 +146,33 @@ internal async Task WriteCentralDirectoryFileHeaderAsync(bool forceWrite, Cancel await _archive.ArchiveStream.WriteAsync(cdStaticHeader, cancellationToken).ConfigureAwait(false); await _archive.ArchiveStream.WriteAsync(_storedEntryNameBytes, cancellationToken).ConfigureAwait(false); - // only write zip64ExtraField if we decided we need it (it's not null) + // Write zip64ExtraField first if we decided we need it if (zip64ExtraField != null) { await zip64ExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); } + // Write WinZip AES extra field AFTER Zip64 (matching sync version order) + // Must match the exact check used in the sync version WriteCentralDirectoryFileHeader + if (_encryptionMethod == EncryptionMethod.Aes128 || _encryptionMethod == EncryptionMethod.Aes192 || _encryptionMethod == EncryptionMethod.Aes256) + { + var aesExtraField = new WinZipAesExtraField + { + VendorVersion = 2, // AE-2 + AesStrength = _encryptionMethod switch + { + EncryptionMethod.Aes128 => (byte)1, + EncryptionMethod.Aes192 => (byte)2, + EncryptionMethod.Aes256 => (byte)3, + _ => (byte)3 + }, + CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? + (ushort)CompressionMethodValues.Stored : + (ushort)CompressionMethodValues.Deflate + }; + await aesExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + } + // write extra fields (and any malformed trailing data). await ZipGenericExtraField.WriteAllBlocksAsync(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, cancellationToken).ConfigureAwait(false); @@ -161,7 +182,6 @@ internal async Task WriteCentralDirectoryFileHeaderAsync(bool forceWrite, Cancel } } } - internal async Task LoadLocalHeaderExtraFieldIfNeededAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -289,18 +309,37 @@ private async Task WriteLocalFileHeaderAsync(bool isEmptyFile, bool forceW await _archive.ArchiveStream.WriteAsync(lfStaticHeader, cancellationToken).ConfigureAwait(false); await _archive.ArchiveStream.WriteAsync(_storedEntryNameBytes, cancellationToken).ConfigureAwait(false); - // Only when handling zip64 if (zip64ExtraField != null) { await zip64ExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); } + // Write WinZip AES extra field if using AES encryption + // Must match the exact check used in the sync version WriteLocalFileHeader + if (_encryptionMethod == EncryptionMethod.Aes128 || _encryptionMethod == EncryptionMethod.Aes192 || _encryptionMethod == EncryptionMethod.Aes256) + { + var aesExtraField = new WinZipAesExtraField + { + VendorVersion = 2, // AE-2 + AesStrength = _encryptionMethod switch + { + EncryptionMethod.Aes128 => (byte)1, + EncryptionMethod.Aes192 => (byte)2, + EncryptionMethod.Aes256 => (byte)3, + _ => (byte)3 + }, + CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? + (ushort)CompressionMethodValues.Stored : + (ushort)CompressionMethodValues.Deflate + }; + await aesExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + } + await ZipGenericExtraField.WriteAllBlocksAsync(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, cancellationToken).ConfigureAwait(false); } return zip64ExtraField != null; } - private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index 3b9dd1da3c3ddc..d0eb44a2215aa7 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -992,26 +992,35 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod crc32: null, leaveOpen: true); } - else if (encryptionMethod == EncryptionMethod.Aes256) + else if (encryptionMethod == EncryptionMethod.Aes256 || encryptionMethod == EncryptionMethod.Aes192 || encryptionMethod == EncryptionMethod.Aes128) { if (string.IsNullOrEmpty(password)) throw new InvalidOperationException("Password is required for encryption."); Encryption = encryptionMethod; + // use switch to calculate keysizebits based on encryption strength + int keysizebits = encryptionMethod switch + { + EncryptionMethod.Aes128 => 128, + EncryptionMethod.Aes192 => 192, + EncryptionMethod.Aes256 => 256, + _ => 256 // Default to AES-256 + }; + // targetstream should be new winzipaesstream for wrting, ae2 targetStream = new WinZipAesStream( baseStream: _archive.ArchiveStream, password: password.AsMemory(), encrypting: true, - keySizeBits: 256, + keySizeBits: keysizebits, ae2: true, leaveOpen: true); } CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor( targetStream, - encryptionMethod == EncryptionMethod.Aes256 ? false : true, + encryptionMethod == EncryptionMethod.Aes256 || encryptionMethod == EncryptionMethod.Aes192 || encryptionMethod == EncryptionMethod.Aes128 ? false : true, (object? o, EventArgs e) => { // release the archive stream @@ -1245,24 +1254,30 @@ internal sealed class WinZipAesExtraField public void WriteBlock(Stream stream) { - Span buffer = stackalloc byte[TotalSize]; + Span buffer = new byte[TotalSize]; WriteBlockCore(buffer); stream.Write(buffer); } + public async Task WriteBlockAsync(Stream stream, CancellationToken cancellationToken = default) + { + byte[] buffer = new byte[TotalSize]; + WriteBlockCore(buffer); + await stream.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); + } + private void WriteBlockCore(Span buffer) { - BinaryPrimitives.WriteUInt16LittleEndian(buffer[0..], HeaderId); - BinaryPrimitives.WriteUInt16LittleEndian(buffer[2..], 7); // Data size - BinaryPrimitives.WriteUInt16LittleEndian(buffer[4..], VendorVersion); - // Write "AE" as two ASCII bytes, vendor ID - buffer[6] = (byte)'A'; // 0x41 - buffer[7] = (byte)'E'; // 0x45 + BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(0), HeaderId); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(2), 7); // DataSize + BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(4), VendorVersion); + buffer[6] = (byte)'A'; + buffer[7] = (byte)'E'; buffer[8] = AesStrength; + BinaryPrimitives.WriteUInt16LittleEndian(buffer[9..], CompressionMethod); } } - private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, out Zip64ExtraField? zip64ExtraField, out uint compressedSizeTruncated, out uint uncompressedSizeTruncated, out ushort extraFieldLength) { // _entryname only gets set when we read in or call moveTo. MoveTo does a check, and From 6732696fe982903e47edb7a49647fab0aa526920 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Thu, 27 Nov 2025 19:30:18 +0100 Subject: [PATCH 17/39] async methods done, fix ae-1 validation and add more tests --- .../tests/ZipFile.Extract.cs | 324 +++++++++++++++++- .../System/IO/Compression/WinZipAesStream.cs | 47 ++- .../System/IO/Compression/ZipArchiveEntry.cs | 36 +- .../src/System/IO/Compression/ZipBlocks.cs | 10 +- .../System/IO/Compression/ZipCryptoStream.cs | 26 +- .../System/IO/Compression/ZipCustomStreams.cs | 210 ++++++++++++ 6 files changed, 611 insertions(+), 42 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 253fca3c664901..1e46b8bf8d08a1 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -237,7 +237,6 @@ public void OpenEncryptedTxtFile_ShouldReturnPlaintext() public void ExtractEncryptedEntryToFile_ShouldCreatePlaintextFile() { - string ZipPath = @"C:\Users\spahontu\Downloads\test.zip"; string EntryName = "hello.txt"; string CorrectPassword = "123456789"; @@ -694,7 +693,309 @@ public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() } } + [Fact] + public async Task ZipCrypto_AsyncWrite_ThenAsyncRead_ContentMatches() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_async_test.zip"); + const string entryName = "async_test.txt"; + const string password = "AsyncP@ss123"; + const string expectedContent = "This is async ZipCrypto content"; + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Act 1: Create archive with async write + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var entry = za.CreateEntry(entryName); + using var stream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + + byte[] data = Encoding.UTF8.GetBytes(expectedContent); + await stream.WriteAsync(data, 0, data.Length); + await stream.FlushAsync(); + } + + // Act 2: Read back with async read + string actualContent; + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var entry = za.GetEntry(entryName); + Assert.NotNull(entry); + using var stream = entry!.Open(password); + using var reader = new StreamReader(stream, Encoding.UTF8); + actualContent = await reader.ReadToEndAsync(); + } + + // Assert + Assert.Equal(expectedContent, actualContent); + } + + [Fact] + public async Task ZipCrypto_MultipleAsyncWrites_SingleEntry_ContentMatches() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_multi_write.zip"); + const string entryName = "multi_write.txt"; + const string password = "MultiWrite123"; + + var parts = new[] { "Part1-", "Part2-", "Part3" }; + string expectedContent = string.Concat(parts); + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Act 1: Create with multiple async writes + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var entry = za.CreateEntry(entryName); + using var stream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + + foreach (var part in parts) + { + byte[] data = Encoding.UTF8.GetBytes(part); + await stream.WriteAsync(data, 0, data.Length); + } + await stream.FlushAsync(); + } + + // Act 2: Read back + string actualContent; + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var entry = za.GetEntry(entryName); + Assert.NotNull(entry); + + using var reader = new StreamReader(entry!.Open(password), Encoding.UTF8); + actualContent = await reader.ReadToEndAsync(); + } + + // Assert + Assert.Equal(expectedContent, actualContent); + } + + [Fact] + public async Task ZipCrypto_ChunkedAsyncRead_ContentMatches() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_chunked_read.zip"); + const string entryName = "chunked.txt"; + const string password = "ChunkedRead!"; + + // Create larger content + string expectedContent = string.Concat(Enumerable.Repeat("0123456789ABCDEF", 100)); + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Act 1: Create entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var entry = za.CreateEntry(entryName); + using var writer = new StreamWriter(entry.Open(password, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); + await writer.WriteAsync(expectedContent); + } + + // Act 2: Read in chunks asynchronously using StreamReader to handle BOM properly + string actualContent; + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var entry = za.GetEntry(entryName); + Assert.NotNull(entry); + + using var stream = entry!.Open(password); + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + actualContent = await reader.ReadToEndAsync(); + } + + // Assert + Assert.Equal(expectedContent, actualContent); + } + + [Fact] + public async Task ZipCrypto_MixedSyncAsyncOperations_ContentMatches() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_mixed_ops.zip"); + const string syncEntryName = "sync.txt"; + const string asyncEntryName = "async.txt"; + const string password = "MixedOps123"; + const string syncContent = "Synchronous write content"; + const string asyncContent = "Asynchronous write content"; + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Act 1: Create with mixed sync/async writes + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + // Synchronous write + var syncEntry = za.CreateEntry(syncEntryName); + using (var syncWriter = new StreamWriter(syncEntry.Open(password, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8)) + { + syncWriter.Write(syncContent); + } + + // Asynchronous write + var asyncEntry = za.CreateEntry(asyncEntryName); + using (var asyncWriter = new StreamWriter(asyncEntry.Open(password, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8)) + { + await asyncWriter.WriteAsync(asyncContent); + } + } + + // Act 2: Read with mixed sync/async reads + string actualSyncContent, actualAsyncContent; + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + // Async read of sync-written entry + var syncEntry = za.GetEntry(syncEntryName); + Assert.NotNull(syncEntry); + using (var reader1 = new StreamReader(syncEntry!.Open(password), Encoding.UTF8)) + { + actualSyncContent = await reader1.ReadToEndAsync(); + } + + // Sync read of async-written entry + var asyncEntry = za.GetEntry(asyncEntryName); + Assert.NotNull(asyncEntry); + using (var reader2 = new StreamReader(asyncEntry!.Open(password), Encoding.UTF8)) + { + actualAsyncContent = reader2.ReadToEnd(); + } + } + + // Assert + Assert.Equal(syncContent, actualSyncContent); + Assert.Equal(asyncContent, actualAsyncContent); + } + + [Fact] + public async Task ZipCrypto_LargeFileAsyncOperations_ContentMatches() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_large_async.zip"); + const string entryName = "large.bin"; + const string password = "LargeFile123"; + + // Create 1MB of random data + var random = new Random(42); + var expectedData = new byte[1024 * 1024]; + random.NextBytes(expectedData); + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Act 1: Write large data asynchronously + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var entry = za.CreateEntry(entryName); + using var stream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + + // Write in 64KB chunks + const int chunkSize = 65536; + for (int offset = 0; offset < expectedData.Length; offset += chunkSize) + { + int count = Math.Min(chunkSize, expectedData.Length - offset); + await stream.WriteAsync(expectedData, offset, count); + } + await stream.FlushAsync(); + } + + // Act 2: Read large data asynchronously + byte[] actualData; + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var entry = za.GetEntry(entryName); + Assert.NotNull(entry); + + using var stream = entry!.Open(password); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + actualData = ms.ToArray(); + } + + // Assert + Assert.Equal(expectedData.Length, actualData.Length); + Assert.Equal(expectedData, actualData); + } + + [Fact] + public async Task ZipCrypto_StreamCopyToAsync_ContentMatches() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_copyto.zip"); + const string entryName = "copyto.dat"; + const string password = "CopyTo123!"; + + var expectedData = new byte[32768]; + new Random(123).NextBytes(expectedData); + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Act 1: Write using CopyToAsync + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var entry = za.CreateEntry(entryName); + using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.ZipCrypto); + using var sourceStream = new MemoryStream(expectedData); + + await sourceStream.CopyToAsync(entryStream); + } + + // Act 2: Read using CopyToAsync + byte[] actualData; + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) + { + var entry = za.GetEntry(entryName); + Assert.NotNull(entry); + + using var entryStream = entry!.Open(password); + using var destStream = new MemoryStream(); + + await entryStream.CopyToAsync(destStream); + actualData = destStream.ToArray(); + } + + // Assert + Assert.Equal(expectedData, actualData); + } + + [Fact] + public async Task ZipCrypto_AsyncWithWrongPassword_ThrowsInvalidDataException() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string zipPath = NewPath("zipcrypto_wrong_pw_async.zip"); + const string entryName = "secure.txt"; + const string correctPassword = "Correct123"; + const string wrongPassword = "Wrong123"; + const string content = "Secret content"; + + if (File.Exists(zipPath)) File.Delete(zipPath); + + // Create encrypted entry + using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) + { + var entry = za.CreateEntry(entryName); + using var writer = new StreamWriter(entry.Open(correctPassword, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); + await writer.WriteAsync(content); + } + + // Act & Assert - Try to read with wrong password + await Assert.ThrowsAsync(async () => + { + using var za = ZipFile.Open(zipPath, ZipArchiveMode.Read); + var entry = za.GetEntry(entryName); + Assert.NotNull(entry); + + using var stream = entry!.Open(wrongPassword); + byte[] buffer = new byte[100]; + await stream.ReadAsync(buffer, 0, buffer.Length); + }); + } [Fact] public async Task Update_AddEncryptedEntry_RoundTrip() @@ -1898,6 +2199,27 @@ public async Task MixAllEncryptionTypes_RoundTrip() } } + [Fact] + public void OpenAESEncryptedTxtFile_AE1_ShouldReturnPlaintext() + { + // Arrange + string zipPath = Path.Join(DownloadsDir, "source_plain_ae1.zip"); + const string entryName = "source_plain.txt"; + const string password = "123456789"; + const string expectedContent = "this is plain"; + + // Act + using var archive = ZipFile.OpenRead(zipPath); + var entry = archive.Entries.First(e => e.FullName.EndsWith(entryName)); + + using var stream = entry.Open(password); + using var reader = new StreamReader(stream); + string actualContent = reader.ReadToEnd(); + + // Assert + Assert.Equal(expectedContent, actualContent); + } + [Fact] public async Task AES128WithSpecialCharacters_RoundTrip() { diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 28ab01fdb7de7b..a7859dd26824cc 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -14,8 +14,6 @@ internal sealed class WinZipAesStream : Stream private readonly Stream _baseStream; private readonly bool _encrypting; private readonly int _keySizeBits; - private readonly bool _ae2; - private readonly uint? _crc32ForHeader; private readonly Aes _aes; private ICryptoTransform? _aesEncryptor; #pragma warning disable CA1416 // HMACSHA1 is available on all platforms @@ -37,8 +35,7 @@ internal sealed class WinZipAesStream : Stream private readonly bool _leaveOpen; private readonly MemoryStream? _encryptionBuffer; - - public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, bool ae2 = true, uint? crc32 = null, long totalStreamSize = -1, bool leaveOpen = false) + public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, long totalStreamSize = -1, bool leaveOpen = false) { ArgumentNullException.ThrowIfNull(baseStream); @@ -47,8 +44,6 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en _password = password; _encrypting = encrypting; _keySizeBits = keySizeBits; - _ae2 = ae2; - _crc32ForHeader = crc32; _totalStreamSize = totalStreamSize; // Store the total size _bytesReadFromBase = 0; _leaveOpen = leaveOpen; @@ -147,6 +142,29 @@ private void ValidateAuthCode() _authCodeValidated = true; } + private async Task ValidateAuthCodeAsync(CancellationToken cancellationToken) + { + if (_encrypting || _authCodeValidated) + return; + + // Finalize HMAC computation + _hmac.TransformFinalBlock(Array.Empty(), 0, 0); + byte[]? expectedAuth = _hmac.Hash; + + if (expectedAuth is not null) + { + // Read the 10-byte stored authentication code from the stream + byte[] storedAuth = new byte[10]; + await _baseStream.ReadExactlyAsync(storedAuth, cancellationToken).ConfigureAwait(false); + + // Compare the first 10 bytes of the expected hash + if (!storedAuth.AsSpan().SequenceEqual(expectedAuth.AsSpan(0, 10))) + throw new InvalidDataException("Authentication code mismatch."); + } + + _authCodeValidated = true; + } + private void GenerateKeys() { // 8 for AES-128, 12 for AES-192, 16 for AES-256 @@ -293,7 +311,6 @@ private void ProcessBlock(byte[] buffer, int offset, int count) Debug.WriteLine($"Final counter after processing: {BitConverter.ToString(_counterBlock)}"); } - private void IncrementCounter() { // WinZip AES treats the entire 16-byte block as a little-endian 128-bit integer @@ -540,7 +557,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation { if (!_authCodeValidated) { - ValidateAuthCode(); + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); } return 0; } @@ -552,7 +569,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation { if (!_authCodeValidated && _totalStreamSize > 0) { - ValidateAuthCode(); + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); } return 0; } @@ -571,7 +588,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation } else if (!_authCodeValidated) { - ValidateAuthCode(); + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); } return n; @@ -606,6 +623,11 @@ protected override void Dispose(bool disposing) _baseStream.Flush(); } } + else if (!_encrypting && !_authCodeValidated && _headerRead) + { + // For decryption, validate auth code and CRC if not already done + ValidateAuthCode(); + } } finally { @@ -657,6 +679,11 @@ public override async ValueTask DisposeAsync() await _baseStream.FlushAsync(CancellationToken.None).ConfigureAwait(false); } } + else if (!_encrypting && !_authCodeValidated && _headerRead) + { + // For decryption, validate auth code and CRC if not already done + await ValidateAuthCodeAsync(CancellationToken.None).ConfigureAwait(false); + } } finally { diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index edeb1d2a5413bb..0b9ddb8799aa86 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -446,7 +446,7 @@ internal long GetOffsetOfCompressedData() else { // AES case - if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _, out _, out _)) + if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _, out _, out _, out _)) throw new InvalidDataException(SR.LocalFileHeaderCorrupt); baseOffset = _archive.ArchiveStream.Position; @@ -954,21 +954,27 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read EncryptionMethod.Aes128 => 128, EncryptionMethod.Aes192 => 192, EncryptionMethod.Aes256 => 256, - _ => 256 // Default to AES-256 + _ => 256 // default for aes }; - // AES implementation placeholder as indicated in the original code - // When AES is implemented, create the appropriate decryption stream here streamToDecompress = new WinZipAesStream( baseStream: compressedStream, password: password, - encrypting: false, // false for decryption + encrypting: false, keySizeBits: keySizeBits, - ae2: _aeVersion == 2, // AE-2 format (standard) - crc32: null, totalStreamSize: _compressedSize); } - return GetDataDecompressor(streamToDecompress); + + // Get decompressed stream + Stream decompressedStream = GetDataDecompressor(streamToDecompress); + + if (ForAesEncryption() && _aeVersion == 1) + { + // Wrap with CRC validator for AE-1 + return new CrcValidatingReadStream(decompressedStream, _crc32, _uncompressedSize); + } + + return decompressedStream; } private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.None) @@ -1024,7 +1030,6 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod password: password.AsMemory(), encrypting: true, keySizeBits: keysizebits, - ae2: true, leaveOpen: true); } @@ -1090,7 +1095,8 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st byte? aesStrength; ushort? originalCompressionMethod; ushort? aeVersion; - if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out aesStrength, out originalCompressionMethod, out aeVersion)) + uint? crc32; + if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out aesStrength, out originalCompressionMethod, out aeVersion, out crc32)) { message = SR.LocalFileHeaderCorrupt; return false; @@ -1114,12 +1120,16 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st _aeVersion = aeVersion.Value; } - // CRITICAL: Store the actual compression method that will be used after decryption + if (crc32.HasValue && aeVersion == 1) + { + _crc32 = crc32.Value; + } + + // Store the actual compression method that will be used after decryption // This is needed for GetDataDecompressor to work correctly if (originalCompressionMethod.HasValue) { - // Temporarily set the compression method to the actual method for decompression - // Note: We're modifying _storedCompressionMethod, not the property + // Set the compression method to the actual method for decompression CompressionMethod = (CompressionMethodValues)originalCompressionMethod.Value; } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs index 21d2a58da47238..8d9ce784004b11 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs @@ -671,11 +671,12 @@ public static bool TrySkipBlock(Stream stream) return TrySkipBlockFinalize(stream, blockBytes, bytesRead); } - public static bool TrySkipBlockAESAware(Stream stream, out byte? aesStrength, out ushort? originalCompressionMethod, out ushort? aesVersion) + public static bool TrySkipBlockAESAware(Stream stream, out byte? aesStrength, out ushort? originalCompressionMethod, out ushort? aesVersion, out uint? crc32) { aesStrength = null; originalCompressionMethod = null; aesVersion = null; + crc32 = null; BinaryReader reader = new BinaryReader(stream); // Read the first 4 bytes (local file header signature) @@ -684,12 +685,16 @@ public static bool TrySkipBlockAESAware(Stream stream, out byte? aesStrength, ou { return false; // Not a valid local file header } + // Read fixed-size fields after signature // Local file header layout: // signature (4) + version (2) + flags (2) + compression (2) + // mod time (2) + mod date (2) + CRC32 (4) + compressed size (4) + // uncompressed size (4) + name length (2) + extra length (2) - reader.ReadBytes(22); // Skip version through sizes + + reader.ReadBytes(10); // Skip version through mod date + crc32 = reader.ReadUInt32(); // Read CRC32 + reader.ReadBytes(8); // Skip compressed and uncompressed sizes ushort nameLength = reader.ReadUInt16(); ushort extraLength = reader.ReadUInt16(); @@ -721,7 +726,6 @@ public static bool TrySkipBlockAESAware(Stream stream, out byte? aesStrength, ou return true; } - } internal sealed partial class ZipCentralDirectoryFileHeader diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index c2dc0b3653a429..34fed48cc94826 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -250,8 +250,6 @@ private byte DecryptByte(byte ciph) return plain; } - // ---- Stream overrides ---- - public override bool CanRead => !_encrypting; public override bool CanSeek => false; public override bool CanWrite => _encrypting; @@ -322,6 +320,16 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + public override async ValueTask DisposeAsync() + { + // If encrypted empty entry (no payload written), still must emit 12-byte header: + if (_encrypting && !_headerWritten) + await EnsureHeaderAsync(CancellationToken.None).ConfigureAwait(false); + if (!_leaveOpen) + await _base.DisposeAsync().ConfigureAwait(false); + await base.DisposeAsync().ConfigureAwait(false); + } + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { ValidateBufferArguments(buffer, offset, count); @@ -356,7 +364,7 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella cancellationToken.ThrowIfCancellationRequested(); - EnsureHeader(); + await EnsureHeaderAsync(cancellationToken).ConfigureAwait(false); byte[] tmp = new byte[buffer.Length]; ReadOnlySpan span = buffer.Span; @@ -377,18 +385,6 @@ public override Task FlushAsync(CancellationToken cancellationToken) return _base.FlushAsync(cancellationToken); } - public override async ValueTask DisposeAsync() - { - // If encrypted empty entry (no payload written), still must emit 12-byte header: - if (_encrypting && !_headerWritten) - EnsureHeader(); - - if (!_leaveOpen) - await _base.DisposeAsync().ConfigureAwait(false); - - await base.DisposeAsync().ConfigureAwait(false); - } - private static uint Crc32Update(uint crc, byte b) => crc2Table[(crc ^ b) & 0xFF] ^ (crc >> 8); } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs index fbbecefe676373..495ff7c7dd8502 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs @@ -679,4 +679,214 @@ public override async ValueTask DisposeAsync() await base.DisposeAsync().ConfigureAwait(false); } } + + internal sealed class CrcValidatingReadStream : Stream + { + private readonly Stream _baseStream; + private uint _runningCrc; + private readonly uint _expectedCrc; + private long _totalBytesRead; + private readonly long _expectedLength; + private bool _isDisposed; + + public CrcValidatingReadStream(Stream baseStream, uint expectedCrc, long expectedLength) + { + _baseStream = baseStream; + _expectedCrc = expectedCrc; + _expectedLength = expectedLength; + _runningCrc = 0; + _totalBytesRead = 0; + } + + public override bool CanRead => !_isDisposed && _baseStream.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + + public override long Length => _baseStream.Length; + + public override long Position + { + get => _baseStream.Position; + set => throw new NotSupportedException(SR.SeekingNotSupported); + } + + public override int Read(byte[] buffer, int offset, int count) + { + ThrowIfDisposed(); + ValidateBufferArguments(buffer, offset, count); + + int bytesRead = _baseStream.Read(buffer, offset, count); + + if (bytesRead > 0) + { + _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, buffer, offset, bytesRead); + _totalBytesRead += bytesRead; + } + else if (bytesRead == 0) + { + // End of stream reached, validate CRC + ValidateCrc(); + } + + return bytesRead; + } + + public override int Read(Span buffer) + { + ThrowIfDisposed(); + + int bytesRead = _baseStream.Read(buffer); + + if (bytesRead > 0) + { + _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, buffer.Slice(0, bytesRead)); + _totalBytesRead += bytesRead; + } + else if (bytesRead == 0) + { + // End of stream reached, validate CRC + ValidateCrc(); + } + + return bytesRead; + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ThrowIfDisposed(); + ValidateBufferArguments(buffer, offset, count); + + int bytesRead = await _baseStream.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); + + if (bytesRead > 0) + { + _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, buffer, offset, bytesRead); + _totalBytesRead += bytesRead; + } + else if (bytesRead == 0) + { + // End of stream reached, validate CRC + ValidateCrc(); + } + + return bytesRead; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + int bytesRead = await _baseStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + + if (bytesRead > 0) + { + _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, buffer.Span.Slice(0, bytesRead)); + _totalBytesRead += bytesRead; + } + else if (bytesRead == 0) + { + // End of stream reached, validate CRC + ValidateCrc(); + } + + return bytesRead; + } + + public override void Write(byte[] buffer, int offset, int count) + { + ThrowIfDisposed(); + throw new NotSupportedException(SR.WritingNotSupported); + } + + public override void Write(ReadOnlySpan buffer) + { + ThrowIfDisposed(); + throw new NotSupportedException(SR.WritingNotSupported); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ThrowIfDisposed(); + throw new NotSupportedException(SR.WritingNotSupported); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + throw new NotSupportedException(SR.WritingNotSupported); + } + + public override void Flush() + { + ThrowIfDisposed(); + _baseStream.Flush(); + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + return _baseStream.FlushAsync(cancellationToken); + } + + public override long Seek(long offset, SeekOrigin origin) + { + ThrowIfDisposed(); + throw new NotSupportedException(SR.SeekingNotSupported); + } + + public override void SetLength(long value) + { + ThrowIfDisposed(); + throw new NotSupportedException(SR.SetLengthRequiresSeekingAndWriting); + } + + private void ValidateCrc() + { + if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) + { + throw new InvalidDataException( + $"CRC mismatch. Expected: 0x{_expectedCrc:X8}, Got: 0x{_runningCrc:X8}"); + } + } + + private void ThrowIfDisposed() + { + if (_isDisposed) + throw new ObjectDisposedException(GetType().ToString(), SR.HiddenStreamName); + } + + protected override void Dispose(bool disposing) + { + if (disposing && !_isDisposed) + { + // Validate CRC when stream is closed (if all data was read) + if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) + { + throw new InvalidDataException( + $"CRC mismatch. Expected: 0x{_expectedCrc:X8}, Got: 0x{_runningCrc:X8}"); + } + + _baseStream.Dispose(); + _isDisposed = true; + } + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + // Validate CRC when stream is closed (if all data was read) + if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) + { + throw new InvalidDataException( + $"CRC mismatch. Expected: 0x{_expectedCrc:X8}, Got: 0x{_runningCrc:X8}"); + } + + await _baseStream.DisposeAsync().ConfigureAwait(false); + _isDisposed = true; + } + await base.DisposeAsync().ConfigureAwait(false); + } + } } From 8efa7729da1543604935d62a4978ffe124e3365a Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 1 Dec 2025 20:33:24 +0100 Subject: [PATCH 18/39] Solve some comments --- .../System/IO/Compression/WinZipAesStream.cs | 612 +++++++++--------- .../IO/Compression/ZipArchiveEntry.Async.cs | 14 +- .../System/IO/Compression/ZipArchiveEntry.cs | 125 ++-- .../src/System/IO/Compression/ZipBlocks.cs | 108 +++- .../System/IO/Compression/ZipCryptoStream.cs | 143 +--- .../System/IO/Compression/ZipCustomStreams.cs | 3 +- 6 files changed, 447 insertions(+), 558 deletions(-) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index a7859dd26824cc..dad607aff1791a 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -11,6 +11,7 @@ namespace System.IO.Compression { internal sealed class WinZipAesStream : Stream { + private const int BLOCK_SIZE = 16; // AES block size in bytes private readonly Stream _baseStream; private readonly bool _encrypting; private readonly int _keySizeBits; @@ -19,7 +20,7 @@ internal sealed class WinZipAesStream : Stream #pragma warning disable CA1416 // HMACSHA1 is available on all platforms private readonly HMACSHA1 _hmac; #pragma warning restore CA1416 - private readonly byte[] _counterBlock = new byte[16]; + private readonly byte[] _counterBlock = new byte[BLOCK_SIZE]; private byte[]? _key; private byte[]? _hmacKey; private byte[]? _salt; @@ -27,25 +28,23 @@ internal sealed class WinZipAesStream : Stream private bool _headerWritten; private bool _headerRead; private long _position; - private readonly ReadOnlyMemory _password; private bool _disposed; private bool _authCodeValidated; private readonly long _totalStreamSize; - private long _bytesReadFromBase; private readonly bool _leaveOpen; - private readonly MemoryStream? _encryptionBuffer; + private readonly long _encryptedDataSize; + private long _encryptedDataRemaining; + private readonly byte[] _partialBlock = new byte[BLOCK_SIZE]; + private int _partialBlockBytes; public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, long totalStreamSize = -1, bool leaveOpen = false) { ArgumentNullException.ThrowIfNull(baseStream); - _baseStream = baseStream; - _password = password; _encrypting = encrypting; _keySizeBits = keySizeBits; _totalStreamSize = totalStreamSize; // Store the total size - _bytesReadFromBase = 0; _leaveOpen = leaveOpen; #pragma warning disable CA1416 // HMACSHA1 is available on all platforms _aes = Aes.Create(); @@ -62,19 +61,43 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en Array.Clear(_counterBlock, 0, 16); _counterBlock[0] = 1; + if (_totalStreamSize > 0) + { + int saltSize = _keySizeBits / 16; + int headerSize = saltSize + 2; // Salt + Password Verifier + const int hmacSize = 10; // 10-byte HMAC + + _encryptedDataSize = _totalStreamSize - headerSize - hmacSize; + _encryptedDataRemaining = _encryptedDataSize; + } + else + { + _encryptedDataSize = -1; + _encryptedDataRemaining = -1; + } + if (_encrypting) { - _encryptionBuffer = new MemoryStream(); - GenerateKeys(); + // 8 for AES-128, 12 for AES-192, 16 for AES-256 + int saltSize = _keySizeBits / 16; + _salt = new byte[saltSize]; + RandomNumberGenerator.Fill(_salt); + + DeriveKeysFromPassword(password, _salt); + + Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); + _hmac.Key = _hmacKey!; InitCipher(); } + else + { + ReadHeader(password); + } } - private void DeriveKeysFromPassword() + private void DeriveKeysFromPassword(ReadOnlyMemory password, byte[] salt) { - Debug.Assert(_salt is not null, "Salt must be initialized before deriving keys"); - - byte[] passwordBytes = Encoding.UTF8.GetBytes(_password.ToArray()); + byte[] passwordBytes = Encoding.UTF8.GetBytes(password.ToArray()); try { @@ -85,7 +108,7 @@ private void DeriveKeysFromPassword() // WinZip AES uses SHA1 for PBKDF2 with 1000 iterations per spec byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2( passwordBytes, - _salt!, + salt, 1000, HashAlgorithmName.SHA1, totalKeySize); @@ -95,20 +118,12 @@ private void DeriveKeysFromPassword() _hmacKey = new byte[keySizeInBytes]; _passwordVerifier = new byte[2]; - // Copy the key material in the correct order - int offset = 0; - // First: AES encryption key - Buffer.BlockCopy(derivedKey, offset, _key, 0, _key.Length); - offset += _key.Length; - + derivedKey.AsSpan(0, _key.Length).CopyTo(_key); // Second: HMAC authentication key (same size as encryption key) - Buffer.BlockCopy(derivedKey, offset, _hmacKey, 0, _hmacKey.Length); - offset += _hmacKey.Length; - + derivedKey.AsSpan(_key.Length, _hmacKey.Length).CopyTo(_hmacKey); // Third: Password verification value (2 bytes) - Buffer.BlockCopy(derivedKey, offset, _passwordVerifier, 0, _passwordVerifier.Length); - + derivedKey.AsSpan(_key.Length + _hmacKey.Length).CopyTo(_passwordVerifier); // Clear the derived key from memory Array.Clear(derivedKey, 0, derivedKey.Length); } @@ -119,30 +134,44 @@ private void DeriveKeysFromPassword() } } - private void ValidateAuthCode() + private void ReadHeader(ReadOnlyMemory password) { - if (_encrypting || _authCodeValidated) - return; + if (_headerRead) return; - // Finalize HMAC computation - _hmac.TransformFinalBlock(Array.Empty(), 0, 0); - byte[]? expectedAuth = _hmac.Hash; + // Salt size depends on AES strength: 8 for AES-128, 12 for AES-192, 16 for AES-256 + int saltSize = _keySizeBits / 16; + _salt = new byte[saltSize]; + _baseStream.ReadExactly(_salt); - if (expectedAuth is not null) - { - // Read the 10-byte stored authentication code from the stream - byte[] storedAuth = new byte[10]; - _baseStream.ReadExactly(storedAuth); + // Debug: Log the salt + Debug.WriteLine($"Salt ({saltSize} bytes): {BitConverter.ToString(_salt)}"); - // Compare the first 10 bytes of the expected hash - if (!storedAuth.AsSpan().SequenceEqual(expectedAuth.AsSpan(0, 10))) - throw new InvalidDataException("Authentication code mismatch."); + // Read the 2-byte password verifier + byte[] verifier = new byte[2]; + _baseStream.ReadExactly(verifier); + + // Derive keys from password and salt + DeriveKeysFromPassword(password, _salt); + + // Verify the password + Debug.Assert(_passwordVerifier is not null, "Password verifier should be derived"); + + if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) + { + throw new InvalidDataException($"Invalid password. Expected verifier: {BitConverter.ToString(_passwordVerifier!)}, Got: {BitConverter.ToString(verifier)}"); } - _authCodeValidated = true; + Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); + _hmac.Key = _hmacKey!; + InitCipher(); + + Array.Clear(_counterBlock, 0, 16); + _counterBlock[0] = 1; + + _headerRead = true; } - private async Task ValidateAuthCodeAsync(CancellationToken cancellationToken) + private async Task ValidateAuthCodeCoreAsync(bool isAsync, CancellationToken cancellationToken) { if (_encrypting || _authCodeValidated) return; @@ -155,7 +184,15 @@ private async Task ValidateAuthCodeAsync(CancellationToken cancellationToken) { // Read the 10-byte stored authentication code from the stream byte[] storedAuth = new byte[10]; - await _baseStream.ReadExactlyAsync(storedAuth, cancellationToken).ConfigureAwait(false); + + if (isAsync) + { + await _baseStream.ReadExactlyAsync(storedAuth, cancellationToken).ConfigureAwait(false); + } + else + { + _baseStream.ReadExactly(storedAuth); + } // Compare the first 10 bytes of the expected hash if (!storedAuth.AsSpan().SequenceEqual(expectedAuth.AsSpan(0, 10))) @@ -165,17 +202,14 @@ private async Task ValidateAuthCodeAsync(CancellationToken cancellationToken) _authCodeValidated = true; } - private void GenerateKeys() + private void ValidateAuthCode() { - // 8 for AES-128, 12 for AES-192, 16 for AES-256 - int saltSize = _keySizeBits / 16; - _salt = new byte[saltSize]; - RandomNumberGenerator.Fill(_salt); - - DeriveKeysFromPassword(); + ValidateAuthCodeCoreAsync(isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); + } - Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); - _hmac.Key = _hmacKey!; + private Task ValidateAuthCodeAsync(CancellationToken cancellationToken) + { + return ValidateAuthCodeCoreAsync(isAsync: true, cancellationToken); } private void InitCipher() @@ -186,14 +220,23 @@ private void InitCipher() _aesEncryptor = _aes.CreateEncryptor(); } - private void WriteHeader() + private async Task WriteHeaderCoreAsync(bool isAsync, CancellationToken cancellationToken) { if (_headerWritten) return; Debug.Assert(_salt is not null && _passwordVerifier is not null, "Keys should have been generated before writing header"); - _baseStream.Write(_salt); - _baseStream.Write(_passwordVerifier); + if (isAsync) + { + await _baseStream.WriteAsync(_salt, cancellationToken).ConfigureAwait(false); + await _baseStream.WriteAsync(_passwordVerifier, cancellationToken).ConfigureAwait(false); + } + else + { + _baseStream.Write(_salt); + _baseStream.Write(_passwordVerifier); + } + // output to debug log Debug.WriteLine($"Wrote salt: {BitConverter.ToString(_salt)}"); Debug.WriteLine($"Wrote password verifier: {BitConverter.ToString(_passwordVerifier)}"); @@ -201,59 +244,14 @@ private void WriteHeader() _headerWritten = true; } - private async Task WriteHeaderAsync(CancellationToken cancellationToken) + private void WriteHeader() { - if (_headerWritten) return; - Debug.Assert(_salt is not null && _passwordVerifier is not null, "Keys should have been generated before writing header"); - await _baseStream.WriteAsync(_salt, cancellationToken).ConfigureAwait(false); - await _baseStream.WriteAsync(_passwordVerifier, cancellationToken).ConfigureAwait(false); - // output to debug log - Debug.WriteLine($"Wrote salt: {BitConverter.ToString(_salt)}"); - Debug.WriteLine($"Wrote password verifier: {BitConverter.ToString(_passwordVerifier)}"); - _headerWritten = true; + WriteHeaderCoreAsync(isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); } - private void ReadHeader() + private Task WriteHeaderAsync(CancellationToken cancellationToken) { - if (_headerRead) return; - - // Salt size depends on AES strength: 8 for AES-128, 12 for AES-192, 16 for AES-256 - int saltSize = _keySizeBits / 16; - _salt = new byte[saltSize]; - _baseStream.ReadExactly(_salt); - - // Debug: Log the salt - Debug.WriteLine($"Salt ({saltSize} bytes): {BitConverter.ToString(_salt)}"); - - // Read the 2-byte password verifier - byte[] verifier = new byte[2]; - _baseStream.ReadExactly(verifier); - - // Debug: Log the verifier - Debug.WriteLine($"Password verifier: {BitConverter.ToString(verifier)}"); - - // Derive keys from password and salt - DeriveKeysFromPassword(); - - // Verify the password - Debug.Assert(_passwordVerifier is not null, "Password verifier should be derived"); - - // Debug: Log derived verifier - Debug.WriteLine($"Derived verifier: {BitConverter.ToString(_passwordVerifier!)}"); - - if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) - { - throw new InvalidDataException($"Invalid password. Expected verifier: {BitConverter.ToString(_passwordVerifier!)}, Got: {BitConverter.ToString(verifier)}"); - } - - Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); - _hmac.Key = _hmacKey!; - InitCipher(); - - Array.Clear(_counterBlock, 0, 16); - _counterBlock[0] = 1; - - _headerRead = true; + return WriteHeaderCoreAsync(isAsync: true, cancellationToken); } private void ProcessBlock(byte[] buffer, int offset, int count) @@ -263,11 +261,6 @@ private void ProcessBlock(byte[] buffer, int offset, int count) int processed = 0; byte[] keystream = new byte[16]; - // Log initial counter state - Debug.WriteLine($"=== ProcessBlock Debug ==="); - Debug.WriteLine($"Processing {count} bytes at offset {offset}"); - Debug.WriteLine($"Initial counter: {BitConverter.ToString(_counterBlock)}"); - while (processed < count) { _aesEncryptor.TransformBlock(_counterBlock, 0, 16, keystream, 0); @@ -321,7 +314,7 @@ private void IncrementCounter() } } - private void WriteAuthCode() + private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancellationToken) { if (!_encrypting || _authCodeValidated) return; @@ -332,74 +325,32 @@ private void WriteAuthCode() if (authCode is not null) { // WinZip AES spec requires only the first 10 bytes of the HMAC - _baseStream.Write(authCode, 0, 10); - Debug.WriteLine($"Wrote authentication code: {BitConverter.ToString(authCode, 0, 10)}"); - } - - _authCodeValidated = true; - } - - private async Task WriteAuthCodeAsync(CancellationToken cancellationToken) - { - if (!_encrypting || _authCodeValidated) - return; - - _hmac.TransformFinalBlock(Array.Empty(), 0, 0); - byte[]? authCode = _hmac.Hash; + if (isAsync) + { + await _baseStream.WriteAsync(authCode.AsMemory(0, 10), cancellationToken).ConfigureAwait(false); + } + else + { + _baseStream.Write(authCode, 0, 10); + } - if (authCode is not null) - { - // WinZip AES spec requires only the first 10 bytes of the HMAC - await _baseStream.WriteAsync(authCode.AsMemory(0, 10), cancellationToken).ConfigureAwait(false); Debug.WriteLine($"Wrote authentication code: {BitConverter.ToString(authCode, 0, 10)}"); } _authCodeValidated = true; } - private void WriteCore(ReadOnlySpan buffer) - { - ObjectDisposedException.ThrowIf(_disposed, this); - - if (!_encrypting) - throw new NotSupportedException("Stream is in decryption mode."); - - WriteHeader(); - - // Buffer the data - don't process it yet - _encryptionBuffer?.Write(buffer); - - Debug.WriteLine($"Buffered {buffer.Length} bytes for encryption"); - } - - - // Flush all buffered data, encrypt it, and write to base stream - private void FlushEncryptionBuffer() + private void WriteAuthCode() { - if (_encryptionBuffer != null && _encryptionBuffer.Length > 0) - { - byte[] data = _encryptionBuffer.ToArray(); - ProcessBlock(data, 0, data.Length); - _baseStream.Write(data); - _position += data.Length; - _encryptionBuffer.SetLength(0); // Clear the buffer - } + WriteAuthCodeCoreAsync(isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); } - // Replace the FlushEncryptionBufferAsync method with this version: - private async Task FlushEncryptionBufferAsync(CancellationToken cancellationToken) + private Task WriteAuthCodeAsync(CancellationToken cancellationToken) { - if (_encryptionBuffer != null && _encryptionBuffer.Length > 0) - { - byte[] data = _encryptionBuffer.ToArray(); - ProcessBlock(data, 0, data.Length); - await _baseStream.WriteAsync(data, cancellationToken).ConfigureAwait(false); - _position += data.Length; - _encryptionBuffer.SetLength(0); // Clear the buffer - } + return WriteAuthCodeCoreAsync(isAsync: true, cancellationToken); } - private int ReadCore(Span buffer) + private async Task ReadCoreShared(Memory buffer, bool isAsync, CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -407,109 +358,89 @@ private int ReadCore(Span buffer) throw new NotSupportedException("Stream is in encryption mode."); if (!_headerRead) - ReadHeader(); + throw new InvalidOperationException("Header must be read before reading data."); int bytesToRead = buffer.Length; // If we know the total size, ensure we don't read into the HMAC - if (_totalStreamSize > 0) + if (_encryptedDataSize > 0) { - // Calculate the size of the AES overhead - int saltSize = _keySizeBits / 16; - int headerSize = saltSize + 2; // Salt + Password Verifier - const int hmacSize = 10; // 10-byte HMAC - - // The actual encrypted data size is the total minus header and HMAC - long encryptedDataSize = _totalStreamSize - headerSize - hmacSize; - long remainingData = encryptedDataSize - _bytesReadFromBase; - - if (remainingData <= 0) + if (_encryptedDataRemaining <= 0) { if (!_authCodeValidated) { - ValidateAuthCode(); + if (isAsync) + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); + else + ValidateAuthCode(); } return 0; } - bytesToRead = (int)Math.Min(bytesToRead, remainingData); + bytesToRead = (int)Math.Min(bytesToRead, _encryptedDataRemaining); } if (bytesToRead == 0) { - if (!_authCodeValidated && _totalStreamSize > 0) + if (!_authCodeValidated && _encryptedDataSize > 0) { - ValidateAuthCode(); + if (isAsync) + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); + else + ValidateAuthCode(); } return 0; } - int n = _baseStream.Read(buffer.Slice(0, bytesToRead)); - - Debug.WriteLine($"Read {n} bytes from base stream"); - - if (n > 0) + int bytesRead; + if (isAsync) { - _bytesReadFromBase += n; - - // Log the ciphertext before decryption - Debug.WriteLine($"Ciphertext (hex): {BitConverter.ToString(buffer.Slice(0, n).ToArray())}"); + bytesRead = await _baseStream.ReadAsync(buffer.Slice(0, bytesToRead), cancellationToken).ConfigureAwait(false); + } + else + { + bytesRead = _baseStream.Read(buffer.Span.Slice(0, bytesToRead)); + } - // The buffer now contains the ciphertext. - byte[] temp = buffer.Slice(0, n).ToArray(); + Debug.WriteLine($"Read {bytesRead} bytes from base stream"); - // 1. Update the HMAC with the ciphertext from `temp`. - // 2. Decrypt `temp` in-place. - ProcessBlock(temp, 0, n); + if (bytesRead > 0) + { + _encryptedDataRemaining -= bytesRead; - // Copy the decrypted data from `temp` back to the original buffer. - temp.CopyTo(buffer); + // Process the data - we need to copy because ProcessBlock modifies in-place + byte[] temp = buffer.Slice(0, bytesRead).ToArray(); + ProcessBlock(temp, 0, bytesRead); + temp.CopyTo(buffer.Span); - _position += n; + _position += bytesRead; } else // n == 0, meaning end of stream { if (!_authCodeValidated) { - ValidateAuthCode(); + if (isAsync) + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); + else + ValidateAuthCode(); } } - return n; - } - - public override void Write(byte[] buffer, int offset, int count) - { - ValidateBufferArguments(buffer, offset, count); - Write(new ReadOnlySpan(buffer, offset, count)); - } - - public override void Write(ReadOnlySpan buffer) - { - WriteCore(buffer); + return bytesRead; } - public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - ValidateBufferArguments(buffer, offset, count); - await WriteAsync(new ReadOnlyMemory(buffer, offset, count), cancellationToken).ConfigureAwait(false); - } - - // Replace the WriteAsync method with this: - public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + private int ReadCore(Span buffer) { - ObjectDisposedException.ThrowIf(_disposed, this); - - if (!_encrypting) - throw new NotSupportedException("Stream is in decryption mode."); + // Convert span to memory and call shared method synchronously + byte[] tempArray = new byte[buffer.Length]; + Memory memoryBuffer = tempArray.AsMemory(); - await WriteHeaderAsync(cancellationToken).ConfigureAwait(false); + int bytesRead = ReadCoreShared(memoryBuffer, isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); - // Just buffer the data - // The async version should match the sync version logic - await _encryptionBuffer!.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); + // Copy the processed data back to the original span + memoryBuffer.Span.Slice(0, bytesRead).CopyTo(buffer); - Debug.WriteLine($"Buffered {buffer.Length} bytes for encryption"); + return bytesRead; } public override int Read(byte[] buffer, int offset, int count) @@ -526,78 +457,149 @@ public override int Read(Span buffer) public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { ValidateBufferArguments(buffer, offset, count); - return await ReadAsync(new Memory(buffer, offset, count), cancellationToken).ConfigureAwait(false); + return await ReadCoreShared(new Memory(buffer, offset, count), isAsync: true, cancellationToken).ConfigureAwait(false); } public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { - ObjectDisposedException.ThrowIf(_disposed, this); + return await ReadCoreShared(buffer, isAsync: true, cancellationToken).ConfigureAwait(false); + } - if (_encrypting) - throw new NotSupportedException("Stream is in encryption mode."); + private async Task WriteCoreShared(ReadOnlyMemory buffer, bool isAsync, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_encrypting) throw new NotSupportedException("Stream is in decryption mode."); - if (!_headerRead) - ReadHeader(); + // Write header if needed + if (!_headerWritten) + { + if (isAsync) + await WriteHeaderAsync(cancellationToken).ConfigureAwait(false); + else + WriteHeader(); + } - // Apply the same boundary logic as ReadCore - int bytesToRead = buffer.Length; + int inputOffset = 0; + int inputCount = buffer.Length; - if (_totalStreamSize > 0) + // Fill the partial block buffer if it has data + if (_partialBlockBytes > 0) { - // Calculate the size of the AES overhead - int saltSize = _keySizeBits / 16; - int headerSize = saltSize + 2; // Salt + Password Verifier - const int hmacSize = 10; + int copyCount = Math.Min(BLOCK_SIZE - _partialBlockBytes, inputCount); + buffer.Slice(inputOffset, copyCount).CopyTo(_partialBlock.AsMemory(_partialBlockBytes)); - // The actual encrypted data size is the total minus header and HMAC - long encryptedDataSize = _totalStreamSize - headerSize - hmacSize; - long remainingData = encryptedDataSize - _bytesReadFromBase; + _partialBlockBytes += copyCount; + inputOffset += copyCount; + inputCount -= copyCount; - if (remainingData <= 0) + // If full, encrypt and write immediately + if (_partialBlockBytes == BLOCK_SIZE) { - if (!_authCodeValidated) - { - await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); - } - return 0; - } + ProcessBlock(_partialBlock, 0, BLOCK_SIZE); + + if (isAsync) + await _baseStream.WriteAsync(_partialBlock.AsMemory(0, BLOCK_SIZE), cancellationToken).ConfigureAwait(false); + else + _baseStream.Write(_partialBlock, 0, BLOCK_SIZE); - bytesToRead = (int)Math.Min(bytesToRead, remainingData); + _position += BLOCK_SIZE; + _partialBlockBytes = 0; + } } - if (bytesToRead == 0) + // Process full blocks directly from the input + if (inputCount >= BLOCK_SIZE) { - if (!_authCodeValidated && _totalStreamSize > 0) + const int ChunkSize = 4096; + byte[] chunkBuffer = new byte[ChunkSize]; + + while (inputCount >= BLOCK_SIZE) { - await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); - } - return 0; - } + // Round down to nearest multiple of 16 for the chunk + int bytesToProcess = Math.Min(inputCount, ChunkSize); + bytesToProcess = (bytesToProcess / BLOCK_SIZE) * BLOCK_SIZE; - int n = await _baseStream.ReadAsync(buffer.Slice(0, bytesToRead), cancellationToken).ConfigureAwait(false); + // Copy input to local buffer + buffer.Slice(inputOffset, bytesToProcess).CopyTo(chunkBuffer); - if (n > 0) - { - _bytesReadFromBase += n; + // Encrypt in-place + ProcessBlock(chunkBuffer, 0, bytesToProcess); - // Process the data - byte[] temp = buffer.Slice(0, n).ToArray(); - ProcessBlock(temp, 0, n); - temp.CopyTo(buffer.Span); - _position += n; + // Write to stream + if (isAsync) + await _baseStream.WriteAsync(chunkBuffer.AsMemory(0, bytesToProcess), cancellationToken).ConfigureAwait(false); + else + _baseStream.Write(chunkBuffer, 0, bytesToProcess); + + _position += bytesToProcess; + inputOffset += bytesToProcess; + inputCount -= bytesToProcess; + } } - else if (!_authCodeValidated) + + // Buffer any remaining bytes + if (inputCount > 0) { - await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); + buffer.Slice(inputOffset, inputCount).CopyTo(_partialBlock.AsMemory(_partialBlockBytes)); + _partialBlockBytes += inputCount; } + } + + private void WriteCore(ReadOnlySpan buffer) + { + // Convert span to memory and call shared method synchronously + byte[] tempArray = buffer.ToArray(); + WriteCoreShared(tempArray, isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + Write(new ReadOnlySpan(buffer, offset, count)); + } - return n; + public override void Write(ReadOnlySpan buffer) + { + WriteCore(buffer); + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + await WriteCoreShared(new ReadOnlyMemory(buffer, offset, count), isAsync: true, cancellationToken).ConfigureAwait(false); + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + await WriteCoreShared(buffer, isAsync: true, cancellationToken).ConfigureAwait(false); + } + + + private async Task FinalizeEncryptionAsync(bool isAsync, CancellationToken cancellationToken) + { + // Process any bytes remaining in the partial buffer + if (_partialBlockBytes > 0) + { + // Encrypt the partial block (ProcessBlock handles partials by XORing only available bytes) + ProcessBlock(_partialBlock, 0, _partialBlockBytes); + + if (isAsync) + { + await _baseStream.WriteAsync(_partialBlock.AsMemory(0, _partialBlockBytes), cancellationToken).ConfigureAwait(false); + } + else + { + _baseStream.Write(_partialBlock, 0, _partialBlockBytes); + } + + _position += _partialBlockBytes; + _partialBlockBytes = 0; + } } protected override void Dispose(bool disposing) { - if (_disposed) - return; + if (_disposed) return; if (disposing) { @@ -605,28 +607,17 @@ protected override void Dispose(bool disposing) { if (_encrypting && !_authCodeValidated && _headerWritten) { - // Encrypt all buffered data first - FlushEncryptionBuffer(); - - // Flush any pending data - if (_baseStream.CanWrite) - { - _baseStream.Flush(); - } + // 1. Encrypt remaining partial data + FinalizeEncryptionAsync(false, CancellationToken.None).GetAwaiter().GetResult(); - // Write the authentication code + // 2. Write Auth Code WriteAuthCode(); - // Flush again to ensure auth code is written - if (_baseStream.CanWrite) - { - _baseStream.Flush(); - } + if (_baseStream.CanWrite) _baseStream.Flush(); } else if (!_encrypting && !_authCodeValidated && _headerRead) { - // For decryption, validate auth code and CRC if not already done - ValidateAuthCode(); + ValidateAuthCodeCoreAsync(false, CancellationToken.None).GetAwaiter().GetResult(); } } finally @@ -634,55 +625,36 @@ protected override void Dispose(bool disposing) _aesEncryptor?.Dispose(); _aes.Dispose(); _hmac.Dispose(); - _encryptionBuffer?.Dispose(); + // Removed _encryptionBuffer.Dispose() - // Only dispose the base stream if we don't leave it open - if (!_leaveOpen) - { - _baseStream.Dispose(); - } + if (!_leaveOpen) _baseStream.Dispose(); } } _disposed = true; base.Dispose(disposing); } - - // Replace DisposeAsync with this version: public override async ValueTask DisposeAsync() { - if (_disposed) - return; + if (_disposed) return; try { if (_encrypting && !_authCodeValidated && _headerWritten) { - // make sure header is flushed - await _baseStream.FlushAsync(CancellationToken.None).ConfigureAwait(false); + await _baseStream.FlushAsync().ConfigureAwait(false); - // Encrypt all buffered data first - await FlushEncryptionBufferAsync(CancellationToken.None).ConfigureAwait(false); + // 1. Encrypt remaining partial data + await FinalizeEncryptionAsync(true, CancellationToken.None).ConfigureAwait(false); - // Flush any pending data - if (_baseStream.CanWrite) - { - await _baseStream.FlushAsync(CancellationToken.None).ConfigureAwait(false); - } - - // Write the authentication code (this is still sync, which is fine) + // 2. Write Auth Code await WriteAuthCodeAsync(CancellationToken.None).ConfigureAwait(false); - // Flush again to ensure auth code is written - if (_baseStream.CanWrite) - { - await _baseStream.FlushAsync(CancellationToken.None).ConfigureAwait(false); - } + if (_baseStream.CanWrite) await _baseStream.FlushAsync().ConfigureAwait(false); } else if (!_encrypting && !_authCodeValidated && _headerRead) { - // For decryption, validate auth code and CRC if not already done - await ValidateAuthCodeAsync(CancellationToken.None).ConfigureAwait(false); + await ValidateAuthCodeCoreAsync(true, CancellationToken.None).ConfigureAwait(false); } } finally @@ -690,18 +662,14 @@ public override async ValueTask DisposeAsync() _aesEncryptor?.Dispose(); _aes.Dispose(); _hmac.Dispose(); - _encryptionBuffer?.Dispose(); - // Only dispose the base stream if we don't leave it open - if (!_leaveOpen) - { - await _baseStream.DisposeAsync().ConfigureAwait(false); - } + if (!_leaveOpen) await _baseStream.DisposeAsync().ConfigureAwait(false); } _disposed = true; GC.SuppressFinalize(this); } + public override bool CanRead => !_encrypting && !_disposed; public override bool CanSeek => false; public override bool CanWrite => _encrypting && !_disposed; @@ -743,6 +711,7 @@ public override void Flush() _baseStream.Flush(); } } + public override async Task FlushAsync(CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -754,7 +723,6 @@ public override async Task FlushAsync(CancellationToken cancellationToken) { await _baseStream.FlushAsync(cancellationToken).ConfigureAwait(false); } - } // Finally flush base stream to ensure encrypted data is written diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 75d5f099c13863..876d9463da6240 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Threading; using System.Threading.Tasks; +using static System.IO.Compression.ZipLocalFileHeader; namespace System.IO.Compression; @@ -154,17 +155,17 @@ internal async Task WriteCentralDirectoryFileHeaderAsync(bool forceWrite, Cancel // Write WinZip AES extra field AFTER Zip64 (matching sync version order) // Must match the exact check used in the sync version WriteCentralDirectoryFileHeader - if (_encryptionMethod == EncryptionMethod.Aes128 || _encryptionMethod == EncryptionMethod.Aes192 || _encryptionMethod == EncryptionMethod.Aes256) + if (ForAesEncryption()) { var aesExtraField = new WinZipAesExtraField { VendorVersion = 2, // AE-2 - AesStrength = _encryptionMethod switch + AesStrength = Encryption switch { EncryptionMethod.Aes128 => (byte)1, EncryptionMethod.Aes192 => (byte)2, EncryptionMethod.Aes256 => (byte)3, - _ => (byte)3 + _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? (ushort)CompressionMethodValues.Stored : @@ -309,6 +310,7 @@ private async Task WriteLocalFileHeaderAsync(bool isEmptyFile, bool forceW await _archive.ArchiveStream.WriteAsync(lfStaticHeader, cancellationToken).ConfigureAwait(false); await _archive.ArchiveStream.WriteAsync(_storedEntryNameBytes, cancellationToken).ConfigureAwait(false); + // Only when handling zip64 if (zip64ExtraField != null) { await zip64ExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); @@ -316,17 +318,17 @@ private async Task WriteLocalFileHeaderAsync(bool isEmptyFile, bool forceW // Write WinZip AES extra field if using AES encryption // Must match the exact check used in the sync version WriteLocalFileHeader - if (_encryptionMethod == EncryptionMethod.Aes128 || _encryptionMethod == EncryptionMethod.Aes192 || _encryptionMethod == EncryptionMethod.Aes256) + if (ForAesEncryption()) { var aesExtraField = new WinZipAesExtraField { VendorVersion = 2, // AE-2 - AesStrength = _encryptionMethod switch + AesStrength = Encryption switch { EncryptionMethod.Aes128 => (byte)1, EncryptionMethod.Aes192 => (byte)2, EncryptionMethod.Aes256 => (byte)3, - _ => (byte)3 + _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? (ushort)CompressionMethodValues.Stored : diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index 0b9ddb8799aa86..eaa52a32bd33c5 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -49,7 +49,7 @@ public partial class ZipArchiveEntry private byte[] _fileComment; private EncryptionMethod _encryptionMethod; private readonly CompressionLevel _compressionLevel; - private CompressionMethodValues _aesCompressionLevel; + private CompressionMethodValues _aesCompressionMethod; private ushort? _aeVersion; // Initializes a ZipArchiveEntry instance for an existing archive entry. @@ -446,7 +446,7 @@ internal long GetOffsetOfCompressedData() else { // AES case - if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _, out _, out _, out _)) + if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _)) throw new InvalidDataException(SR.LocalFileHeaderCorrupt); baseOffset = _archive.ArchiveStream.Position; @@ -472,7 +472,6 @@ private MemoryStream GetUncompressedData(string? password = null) if (_originallyInArchive) { - if (_isEncrypted) { // We dont support edit-in-place for encrypted entries without an explicit password flow. @@ -486,7 +485,6 @@ private MemoryStream GetUncompressedData(string? password = null) "Read it with Open(password), then delete and recreate the entry with CreateEntry(..., password, ...)."); } - using (Stream decompressor = OpenInReadMode(false, password.AsMemory())) { try @@ -612,7 +610,7 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 WinZipAesExtraField? aesExtraField = null; int aesExtraFieldSize = 0; - if (Encryption == EncryptionMethod.Aes128 || Encryption == EncryptionMethod.Aes192 || Encryption == EncryptionMethod.Aes256) + if (ForAesEncryption()) { aesExtraField = new WinZipAesExtraField { @@ -622,7 +620,7 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 EncryptionMethod.Aes128 => (byte)1, EncryptionMethod.Aes192 => (byte)2, EncryptionMethod.Aes256 => (byte)3, - _ => (byte)3 + _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? (ushort)CompressionMethodValues.Stored : @@ -691,6 +689,7 @@ private void WriteCentralDirectoryFileHeaderPrepare(Span cdStaticHeader, u BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.GeneralPurposeBitFlags..], (ushort)_generalPurposeBitFlag); BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressionMethod..], (ushort)CompressionMethod); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); + // when using aes encryption, ae-2 standard dictates crc to be 0 uint crcToWrite = ForAesEncryption() ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.Crc32..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressedSize..], compressedSizeTruncated); @@ -719,7 +718,7 @@ internal void WriteCentralDirectoryFileHeader(bool forceWrite) zip64ExtraField?.WriteBlock(_archive.ArchiveStream); // Write AES extra field if using AES encryption (add this block) - if (Encryption == EncryptionMethod.Aes128 || Encryption == EncryptionMethod.Aes192 || Encryption == EncryptionMethod.Aes256) + if (ForAesEncryption()) { var aesExtraField = new WinZipAesExtraField { @@ -729,7 +728,7 @@ internal void WriteCentralDirectoryFileHeader(bool forceWrite) EncryptionMethod.Aes128 => (byte)1, EncryptionMethod.Aes192 => (byte)2, EncryptionMethod.Aes256 => (byte)3, - _ => (byte)3 + _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? (ushort)CompressionMethodValues.Stored : @@ -886,14 +885,12 @@ private bool IsZipCryptoEncrypted() private bool IsAesEncrypted() { // Compression method 99 indicates AES encryption - return _aesCompressionLevel == CompressionMethodValues.Aes; + return _aesCompressionMethod == CompressionMethodValues.Aes; } private bool ForAesEncryption() { - return _encryptionMethod == EncryptionMethod.Aes128 || - _encryptionMethod == EncryptionMethod.Aes192 || - _encryptionMethod == EncryptionMethod.Aes256; + return _encryptionMethod is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256; } private Stream GetDataDecompressor(Stream compressedStreamToRead) @@ -1008,7 +1005,7 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod crc32: null, leaveOpen: true); } - else if (encryptionMethod == EncryptionMethod.Aes256 || encryptionMethod == EncryptionMethod.Aes192 || encryptionMethod == EncryptionMethod.Aes128) + else if (encryptionMethod is EncryptionMethod.Aes256 or EncryptionMethod.Aes192 or EncryptionMethod.Aes128) { if (string.IsNullOrEmpty(password)) throw new InvalidOperationException("Password is required for encryption."); @@ -1035,7 +1032,7 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor( targetStream, - encryptionMethod == EncryptionMethod.Aes256 || encryptionMethod == EncryptionMethod.Aes192 || encryptionMethod == EncryptionMethod.Aes128 ? false : true, + encryptionMethod is EncryptionMethod.Aes256 or EncryptionMethod.Aes192 or EncryptionMethod.Aes128 ? false : true, (object? o, EventArgs e) => { // release the archive stream @@ -1091,46 +1088,33 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st else if (IsEncrypted && CompressionMethod == CompressionMethodValues.Aes) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - _aesCompressionLevel = CompressionMethodValues.Aes; - byte? aesStrength; - ushort? originalCompressionMethod; - ushort? aeVersion; - uint? crc32; - if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out aesStrength, out originalCompressionMethod, out aeVersion, out crc32)) + _aesCompressionMethod = CompressionMethodValues.Aes; + if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out WinZipAesExtraField? aesExtraField)) { message = SR.LocalFileHeaderCorrupt; return false; } - if (aesStrength.HasValue) + if (aesExtraField.HasValue) { - EncryptionMethod detectedEncryption = aesStrength switch { - 1 => EncryptionMethod.Aes128, - 2 => EncryptionMethod.Aes192, - 3 => EncryptionMethod.Aes256, - _ => throw new InvalidDataException("Unknown AES strength") - }; - - // Store the detected encryption method - _encryptionMethod = detectedEncryption; - } - if (aeVersion.HasValue) - { - _aeVersion = aeVersion.Value; - } - - if (crc32.HasValue && aeVersion == 1) - { - _crc32 = crc32.Value; - } + EncryptionMethod detectedEncryption = aesExtraField.Value.AesStrength switch + { + 1 => EncryptionMethod.Aes128, + 2 => EncryptionMethod.Aes192, + 3 => EncryptionMethod.Aes256, + _ => throw new InvalidDataException("Unknown AES strength") + }; + + // Store the detected encryption method + _encryptionMethod = detectedEncryption; + } + _aeVersion = aesExtraField.Value.VendorVersion; - // Store the actual compression method that will be used after decryption - // This is needed for GetDataDecompressor to work correctly - if (originalCompressionMethod.HasValue) - { + // Store the actual compression method that will be used after decryption + // This is needed for GetDataDecompressor to work correctly // Set the compression method to the actual method for decompression - CompressionMethod = (CompressionMethodValues)originalCompressionMethod.Value; + CompressionMethod = (CompressionMethodValues)aesExtraField.Value.CompressionMethod; } } @@ -1262,42 +1246,6 @@ private static BitFlagValues MapDeflateCompressionOption(BitFlagValues generalPu private bool ShouldUseZIP64 => AreSizesTooLarge || IsOffsetTooLarge; internal EncryptionMethod Encryption { get => _encryptionMethod; set => _encryptionMethod = value; } - internal sealed class WinZipAesExtraField - { - public const ushort HeaderId = 0x9901; - - public ushort VendorVersion { get; set; } = 2; // AE-2 - public byte AesStrength { get; set; } // 1=128bit, 2=192bit, 3=256bit - public ushort CompressionMethod { get; set; } // Original compression method - - public static int TotalSize => 11; // 2 (header) + 2 (size) + 7 (data) - - public void WriteBlock(Stream stream) - { - Span buffer = new byte[TotalSize]; - WriteBlockCore(buffer); - stream.Write(buffer); - } - - public async Task WriteBlockAsync(Stream stream, CancellationToken cancellationToken = default) - { - byte[] buffer = new byte[TotalSize]; - WriteBlockCore(buffer); - await stream.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); - } - - private void WriteBlockCore(Span buffer) - { - BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(0), HeaderId); - BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(2), 7); // DataSize - BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(4), VendorVersion); - buffer[6] = (byte)'A'; - buffer[7] = (byte)'E'; - buffer[8] = AesStrength; - - BinaryPrimitives.WriteUInt16LittleEndian(buffer[9..], CompressionMethod); - } - } private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, out Zip64ExtraField? zip64ExtraField, out uint compressedSizeTruncated, out uint uncompressedSizeTruncated, out ushort extraFieldLength) { // _entryname only gets set when we read in or call moveTo. MoveTo does a check, and @@ -1345,19 +1293,18 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o uncompressedSizeTruncated = 0; aesExtraField = new WinZipAesExtraField { - VendorVersion = 2, // AE-2 AesStrength = Encryption switch { EncryptionMethod.Aes128 => (byte)1, EncryptionMethod.Aes192 => (byte)2, EncryptionMethod.Aes256 => (byte)3, - _ => (byte)3 + _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? (ushort)CompressionMethodValues.Stored : (ushort)CompressionMethodValues.Deflate }; - aesExtraFieldSize = 11; + aesExtraFieldSize = WinZipAesExtraField.TotalSize; } // if we have a non-seekable stream, don't worry about sizes at all, and just set the right bit // if we are using the data descriptor, then sizes and crc should be set to 0 in the header @@ -1444,6 +1391,7 @@ private void WriteLocalFileHeaderPrepare(Span lfStaticHeader, uint compres BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.GeneralPurposeBitFlags..], (ushort)_generalPurposeBitFlag); BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressionMethod..], (ushort)CompressionMethod); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); + // when using aes encryption, ae-2 standard dictates crc to be 0 uint crcToWrite = ForAesEncryption() ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.Crc32..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressedSize..], compressedSizeTruncated); @@ -1468,17 +1416,16 @@ private bool WriteLocalFileHeader(bool isEmptyFile, bool forceWrite) zip64ExtraField?.WriteBlock(_archive.ArchiveStream); // Write AES extra field if using AES encryption - if (Encryption == EncryptionMethod.Aes128 || Encryption == EncryptionMethod.Aes192 || Encryption == EncryptionMethod.Aes256) + if (ForAesEncryption()) { var aesExtraField = new WinZipAesExtraField { - VendorVersion = 2, AesStrength = Encryption switch { EncryptionMethod.Aes128 => (byte)1, EncryptionMethod.Aes192 => (byte)2, EncryptionMethod.Aes256 => (byte)3, - _ => (byte)3 + _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? (ushort)CompressionMethodValues.Stored : @@ -1647,6 +1594,7 @@ private void WriteCrcAndSizesInLocalHeaderPrepareFor32bitValuesWriting(bool pret int relativeCrc32Location = ZipLocalFileHeader.FieldLocations.Crc32 - ZipLocalFileHeader.FieldLocations.Crc32; int relativeCompressedSizeLocation = ZipLocalFileHeader.FieldLocations.CompressedSize - ZipLocalFileHeader.FieldLocations.Crc32; int relativeUncompressedSizeLocation = ZipLocalFileHeader.FieldLocations.UncompressedSize - ZipLocalFileHeader.FieldLocations.Crc32; + // when using aes encryption, ae-2 standard dictates crc to be 0 uint crcToWrite = ForAesEncryption() ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeCrc32Location..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeCompressedSizeLocation..], compressedSizeTruncated); @@ -1675,7 +1623,7 @@ private void WriteCrcAndSizesInLocalHeaderPrepareForWritingDataDescriptor(Span dataDescriptor) int bytesToWrite; ZipLocalFileHeader.DataDescriptorSignatureConstantBytes.CopyTo(dataDescriptor[ZipLocalFileHeader.ZipDataDescriptor.FieldLocations.Signature..]); + // when using aes encryption, ae-2 standard dictates crc to be 0 uint crcToWrite = ForAesEncryption() ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(dataDescriptor[ZipLocalFileHeader.ZipDataDescriptor.FieldLocations.Crc32..], crcToWrite); diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs index 8d9ce784004b11..893e03cab06c95 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs @@ -671,62 +671,108 @@ public static bool TrySkipBlock(Stream stream) return TrySkipBlockFinalize(stream, blockBytes, bytesRead); } - public static bool TrySkipBlockAESAware(Stream stream, out byte? aesStrength, out ushort? originalCompressionMethod, out ushort? aesVersion, out uint? crc32) + public static bool TrySkipBlockAESAware(Stream stream, out WinZipAesExtraField? aesExtraField) { - aesStrength = null; - originalCompressionMethod = null; - aesVersion = null; - crc32 = null; + aesExtraField = null; BinaryReader reader = new BinaryReader(stream); // Read the first 4 bytes (local file header signature) byte[] signatureBytes = reader.ReadBytes(4); - if (!signatureBytes.AsSpan().SequenceEqual(ZipLocalFileHeader.SignatureConstantBytes)) + if (!signatureBytes.AsSpan().SequenceEqual(SignatureConstantBytes)) { return false; // Not a valid local file header } // Read fixed-size fields after signature - // Local file header layout: - // signature (4) + version (2) + flags (2) + compression (2) + - // mod time (2) + mod date (2) + CRC32 (4) + compressed size (4) + - // uncompressed size (4) + name length (2) + extra length (2) - - reader.ReadBytes(10); // Skip version through mod date - crc32 = reader.ReadUInt32(); // Read CRC32 - reader.ReadBytes(8); // Skip compressed and uncompressed sizes + // Skip version through mod date (10 bytes), then skip CRC32 + sizes (12 bytes) + reader.ReadBytes(22); // Skip 22 bytes total + ushort nameLength = reader.ReadUInt16(); ushort extraLength = reader.ReadUInt16(); // Skip file name stream.Seek(nameLength, SeekOrigin.Current); - // Parse extra fields - long extraStart = stream.Position; - long extraEnd = extraStart + extraLength; - while (stream.Position < extraEnd) + // Parse extra fields if present + if (extraLength > 0) { - ushort headerId = reader.ReadUInt16(); - ushort dataSize = reader.ReadUInt16(); + long extraStart = stream.Position; + long extraEnd = extraStart + extraLength; - if (headerId == 0x9901) // AES extra field - { - // AES extra field structure: - // Vendor version (2) + Vendor ID (2) + AES strength (1) + Original compression (2) - aesVersion = reader.ReadUInt16(); - reader.ReadBytes(2); // Vendor ID - aesStrength = reader.ReadByte(); // 1, 2, or 3 - originalCompressionMethod = reader.ReadUInt16(); - } - else + while (stream.Position < extraEnd) { - stream.Seek(dataSize, SeekOrigin.Current); // Skip unknown extra field + ushort headerId = reader.ReadUInt16(); + ushort dataSize = reader.ReadUInt16(); + + if (headerId == WinZipAesExtraField.HeaderId) // 0x9901 + { + // AES extra field structure: + // Vendor version (2) + Vendor ID (2) + AES strength (1) + Original compression (2) + ushort vendorVersion = reader.ReadUInt16(); + reader.ReadBytes(2); // Skip vendor ID 'AE' + byte aesStrength = reader.ReadByte(); // 1, 2, or 3 + ushort compressionMethod = reader.ReadUInt16(); + + aesExtraField = new WinZipAesExtraField(vendorVersion, aesStrength, compressionMethod); + break; + } + else + { + stream.Seek(dataSize, SeekOrigin.Current); // Skip unknown extra field + } } } return true; } } + internal struct WinZipAesExtraField + { + public const ushort HeaderId = 0x9901; + private ushort _vendorVersion = 2; + private byte _aesStrength; + private ushort _compressionMethod; + + public WinZipAesExtraField(ushort VendorVersion, byte AesStrength, ushort CompressionMethod) + { + this.VendorVersion = VendorVersion; + this.AesStrength = AesStrength; + this.CompressionMethod = CompressionMethod; + } + + public ushort VendorVersion { get => _vendorVersion; set => _vendorVersion = value; } + public byte AesStrength { get => _aesStrength; set => _aesStrength = value; } // 1=128bit, 2=192bit, 3=256bit + public ushort CompressionMethod { get => _compressionMethod; set => _compressionMethod = value; } // Original compression method + + public static int TotalSize => 11; // 2 (header) + 2 (size) + 7 (data) + + public void WriteBlock(Stream stream) + { + Span buffer = new byte[TotalSize]; + WriteBlockCore(buffer); + stream.Write(buffer); + } + + public async Task WriteBlockAsync(Stream stream, CancellationToken cancellationToken = default) + { + byte[] buffer = new byte[TotalSize]; + WriteBlockCore(buffer); + await stream.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); + } + + private void WriteBlockCore(Span buffer) + { + BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(0), HeaderId); + BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(2), 7); // DataSize + BinaryPrimitives.WriteUInt16LittleEndian(buffer.Slice(4), VendorVersion); + buffer[6] = (byte)'A'; + buffer[7] = (byte)'E'; + buffer[8] = AesStrength; + + BinaryPrimitives.WriteUInt16LittleEndian(buffer[9..], CompressionMethod); + } + } + internal sealed partial class ZipCentralDirectoryFileHeader { diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index 34fed48cc94826..8cd53d2b08fe6f 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers.Binary; using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; @@ -35,18 +36,7 @@ private static uint[] CreateCrc32Table() return table; } - // Private constructor for async factory method - private ZipCryptoStream(Stream baseStream, bool encrypting, bool leaveOpen = false, ushort verifierLow2Bytes = 0, uint? crc32ForHeader = null) - { - _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); - _encrypting = encrypting; - _leaveOpen = leaveOpen; - _verifierLow2Bytes = verifierLow2Bytes; - _crc32ForHeader = crc32ForHeader; - _position = 0; - } - - // Synchronous decryption constructor (existing) + // Decryption constructor public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) { _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); @@ -56,7 +46,7 @@ public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte ex _position = 0; } - // Synchronous encryption constructor (existing) + // Encryption constructor public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, ushort passwordVerifierLow2Bytes, @@ -72,114 +62,66 @@ public ZipCryptoStream(Stream baseStream, InitKeysFromBytes(password.Span); } - // Async factory method for decryption - public static async Task CreateForDecryptionAsync( - Stream baseStream, - ReadOnlyMemory password, - byte expectedCheckByte, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(baseStream); - - var stream = new ZipCryptoStream(baseStream, encrypting: false); - stream.InitKeysFromBytes(password.Span); - await stream.ValidateHeaderAsync(expectedCheckByte, cancellationToken).ConfigureAwait(false); - return stream; - } - - // Async factory method for encryption - public static Task CreateForEncryptionAsync( - Stream baseStream, - ReadOnlyMemory password, - ushort passwordVerifierLow2Bytes, - uint? crc32 = null, - bool leaveOpen = false, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(baseStream); - cancellationToken.ThrowIfCancellationRequested(); - - var stream = new ZipCryptoStream(baseStream, encrypting: true, leaveOpen, passwordVerifierLow2Bytes, crc32); - stream.InitKeysFromBytes(password.Span); - - // No async work needed for encryption constructor - return Task.FromResult(stream); - } - - private void EnsureHeader() + private byte[] CalculateHeader() { - if (!_encrypting || _headerWritten) return; - - Span hdrPlain = stackalloc byte[12]; + byte[] hdrPlain = new byte[12]; // bytes 0..9 are random - RandomNumberGenerator.Fill(hdrPlain.Slice(0, 10)); + RandomNumberGenerator.Fill(hdrPlain.AsSpan(0, 10)); // bytes 10..11: check bytes (CRC-based if crc32 provided; else DOS time low word) if (_crc32ForHeader.HasValue) { uint crc = _crc32ForHeader.Value; - hdrPlain[10] = (byte)((crc >> 16) & 0xFF); - hdrPlain[11] = (byte)((crc >> 24) & 0xFF); + BinaryPrimitives.WriteUInt16LittleEndian(hdrPlain.AsSpan(10), (ushort)(crc >> 16)); } else { - hdrPlain[10] = (byte)(_verifierLow2Bytes & 0xFF); - hdrPlain[11] = (byte)((_verifierLow2Bytes >> 8) & 0xFF); + BinaryPrimitives.WriteUInt16LittleEndian(hdrPlain.AsSpan(10), _verifierLow2Bytes); } - // Encrypt & write; update keys with PLAINTEXT per spec + // Update keys with PLAINTEXT per spec byte[] hdrCiph = new byte[12]; for (int i = 0; i < 12; i++) { - byte ks = DecipherByte(); + byte ks = Decrypt(); byte p = hdrPlain[i]; hdrCiph[i] = (byte)(p ^ ks); UpdateKeys(p); } - _base.Write(hdrCiph, 0, 12); - _headerWritten = true; - _position += 12; + return hdrCiph; } - private async ValueTask EnsureHeaderAsync(CancellationToken cancellationToken) + private async ValueTask WriteHeaderCore(bool isAsync, CancellationToken cancellationToken = default) { if (!_encrypting || _headerWritten) return; - byte[] hdrPlain = new byte[12]; + byte[] hdrCiph = CalculateHeader(); - // bytes 0..9 are random - RandomNumberGenerator.Fill(hdrPlain.AsSpan(0, 10)); - - // bytes 10..11: check bytes (CRC-based if crc32 provided; else DOS time low word) - if (_crc32ForHeader.HasValue) + if (isAsync) { - uint crc = _crc32ForHeader.Value; - hdrPlain[10] = (byte)((crc >> 16) & 0xFF); - hdrPlain[11] = (byte)((crc >> 24) & 0xFF); + await _base.WriteAsync(hdrCiph.AsMemory(0, 12), cancellationToken).ConfigureAwait(false); } else { - hdrPlain[10] = (byte)(_verifierLow2Bytes & 0xFF); - hdrPlain[11] = (byte)((_verifierLow2Bytes >> 8) & 0xFF); - } - - // Encrypt & write; update keys with PLAINTEXT per spec - byte[] hdrCiph = new byte[12]; - for (int i = 0; i < 12; i++) - { - byte ks = DecipherByte(); - byte p = hdrPlain[i]; - hdrCiph[i] = (byte)(p ^ ks); - UpdateKeys(p); + _base.Write(hdrCiph, 0, 12); } - await _base.WriteAsync(hdrCiph.AsMemory(0, 12), cancellationToken).ConfigureAwait(false); _headerWritten = true; _position += 12; } + private void EnsureHeader() + { + WriteHeaderCore(isAsync: false).AsTask().GetAwaiter().GetResult(); + } + + private ValueTask EnsureHeaderAsync(CancellationToken cancellationToken) + { + return WriteHeaderCore(isAsync: true, cancellationToken); + } + private void InitKeysFromBytes(ReadOnlySpan password) { _key0 = 305419896; @@ -195,30 +137,13 @@ private void InitKeysFromBytes(ReadOnlySpan password) private void ValidateHeader(byte expectedCheckByte) { byte[] hdr = new byte[12]; - int read = 0; - while (read < hdr.Length) + try { - int n = _base.Read(hdr, read, hdr.Length - read); - if (n <= 0) throw new InvalidDataException("Truncated ZipCrypto header."); - read += n; + _base.ReadExactly(hdr); } - - for (int i = 0; i < hdr.Length; i++) - hdr[i] = DecryptByte(hdr[i]); - - if (hdr[11] != expectedCheckByte) - throw new InvalidDataException("Invalid password for encrypted ZIP entry."); - } - - private async ValueTask ValidateHeaderAsync(byte expectedCheckByte, CancellationToken cancellationToken) - { - byte[] hdr = new byte[12]; - int read = 0; - while (read < hdr.Length) + catch (EndOfStreamException) { - int n = await _base.ReadAsync(hdr.AsMemory(read, hdr.Length - read), cancellationToken).ConfigureAwait(false); - if (n <= 0) throw new InvalidDataException("Truncated ZipCrypto header."); - read += n; + throw new InvalidDataException("Truncated ZipCrypto header."); } for (int i = 0; i < hdr.Length; i++) @@ -236,7 +161,7 @@ private void UpdateKeys(byte b) _key2 = Crc32Update(_key2, (byte)(_key1 >> 24)); } - private byte DecipherByte() + private byte Decrypt() { uint temp = _key2 | 2; // use uint to avoid narrowing issues return (byte)((temp * (temp ^ 1)) >> 8); @@ -244,7 +169,7 @@ private byte DecipherByte() private byte DecryptByte(byte ciph) { - byte m = DecipherByte(); + byte m = Decrypt(); byte plain = (byte)(ciph ^ m); UpdateKeys(plain); return plain; @@ -297,7 +222,7 @@ public override void Write(ReadOnlySpan buffer) byte[] tmp = new byte[buffer.Length]; for (int i = 0; i < buffer.Length; i++) { - byte ks = DecipherByte(); + byte ks = Decrypt(); byte p = buffer[i]; tmp[i] = (byte)(p ^ ks); UpdateKeys(p); @@ -370,7 +295,7 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella ReadOnlySpan span = buffer.Span; for (int i = 0; i < buffer.Length; i++) { - byte ks = DecipherByte(); + byte ks = Decrypt(); byte p = span[i]; tmp[i] = (byte)(p ^ ks); UpdateKeys(p); diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs index 495ff7c7dd8502..2a6a47b1a726e9 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs @@ -862,8 +862,7 @@ protected override void Dispose(bool disposing) // Validate CRC when stream is closed (if all data was read) if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) { - throw new InvalidDataException( - $"CRC mismatch. Expected: 0x{_expectedCrc:X8}, Got: 0x{_runningCrc:X8}"); + throw new InvalidDataException("CRC mismatch"); } _baseStream.Dispose(); From d5bb294dc684dc1cf236c4e3b02e585844f4b5d7 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 1 Dec 2025 22:27:13 +0100 Subject: [PATCH 19/39] refactor tests and ci disable local ones --- ...System.IO.Compression.ZipFile.Tests.csproj | 33 +- .../tests/ZipFile.Encryption.cs | 328 +++++++ .../tests/ZipFile.Extract.cs | 801 +----------------- 3 files changed, 377 insertions(+), 785 deletions(-) create mode 100644 src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/System.IO.Compression.ZipFile.Tests.csproj b/src/libraries/System.IO.Compression.ZipFile/tests/System.IO.Compression.ZipFile.Tests.csproj index 12005f1aae45aa..d1fe53d93af88e 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/System.IO.Compression.ZipFile.Tests.csproj +++ b/src/libraries/System.IO.Compression.ZipFile/tests/System.IO.Compression.ZipFile.Tests.csproj @@ -1,4 +1,4 @@ - + true true @@ -16,32 +16,23 @@ + - - - - - - - - - - + + + + + + + + + + diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs new file mode 100644 index 00000000000000..a0b7432a9d4dd0 --- /dev/null +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -0,0 +1,328 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace System.IO.Compression.Tests +{ + public class ZipFile_EncryptionTests : ZipFileTestBase + { + public static IEnumerable Get_SingleEntry_Data() + { + foreach (var method in new[] + { + ZipArchiveEntry.EncryptionMethod.ZipCrypto, + ZipArchiveEntry.EncryptionMethod.Aes128, + ZipArchiveEntry.EncryptionMethod.Aes192, + ZipArchiveEntry.EncryptionMethod.Aes256 + }) + { + yield return new object[] { method, false }; + yield return new object[] { method, true }; + } + } + + public static IEnumerable Get_MultipleEntries_SamePassword_Data() + { + foreach (var method in new[] + { + ZipArchiveEntry.EncryptionMethod.ZipCrypto, + ZipArchiveEntry.EncryptionMethod.Aes256 + }) + { + yield return new object[] { method, false }; + yield return new object[] { method, true }; + } + } + + public static IEnumerable Get_MultipleEntries_DifferentPasswords_Data() + { + foreach (var method in new[] + { + ZipArchiveEntry.EncryptionMethod.ZipCrypto, + ZipArchiveEntry.EncryptionMethod.Aes256 + }) + { + yield return new object[] { method, false }; + yield return new object[] { method, true }; + } + } + + public static IEnumerable Get_MixedPlainEncrypted_Data() + { + foreach (var method in new[] + { + ZipArchiveEntry.EncryptionMethod.ZipCrypto, + ZipArchiveEntry.EncryptionMethod.Aes128, + ZipArchiveEntry.EncryptionMethod.Aes256 + }) + { + yield return new object[] { method, false }; + yield return new object[] { method, true }; + } + } + + public static IEnumerable Get_Is_Async() + { + yield return new object[] { false }; + yield return new object[] { true }; + } + + [Theory] + [MemberData(nameof(Get_SingleEntry_Data))] + public async Task Encryption_SingleEntry_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string entryName = "test.txt"; + string content = "Secret Content"; + string password = "password123"; + + var entries = new[] { (entryName, content, (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + await AssertEntryTextEquals(entry, content, password, async); + } + } + + [Theory] + [MemberData(nameof(Get_MultipleEntries_SamePassword_Data))] + public async Task Encryption_MultipleEntries_SamePassword_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string password = "SharedPassword"; + var entries = new[] + { + ("file1.txt", "Content 1", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("folder/file2.txt", "Content 2", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + foreach (var (name, content, pwd, _) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + await AssertEntryTextEquals(entry, content, pwd, async); + } + } + } + + [Theory] + [MemberData(nameof(Get_MultipleEntries_DifferentPasswords_Data))] + public async Task Encryption_MultipleEntries_DifferentPasswords_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + var entries = new[] + { + ("file1.txt", "Content 1", (string?)"pass1", (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("file2.txt", "Content 2", (string?)"pass2", (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + foreach (var (name, content, pwd, _) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + await AssertEntryTextEquals(entry, content, pwd, async); + } + } + } + + [Theory] + [MemberData(nameof(Get_MixedPlainEncrypted_Data))] + public async Task Encryption_MixedPlainAndEncrypted_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + var entries = new[] + { + ("plain.txt", "Plain Content", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), + ("encrypted.txt", "Encrypted Content", (string?)"pass", (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + // Check plain + var plainEntry = archive.GetEntry("plain.txt"); + Assert.NotNull(plainEntry); + await AssertEntryTextEquals(plainEntry, "Plain Content", null, async); + + // Check encrypted + var encEntry = archive.GetEntry("encrypted.txt"); + Assert.NotNull(encEntry); + await AssertEntryTextEquals(encEntry, "Encrypted Content", "pass", async); + } + } + + [Theory] + [MemberData(nameof(Get_Is_Async))] + public async Task Encryption_Combinations_RoundTrip(bool async) + { + string archivePath = GetTempArchivePath(); + var entries = new[] + { + ("zipcrypto.txt", "ZipCrypto Content", (string?)"pass1", (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.ZipCrypto), + ("aes128.txt", "AES128 Content", (string?)"pass2", (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes128), + ("aes256.txt", "AES256 Content", (string?)"pass3", (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + foreach (var (name, content, pwd, _) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + await AssertEntryTextEquals(entry, content, pwd, async); + } + } + } + + [Fact] + public void Negative_WrongPassword_Throws_InvalidDataException() + { + string archivePath = GetTempArchivePath(); + string password = "correct"; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) }; + CreateArchiveWithEntries(archivePath, entries, async: false).GetAwaiter().GetResult(); + + using (ZipArchive archive = ZipFile.OpenRead(archivePath)) + { + var entry = archive.GetEntry("test.txt"); + Assert.Throws(() => entry.Open("wrong")); + } + } + + [Fact] + public void Negative_MissingPassword_Throws_InvalidDataException() + { + string archivePath = GetTempArchivePath(); + string password = "correct"; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) }; + CreateArchiveWithEntries(archivePath, entries, async: false).GetAwaiter().GetResult(); + + using (ZipArchive archive = ZipFile.OpenRead(archivePath)) + { + var entry = archive.GetEntry("test.txt"); + Assert.Throws(() => entry.Open()); + } + } + + [Fact] + public void Negative_OpeningPlainEntryWithPassword_Throws() + { + string archivePath = GetTempArchivePath(); + var entries = new[] { ("plain.txt", "content", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null) }; + CreateArchiveWithEntries(archivePath, entries, async: false).GetAwaiter().GetResult(); + + using (ZipArchive archive = ZipFile.OpenRead(archivePath)) + { + var entry = archive.GetEntry("plain.txt"); + Assert.ThrowsAny(() => entry.Open("password")); + } + } + + [Theory] + [MemberData(nameof(Get_Is_Async))] + // todo: some async methods in ziparchiveentry missing implementation for winzipaesstream + public async Task ExtractToFile_Encrypted_Success(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "pass"; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + var entry = archive.GetEntry("test.txt"); + string destFile = GetTestFilePath(); + + if (async) + { + await entry.ExtractToFileAsync(destFile, overwrite: true, password: password); + Assert.Equal("content", await File.ReadAllTextAsync(destFile)); + } + else + { + entry.ExtractToFile(destFile, overwrite: true, password: password); + Assert.Equal("content", File.ReadAllText(destFile)); + } + } + } + + private string GetTempArchivePath() => GetTestFilePath(); + + private async Task CreateArchiveWithEntries(string archivePath, (string Name, string Content, string? Password, ZipArchiveEntry.EncryptionMethod? Encryption)[] entries, bool async) + { + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) + { + foreach (var (name, content, password, encryption) in entries) + { + ZipArchiveEntry entry = archive.CreateEntry(name); + Stream s; + if (password != null && encryption.HasValue) + { + s = entry.Open(password, encryption.Value); + } + else + { + s = await OpenEntryStream(async, entry); + } + + using (s) + using (StreamWriter w = new StreamWriter(s, Encoding.UTF8)) + { + if (async) + await w.WriteAsync(content); + else + w.Write(content); + } + } + } + } + + private async Task AssertEntryTextEquals(ZipArchiveEntry entry, string expected, string? password, bool async) + { + Stream s; + if (password != null) + { + s = entry.Open(password); + } + else + { + s = await OpenEntryStream(async, entry); + } + + using (s) + using (StreamReader r = new StreamReader(s, Encoding.UTF8)) + { + string actual; + if (async) + actual = await r.ReadToEndAsync(); + else + actual = r.ReadToEnd(); + + Assert.Equal(expected, actual); + } + } + } +} diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index 1e46b8bf8d08a1..8e46556db19b88 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -218,6 +218,7 @@ public async Task DirectoryEntryWithData(bool async) } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void OpenEncryptedTxtFile_ShouldReturnPlaintext() { @@ -232,7 +233,7 @@ public void OpenEncryptedTxtFile_ShouldReturnPlaintext() Assert.Equal("Hello ZipCrypto!", content); } - + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void ExtractEncryptedEntryToFile_ShouldCreatePlaintextFile() { @@ -259,6 +260,7 @@ public void ExtractEncryptedEntryToFile_ShouldCreatePlaintextFile() File.Delete(tempFile); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void ExtractEncryptedEntryToFile_WithWrongPassword_ShouldThrow() { @@ -277,6 +279,7 @@ public void ExtractEncryptedEntryToFile_WithWrongPassword_ShouldThrow() }); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void ExtractEncryptedEntryToFile_WithoutPassword_ShouldThrow() { @@ -296,7 +299,6 @@ public void ExtractEncryptedEntryToFile_WithoutPassword_ShouldThrow() }); } - [Fact] public async Task ExtractToFileAsync_WithPassword_ShouldCreatePlaintextFile() { @@ -318,6 +320,7 @@ public async Task ExtractToFileAsync_WithPassword_ShouldCreatePlaintextFile() File.Delete(tempFile); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ExtractToFileAsync_WithWrongPassword_ShouldThrow() { @@ -336,7 +339,7 @@ await Assert.ThrowsAsync(async () => }); } - + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ExtractToFileAsync_WithoutPassword_ShouldThrow() { @@ -355,50 +358,6 @@ await Assert.ThrowsAsync(async () => }); } - [Fact] - public async Task ExtractToFileAsync_WithCancellation_ShouldCancel() - { - string zipPath = @"C:\Users\spahontu\Downloads\test.zip"; - Assert.True(File.Exists(zipPath), $"Test ZIP not found at {zipPath}"); - - string tempFile = Path.Combine(Path.GetTempPath(), "hello_async_cancel.txt"); - if (File.Exists(tempFile)) File.Delete(tempFile); - - using var archive = ZipFile.OpenRead(zipPath); - var entry = archive.Entries.First(e => e.FullName.EndsWith("hello.txt", StringComparison.OrdinalIgnoreCase)); - - using var cts = new CancellationTokenSource(); - cts.Cancel(); // Cancel immediately - await Assert.ThrowsAsync(async () => - { - await entry.ExtractToFileAsync(tempFile, overwrite: true, password: "123456789", cts.Token); - }); - } - - [Fact] - public void OpenEncryptedJpeg_ShouldDecryptAndMatchOriginal() - { - // Arrange - string zipPath = @"C:\Users\spahontu\Downloads\jpg.zip"; - string originalPath = @"C:\Users\spahontu\Downloads\test.jpg"; // original JPEG for comparison - Assert.True(File.Exists(zipPath), $"Encrypted ZIP not found at {zipPath}"); - Assert.True(File.Exists(originalPath), $"Original JPEG not found at {originalPath}"); - - using var archive = ZipFile.OpenRead(zipPath); - var entry = archive.Entries.First(e => e.FullName.EndsWith("test.jpg", StringComparison.OrdinalIgnoreCase)); - - using var stream = entry.Open("123456789"); - - using var ms = new MemoryStream(); - stream.CopyTo(ms); - byte[] actualBytes = ms.ToArray(); - - byte[] expectedBytes = File.ReadAllBytes(originalPath); - Assert.Equal(expectedBytes.Length, actualBytes.Length); - Assert.Equal(expectedBytes, actualBytes); - } - - [Fact] public void OpenEncryptedArchive_WithMultipleEntries_ShouldDecryptBoth() { @@ -439,6 +398,7 @@ public void OpenEncryptedArchive_WithMultipleEntries_ShouldDecryptBoth() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void OpenEncryptedArchive_WithMultipleEntries_DifferentPassword_ShouldDecryptBoth() { @@ -479,8 +439,7 @@ public void OpenEncryptedArchive_WithMultipleEntries_DifferentPassword_ShouldDec } } - - + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ZipCrypto_CreateEntry_ThenRead_Back_ContentMatches() { @@ -524,8 +483,6 @@ public async Task ZipCrypto_CreateEntry_ThenRead_Back_ContentMatches() Assert.Equal(expectedContent, actualContent); } - - [Fact] public async Task ZipCrypto_MultipleEntries_SamePassword_AllRoundTrip() { @@ -568,6 +525,7 @@ public async Task ZipCrypto_MultipleEntries_SamePassword_AllRoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ZipCrypto_MultipleEntries_DifferentPasswords_AllRoundTrip() { @@ -619,6 +577,7 @@ public async Task ZipCrypto_MultipleEntries_DifferentPasswords_AllRoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() { @@ -693,6 +652,7 @@ public async Task ZipCrypto_Mixed_EncryptedAndPlainEntries_AllRoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ZipCrypto_AsyncWrite_ThenAsyncRead_ContentMatches() { @@ -732,6 +692,7 @@ public async Task ZipCrypto_AsyncWrite_ThenAsyncRead_ContentMatches() Assert.Equal(expectedContent, actualContent); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ZipCrypto_MultipleAsyncWrites_SingleEntry_ContentMatches() { @@ -775,6 +736,7 @@ public async Task ZipCrypto_MultipleAsyncWrites_SingleEntry_ContentMatches() Assert.Equal(expectedContent, actualContent); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ZipCrypto_ChunkedAsyncRead_ContentMatches() { @@ -813,6 +775,7 @@ public async Task ZipCrypto_ChunkedAsyncRead_ContentMatches() Assert.Equal(expectedContent, actualContent); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ZipCrypto_MixedSyncAsyncOperations_ContentMatches() { @@ -871,6 +834,7 @@ public async Task ZipCrypto_MixedSyncAsyncOperations_ContentMatches() Assert.Equal(asyncContent, actualAsyncContent); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ZipCrypto_LargeFileAsyncOperations_ContentMatches() { @@ -921,6 +885,7 @@ public async Task ZipCrypto_LargeFileAsyncOperations_ContentMatches() Assert.Equal(expectedData, actualData); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task ZipCrypto_StreamCopyToAsync_ContentMatches() { @@ -963,677 +928,7 @@ public async Task ZipCrypto_StreamCopyToAsync_ContentMatches() Assert.Equal(expectedData, actualData); } - [Fact] - public async Task ZipCrypto_AsyncWithWrongPassword_ThrowsInvalidDataException() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string zipPath = NewPath("zipcrypto_wrong_pw_async.zip"); - const string entryName = "secure.txt"; - const string correctPassword = "Correct123"; - const string wrongPassword = "Wrong123"; - const string content = "Secret content"; - - if (File.Exists(zipPath)) File.Delete(zipPath); - - // Create encrypted entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var entry = za.CreateEntry(entryName); - using var writer = new StreamWriter(entry.Open(correctPassword, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); - await writer.WriteAsync(content); - } - - // Act & Assert - Try to read with wrong password - await Assert.ThrowsAsync(async () => - { - using var za = ZipFile.Open(zipPath, ZipArchiveMode.Read); - var entry = za.GetEntry(entryName); - Assert.NotNull(entry); - - using var stream = entry!.Open(wrongPassword); - byte[] buffer = new byte[100]; - await stream.ReadAsync(buffer, 0, buffer.Length); - }); - } - - [Fact] - public async Task Update_AddEncryptedEntry_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string zipPath = NewPath("update_add.zip"); - if (File.Exists(zipPath)) File.Delete(zipPath); - - // Create initial archive with one plain entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntry("plain.txt"); - using var w = new StreamWriter(e.Open(), Encoding.UTF8); - await w.WriteAsync("plain content"); - } - - // Act: Open in Update mode and add encrypted entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var encEntry = za.CreateEntry("secure/new.txt"); - using var w = new StreamWriter(encEntry.Open("pw123", ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); - await w.WriteAsync("secret data"); - } - - // Assert: Verify both entries exist and encrypted one decrypts correctly - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var plain = za.GetEntry("plain.txt"); - Assert.NotNull(plain); - using (var r = new StreamReader(plain!.Open(), Encoding.UTF8)) - Assert.Equal("plain content", await r.ReadToEndAsync()); - - var secure = za.GetEntry("secure/new.txt"); - Assert.NotNull(secure); - using (var r = new StreamReader(secure!.Open("pw123"), Encoding.UTF8)) - Assert.Equal("secret data", await r.ReadToEndAsync()); - } - } - - [Fact] - public async Task Update_DeleteEncryptedEntry_RemovesSuccessfully() - { - // Arrange - string zipPath = NewPath("update_delete.zip"); - if (File.Exists(zipPath)) File.Delete(zipPath); - - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntry("secure/delete.txt"); - using var w = new StreamWriter(e.Open("delpw", ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); - await w.WriteAsync("to be deleted"); - } - - // Act: Delete the encrypted entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var e = za.GetEntry("secure/delete.txt"); - Assert.NotNull(e); - e!.Delete(); - } - - // Assert: Entry should not exist - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - Assert.Null(za.GetEntry("secure/delete.txt")); - } - } - - [Fact] - public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip() - { - // Arrange - string zipPath = NewPath("update_copy.zip"); - if (File.Exists(zipPath)) File.Delete(zipPath); - - const string pw = "copy-pw"; - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntry("secure/original.txt"); - using var w = new StreamWriter(e.Open(pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); - w.Write("original content"); - } - - // Act: Copy encrypted entry to new name - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var src = za.GetEntry("secure/original.txt"); - Assert.NotNull(src); - - // Read original - string content; - using (var r = new StreamReader(src!.Open(pw), Encoding.UTF8)) - content = r.ReadToEnd(); - - // Create new entry with same password - var dst = za.CreateEntry("secure/copy.txt"); - using var w = new StreamWriter(dst.Open(pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8); - w.Write(content); - } - - // Assert: Both entries exist and decrypt correctly - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var orig = za.GetEntry("secure/original.txt"); - var copy = za.GetEntry("secure/copy.txt"); - Assert.NotNull(orig); - Assert.NotNull(copy); - - using (var r1 = new StreamReader(orig!.Open(pw), Encoding.UTF8)) - Assert.Equal("original content", await r1.ReadToEndAsync()); - - using (var r2 = new StreamReader(copy!.Open(pw), Encoding.UTF8)) - Assert.Equal("original content", await r2.ReadToEndAsync()); - } - } - - - [Fact] - public async Task Update_CopyEncryptedEntry_ToNewName_RoundTrip_2() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string zipPath = NewPath("update_copy.zip"); - if (File.Exists(zipPath)) File.Delete(zipPath); - - const string pw = "copy-pw"; - const string originalName = "secure/original.txt"; - const string copyName = "secure/copy.txt"; - const string payload = "original content"; - - // Create archive and a single encrypted entry - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - { - var e = za.CreateEntry(originalName); - using var w = new StreamWriter(e.Open(pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); - await w.WriteAsync(payload); - } - - // Act: Open in Update mode and copy encrypted entry to a new name - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - { - var src = za.GetEntry(originalName); - Assert.NotNull(src); - - // READ-ONLY decrypt in Update mode (Option A): Open(password) returns a readable stream, - // does NOT mark the entry as modified, and does NOT materialize to an edit buffer. - string content; - using (var r = new StreamReader(src!.Open(pw), Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) - content = await r.ReadToEndAsync(); - - // Optional: wrong password should fail early - Assert.ThrowsAny(() => - { - using var _ = src.Open("WRONG"); - }); - - // Create the destination entry with the same password and write the copied content. - var dst = za.CreateEntry(copyName); - using var w = new StreamWriter(dst.Open(pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto), Encoding.UTF8, bufferSize: 1024, leaveOpen: false); - await w.WriteAsync(content); - } - - // Assert: Both entries exist and decrypt to the expected content - using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - { - var orig = za.GetEntry(originalName); - var copy = za.GetEntry(copyName); - Assert.NotNull(orig); - Assert.NotNull(copy); - - using (var r1 = new StreamReader(orig!.Open(pw), Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) - { - var text = await r1.ReadToEndAsync(); - Assert.Equal(payload, text); - } - - using (var r2 = new StreamReader(copy!.Open(pw), Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) - { - var text = await r2.ReadToEndAsync(); - Assert.Equal(payload, text); - } - } - } - - - //[Fact] - //public void Update_OpenEncryptedEntry_WrongPassword_Throws() - //{ - // string zipPath = NewPath("update_wrong_pw.zip"); - // const string pw = "correct-pw"; - - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - // { - // var e = za.CreateEntry("secure/file.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - // using var w = new StreamWriter(e.Open(), Encoding.UTF8); - // w.Write("secret"); - // } - - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - // { - // var e = za.GetEntry("secure/file.txt"); - // Assert.NotNull(e); - // Assert.ThrowsAny(() => - // { - // using var _ = e.Open("wrong-pw"); - // }); - // } - //} - - - //[Fact] - //public async Task Update_EditPlainEntry_RoundTrip() - //{ - // string zipPath = NewPath("update_edit_plain.zip"); - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // // Create plain entry - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - // { - // var e = za.CreateEntry("plain.txt"); - // using var w = new StreamWriter(e.Open(), Encoding.UTF8); - // await w.WriteAsync("original"); - // } - - // // Edit in Update mode - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - // { - // var e = za.GetEntry("plain.txt"); - // Assert.NotNull(e); - - // using var w = new StreamWriter(e.Open(), Encoding.UTF8); - // await w.WriteAsync("modified"); - // } - - // // Verify updated content - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - // { - // var e = za.GetEntry("plain.txt"); - // using var r = new StreamReader(e.Open(), Encoding.UTF8); - // Assert.Equal("modified", await r.ReadToEndAsync()); - // } - //} - - - - //[Fact] - //public void Update_EditEncryptedEntryWithoutPassword_Throws() - //{ - // string zipPath = NewPath("update_edit_encrypted.zip"); - // const string pw = "edit-pw"; - - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - // { - // var e = za.CreateEntry("secure/edit.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - // using var w = new StreamWriter(e.Open(), Encoding.UTF8); - // w.Write("secret"); - // } - - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - // { - // var e = za.GetEntry("secure/edit.txt"); - // Assert.NotNull(e); - - // // Should throw because edit-in-place for encrypted entries is not supported - // Assert.Throws(() => - // { - // using var _ = e.Open(); // no password - // }); - // } - //} - - - //[Fact] - //public async Task Update_MixedEntries_ReadEncrypted_EditPlain() - //{ - // string zipPath = NewPath("update_mixed.zip"); - // const string pw = "mixed-pw"; - - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // // Create initial zip with encrypted and plain entries - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - // { - // var encEntry = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - // using (var w = new StreamWriter(encEntry.Open(), Encoding.UTF8)) - // await w.WriteAsync("encrypted"); - - // var plainEntry = za.CreateEntry("plain.txt"); - // using (var w = new StreamWriter(plainEntry.Open(), Encoding.UTF8)) - // await w.WriteAsync("original"); - // } - - // // First update: read encrypted, modify plain - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - // { - // var enc = za.GetEntry("secure/data.txt"); - // Assert.NotNull(enc); - - // string encryptedContent; - // using (var r = new StreamReader(enc.Open(pw), Encoding.UTF8)) - // encryptedContent = await r.ReadToEndAsync(); - - // var plain = za.GetEntry("plain.txt"); - // using var w = new StreamWriter(plain.Open(), Encoding.UTF8); - // await w.WriteAsync("modified"); - // } - - // // Second update: verify encrypted, re-modify plain - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - // { - // var enc = za.GetEntry("secure/data.txt"); - // using (var r = new StreamReader(enc.Open(pw), Encoding.UTF8)) - // Assert.Equal("encrypted", await r.ReadToEndAsync()); - - // var plain = za.GetEntry("plain.txt"); - // using var w = new StreamWriter(plain.Open(), Encoding.UTF8); - // await w.WriteAsync("modified"); - // } - - // // Final read: verify both entries - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - // { - // using (var r1 = new StreamReader(za.GetEntry("secure/data.txt").Open(pw), Encoding.UTF8)) - // Assert.Equal("encrypted", await r1.ReadToEndAsync()); - - // using (var r2 = new StreamReader(za.GetEntry("plain.txt").Open(), Encoding.UTF8)) - // Assert.Equal("modified", await r2.ReadToEndAsync()); - // } - //} - - - - //[Fact] - //public async Task Update_ModifySameEncryptedEntryMultipleTimes() - //{ - // string zipPath = NewPath("update_modify_multiple.zip"); - // const string pw = "multi-pw"; - - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // // Create initial encrypted entry - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - // { - // var e = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - // using var w = new StreamWriter(e.Open(), Encoding.UTF8); - // await w.WriteAsync("version1"); - // } - - // // Modify entry multiple times - // for (int i = 2; i <= 3; i++) - // { - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - // { - // var e = za.GetEntry("secure/data.txt"); - // Assert.NotNull(e); - - // string oldContent; - // using (var r = new StreamReader(e!.Open(pw), Encoding.UTF8)) - // oldContent = await r.ReadToEndAsync(); - - // e.Delete(); // remove old entry - - // var newEntry = za.CreateEntry("secure/data.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - // using var w = new StreamWriter(newEntry.Open(), Encoding.UTF8); - // await w.WriteAsync($"{oldContent}-version{i}"); - // } - // } - - // // Assert final content - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - // { - // var e = za.GetEntry("secure/data.txt"); - // Assert.NotNull(e); - // using var r = new StreamReader(e!.Open(pw), Encoding.UTF8); - // var text = await r.ReadToEndAsync(); - // Assert.Equal("version1-version2-version3", text); - // } - //} - - - //[Fact] - //public async Task Update_CopyEncryptedEntryToPlainEntry() - //{ - // string zipPath = NewPath("update_copy_to_plain.zip"); - // const string pw = "plain-copy"; - - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // // Create encrypted entry - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - // { - // var e = za.CreateEntry("secure/original.txt", pw, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - // using var w = new StreamWriter(e.Open(), Encoding.UTF8); - // await w.WriteAsync("secret content"); - // } - - // // Copy encrypted content to a plain entry - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Update)) - // { - // var src = za.GetEntry("secure/original.txt"); - // Assert.NotNull(src); - - // string content; - // using (var r = new StreamReader(src!.Open(pw), Encoding.UTF8)) - // content = await r.ReadToEndAsync(); - - // var plainEntry = za.CreateEntry("public/copy.txt"); // no encryption - // using var w = new StreamWriter(plainEntry.Open(), Encoding.UTF8); - // await w.WriteAsync(content); - // } - - // // Assert both entries exist and content matches - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - // { - // var enc = za.GetEntry("secure/original.txt"); - // var plain = za.GetEntry("public/copy.txt"); - // Assert.NotNull(enc); - // Assert.NotNull(plain); - - // using (var r1 = new StreamReader(enc!.Open(pw), Encoding.UTF8)) - // Assert.Equal("secret content", await r1.ReadToEndAsync()); - - // using (var r2 = new StreamReader(plain!.Open(), Encoding.UTF8)) - // Assert.Equal("secret content", await r2.ReadToEndAsync()); - // } - //} - - - //[Fact] - //public void CreateEntryFromFile_WithPassword_WrongPassword_Throws() - //{ - // // Arrange - // Directory.CreateDirectory(DownloadsDir); - // string srcPath = NewPath("source_wrong_pw.txt"); - // string zipPath = NewPath("create_from_file_encrypted_wrongpw.zip"); - // const string entryName = "secure/wrong.txt"; - // const string correctPassword = "correct!"; - // const string badPassword = "wrong!"; - // const string payload = "secret data"; - - // if (File.Exists(srcPath)) File.Delete(srcPath); - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // File.WriteAllText(srcPath, payload, new UTF8Encoding(false)); - - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - // { - // var e = za.CreateEntryFromFile( - // sourceFileName: srcPath, - // entryName: entryName, - // compressionLevel: CompressionLevel.Optimal, - // password: correctPassword, - // encryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto); - // } - - // // Act & Assert - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - // { - // var e = za.GetEntry(entryName); - // Assert.NotNull(e); - - // Assert.ThrowsAny(() => - // { - // using var _ = e!.Open(badPassword); - // }); - // } - //} - - - //[Fact] - //public async Task CreateEntryFromFile_WithEncryption_RoundTrip() - //{ - // // Arrange - // Directory.CreateDirectory(DownloadsDir); - // string srcPath = NewPath("source_plain.txt"); - // string zipPath = NewPath("create_from_file_plain.zip"); - // const string entryName = "plain/copy.txt"; - // const string payload = "this is plain"; - // const string pwd = "anything"; - - // if (File.Exists(srcPath)) File.Delete(srcPath); - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // await File.WriteAllTextAsync(srcPath, payload, new UTF8Encoding(false)); - - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Create)) - // { - // var e = za.CreateEntryFromFile( - // sourceFileName: srcPath, - // entryName: entryName, - // compressionLevel: CompressionLevel.Optimal, - // password: pwd, - // encryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto); - // } - - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - // { - // var e = za.GetEntry(entryName); - // Assert.NotNull(e); - - // using var r = new StreamReader(e!.Open(pwd), Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - // string text = await r.ReadToEndAsync(); - // Assert.Equal(payload, text); - - // // Opening a plain entry with a password should throw - // Assert.ThrowsAny(() => - // { - // using var _ = e.Open("some-password"); - // }); - // } - //} - - //[Fact] - //public void CreateEntry_UsesArchiveDefaults_WhenNotOverridden() - //{ - // Directory.CreateDirectory(DownloadsDir); - // var zipPath = NewPath("defaults_apply.zip"); - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // const string defaultPassword = "archive-pw"; - // const string payload = "default encryption content"; - // const string entryName = "secure/default.txt"; - - // using (var zipFs = File.Create(zipPath)) - // using (var za = new ZipArchive(zipFs, - // ZipArchiveMode.Create, - // leaveOpen: false, - // entryNameEncoding: Encoding.UTF8, - // defaultPassword: defaultPassword, - // defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) - // { - // var e = za.CreateEntry(entryName); - - // using (var es = e.Open()) - // { - // var bytes = Encoding.UTF8.GetBytes(payload); - // es.Write(bytes, 0, bytes.Length); - // } - // } - - // // Verify with the archive default password - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - // { - // var e = za.GetEntry(entryName); - // Assert.NotNull(e); - // using var r = new StreamReader(e!.Open(defaultPassword), Encoding.UTF8); - // Assert.Equal(payload, r.ReadToEnd()); - // } - //} - - //[Fact] - //public async Task CreateMode_DefaultPassword_AppliesToMultipleEntries() - //{ - // string zipPath = NewPath("defaults_multiple.zip"); - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // const string defaultPassword = "archive-pw"; - - // using (var zipFs = File.Create(zipPath)) - // using (var za = new ZipArchive(zipFs, - // ZipArchiveMode.Create, - // leaveOpen: false, - // entryNameEncoding: Encoding.UTF8, - // defaultPassword: defaultPassword, - // defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) - // { - // var e1 = za.CreateEntry("secure/one.txt"); - // using (var s1 = e1.Open()) - // { - // var b = Encoding.UTF8.GetBytes("ONE"); - // s1.Write(b, 0, b.Length); - // } - - // var e2 = za.CreateEntry("secure/two.txt"); - // using (var s2 = e2.Open()) - // { - // var b = Encoding.UTF8.GetBytes("TWO"); - // s2.Write(b, 0, b.Length); - // } - // } - - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - // { - // using (var r1 = new StreamReader(za.GetEntry("secure/one.txt")!.Open(defaultPassword), Encoding.UTF8)) - // Assert.Equal("ONE", await r1.ReadToEndAsync()); - - // using (var r2 = new StreamReader(za.GetEntry("secure/two.txt")!.Open(defaultPassword), Encoding.UTF8)) - // Assert.Equal("TWO", await r2.ReadToEndAsync()); - // } - //} - - //[Fact] - //public async Task CreateEntry_WithExplicitPassword_OverridesDefaultPassword() - //{ - // string zipPath = NewPath("override_default.zip"); - // if (File.Exists(zipPath)) File.Delete(zipPath); - - // const string archivePassword = "archive-pw"; - // const string entryPassword = "entry-pw"; - - // using (var zipFs = File.Create(zipPath)) - // using (var za = new ZipArchive(zipFs, - // ZipArchiveMode.Create, - // leaveOpen: false, - // entryNameEncoding: Encoding.UTF8, - // defaultPassword: archivePassword, - // defaultEncryption: ZipArchiveEntry.EncryptionMethod.ZipCrypto)) - // { - // var e = za.CreateEntry("secure/override.txt", entryPassword, ZipArchiveEntry.EncryptionMethod.ZipCrypto); - // using (var s = e.Open()) - // { - // var b = Encoding.UTF8.GetBytes("OVERRIDE"); - // s.Write(b, 0, b.Length); - // } - // } - - // using (var za = ZipFile.Open(zipPath, ZipArchiveMode.Read)) - // { - // var e = za.GetEntry("secure/override.txt"); - // Assert.NotNull(e); - - // // Should succeed with entry password - // using (var rOk = new StreamReader(e!.Open(entryPassword), Encoding.UTF8)) - // Assert.Equal("OVERRIDE", await rOk.ReadToEndAsync()); - - // // Wrong: using archive default should fail - // Assert.ThrowsAny(() => - // { - // using var _ = e.Open(archivePassword); - // }); - // } - //} - + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void OpenAESEncryptedTxtFile_ShouldReturnPlaintextWinZip() { @@ -1648,6 +943,7 @@ public void OpenAESEncryptedTxtFile_ShouldReturnPlaintextWinZip() Assert.Equal("this is plain", content); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void OpenAESEncryptedTxtFile_ShouldReturnPlaintextWinRar() { @@ -1662,6 +958,7 @@ public void OpenAESEncryptedTxtFile_ShouldReturnPlaintextWinRar() Assert.Equal("this is plain", content); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void OpenAESEncryptedTxtFile_ShouldReturnPlaintext7zip() { @@ -1676,6 +973,7 @@ public void OpenAESEncryptedTxtFile_ShouldReturnPlaintext7zip() Assert.Equal("this is plain", content); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void OpenMultipleAESEncryptedEntries_ShouldReturnCorrectContent() { @@ -1701,6 +999,7 @@ public void OpenMultipleAESEncryptedEntries_ShouldReturnCorrectContent() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task CreateAndReadAES256EncryptedEntry_RoundTrip() { @@ -1738,6 +1037,7 @@ public async Task CreateAndReadAES256EncryptedEntry_RoundTrip() } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task CreateAndReadMultipleAES256EncryptedEntries_RoundTrip() { @@ -1791,6 +1091,7 @@ public async Task CreateAndReadMultipleAES256EncryptedEntries_RoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task CreateAndReadAES256EntriesWithDifferentPasswords_RoundTrip() { @@ -1838,6 +1139,7 @@ public async Task CreateAndReadAES256EntriesWithDifferentPasswords_RoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task CreateMixedPlainAndAES256EncryptedEntries_RoundTrip() { @@ -1911,6 +1213,7 @@ public async Task CreateMixedPlainAndAES256EncryptedEntries_RoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task CreateAndReadAES128EncryptedEntry_RoundTrip() { @@ -1949,6 +1252,7 @@ public async Task CreateAndReadAES128EncryptedEntry_RoundTrip() } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task CreateAndReadAES192EncryptedEntry_RoundTrip() { @@ -1986,6 +1290,7 @@ public async Task CreateAndReadAES192EncryptedEntry_RoundTrip() Assert.Equal(expectedContent, actualContent); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void CreateAndReadMultipleEntriesWithDifferentAESLevels_RoundTrip() { @@ -2036,6 +1341,7 @@ public void CreateAndReadMultipleEntriesWithDifferentAESLevels_RoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void CreateLargeFileWithAES128_RoundTrip() { @@ -2077,6 +1383,7 @@ public void CreateLargeFileWithAES128_RoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task CreateCompressedAndAES192Encrypted_RoundTrip() { @@ -2132,6 +1439,7 @@ public async Task CreateCompressedAndAES192Encrypted_RoundTrip() Assert.True(fileInfo.Length > 0); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task MixAllEncryptionTypes_RoundTrip() { @@ -2199,6 +1507,7 @@ public async Task MixAllEncryptionTypes_RoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public void OpenAESEncryptedTxtFile_AE1_ShouldReturnPlaintext() { @@ -2220,6 +1529,7 @@ public void OpenAESEncryptedTxtFile_AE1_ShouldReturnPlaintext() Assert.Equal(expectedContent, actualContent); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task AES128WithSpecialCharacters_RoundTrip() { @@ -2307,6 +1617,7 @@ public async Task CreateAndReadAES256WithAsyncOperations_RoundTrip() Assert.Equal(expectedContent, actualContent); } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task CreateMultipleAESEntriesWithAsyncWrites_RoundTrip() { @@ -2362,6 +1673,7 @@ public async Task CreateMultipleAESEntriesWithAsyncWrites_RoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task CreateLargeBinaryDataWithAES128Async_RoundTrip() { @@ -2413,48 +1725,7 @@ public async Task CreateLargeBinaryDataWithAES128Async_RoundTrip() } } - [Fact] - public async Task StreamCopyAsyncWithAES192_RoundTrip() - { - // Arrange - Directory.CreateDirectory(DownloadsDir); - string tempPath = Path.Join(DownloadsDir, "aes192_stream_copy.zip"); - const string entryName = "stream_copy.dat"; - const string password = "StreamCopy192!"; - - // Create test data - var testData = new byte[64 * 1024]; // 64KB - new Random(456).NextBytes(testData); - - // Act 1: Use CopyToAsync to write - using (var createStream = File.Create(tempPath)) - using (var archive = new ZipArchive(createStream, ZipArchiveMode.Create)) - { - var entry = archive.CreateEntry(entryName); - using var entryStream = entry.Open(password, ZipArchiveEntry.EncryptionMethod.Aes192); - using var sourceStream = new MemoryStream(testData); - - await sourceStream.CopyToAsync(entryStream); - } - - // Act 2: Use CopyToAsync to read - using (var readStream = File.OpenRead(tempPath)) - using (var archive = new ZipArchive(readStream, ZipArchiveMode.Read)) - { - var entry = archive.GetEntry(entryName); - Assert.NotNull(entry); - - using var entryStream = entry!.Open(password); - using var destStream = new MemoryStream(); - - await entryStream.CopyToAsync(destStream); - var actualData = destStream.ToArray(); - - // Assert - Assert.Equal(testData, actualData); - } - } - + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task MultipleAsyncWritesInSingleEntry_AES256_RoundTrip() { @@ -2505,6 +1776,7 @@ public async Task MultipleAsyncWritesInSingleEntry_AES256_RoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task AsyncReadInChunks_AES128_VerifyContent() { @@ -2555,6 +1827,7 @@ public async Task AsyncReadInChunks_AES128_VerifyContent() } } + [SkipOnCI("Local development test - requires specific file paths")] [Fact] public async Task MixedSyncAsyncOperations_AES192_RoundTrip() { From 0c659fcd36d30b5ecdcff385646c39c2aa68f90c Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 1 Dec 2025 22:46:01 +0100 Subject: [PATCH 20/39] improve winzipaesstream writing by generating larger keystream --- .../tests/ZipFile.Encryption.cs | 44 +++++++++++ .../System/IO/Compression/WinZipAesStream.cs | 75 +++++++++++++------ 2 files changed, 98 insertions(+), 21 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index a0b7432a9d4dd0..dad9cd96be8ead 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -197,6 +197,50 @@ public async Task Encryption_Combinations_RoundTrip(bool async) } } + [Theory] + [MemberData(nameof(Get_Is_Async))] + public async Task Encryption_LargeFile_RoundTrip(bool async) + { + string archivePath = GetTempArchivePath(); + string entryName = "large.bin"; + int size = 1024 * 1024; // 1MB + byte[] content = new byte[size]; + new Random(42).NextBytes(content); + string password = "password123"; + var encryptionMethod = ZipArchiveEntry.EncryptionMethod.Aes256; + + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry(entryName); + Stream s = entry.Open(password, encryptionMethod); + using (s) + { + if (async) + await s.WriteAsync(content, 0, content.Length); + else + s.Write(content, 0, content.Length); + } + } + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + Stream s = entry.Open(password); + using (s) + using (MemoryStream ms = new MemoryStream()) + { + if (async) + await s.CopyToAsync(ms); + else + s.CopyTo(ms); + + Assert.Equal(content, ms.ToArray()); + } + } + } + [Fact] public void Negative_WrongPassword_Throws_InvalidDataException() { diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index dad607aff1791a..f723e5515131b9 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -12,6 +12,7 @@ namespace System.IO.Compression internal sealed class WinZipAesStream : Stream { private const int BLOCK_SIZE = 16; // AES block size in bytes + private const int KEYSTREAM_BUFFER_SIZE = 4096; // Pre-generate 4KB of keystream (256 blocks) private readonly Stream _baseStream; private readonly bool _encrypting; private readonly int _keySizeBits; @@ -37,6 +38,10 @@ internal sealed class WinZipAesStream : Stream private readonly byte[] _partialBlock = new byte[BLOCK_SIZE]; private int _partialBlockBytes; + // Pre-generated keystream buffer for efficiency + private readonly byte[] _keystreamBuffer = new byte[KEYSTREAM_BUFFER_SIZE]; + private int _keystreamOffset = KEYSTREAM_BUFFER_SIZE; // Start depleted to force initial generation + public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, long totalStreamSize = -1, bool leaveOpen = false) { ArgumentNullException.ThrowIfNull(baseStream); @@ -259,49 +264,77 @@ private void ProcessBlock(byte[] buffer, int offset, int count) Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized before processing blocks"); int processed = 0; - byte[] keystream = new byte[16]; while (processed < count) { - _aesEncryptor.TransformBlock(_counterBlock, 0, 16, keystream, 0); - - // Log keystream for first block - if (processed == 0) + // Ensure we have enough keystream bytes available + int keystreamAvailable = KEYSTREAM_BUFFER_SIZE - _keystreamOffset; + if (keystreamAvailable == 0) { - Debug.WriteLine($"First keystream block: {BitConverter.ToString(keystream)}"); + GenerateKeystreamBuffer(); + keystreamAvailable = KEYSTREAM_BUFFER_SIZE; } - IncrementCounter(); - - int blockSize = Math.Min(16, count - processed); + // Process as many bytes as possible with the available keystream + int bytesToProcess = Math.Min(count - processed, keystreamAvailable); // For encryption: XOR first, then HMAC the ciphertext if (_encrypting) { // XOR the data with the keystream to create ciphertext - for (int i = 0; i < blockSize; i++) - { - buffer[offset + processed + i] ^= keystream[i]; - } + XorBytes(buffer, offset + processed, _keystreamBuffer, _keystreamOffset, bytesToProcess); // HMAC is computed on the ciphertext (after XOR) - _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); + _hmac.TransformBlock(buffer, offset + processed, bytesToProcess, null, 0); } // For decryption: HMAC first (on ciphertext), then XOR else { // HMAC is computed on the ciphertext (before XOR) - _hmac.TransformBlock(buffer, offset + processed, blockSize, null, 0); + _hmac.TransformBlock(buffer, offset + processed, bytesToProcess, null, 0); // XOR the ciphertext with the keystream to recover plaintext - for (int i = 0; i < blockSize; i++) - { - buffer[offset + processed + i] ^= keystream[i]; - } + XorBytes(buffer, offset + processed, _keystreamBuffer, _keystreamOffset, bytesToProcess); } - processed += blockSize; + _keystreamOffset += bytesToProcess; + processed += bytesToProcess; + } + } + + private void GenerateKeystreamBuffer() + { + Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized"); + + // Generate KEYSTREAM_BUFFER_SIZE bytes of keystream (256 blocks of 16 bytes each) + for (int i = 0; i < KEYSTREAM_BUFFER_SIZE; i += BLOCK_SIZE) + { + _aesEncryptor.TransformBlock(_counterBlock, 0, BLOCK_SIZE, _keystreamBuffer, i); + IncrementCounter(); + } + + _keystreamOffset = 0; + } + + private static void XorBytes(byte[] dest, int destOffset, byte[] src, int srcOffset, int count) + { + Span destSpan = dest.AsSpan(destOffset, count); + ReadOnlySpan srcSpan = src.AsSpan(srcOffset, count); + + // Process 8 bytes at a time when possible for better performance + int i = 0; + while (i + 8 <= count) + { + long destVal = System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(destSpan.Slice(i)); + long srcVal = System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(srcSpan.Slice(i)); + System.Buffers.Binary.BinaryPrimitives.WriteInt64LittleEndian(destSpan.Slice(i), destVal ^ srcVal); + i += 8; } - Debug.WriteLine($"Final counter after processing: {BitConverter.ToString(_counterBlock)}"); + // Handle remaining bytes + while (i < count) + { + destSpan[i] ^= srcSpan[i]; + i++; + } } private void IncrementCounter() From e2b21ff0734cd32360722ac07688159bb8869250 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 2 Dec 2025 15:52:26 +0100 Subject: [PATCH 21/39] update tests, fixed async opening and updated stream positioning --- .../tests/ZipFile.Encryption.cs | 1 - .../System/IO/Compression/WinZipAesStream.cs | 29 +------ .../IO/Compression/ZipArchiveEntry.Async.cs | 78 +++++++++++++++++-- .../System/IO/Compression/ZipArchiveEntry.cs | 11 +-- .../System/IO/Compression/ZipBlocks.Async.cs | 64 +++++++++++++++ .../System/IO/Compression/ZipCryptoStream.cs | 10 +-- 6 files changed, 143 insertions(+), 50 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index dad9cd96be8ead..230abc3312a508 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -287,7 +287,6 @@ public void Negative_OpeningPlainEntryWithPassword_Throws() [Theory] [MemberData(nameof(Get_Is_Async))] - // todo: some async methods in ziparchiveentry missing implementation for winzipaesstream public async Task ExtractToFile_Encrypted_Success(bool async) { string archivePath = GetTempArchivePath(); diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index f723e5515131b9..03851f02408820 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -28,7 +28,6 @@ internal sealed class WinZipAesStream : Stream private byte[]? _passwordVerifier; private bool _headerWritten; private bool _headerRead; - private long _position; private bool _disposed; private bool _authCodeValidated; private readonly long _totalStreamSize; @@ -445,8 +444,6 @@ private async Task ReadCoreShared(Memory buffer, bool isAsync, Cancel byte[] temp = buffer.Slice(0, bytesRead).ToArray(); ProcessBlock(temp, 0, bytesRead); temp.CopyTo(buffer.Span); - - _position += bytesRead; } else // n == 0, meaning end of stream { @@ -535,7 +532,6 @@ private async Task WriteCoreShared(ReadOnlyMemory buffer, bool isAsync, Ca else _baseStream.Write(_partialBlock, 0, BLOCK_SIZE); - _position += BLOCK_SIZE; _partialBlockBytes = 0; } } @@ -564,7 +560,6 @@ private async Task WriteCoreShared(ReadOnlyMemory buffer, bool isAsync, Ca else _baseStream.Write(chunkBuffer, 0, bytesToProcess); - _position += bytesToProcess; inputOffset += bytesToProcess; inputCount -= bytesToProcess; } @@ -625,7 +620,6 @@ private async Task FinalizeEncryptionAsync(bool isAsync, CancellationToken cance _baseStream.Write(_partialBlock, 0, _partialBlockBytes); } - _position += _partialBlockBytes; _partialBlockBytes = 0; } } @@ -709,28 +703,7 @@ public override async ValueTask DisposeAsync() public override long Length => throw new NotSupportedException(); public override long Position { - get - { - // Calculate the actual position including all metadata - long position = _position; - - // Add header size if it has been written/read - if (_headerWritten || _headerRead) - { - int saltSize = _keySizeBits / 16; - int headerSize = saltSize + 2; // Salt + Password Verifier - position += headerSize; - } - - // Add auth code size if it has been written/validated - if (_authCodeValidated) - { - const int authCodeSize = 10; - position += authCodeSize; - } - - return position; - } + get => throw new NotSupportedException(); set => throw new NotSupportedException(); } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 876d9463da6240..364acdd9cd53eb 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -61,12 +61,30 @@ internal async Task GetOffsetOfCompressedDataAsync(CancellationToken cance cancellationToken.ThrowIfCancellationRequested(); if (_storedOffsetOfCompressedData == null) { + // Seek to local header _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - // by calling this, we are using local header _storedEntryNameBytes.Length and extraFieldLength - // to find start of data, but still using central directory size information - if (!await ZipLocalFileHeader.TrySkipBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false)) - throw new InvalidDataException(SR.LocalFileHeaderCorrupt); - _storedOffsetOfCompressedData = _archive.ArchiveStream.Position; + + long baseOffset; + + if (!IsEncrypted || IsZipCryptoEncrypted()) + { + // Non-AES case: just skip the local header + if (!await ZipLocalFileHeader.TrySkipBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false)) + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + + baseOffset = _archive.ArchiveStream.Position; + } + else + { + // AES case + var (success, _) = await ZipLocalFileHeader.TrySkipBlockAESAwareAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + if (!success) + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + + baseOffset = _archive.ArchiveStream.Position; + } + + _storedOffsetOfCompressedData = baseOffset; } return _storedOffsetOfCompressedData.Value; } @@ -84,6 +102,19 @@ private async Task GetUncompressedDataAsync(CancellationToken canc if (_originallyInArchive) { + if (_isEncrypted) + { + // We don't support edit-in-place for encrypted entries without an explicit password flow. + // Tell the caller to do the safe pattern: read with Open(password), then delete+recreate. + await _storedUncompressedData.DisposeAsync().ConfigureAwait(false); + _storedUncompressedData = null; + _currentlyOpenForWrite = false; + _everOpenedForWrite = false; + throw new InvalidOperationException( + "Editing an encrypted entry in-place is not supported. " + + "Read it with Open(password), then delete and recreate the entry with CreateEntry(..., password, ...)."); + } + Stream decompressor = await OpenInReadModeAsync(false, cancellationToken).ConfigureAwait(false); await using (decompressor) { @@ -280,11 +311,44 @@ private async Task OpenInUpdateModeAsync(CancellationToken cancel { return (false, message); } - if (!await ZipLocalFileHeader.TrySkipBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false)) + + if (!IsEncrypted && !await ZipLocalFileHeader.TrySkipBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false)) { message = SR.LocalFileHeaderCorrupt; return (false, message); } + else if (IsEncrypted && CompressionMethod == CompressionMethodValues.Aes) + { + _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); + _aesCompressionMethod = CompressionMethodValues.Aes; + var (success, aesExtraField) = await ZipLocalFileHeader.TrySkipBlockAESAwareAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + if (!success) + { + message = SR.LocalFileHeaderCorrupt; + return (false, message); + } + + if (aesExtraField.HasValue) + { + EncryptionMethod detectedEncryption = aesExtraField.Value.AesStrength switch + { + 1 => EncryptionMethod.Aes128, + 2 => EncryptionMethod.Aes192, + 3 => EncryptionMethod.Aes256, + _ => throw new InvalidDataException("Unknown AES strength") + }; + + // Store the detected encryption method + _encryptionMethod = detectedEncryption; + + _aeVersion = aesExtraField.Value.VendorVersion; + + // Store the actual compression method that will be used after decryption + // This is needed for GetDataDecompressor to work correctly + // Set the compression method to the actual method for decompression + CompressionMethod = (CompressionMethodValues)aesExtraField.Value.CompressionMethod; + } + } // when this property gets called, some duplicated work long offsetOfCompressedData = await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false); @@ -354,7 +418,7 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can //The compressor fills in CRC and sizes //The DirectToArchiveWriterStream writes headers and such - DirectToArchiveWriterStream entryWriter = new(GetDataCompressor(_archive.ArchiveStream, true, null), this); + DirectToArchiveWriterStream entryWriter = new(GetDataCompressor(_archive.ArchiveStream, true, null, null), this); await using (entryWriter) { _storedUncompressedData.Seek(0, SeekOrigin.Begin); diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index eaa52a32bd33c5..d4fb02457036e5 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -474,7 +474,7 @@ private MemoryStream GetUncompressedData(string? password = null) if (_isEncrypted) { - // We dont support edit-in-place for encrypted entries without an explicit password flow. + // We don’t support edit-in-place for encrypted entries without an explicit password flow. // Tell the caller to do the safe pattern: read with Open(password), then delete+recreate. _storedUncompressedData.Dispose(); _storedUncompressedData = null; @@ -813,7 +813,7 @@ private void DetectEntryNameVersion() } - private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose) + private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool leaveBackingStreamOpen, EventHandler? onClose, Stream? streamForPosition = null) { // stream stack: backingStream -> DeflateStream -> CheckSumWriteStream @@ -851,7 +851,7 @@ private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool bool leaveCompressorStreamOpenOnClose = leaveBackingStreamOpen && !isIntermediateStream; var checkSumStream = new CheckSumAndSizeWriteStream( compressorStreamFactory, - backingStream, + streamForPosition ?? backingStream, leaveCompressorStreamOpenOnClose, this, onClose, @@ -1039,7 +1039,8 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod var entry = (ZipArchiveEntry)o!; entry._archive.ReleaseArchiveStream(entry); entry._outstandingWriteStream = null; - }); + }, + encryptionMethod != EncryptionMethod.None ? _archive.ArchiveStream : null); _outstandingWriteStream = new DirectToArchiveWriterStream(crcSizeStream, this, encryptionMethod); @@ -1453,7 +1454,7 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) //The compressor fills in CRC and sizes //The DirectToArchiveWriterStream writes headers and such using (DirectToArchiveWriterStream entryWriter = new( - GetDataCompressor(_archive.ArchiveStream, true, null), + GetDataCompressor(_archive.ArchiveStream, true, null, null), this)) { _storedUncompressedData.Seek(0, SeekOrigin.Begin); diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.Async.cs index 8305b5e42bfdce..d5d8b8c232ebb5 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.Async.cs @@ -156,6 +156,70 @@ public static async Task TrySkipBlockAsync(Stream stream, CancellationToke bytesRead = await stream.ReadAtLeastAsync(blockBytes, blockBytes.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false); return TrySkipBlockFinalize(stream, blockBytes, bytesRead); } + + public static async Task<(bool success, WinZipAesExtraField? aesExtraField)> TrySkipBlockAESAwareAsync(Stream stream, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + WinZipAesExtraField? aesExtraField = null; + + // Read the first 4 bytes (local file header signature) + byte[] signatureBytes = new byte[4]; + await stream.ReadExactlyAsync(signatureBytes, cancellationToken).ConfigureAwait(false); + if (!signatureBytes.AsSpan().SequenceEqual(SignatureConstantBytes)) + { + return (false, null); // Not a valid local file header + } + + // Read fixed-size fields after signature + // Skip version through mod date (10 bytes), then skip CRC32 + sizes (12 bytes) + byte[] skipBuffer = new byte[22]; + await stream.ReadExactlyAsync(skipBuffer, cancellationToken).ConfigureAwait(false); + + byte[] lengthBuffer = new byte[4]; + await stream.ReadExactlyAsync(lengthBuffer, cancellationToken).ConfigureAwait(false); + ushort nameLength = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(lengthBuffer.AsSpan(0, 2)); + ushort extraLength = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(lengthBuffer.AsSpan(2, 2)); + + // Skip file name + stream.Seek(nameLength, SeekOrigin.Current); + + // Parse extra fields if present + if (extraLength > 0) + { + long extraStart = stream.Position; + long extraEnd = extraStart + extraLength; + + byte[] fieldHeader = new byte[4]; + while (stream.Position < extraEnd) + { + await stream.ReadExactlyAsync(fieldHeader, cancellationToken).ConfigureAwait(false); + ushort headerId = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(fieldHeader.AsSpan(0, 2)); + ushort dataSize = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(fieldHeader.AsSpan(2, 2)); + + if (headerId == WinZipAesExtraField.HeaderId) // 0x9901 + { + // AES extra field structure: + // Vendor version (2) + Vendor ID (2) + AES strength (1) + Original compression (2) + byte[] aesData = new byte[7]; + await stream.ReadExactlyAsync(aesData, cancellationToken).ConfigureAwait(false); + ushort vendorVersion = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(aesData.AsSpan(0, 2)); + // Skip vendor ID 'AE' (bytes 2-3) + byte aesStrength = aesData[4]; // 1, 2, or 3 + ushort compressionMethod = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(aesData.AsSpan(5, 2)); + + aesExtraField = new WinZipAesExtraField(vendorVersion, aesStrength, compressionMethod); + break; + } + else + { + stream.Seek(dataSize, SeekOrigin.Current); // Skip unknown extra field + } + } + } + + return (true, aesExtraField); + } } internal sealed partial class ZipCentralDirectoryFileHeader diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index 8cd53d2b08fe6f..bdda034f6c9c8a 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -16,7 +16,6 @@ internal sealed class ZipCryptoStream : Stream private bool _headerWritten; private readonly ushort _verifierLow2Bytes; // (DOS time low word when streaming) private readonly uint? _crc32ForHeader; // (CRC-based header when not streaming) - private int _position; private uint _key0; private uint _key1; @@ -43,7 +42,6 @@ public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte ex InitKeysFromBytes(password.Span); _encrypting = false; ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes - _position = 0; } // Encryption constructor @@ -58,7 +56,6 @@ public ZipCryptoStream(Stream baseStream, _leaveOpen = leaveOpen; _verifierLow2Bytes = passwordVerifierLow2Bytes; _crc32ForHeader = crc32; - _position = 0; InitKeysFromBytes(password.Span); } @@ -109,7 +106,6 @@ private async ValueTask WriteHeaderCore(bool isAsync, CancellationToken cancella } _headerWritten = true; - _position += 12; } private void EnsureHeader() @@ -181,7 +177,7 @@ private byte DecryptByte(byte ciph) public override long Length => throw new NotSupportedException(); public override long Position { - get => _position; + get => throw new NotSupportedException(); set => throw new NotSupportedException("ZipCryptoStream does not support seeking."); } public override void Flush() => _base.Flush(); @@ -199,7 +195,6 @@ public override int Read(Span destination) int n = _base.Read(destination); for (int i = 0; i < n; i++) destination[i] = DecryptByte(destination[i]); - _position += n; return n; } throw new NotSupportedException("Stream is in encryption (write-only) mode."); @@ -228,7 +223,6 @@ public override void Write(ReadOnlySpan buffer) UpdateKeys(p); } _base.Write(tmp, 0, tmp.Length); - _position += tmp.Length; } protected override void Dispose(bool disposing) @@ -271,7 +265,6 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation Span span = buffer.Span; for (int i = 0; i < n; i++) span[i] = DecryptByte(span[i]); - _position += n; return n; } throw new NotSupportedException("Stream is in encryption (write-only) mode."); @@ -302,7 +295,6 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella } await _base.WriteAsync(tmp, cancellationToken).ConfigureAwait(false); - _position += tmp.Length; } public override Task FlushAsync(CancellationToken cancellationToken) From 3a0e9630dc7a4e453ef3886fc6705e7e39d4eb59 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Fri, 12 Dec 2025 15:24:01 +0200 Subject: [PATCH 22/39] fix comments, proper conflict resolution and break circular dependency --- .../Win32/SafeHandles/SafeX509Handles.Unix.cs | 6 +- .../ZipFileExtensions.ZipArchive.Create.cs | 1 + .../tests/ZipFile.Encryption.cs | 62 +-- .../ref/System.IO.Compression.cs | 1 + .../src/System.IO.Compression.csproj | 4 +- .../System/IO/Compression/WinZipAesStream.cs | 524 +++++++++--------- .../IO/Compression/ZipArchiveEntry.Async.cs | 8 +- .../System/IO/Compression/ZipArchiveEntry.cs | 60 +- .../IO/Compression/ZipCompressionMethod.cs | 5 + .../System/IO/Compression/ZipCustomStreams.cs | 112 +--- .../src/System.Security.Cryptography.csproj | 2 +- 11 files changed, 363 insertions(+), 422 deletions(-) diff --git a/src/libraries/Common/src/Microsoft/Win32/SafeHandles/SafeX509Handles.Unix.cs b/src/libraries/Common/src/Microsoft/Win32/SafeHandles/SafeX509Handles.Unix.cs index 7017405cb4a6a0..45b426661eeaa9 100644 --- a/src/libraries/Common/src/Microsoft/Win32/SafeHandles/SafeX509Handles.Unix.cs +++ b/src/libraries/Common/src/Microsoft/Win32/SafeHandles/SafeX509Handles.Unix.cs @@ -13,8 +13,10 @@ internal sealed class SafeX509Handle : SafeHandle private static readonly bool s_captureTrace = Environment.GetEnvironmentVariable("DEBUG_SAFEX509HANDLE_FINALIZATION") != null; - private readonly StackTrace? _stacktrace = - s_captureTrace ? new StackTrace(fNeedFileInfo: true) : null; + // Using reflection to avoid a hard dependency on System.Diagnostics.StackTrace, which prevents + // System.IO.Compression from referencing this assembly. + private readonly object? _stacktrace = + s_captureTrace ? Activator.CreateInstance(Type.GetType("System.Diagnostics.StackTrace")!, true) : null; ~SafeX509Handle() { diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs index f9a79d83be7df7..90c28c1fedda01 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs @@ -102,6 +102,7 @@ private static (FileStream, ZipArchiveEntry) InitializeDoCreateEntryFromFile(Zip ArgumentNullException.ThrowIfNull(destination); ArgumentNullException.ThrowIfNull(sourceFileName); ArgumentNullException.ThrowIfNull(entryName); + // Checking of compressionLevel is passed down to DeflateStream and the IDeflater implementation // as it is a pluggable component that completely encapsulates the meaning of compressionLevel. diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 230abc3312a508..50af49563fb800 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -13,7 +13,7 @@ namespace System.IO.Compression.Tests { public class ZipFile_EncryptionTests : ZipFileTestBase { - public static IEnumerable Get_SingleEntry_Data() + public static IEnumerable EncryptionMethodAndBoolTestData() { foreach (var method in new[] { @@ -28,54 +28,8 @@ public static IEnumerable Get_SingleEntry_Data() } } - public static IEnumerable Get_MultipleEntries_SamePassword_Data() - { - foreach (var method in new[] - { - ZipArchiveEntry.EncryptionMethod.ZipCrypto, - ZipArchiveEntry.EncryptionMethod.Aes256 - }) - { - yield return new object[] { method, false }; - yield return new object[] { method, true }; - } - } - - public static IEnumerable Get_MultipleEntries_DifferentPasswords_Data() - { - foreach (var method in new[] - { - ZipArchiveEntry.EncryptionMethod.ZipCrypto, - ZipArchiveEntry.EncryptionMethod.Aes256 - }) - { - yield return new object[] { method, false }; - yield return new object[] { method, true }; - } - } - - public static IEnumerable Get_MixedPlainEncrypted_Data() - { - foreach (var method in new[] - { - ZipArchiveEntry.EncryptionMethod.ZipCrypto, - ZipArchiveEntry.EncryptionMethod.Aes128, - ZipArchiveEntry.EncryptionMethod.Aes256 - }) - { - yield return new object[] { method, false }; - yield return new object[] { method, true }; - } - } - - public static IEnumerable Get_Is_Async() - { - yield return new object[] { false }; - yield return new object[] { true }; - } - [Theory] - [MemberData(nameof(Get_SingleEntry_Data))] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] public async Task Encryption_SingleEntry_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -97,7 +51,7 @@ public async Task Encryption_SingleEntry_RoundTrip(ZipArchiveEntry.EncryptionMet } [Theory] - [MemberData(nameof(Get_MultipleEntries_SamePassword_Data))] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] public async Task Encryption_MultipleEntries_SamePassword_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -122,7 +76,7 @@ public async Task Encryption_MultipleEntries_SamePassword_RoundTrip(ZipArchiveEn } [Theory] - [MemberData(nameof(Get_MultipleEntries_DifferentPasswords_Data))] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] public async Task Encryption_MultipleEntries_DifferentPasswords_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -146,7 +100,7 @@ public async Task Encryption_MultipleEntries_DifferentPasswords_RoundTrip(ZipArc } [Theory] - [MemberData(nameof(Get_MixedPlainEncrypted_Data))] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] public async Task Encryption_MixedPlainAndEncrypted_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -173,7 +127,7 @@ public async Task Encryption_MixedPlainAndEncrypted_RoundTrip(ZipArchiveEntry.En } [Theory] - [MemberData(nameof(Get_Is_Async))] + [MemberData(nameof(Get_Booleans_Data))] public async Task Encryption_Combinations_RoundTrip(bool async) { string archivePath = GetTempArchivePath(); @@ -198,7 +152,7 @@ public async Task Encryption_Combinations_RoundTrip(bool async) } [Theory] - [MemberData(nameof(Get_Is_Async))] + [MemberData(nameof(Get_Booleans_Data))] public async Task Encryption_LargeFile_RoundTrip(bool async) { string archivePath = GetTempArchivePath(); @@ -286,7 +240,7 @@ public void Negative_OpeningPlainEntryWithPassword_Throws() } [Theory] - [MemberData(nameof(Get_Is_Async))] + [MemberData(nameof(Get_Booleans_Data))] public async Task ExtractToFile_Encrypted_Success(bool async) { string archivePath = GetTempArchivePath(); diff --git a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs index fe8cc2c10dd532..03eda5c7a279da 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -151,6 +151,7 @@ public enum ZipCompressionMethod Stored = 0, Deflate = 8, Deflate64 = 9, + Aes = 99 } public sealed partial class ZLibCompressionOptions { diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index 6715dc201493bc..994062f96a0a40 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -51,6 +51,7 @@ + @@ -80,6 +81,7 @@ + - + \ No newline at end of file diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 03851f02408820..016eeddc2d1edf 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Threading; @@ -11,8 +12,8 @@ namespace System.IO.Compression { internal sealed class WinZipAesStream : Stream { - private const int BLOCK_SIZE = 16; // AES block size in bytes - private const int KEYSTREAM_BUFFER_SIZE = 4096; // Pre-generate 4KB of keystream (256 blocks) + private const int BlockSize = 16; // AES block size in bytes + private const int KeystreamBufferSize = 4096; // Pre-generate 4KB of keystream (256 blocks) private readonly Stream _baseStream; private readonly bool _encrypting; private readonly int _keySizeBits; @@ -21,7 +22,7 @@ internal sealed class WinZipAesStream : Stream #pragma warning disable CA1416 // HMACSHA1 is available on all platforms private readonly HMACSHA1 _hmac; #pragma warning restore CA1416 - private readonly byte[] _counterBlock = new byte[BLOCK_SIZE]; + private readonly byte[] _counterBlock = new byte[BlockSize]; private byte[]? _key; private byte[]? _hmacKey; private byte[]? _salt; @@ -34,12 +35,12 @@ internal sealed class WinZipAesStream : Stream private readonly bool _leaveOpen; private readonly long _encryptedDataSize; private long _encryptedDataRemaining; - private readonly byte[] _partialBlock = new byte[BLOCK_SIZE]; + private readonly byte[] _partialBlock = new byte[BlockSize]; private int _partialBlockBytes; // Pre-generated keystream buffer for efficiency - private readonly byte[] _keystreamBuffer = new byte[KEYSTREAM_BUFFER_SIZE]; - private int _keystreamOffset = KEYSTREAM_BUFFER_SIZE; // Start depleted to force initial generation + private readonly byte[] _keystreamBuffer = new byte[KeystreamBufferSize]; + private int _keystreamOffset = KeystreamBufferSize; // Start depleted to force initial generation public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, long totalStreamSize = -1, bool leaveOpen = false) { @@ -95,89 +96,77 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en } else { + // For decryption, we must know the total size to locate the auth tag + if (_totalStreamSize <= 0) + { + throw new ArgumentException("Total stream size must be provided for decryption.", nameof(totalStreamSize)); + } + + int saltSize = _keySizeBits / 16; + int headerSize = saltSize + 2; + const int hmacSize = 10; + + _encryptedDataSize = _totalStreamSize - headerSize - hmacSize; + _encryptedDataRemaining = _encryptedDataSize; + + if (_encryptedDataSize < 0) + { + throw new InvalidDataException("Stream size is too small for WinZip AES format."); + } + ReadHeader(password); } } private void DeriveKeysFromPassword(ReadOnlyMemory password, byte[] salt) { - byte[] passwordBytes = Encoding.UTF8.GetBytes(password.ToArray()); + // Calculate sizes + int keySizeInBytes = _keySizeBits / 8; + int totalKeySize = keySizeInBytes + keySizeInBytes + 2; + + // Handle password encoding without heap allocation + // We use a buffer on the stack for the UTF8 bytes + int maxPasswordByteCount = Encoding.UTF8.GetMaxByteCount(password.Length); + Span passwordBytes = stackalloc byte[maxPasswordByteCount]; + int actualByteCount = Encoding.UTF8.GetBytes(password.Span, passwordBytes); + Span passwordSpan = passwordBytes[..actualByteCount]; + + // Allocate the derived key buffer on the stack + Span derivedKey = stackalloc byte[totalKeySize]; try { - // AES key size + HMAC key size (same as AES key) + password verifier (2 bytes) - int keySizeInBytes = _keySizeBits / 8; - int totalKeySize = keySizeInBytes + keySizeInBytes + 2; - - // WinZip AES uses SHA1 for PBKDF2 with 1000 iterations per spec - byte[] derivedKey = Rfc2898DeriveBytes.Pbkdf2( - passwordBytes, - salt, - 1000, - HashAlgorithmName.SHA1, - totalKeySize); + // Use the Span-based PBKDF2 overload (No heap allocation for result) + Rfc2898DeriveBytes.Pbkdf2( + passwordSpan, + salt, + derivedKey, + 1000, + HashAlgorithmName.SHA1); - // Split the derived key material + // Initialize class-level arrays _key = new byte[keySizeInBytes]; _hmacKey = new byte[keySizeInBytes]; _passwordVerifier = new byte[2]; - // First: AES encryption key - derivedKey.AsSpan(0, _key.Length).CopyTo(_key); - // Second: HMAC authentication key (same size as encryption key) - derivedKey.AsSpan(_key.Length, _hmacKey.Length).CopyTo(_hmacKey); - // Third: Password verification value (2 bytes) - derivedKey.AsSpan(_key.Length + _hmacKey.Length).CopyTo(_passwordVerifier); - // Clear the derived key from memory - Array.Clear(derivedKey, 0, derivedKey.Length); + // Copy derived material into destination buffers + derivedKey.Slice(0, keySizeInBytes).CopyTo(_key); + derivedKey.Slice(keySizeInBytes, keySizeInBytes).CopyTo(_hmacKey); + derivedKey.Slice(keySizeInBytes * 2, 2).CopyTo(_passwordVerifier); } finally { - // Clear the password bytes from memory - Array.Clear(passwordBytes, 0, passwordBytes.Length); + // Clear the stack memory + CryptographicOperations.ZeroMemory(passwordBytes); + CryptographicOperations.ZeroMemory(derivedKey); } } - private void ReadHeader(ReadOnlyMemory password) - { - if (_headerRead) return; - - // Salt size depends on AES strength: 8 for AES-128, 12 for AES-192, 16 for AES-256 - int saltSize = _keySizeBits / 16; - _salt = new byte[saltSize]; - _baseStream.ReadExactly(_salt); - - // Debug: Log the salt - Debug.WriteLine($"Salt ({saltSize} bytes): {BitConverter.ToString(_salt)}"); - - // Read the 2-byte password verifier - byte[] verifier = new byte[2]; - _baseStream.ReadExactly(verifier); - - // Derive keys from password and salt - DeriveKeysFromPassword(password, _salt); - - // Verify the password - Debug.Assert(_passwordVerifier is not null, "Password verifier should be derived"); - - if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) - { - throw new InvalidDataException($"Invalid password. Expected verifier: {BitConverter.ToString(_passwordVerifier!)}, Got: {BitConverter.ToString(verifier)}"); - } - - Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); - _hmac.Key = _hmacKey!; - InitCipher(); - - Array.Clear(_counterBlock, 0, 16); - _counterBlock[0] = 1; - - _headerRead = true; - } - private async Task ValidateAuthCodeCoreAsync(bool isAsync, CancellationToken cancellationToken) { - if (_encrypting || _authCodeValidated) + Debug.Assert(!_encrypting, "ValidateAuthCode should only be called during decryption."); + + if (_authCodeValidated) return; // Finalize HMAC computation @@ -216,6 +205,54 @@ private Task ValidateAuthCodeAsync(CancellationToken cancellationToken) return ValidateAuthCodeCoreAsync(isAsync: true, cancellationToken); } + private async Task ValidateAuthCodeIfNeededAsync(bool isAsync, CancellationToken cancellationToken) + { + if (!_authCodeValidated) + { + if (isAsync) + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); + else + ValidateAuthCode(); + } + } + + private void ReadHeader(ReadOnlyMemory password) + { + if (_headerRead) return; + + // Salt size depends on AES strength: 8 for AES-128, 12 for AES-192, 16 for AES-256 + int saltSize = _keySizeBits / 16; + _salt = new byte[saltSize]; + _baseStream.ReadExactly(_salt); + + // Debug: Log the salt + Debug.WriteLine($"Salt ({saltSize} bytes): {BitConverter.ToString(_salt)}"); + + // Read the 2-byte password verifier + byte[] verifier = new byte[2]; + _baseStream.ReadExactly(verifier); + + // Derive keys from password and salt + DeriveKeysFromPassword(password, _salt); + + // Verify the password + Debug.Assert(_passwordVerifier is not null, "Password verifier should be derived"); + + if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) + { + throw new InvalidDataException($"Invalid password"); + } + + Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); + _hmac.Key = _hmacKey!; + InitCipher(); + + Array.Clear(_counterBlock, 0, 16); + _counterBlock[0] = 1; + + _headerRead = true; + } + private void InitCipher() { Debug.Assert(_key is not null, "_key is not null"); @@ -267,21 +304,24 @@ private void ProcessBlock(byte[] buffer, int offset, int count) while (processed < count) { // Ensure we have enough keystream bytes available - int keystreamAvailable = KEYSTREAM_BUFFER_SIZE - _keystreamOffset; + int keystreamAvailable = KeystreamBufferSize - _keystreamOffset; if (keystreamAvailable == 0) { GenerateKeystreamBuffer(); - keystreamAvailable = KEYSTREAM_BUFFER_SIZE; + keystreamAvailable = KeystreamBufferSize; } // Process as many bytes as possible with the available keystream int bytesToProcess = Math.Min(count - processed, keystreamAvailable); + Span dataSpan = buffer.AsSpan(offset + processed, bytesToProcess); + ReadOnlySpan keystreamSpan = _keystreamBuffer.AsSpan(_keystreamOffset, bytesToProcess); + // For encryption: XOR first, then HMAC the ciphertext if (_encrypting) { // XOR the data with the keystream to create ciphertext - XorBytes(buffer, offset + processed, _keystreamBuffer, _keystreamOffset, bytesToProcess); + XorBytes(dataSpan, keystreamSpan); // HMAC is computed on the ciphertext (after XOR) _hmac.TransformBlock(buffer, offset + processed, bytesToProcess, null, 0); } @@ -291,7 +331,7 @@ private void ProcessBlock(byte[] buffer, int offset, int count) // HMAC is computed on the ciphertext (before XOR) _hmac.TransformBlock(buffer, offset + processed, bytesToProcess, null, 0); // XOR the ciphertext with the keystream to recover plaintext - XorBytes(buffer, offset + processed, _keystreamBuffer, _keystreamOffset, bytesToProcess); + XorBytes(dataSpan, keystreamSpan); } _keystreamOffset += bytesToProcess; @@ -303,36 +343,23 @@ private void GenerateKeystreamBuffer() { Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized"); - // Generate KEYSTREAM_BUFFER_SIZE bytes of keystream (256 blocks of 16 bytes each) - for (int i = 0; i < KEYSTREAM_BUFFER_SIZE; i += BLOCK_SIZE) + // Generate KeystreamBufferSize bytes of keystream (256 blocks of 16 bytes each) + for (int i = 0; i < KeystreamBufferSize; i += BlockSize) { - _aesEncryptor.TransformBlock(_counterBlock, 0, BLOCK_SIZE, _keystreamBuffer, i); + _aesEncryptor.TransformBlock(_counterBlock, 0, BlockSize, _keystreamBuffer, i); IncrementCounter(); } _keystreamOffset = 0; } - private static void XorBytes(byte[] dest, int destOffset, byte[] src, int srcOffset, int count) + private static void XorBytes(Span dest, ReadOnlySpan src) { - Span destSpan = dest.AsSpan(destOffset, count); - ReadOnlySpan srcSpan = src.AsSpan(srcOffset, count); + Debug.Assert(dest.Length <= src.Length); - // Process 8 bytes at a time when possible for better performance - int i = 0; - while (i + 8 <= count) + for (int i = 0; i < dest.Length; i++) { - long destVal = System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(destSpan.Slice(i)); - long srcVal = System.Buffers.Binary.BinaryPrimitives.ReadInt64LittleEndian(srcSpan.Slice(i)); - System.Buffers.Binary.BinaryPrimitives.WriteInt64LittleEndian(destSpan.Slice(i), destVal ^ srcVal); - i += 8; - } - - // Handle remaining bytes - while (i < count) - { - destSpan[i] ^= srcSpan[i]; - i++; + dest[i] ^= src[i]; } } @@ -348,7 +375,9 @@ private void IncrementCounter() private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancellationToken) { - if (!_encrypting || _authCodeValidated) + Debug.Assert(_encrypting, "WriteAuthCode should only be called during encryption."); + + if ( _authCodeValidated) return; _hmac.TransformFinalBlock(Array.Empty(), 0, 0); @@ -372,17 +401,7 @@ private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancel _authCodeValidated = true; } - private void WriteAuthCode() - { - WriteAuthCodeCoreAsync(isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); - } - - private Task WriteAuthCodeAsync(CancellationToken cancellationToken) - { - return WriteAuthCodeCoreAsync(isAsync: true, cancellationToken); - } - - private async Task ReadCoreShared(Memory buffer, bool isAsync, CancellationToken cancellationToken) + private void ThrowIfNotReadable() { ObjectDisposedException.ThrowIf(_disposed, this); @@ -391,215 +410,236 @@ private async Task ReadCoreShared(Memory buffer, bool isAsync, Cancel if (!_headerRead) throw new InvalidOperationException("Header must be read before reading data."); + } - int bytesToRead = buffer.Length; + private int GetBytesToRead(int requestedCount) + { + if (_encryptedDataRemaining <= 0) + return 0; - // If we know the total size, ensure we don't read into the HMAC - if (_encryptedDataSize > 0) - { - if (_encryptedDataRemaining <= 0) - { - if (!_authCodeValidated) - { - if (isAsync) - await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); - else - ValidateAuthCode(); - } - return 0; - } + return (int)Math.Min(requestedCount, _encryptedDataRemaining); + } - bytesToRead = (int)Math.Min(bytesToRead, _encryptedDataRemaining); - } + public override int Read(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + return Read(buffer.AsSpan(offset, count)); + } + + public override int Read(Span buffer) + { + ThrowIfNotReadable(); + int bytesToRead = GetBytesToRead(buffer.Length); if (bytesToRead == 0) { - if (!_authCodeValidated && _encryptedDataSize > 0) - { - if (isAsync) - await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); - else - ValidateAuthCode(); - } + ValidateAuthCode(); return 0; } - int bytesRead; - if (isAsync) - { - bytesRead = await _baseStream.ReadAsync(buffer.Slice(0, bytesToRead), cancellationToken).ConfigureAwait(false); - } - else - { - bytesRead = _baseStream.Read(buffer.Span.Slice(0, bytesToRead)); - } - - Debug.WriteLine($"Read {bytesRead} bytes from base stream"); + // We need a byte[] for ProcessBlock due to HMAC.TransformBlock requirement + byte[] tempArray = new byte[bytesToRead]; + int bytesRead = _baseStream.Read(tempArray, 0, bytesToRead); if (bytesRead > 0) { _encryptedDataRemaining -= bytesRead; + ProcessBlock(tempArray, 0, bytesRead); + tempArray.AsSpan(0, bytesRead).CopyTo(buffer); - // Process the data - we need to copy because ProcessBlock modifies in-place - byte[] temp = buffer.Slice(0, bytesRead).ToArray(); - ProcessBlock(temp, 0, bytesRead); - temp.CopyTo(buffer.Span); - } - else // n == 0, meaning end of stream - { - if (!_authCodeValidated) + // Validate auth code immediately when we've read all encrypted data + if (_encryptedDataRemaining <= 0) { - if (isAsync) - await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); - else - ValidateAuthCode(); + ValidateAuthCode(); } } + else + { + ValidateAuthCode(); + } return bytesRead; } - private int ReadCore(Span buffer) - { - // Convert span to memory and call shared method synchronously - byte[] tempArray = new byte[buffer.Length]; - Memory memoryBuffer = tempArray.AsMemory(); - - int bytesRead = ReadCoreShared(memoryBuffer, isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); - - // Copy the processed data back to the original span - memoryBuffer.Span.Slice(0, bytesRead).CopyTo(buffer); - - return bytesRead; - } - - public override int Read(byte[] buffer, int offset, int count) - { - ValidateBufferArguments(buffer, offset, count); - return ReadCore(new Span(buffer, offset, count)); - } - - public override int Read(Span buffer) - { - return ReadCore(buffer); - } - public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { ValidateBufferArguments(buffer, offset, count); - return await ReadCoreShared(new Memory(buffer, offset, count), isAsync: true, cancellationToken).ConfigureAwait(false); + return await ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); } public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) { - return await ReadCoreShared(buffer, isAsync: true, cancellationToken).ConfigureAwait(false); - } + ThrowIfNotReadable(); - private async Task WriteCoreShared(ReadOnlyMemory buffer, bool isAsync, CancellationToken cancellationToken) - { - ObjectDisposedException.ThrowIf(_disposed, this); - if (!_encrypting) throw new NotSupportedException("Stream is in decryption mode."); + int bytesToRead = GetBytesToRead(buffer.Length); + if (bytesToRead == 0) + { + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); + return 0; + } - // Write header if needed - if (!_headerWritten) + int bytesRead = await _baseStream.ReadAsync(buffer.Slice(0, bytesToRead), cancellationToken).ConfigureAwait(false); + + if (bytesRead > 0) { - if (isAsync) - await WriteHeaderAsync(cancellationToken).ConfigureAwait(false); + _encryptedDataRemaining -= bytesRead; + + // Try to process in-place if Memory is array-backed + if (MemoryMarshal.TryGetArray(buffer.Slice(0, bytesRead), out ArraySegment segment)) + { + ProcessBlock(segment.Array!, segment.Offset, bytesRead); + } else - WriteHeader(); + { + // Fallback for non-array-backed Memory (rare) + byte[] temp = buffer.Slice(0, bytesRead).ToArray(); + ProcessBlock(temp, 0, bytesRead); + temp.CopyTo(buffer.Span); + } + + // Validate auth code immediately when we've read all encrypted data + if (_encryptedDataRemaining <= 0) + { + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); + } } + else + { + await ValidateAuthCodeAsync(cancellationToken).ConfigureAwait(false); + } + + return bytesRead; + } + private void WriteCore(ReadOnlySpan buffer, byte[] workBuffer) + { int inputOffset = 0; int inputCount = buffer.Length; // Fill the partial block buffer if it has data if (_partialBlockBytes > 0) { - int copyCount = Math.Min(BLOCK_SIZE - _partialBlockBytes, inputCount); - buffer.Slice(inputOffset, copyCount).CopyTo(_partialBlock.AsMemory(_partialBlockBytes)); + int copyCount = Math.Min(BlockSize - _partialBlockBytes, inputCount); + buffer.Slice(inputOffset, copyCount).CopyTo(_partialBlock.AsSpan(_partialBlockBytes)); _partialBlockBytes += copyCount; inputOffset += copyCount; inputCount -= copyCount; // If full, encrypt and write immediately - if (_partialBlockBytes == BLOCK_SIZE) + if (_partialBlockBytes == BlockSize) { - ProcessBlock(_partialBlock, 0, BLOCK_SIZE); - - if (isAsync) - await _baseStream.WriteAsync(_partialBlock.AsMemory(0, BLOCK_SIZE), cancellationToken).ConfigureAwait(false); - else - _baseStream.Write(_partialBlock, 0, BLOCK_SIZE); - + ProcessBlock(_partialBlock, 0, BlockSize); + _baseStream.Write(_partialBlock, 0, BlockSize); _partialBlockBytes = 0; } } - // Process full blocks directly from the input - if (inputCount >= BLOCK_SIZE) + // Process full blocks + while (inputCount >= BlockSize) { - const int ChunkSize = 4096; - byte[] chunkBuffer = new byte[ChunkSize]; + int bytesToProcess = Math.Min(inputCount, workBuffer.Length); + bytesToProcess = (bytesToProcess / BlockSize) * BlockSize; - while (inputCount >= BLOCK_SIZE) - { - // Round down to nearest multiple of 16 for the chunk - int bytesToProcess = Math.Min(inputCount, ChunkSize); - bytesToProcess = (bytesToProcess / BLOCK_SIZE) * BLOCK_SIZE; - - // Copy input to local buffer - buffer.Slice(inputOffset, bytesToProcess).CopyTo(chunkBuffer); - - // Encrypt in-place - ProcessBlock(chunkBuffer, 0, bytesToProcess); - - // Write to stream - if (isAsync) - await _baseStream.WriteAsync(chunkBuffer.AsMemory(0, bytesToProcess), cancellationToken).ConfigureAwait(false); - else - _baseStream.Write(chunkBuffer, 0, bytesToProcess); + buffer.Slice(inputOffset, bytesToProcess).CopyTo(workBuffer); + ProcessBlock(workBuffer, 0, bytesToProcess); + _baseStream.Write(workBuffer, 0, bytesToProcess); - inputOffset += bytesToProcess; - inputCount -= bytesToProcess; - } + inputOffset += bytesToProcess; + inputCount -= bytesToProcess; } // Buffer any remaining bytes if (inputCount > 0) { - buffer.Slice(inputOffset, inputCount).CopyTo(_partialBlock.AsMemory(_partialBlockBytes)); + buffer.Slice(inputOffset, inputCount).CopyTo(_partialBlock.AsSpan(_partialBlockBytes)); _partialBlockBytes += inputCount; } } - private void WriteCore(ReadOnlySpan buffer) + private void ThrowIfNotWritable() { - // Convert span to memory and call shared method synchronously - byte[] tempArray = buffer.ToArray(); - WriteCoreShared(tempArray, isAsync: false, CancellationToken.None).GetAwaiter().GetResult(); + ObjectDisposedException.ThrowIf(_disposed, this); + + if (!_encrypting) + throw new NotSupportedException("Stream is in decryption mode."); } public override void Write(byte[] buffer, int offset, int count) { ValidateBufferArguments(buffer, offset, count); - Write(new ReadOnlySpan(buffer, offset, count)); + Write(buffer.AsSpan(offset, count)); } public override void Write(ReadOnlySpan buffer) { - WriteCore(buffer); + ThrowIfNotWritable(); + if (!_headerWritten) + { + WriteHeader(); + } + + byte[] workBuffer = new byte[KeystreamBufferSize]; + WriteCore(buffer, workBuffer); } - public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { ValidateBufferArguments(buffer, offset, count); - await WriteCoreShared(new ReadOnlyMemory(buffer, offset, count), isAsync: true, cancellationToken).ConfigureAwait(false); + return WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); } public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { - await WriteCoreShared(buffer, isAsync: true, cancellationToken).ConfigureAwait(false); + ThrowIfNotWritable(); + if (!_headerWritten) + { + await WriteHeaderAsync(cancellationToken).ConfigureAwait(false); + } + + int inputOffset = 0; + int inputCount = buffer.Length; + byte[] workBuffer = new byte[KeystreamBufferSize]; + + // Fill the partial block buffer if it has data + if (_partialBlockBytes > 0) + { + int copyCount = Math.Min(BlockSize - _partialBlockBytes, inputCount); + buffer.Slice(inputOffset, copyCount).CopyTo(_partialBlock.AsMemory(_partialBlockBytes)); + + _partialBlockBytes += copyCount; + inputOffset += copyCount; + inputCount -= copyCount; + + // If full, encrypt and write immediately + if (_partialBlockBytes == BlockSize) + { + ProcessBlock(_partialBlock, 0, BlockSize); + await _baseStream.WriteAsync(_partialBlock.AsMemory(0, BlockSize), cancellationToken).ConfigureAwait(false); + _partialBlockBytes = 0; + } + } + + // Process full blocks + while (inputCount >= BlockSize) + { + int bytesToProcess = Math.Min(inputCount, workBuffer.Length); + bytesToProcess = (bytesToProcess / BlockSize) * BlockSize; + + buffer.Slice(inputOffset, bytesToProcess).CopyTo(workBuffer); + ProcessBlock(workBuffer, 0, bytesToProcess); + await _baseStream.WriteAsync(workBuffer.AsMemory(0, bytesToProcess), cancellationToken).ConfigureAwait(false); + + inputOffset += bytesToProcess; + inputCount -= bytesToProcess; + } + + // Buffer any remaining bytes + if (inputCount > 0) + { + buffer.Slice(inputOffset, inputCount).CopyTo(_partialBlock.AsMemory(_partialBlockBytes)); + _partialBlockBytes += inputCount; + } } @@ -634,18 +674,14 @@ protected override void Dispose(bool disposing) { if (_encrypting && !_authCodeValidated && _headerWritten) { - // 1. Encrypt remaining partial data + // Encrypt remaining partial data FinalizeEncryptionAsync(false, CancellationToken.None).GetAwaiter().GetResult(); - // 2. Write Auth Code - WriteAuthCode(); + // Write Auth Code + WriteAuthCodeCoreAsync(false, CancellationToken.None).GetAwaiter().GetResult(); if (_baseStream.CanWrite) _baseStream.Flush(); } - else if (!_encrypting && !_authCodeValidated && _headerRead) - { - ValidateAuthCodeCoreAsync(false, CancellationToken.None).GetAwaiter().GetResult(); - } } finally { @@ -669,20 +705,14 @@ public override async ValueTask DisposeAsync() { if (_encrypting && !_authCodeValidated && _headerWritten) { - await _baseStream.FlushAsync().ConfigureAwait(false); - - // 1. Encrypt remaining partial data + // Encrypt remaining partial data await FinalizeEncryptionAsync(true, CancellationToken.None).ConfigureAwait(false); - // 2. Write Auth Code - await WriteAuthCodeAsync(CancellationToken.None).ConfigureAwait(false); + // Write Auth Code + await WriteAuthCodeCoreAsync(true, CancellationToken.None).ConfigureAwait(false); if (_baseStream.CanWrite) await _baseStream.FlushAsync().ConfigureAwait(false); } - else if (!_encrypting && !_authCodeValidated && _headerRead) - { - await ValidateAuthCodeCoreAsync(true, CancellationToken.None).ConfigureAwait(false); - } } finally { diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index dc8514f598514a..97ab25e7d56755 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -76,7 +76,7 @@ internal async Task GetOffsetOfCompressedDataAsync(CancellationToken cance } else { - // AES case + // AES case - need to parse the AES extra field to find the actual compression method and skip the correct number of bytes var (success, _) = await ZipLocalFileHeader.TrySkipBlockAESAwareAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); if (!success) throw new InvalidDataException(SR.LocalFileHeaderCorrupt); @@ -317,10 +317,10 @@ private async Task OpenInUpdateModeAsync(CancellationToken cancel message = SR.LocalFileHeaderCorrupt; return (false, message); } - else if (IsEncrypted && CompressionMethod == CompressionMethodValues.Aes) + else if (IsEncrypted && CompressionMethod == ZipCompressionMethod.Aes) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - _aesCompressionMethod = CompressionMethodValues.Aes; + _aesCompressionMethod = ZipCompressionMethod.Aes; var (success, aesExtraField) = await ZipLocalFileHeader.TrySkipBlockAESAwareAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); if (!success) { @@ -346,7 +346,7 @@ private async Task OpenInUpdateModeAsync(CancellationToken cancel // Store the actual compression method that will be used after decryption // This is needed for GetDataDecompressor to work correctly // Set the compression method to the actual method for decompression - CompressionMethod = (CompressionMethodValues)aesExtraField.Value.CompressionMethod; + CompressionMethod = (ZipCompressionMethod)aesExtraField.Value.CompressionMethod; } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index 7f4dc99e8c66e6..a9fb101c16807a 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -49,7 +49,7 @@ public partial class ZipArchiveEntry private byte[] _fileComment; private EncryptionMethod _encryptionMethod; private readonly CompressionLevel _compressionLevel; - private CompressionMethodValues _aesCompressionMethod; + private ZipCompressionMethod _aesCompressionMethod; private ushort? _aeVersion; // Initializes a ZipArchiveEntry instance for an existing archive entry. @@ -99,7 +99,6 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) _fileComment = cd.FileComment; _compressionLevel = MapCompressionLevel(_generalPurposeBitFlag, CompressionMethod); - } // Initializes a ZipArchiveEntry instance for a new archive entry with a specified compression level. @@ -163,7 +162,6 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName) } Changes = ZipArchive.ChangeState.Unchanged; - } /// @@ -379,6 +377,7 @@ public Stream Open() return OpenInWriteMode(); case ZipArchiveMode.Update: default: + Debug.Assert(_archive.Mode == ZipArchiveMode.Update); return OpenInUpdateMode(); } } @@ -461,7 +460,7 @@ internal long GetOffsetOfCompressedData() } else { - // AES case + // AES case - need to also parse the AES extra field and skip the correct number of bytes if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _)) throw new InvalidDataException(SR.LocalFileHeaderCorrupt); @@ -636,12 +635,12 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 // determine if we can fit zip64 extra field and original extra fields all in int currExtraFieldDataLength = ZipGenericExtraField.TotalSize(_cdUnknownExtraFields, _cdTrailingExtraFieldData?.Length ?? 0); int bigExtraFieldLength = (zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) - + aesExtraFieldSize // Add this line + + aesExtraFieldSize + currExtraFieldDataLength; if (bigExtraFieldLength > ushort.MaxValue) { - extraFieldLength = (ushort)((zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) + aesExtraFieldSize); // Modified line + extraFieldLength = (ushort)((zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) + aesExtraFieldSize); _cdUnknownExtraFields = null; } else @@ -837,7 +836,6 @@ private CheckSumAndSizeWriteStream GetDataCompressor(Stream backingStream, bool || CompressionMethod == ZipCompressionMethod.Stored); Func compressorStreamFactory; - bool isIntermediateStream = true; switch (CompressionMethod) @@ -882,14 +880,13 @@ private byte CalculateZipCryptoCheckByte() private bool IsZipCryptoEncrypted() { - const ushort EncryptionFlag = 0x0001; - return ((ushort)_generalPurposeBitFlag & EncryptionFlag) != 0 && !IsAesEncrypted(); + return (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0 && !IsAesEncrypted(); } private bool IsAesEncrypted() { // Compression method 99 indicates AES encryption - return _aesCompressionMethod == CompressionMethodValues.Aes; + return _aesCompressionMethod == ZipCompressionMethod.Aes; } private bool ForAesEncryption() @@ -908,13 +905,13 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead) case ZipCompressionMethod.Deflate64: uncompressedStream = new DeflateManagedStream(compressedStreamToRead, ZipCompressionMethod.Deflate64, _uncompressedSize); break; - case CompressionMethodValues.Stored: + case ZipCompressionMethod.Stored: uncompressedStream = compressedStreamToRead; break; default: // We should not get here with Aes as CompressionMethod anymore // as it should have been replaced with the actual compression method - Debug.Assert(CompressionMethod != CompressionMethodValues.Aes, + Debug.Assert(CompressionMethod != ZipCompressionMethod.Aes, "AES compression method should have been replaced with actual compression method"); // Fallback to stored if we somehow get here @@ -1090,10 +1087,11 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st message = SR.LocalFileHeaderCorrupt; return false; } - else if (IsEncrypted && CompressionMethod == CompressionMethodValues.Aes) + else if (IsEncrypted && CompressionMethod == ZipCompressionMethod.Aes) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - _aesCompressionMethod = CompressionMethodValues.Aes; + _aesCompressionMethod = ZipCompressionMethod.Aes; + // AES case - need to read the extra field to determine actual compression method and encryption strength if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out WinZipAesExtraField? aesExtraField)) { message = SR.LocalFileHeaderCorrupt; @@ -1102,24 +1100,24 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st if (aesExtraField.HasValue) { + + EncryptionMethod detectedEncryption = aesExtraField.Value.AesStrength switch { - EncryptionMethod detectedEncryption = aesExtraField.Value.AesStrength switch - { - 1 => EncryptionMethod.Aes128, - 2 => EncryptionMethod.Aes192, - 3 => EncryptionMethod.Aes256, - _ => throw new InvalidDataException("Unknown AES strength") - }; - - // Store the detected encryption method - _encryptionMethod = detectedEncryption; - } + 1 => EncryptionMethod.Aes128, + 2 => EncryptionMethod.Aes192, + 3 => EncryptionMethod.Aes256, + _ => throw new InvalidDataException("Unknown AES strength") + }; + + // Store the detected encryption method + _encryptionMethod = detectedEncryption; + _aeVersion = aesExtraField.Value.VendorVersion; // Store the actual compression method that will be used after decryption // This is needed for GetDataDecompressor to work correctly // Set the compression method to the actual method for decompression - CompressionMethod = (CompressionMethodValues)aesExtraField.Value.CompressionMethod; + CompressionMethod = (ZipCompressionMethod)aesExtraField.Value.CompressionMethod; } } @@ -1143,16 +1141,16 @@ private bool IsOpenableInitialVerifications(bool needToUncompress, out string? m if (needToUncompress) { if (!IsEncrypted && - CompressionMethod != CompressionMethodValues.Stored && - CompressionMethod != CompressionMethodValues.Deflate && - CompressionMethod != CompressionMethodValues.Deflate64) + CompressionMethod != ZipCompressionMethod.Stored && + CompressionMethod != ZipCompressionMethod.Deflate && + CompressionMethod != ZipCompressionMethod.Deflate64) { message = SR.UnsupportedCompression; return false; } else { - if (IsEncrypted && CompressionMethod == CompressionMethodValues.Aes) + if (IsEncrypted && CompressionMethod == ZipCompressionMethod.Aes) { return true; } @@ -1289,7 +1287,7 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; // Set compression method to 99 (AES indicator) in the header - CompressionMethod = CompressionMethodValues.Aes; + CompressionMethod = ZipCompressionMethod.Aes; compressedSizeTruncated = 0; uncompressedSizeTruncated = 0; aesExtraField = new WinZipAesExtraField diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCompressionMethod.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCompressionMethod.cs index 7ca56e277bbd2f..4ad210316bd4f0 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCompressionMethod.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCompressionMethod.cs @@ -25,5 +25,10 @@ public enum ZipCompressionMethod /// The entry is compressed using the Deflate64 algorithm. /// Deflate64 = 0x9, + + /// + /// The entry is encrypted using AES standard. + /// + Aes = 99 } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs index 43aa748169842d..4597cdd4f0a712 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs @@ -702,6 +702,7 @@ internal sealed class CrcValidatingReadStream : Stream private long _totalBytesRead; private readonly long _expectedLength; private bool _isDisposed; + private bool _crcValidated; public CrcValidatingReadStream(Stream baseStream, uint expectedCrc, long expectedLength) { @@ -710,6 +711,7 @@ public CrcValidatingReadStream(Stream baseStream, uint expectedCrc, long expecte _expectedLength = expectedLength; _runningCrc = 0; _totalBytesRead = 0; + _crcValidated = false; } public override bool CanRead => !_isDisposed && _baseStream.CanRead; @@ -730,17 +732,7 @@ public override int Read(byte[] buffer, int offset, int count) ValidateBufferArguments(buffer, offset, count); int bytesRead = _baseStream.Read(buffer, offset, count); - - if (bytesRead > 0) - { - _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, buffer, offset, bytesRead); - _totalBytesRead += bytesRead; - } - else if (bytesRead == 0) - { - // End of stream reached, validate CRC - ValidateCrc(); - } + ProcessBytesRead(buffer.AsSpan(offset, bytesRead)); return bytesRead; } @@ -750,17 +742,7 @@ public override int Read(Span buffer) ThrowIfDisposed(); int bytesRead = _baseStream.Read(buffer); - - if (bytesRead > 0) - { - _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, buffer.Slice(0, bytesRead)); - _totalBytesRead += bytesRead; - } - else if (bytesRead == 0) - { - // End of stream reached, validate CRC - ValidateCrc(); - } + ProcessBytesRead(buffer.Slice(0, bytesRead)); return bytesRead; } @@ -771,17 +753,7 @@ public override async Task ReadAsync(byte[] buffer, int offset, int count, ValidateBufferArguments(buffer, offset, count); int bytesRead = await _baseStream.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); - - if (bytesRead > 0) - { - _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, buffer, offset, bytesRead); - _totalBytesRead += bytesRead; - } - else if (bytesRead == 0) - { - // End of stream reached, validate CRC - ValidateCrc(); - } + ProcessBytesRead(buffer.AsSpan(offset, bytesRead)); return bytesRead; } @@ -791,40 +763,40 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation ThrowIfDisposed(); int bytesRead = await _baseStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - - if (bytesRead > 0) - { - _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, buffer.Span.Slice(0, bytesRead)); - _totalBytesRead += bytesRead; - } - else if (bytesRead == 0) - { - // End of stream reached, validate CRC - ValidateCrc(); - } + ProcessBytesRead(buffer.Span.Slice(0, bytesRead)); return bytesRead; } - public override void Write(byte[] buffer, int offset, int count) + private void ProcessBytesRead(ReadOnlySpan data) { - ThrowIfDisposed(); - throw new NotSupportedException(SR.WritingNotSupported); - } + if (data.Length > 0) + { + _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, data); + _totalBytesRead += data.Length; - public override void Write(ReadOnlySpan buffer) - { - ThrowIfDisposed(); - throw new NotSupportedException(SR.WritingNotSupported); + if (_totalBytesRead >= _expectedLength) + { + ValidateCrc(); + } + } } - public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + private void ValidateCrc() { - ThrowIfDisposed(); - throw new NotSupportedException(SR.WritingNotSupported); + if (_crcValidated) + return; + + _crcValidated = true; + + if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) + { + throw new InvalidDataException( + $"CRC mismatch. Expected: 0x{_expectedCrc:X8}, Actual: 0x{_runningCrc:X8}"); + } } - public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + public override void Write(byte[] buffer, int offset, int count) { ThrowIfDisposed(); throw new NotSupportedException(SR.WritingNotSupported); @@ -833,13 +805,12 @@ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationTo public override void Flush() { ThrowIfDisposed(); - _baseStream.Flush(); } public override Task FlushAsync(CancellationToken cancellationToken) { ThrowIfDisposed(); - return _baseStream.FlushAsync(cancellationToken); + return Task.CompletedTask; } public override long Seek(long offset, SeekOrigin origin) @@ -854,31 +825,15 @@ public override void SetLength(long value) throw new NotSupportedException(SR.SetLengthRequiresSeekingAndWriting); } - private void ValidateCrc() - { - if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) - { - throw new InvalidDataException( - $"CRC mismatch. Expected: 0x{_expectedCrc:X8}, Got: 0x{_runningCrc:X8}"); - } - } - private void ThrowIfDisposed() { - if (_isDisposed) - throw new ObjectDisposedException(GetType().ToString(), SR.HiddenStreamName); + ObjectDisposedException.ThrowIf(_isDisposed, this); } protected override void Dispose(bool disposing) { if (disposing && !_isDisposed) { - // Validate CRC when stream is closed (if all data was read) - if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) - { - throw new InvalidDataException("CRC mismatch"); - } - _baseStream.Dispose(); _isDisposed = true; } @@ -889,13 +844,6 @@ public override async ValueTask DisposeAsync() { if (!_isDisposed) { - // Validate CRC when stream is closed (if all data was read) - if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) - { - throw new InvalidDataException( - $"CRC mismatch. Expected: 0x{_expectedCrc:X8}, Got: 0x{_runningCrc:X8}"); - } - await _baseStream.DisposeAsync().ConfigureAwait(false); _isDisposed = true; } diff --git a/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj b/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj index b2f8508b14ef39..fae2ff40c02c0e 100644 --- a/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj +++ b/src/libraries/System.Security.Cryptography/src/System.Security.Cryptography.csproj @@ -1,4 +1,4 @@ - + $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-unix;$(NetCoreAppCurrent)-android;$(NetCoreAppCurrent)-osx;$(NetCoreAppCurrent)-ios;$(NetCoreAppCurrent)-tvos;$(NetCoreAppCurrent)-browser;$(NetCoreAppCurrent) From 703a6ec4e0c79df57e95344e2b55ff89f423ffd5 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 15 Dec 2025 13:15:29 +0200 Subject: [PATCH 23/39] replace error strings --- .../src/Resources/Strings.resx | 27 +++++++++++++++++++ .../System/IO/Compression/WinZipAesStream.cs | 21 +++++++-------- .../System/IO/Compression/ZipArchiveEntry.cs | 14 +++++----- .../System/IO/Compression/ZipCryptoStream.cs | 14 +++++----- .../System/IO/Compression/ZipCustomStreams.cs | 3 +-- 5 files changed, 51 insertions(+), 28 deletions(-) diff --git a/src/libraries/System.IO.Compression/src/Resources/Strings.resx b/src/libraries/System.IO.Compression/src/Resources/Strings.resx index d477d0c40f6624..b8bfc547450c11 100644 --- a/src/libraries/System.IO.Compression/src/Resources/Strings.resx +++ b/src/libraries/System.IO.Compression/src/Resources/Strings.resx @@ -299,4 +299,31 @@ An attempt was made to move the position before the beginning of the stream. + + Stream size is too small for WinZip standard + + + Invalid password + + + Authentication code mismatch for WinZip encrypted entry. + + + Entry is not encrypted. + + + A password is required for encrypted entries. + + + No password was provided for encrypting this entry + + + Invalid AES strength value. + + + Truncated ZipCrypto header. + + + CRC mismatch. + diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 016eeddc2d1edf..7ebd4a0ea1b09a 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -97,10 +97,8 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en else { // For decryption, we must know the total size to locate the auth tag - if (_totalStreamSize <= 0) - { - throw new ArgumentException("Total stream size must be provided for decryption.", nameof(totalStreamSize)); - } + + Debug.Assert(_totalStreamSize > 0, "Total stream size must be provided for decryption."); int saltSize = _keySizeBits / 16; int headerSize = saltSize + 2; @@ -111,7 +109,7 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en if (_encryptedDataSize < 0) { - throw new InvalidDataException("Stream size is too small for WinZip AES format."); + throw new InvalidDataException(SR.InvalidWinZipSize);//("Stream size is too small for WinZip AES format."); } ReadHeader(password); @@ -189,7 +187,7 @@ private async Task ValidateAuthCodeCoreAsync(bool isAsync, CancellationToken can // Compare the first 10 bytes of the expected hash if (!storedAuth.AsSpan().SequenceEqual(expectedAuth.AsSpan(0, 10))) - throw new InvalidDataException("Authentication code mismatch."); + throw new InvalidDataException(SR.WinZipAuthCodeMismatch); } _authCodeValidated = true; @@ -240,7 +238,7 @@ private void ReadHeader(ReadOnlyMemory password) if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) { - throw new InvalidDataException($"Invalid password"); + throw new InvalidDataException(SR.InvalidPassword); } Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); @@ -377,7 +375,7 @@ private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancel { Debug.Assert(_encrypting, "WriteAuthCode should only be called during encryption."); - if ( _authCodeValidated) + if (_authCodeValidated) return; _hmac.TransformFinalBlock(Array.Empty(), 0, 0); @@ -406,10 +404,9 @@ private void ThrowIfNotReadable() ObjectDisposedException.ThrowIf(_disposed, this); if (_encrypting) - throw new NotSupportedException("Stream is in encryption mode."); + throw new NotSupportedException(SR.ReadingNotSupported); - if (!_headerRead) - throw new InvalidOperationException("Header must be read before reading data."); + Debug.Assert(_headerRead, "Header must be read before reading data."); } private int GetBytesToRead(int requestedCount) @@ -562,7 +559,7 @@ private void ThrowIfNotWritable() ObjectDisposedException.ThrowIf(_disposed, this); if (!_encrypting) - throw new NotSupportedException("Stream is in decryption mode."); + throw new NotSupportedException(SR.WritingNotSupported); } public override void Write(byte[] buffer, int offset, int count) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index a9fb101c16807a..d7ac38b8d47eb3 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -398,7 +398,7 @@ public Stream Open(string? password = null, EncryptionMethod encryptionMethod = case ZipArchiveMode.Read: if (!IsEncrypted) { - throw new InvalidDataException("Entry is not encrypted"); + throw new InvalidDataException(SR.EntryNotEncrypted); } return OpenInReadMode(checkOpenable: true, password.AsMemory()); case ZipArchiveMode.Create: @@ -407,7 +407,7 @@ public Stream Open(string? password = null, EncryptionMethod encryptionMethod = default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - if (!_isEncrypted) throw new InvalidDataException("Entry is not encrypted."); + if (!_isEncrypted) throw new InvalidDataException(SR.EntryNotEncrypted); return OpenInReadMode(checkOpenable: true, password.AsMemory()); } @@ -937,7 +937,7 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read if (IsZipCryptoEncrypted()) { if (password.IsEmpty) - throw new InvalidDataException("Password required for encrypted ZIP entry."); + throw new InvalidDataException(SR.PasswordRequired); byte expectedCheckByte = CalculateZipCryptoCheckByte(); streamToDecompress = new ZipCryptoStream(compressedStream, password, expectedCheckByte); @@ -945,7 +945,7 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read else if (IsAesEncrypted()) { if (password.IsEmpty) - throw new InvalidDataException("Password required for AES-encrypted ZIP entry."); + throw new InvalidDataException(SR.PasswordRequired); int keySizeBits = _encryptionMethod switch { @@ -992,7 +992,7 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod if (encryptionMethod == EncryptionMethod.ZipCrypto) { if (string.IsNullOrEmpty(password)) - throw new InvalidOperationException("Password is required for encryption."); + throw new InvalidOperationException(SR.NoPasswordProvided); Encryption = encryptionMethod; @@ -1009,7 +1009,7 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod else if (encryptionMethod is EncryptionMethod.Aes256 or EncryptionMethod.Aes192 or EncryptionMethod.Aes128) { if (string.IsNullOrEmpty(password)) - throw new InvalidOperationException("Password is required for encryption."); + throw new InvalidOperationException(SR.NoPasswordProvided); Encryption = encryptionMethod; @@ -1106,7 +1106,7 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st 1 => EncryptionMethod.Aes128, 2 => EncryptionMethod.Aes192, 3 => EncryptionMethod.Aes256, - _ => throw new InvalidDataException("Unknown AES strength") + _ => throw new InvalidDataException(SR.InvalidAesStrength) }; // Store the detected encryption method diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index bdda034f6c9c8a..bf20d0250cfb03 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -139,14 +139,14 @@ private void ValidateHeader(byte expectedCheckByte) } catch (EndOfStreamException) { - throw new InvalidDataException("Truncated ZipCrypto header."); + throw new InvalidDataException(SR.TruncatedZipCryptoHeader); } for (int i = 0; i < hdr.Length; i++) hdr[i] = DecryptByte(hdr[i]); if (hdr[11] != expectedCheckByte) - throw new InvalidDataException("Invalid password for encrypted ZIP entry."); + throw new InvalidDataException(SR.InvalidPassword); } private void UpdateKeys(byte b) @@ -178,7 +178,7 @@ private byte DecryptByte(byte ciph) public override long Position { get => throw new NotSupportedException(); - set => throw new NotSupportedException("ZipCryptoStream does not support seeking."); + set => throw new NotSupportedException(); } public override void Flush() => _base.Flush(); @@ -197,7 +197,7 @@ public override int Read(Span destination) destination[i] = DecryptByte(destination[i]); return n; } - throw new NotSupportedException("Stream is in encryption (write-only) mode."); + throw new NotSupportedException(SR.ReadingNotSupported); } public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); @@ -210,7 +210,7 @@ public override void Write(byte[] buffer, int offset, int count) public override void Write(ReadOnlySpan buffer) { - if (!_encrypting) throw new NotSupportedException("Stream is in decryption (read-only) mode."); + if (!_encrypting) throw new NotSupportedException(SR.WritingNotSupported); EnsureHeader(); @@ -267,7 +267,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation span[i] = DecryptByte(span[i]); return n; } - throw new NotSupportedException("Stream is in encryption (write-only) mode."); + throw new NotSupportedException(SR.ReadingNotSupported); } public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) @@ -278,7 +278,7 @@ public override Task WriteAsync(byte[] buffer, int offset, int count, Cancellati public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { - if (!_encrypting) throw new NotSupportedException("Stream is in decryption (read-only) mode."); + if (!_encrypting) throw new NotSupportedException(SR.WritingNotSupported); cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs index 4597cdd4f0a712..acff9c9aa60179 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs @@ -791,8 +791,7 @@ private void ValidateCrc() if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) { - throw new InvalidDataException( - $"CRC mismatch. Expected: 0x{_expectedCrc:X8}, Actual: 0x{_runningCrc:X8}"); + throw new InvalidDataException(SR.CrcMismatch); } } From b4deb713313aa527f70a82aa8544714c700f431e Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 22 Dec 2025 11:41:26 +0200 Subject: [PATCH 24/39] nitpicks and update hashing --- .../System/IO/Compression/WinZipAesStream.cs | 136 +++++++----------- .../IO/Compression/ZipArchiveEntry.Async.cs | 8 +- .../System/IO/Compression/ZipArchiveEntry.cs | 38 ++--- 3 files changed, 72 insertions(+), 110 deletions(-) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 7ebd4a0ea1b09a..66d112980a505b 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -19,9 +19,7 @@ internal sealed class WinZipAesStream : Stream private readonly int _keySizeBits; private readonly Aes _aes; private ICryptoTransform? _aesEncryptor; -#pragma warning disable CA1416 // HMACSHA1 is available on all platforms - private readonly HMACSHA1 _hmac; -#pragma warning restore CA1416 + private IncrementalHash? _hmac; private readonly byte[] _counterBlock = new byte[BlockSize]; private byte[]? _key; private byte[]? _hmacKey; @@ -42,6 +40,7 @@ internal sealed class WinZipAesStream : Stream private readonly byte[] _keystreamBuffer = new byte[KeystreamBufferSize]; private int _keystreamOffset = KeystreamBufferSize; // Start depleted to force initial generation + public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, long totalStreamSize = -1, bool leaveOpen = false) { ArgumentNullException.ThrowIfNull(baseStream); @@ -49,7 +48,7 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en _baseStream = baseStream; _encrypting = encrypting; _keySizeBits = keySizeBits; - _totalStreamSize = totalStreamSize; // Store the total size + _totalStreamSize = totalStreamSize; _leaveOpen = leaveOpen; #pragma warning disable CA1416 // HMACSHA1 is available on all platforms _aes = Aes.Create(); @@ -57,12 +56,6 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en _aes.Mode = CipherMode.ECB; _aes.Padding = PaddingMode.None; -#pragma warning disable CA1416 // HMACSHA1 available on all platforms ? -#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms ? - _hmac = new HMACSHA1(); -#pragma warning restore CA5350 // Do Not Use Weak Cryptographic Algorithms -#pragma warning restore CA1416 - Array.Clear(_counterBlock, 0, 16); _counterBlock[0] = 1; @@ -91,13 +84,14 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en DeriveKeysFromPassword(password, _salt); Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); - _hmac.Key = _hmacKey!; +#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms - required by WinZip AES spec + _hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA1, _hmacKey!); +#pragma warning restore CA5350 InitCipher(); } else { // For decryption, we must know the total size to locate the auth tag - Debug.Assert(_totalStreamSize > 0, "Total stream size must be provided for decryption."); int saltSize = _keySizeBits / 16; @@ -109,13 +103,12 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en if (_encryptedDataSize < 0) { - throw new InvalidDataException(SR.InvalidWinZipSize);//("Stream size is too small for WinZip AES format."); + throw new InvalidDataException(SR.InvalidWinZipSize); } ReadHeader(password); } } - private void DeriveKeysFromPassword(ReadOnlyMemory password, byte[] salt) { // Calculate sizes @@ -163,32 +156,29 @@ private void DeriveKeysFromPassword(ReadOnlyMemory password, byte[] salt) private async Task ValidateAuthCodeCoreAsync(bool isAsync, CancellationToken cancellationToken) { Debug.Assert(!_encrypting, "ValidateAuthCode should only be called during decryption."); + Debug.Assert(_hmac is not null, "HMAC should have been initialized"); if (_authCodeValidated) return; // Finalize HMAC computation - _hmac.TransformFinalBlock(Array.Empty(), 0, 0); - byte[]? expectedAuth = _hmac.Hash; + byte[] expectedAuth = _hmac.GetHashAndReset(); - if (expectedAuth is not null) - { - // Read the 10-byte stored authentication code from the stream - byte[] storedAuth = new byte[10]; + // Read the 10-byte stored authentication code from the stream + byte[] storedAuth = new byte[10]; - if (isAsync) - { - await _baseStream.ReadExactlyAsync(storedAuth, cancellationToken).ConfigureAwait(false); - } - else - { - _baseStream.ReadExactly(storedAuth); - } - - // Compare the first 10 bytes of the expected hash - if (!storedAuth.AsSpan().SequenceEqual(expectedAuth.AsSpan(0, 10))) - throw new InvalidDataException(SR.WinZipAuthCodeMismatch); + if (isAsync) + { + await _baseStream.ReadExactlyAsync(storedAuth, cancellationToken).ConfigureAwait(false); } + else + { + _baseStream.ReadExactly(storedAuth); + } + + // Compare the first 10 bytes of the expected hash + if (!storedAuth.AsSpan().SequenceEqual(expectedAuth.AsSpan(0, 10))) + throw new InvalidDataException(SR.WinZipAuthCodeMismatch); _authCodeValidated = true; } @@ -242,7 +232,9 @@ private void ReadHeader(ReadOnlyMemory password) } Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); - _hmac.Key = _hmacKey!; +#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms - required by WinZip AES spec + _hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA1, _hmacKey!); +#pragma warning restore CA5350 InitCipher(); Array.Clear(_counterBlock, 0, 16); @@ -250,7 +242,6 @@ private void ReadHeader(ReadOnlyMemory password) _headerRead = true; } - private void InitCipher() { Debug.Assert(_key is not null, "_key is not null"); @@ -293,13 +284,14 @@ private Task WriteHeaderAsync(CancellationToken cancellationToken) return WriteHeaderCoreAsync(isAsync: true, cancellationToken); } - private void ProcessBlock(byte[] buffer, int offset, int count) + private void ProcessBlock(Span buffer) { Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized before processing blocks"); + Debug.Assert(_hmac is not null, "HMAC should have been initialized"); int processed = 0; - while (processed < count) + while (processed < buffer.Length) { // Ensure we have enough keystream bytes available int keystreamAvailable = KeystreamBufferSize - _keystreamOffset; @@ -310,9 +302,9 @@ private void ProcessBlock(byte[] buffer, int offset, int count) } // Process as many bytes as possible with the available keystream - int bytesToProcess = Math.Min(count - processed, keystreamAvailable); + int bytesToProcess = Math.Min(buffer.Length - processed, keystreamAvailable); - Span dataSpan = buffer.AsSpan(offset + processed, bytesToProcess); + Span dataSpan = buffer.Slice(processed, bytesToProcess); ReadOnlySpan keystreamSpan = _keystreamBuffer.AsSpan(_keystreamOffset, bytesToProcess); // For encryption: XOR first, then HMAC the ciphertext @@ -321,13 +313,13 @@ private void ProcessBlock(byte[] buffer, int offset, int count) // XOR the data with the keystream to create ciphertext XorBytes(dataSpan, keystreamSpan); // HMAC is computed on the ciphertext (after XOR) - _hmac.TransformBlock(buffer, offset + processed, bytesToProcess, null, 0); + _hmac.AppendData(dataSpan); } // For decryption: HMAC first (on ciphertext), then XOR else { // HMAC is computed on the ciphertext (before XOR) - _hmac.TransformBlock(buffer, offset + processed, bytesToProcess, null, 0); + _hmac.AppendData(dataSpan); // XOR the ciphertext with the keystream to recover plaintext XorBytes(dataSpan, keystreamSpan); } @@ -336,7 +328,6 @@ private void ProcessBlock(byte[] buffer, int offset, int count) processed += bytesToProcess; } } - private void GenerateKeystreamBuffer() { Debug.Assert(_aesEncryptor is not null, "Cipher should have been initialized"); @@ -374,31 +365,27 @@ private void IncrementCounter() private async Task WriteAuthCodeCoreAsync(bool isAsync, CancellationToken cancellationToken) { Debug.Assert(_encrypting, "WriteAuthCode should only be called during encryption."); + Debug.Assert(_hmac is not null, "HMAC should have been initialized"); if (_authCodeValidated) return; - _hmac.TransformFinalBlock(Array.Empty(), 0, 0); - byte[]? authCode = _hmac.Hash; + byte[] authCode = _hmac.GetHashAndReset(); - if (authCode is not null) + // WinZip AES spec requires only the first 10 bytes of the HMAC + if (isAsync) { - // WinZip AES spec requires only the first 10 bytes of the HMAC - if (isAsync) - { - await _baseStream.WriteAsync(authCode.AsMemory(0, 10), cancellationToken).ConfigureAwait(false); - } - else - { - _baseStream.Write(authCode, 0, 10); - } - - Debug.WriteLine($"Wrote authentication code: {BitConverter.ToString(authCode, 0, 10)}"); + await _baseStream.WriteAsync(authCode.AsMemory(0, 10), cancellationToken).ConfigureAwait(false); } + else + { + _baseStream.Write(authCode, 0, 10); + } + + Debug.WriteLine($"Wrote authentication code: {BitConverter.ToString(authCode, 0, 10)}"); _authCodeValidated = true; } - private void ThrowIfNotReadable() { ObjectDisposedException.ThrowIf(_disposed, this); @@ -434,15 +421,13 @@ public override int Read(Span buffer) return 0; } - // We need a byte[] for ProcessBlock due to HMAC.TransformBlock requirement - byte[] tempArray = new byte[bytesToRead]; - int bytesRead = _baseStream.Read(tempArray, 0, bytesToRead); + Span readBuffer = buffer.Slice(0, bytesToRead); + int bytesRead = _baseStream.Read(readBuffer); if (bytesRead > 0) { _encryptedDataRemaining -= bytesRead; - ProcessBlock(tempArray, 0, bytesRead); - tempArray.AsSpan(0, bytesRead).CopyTo(buffer); + ProcessBlock(readBuffer.Slice(0, bytesRead)); // Validate auth code immediately when we've read all encrypted data if (_encryptedDataRemaining <= 0) @@ -480,19 +465,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation if (bytesRead > 0) { _encryptedDataRemaining -= bytesRead; - - // Try to process in-place if Memory is array-backed - if (MemoryMarshal.TryGetArray(buffer.Slice(0, bytesRead), out ArraySegment segment)) - { - ProcessBlock(segment.Array!, segment.Offset, bytesRead); - } - else - { - // Fallback for non-array-backed Memory (rare) - byte[] temp = buffer.Slice(0, bytesRead).ToArray(); - ProcessBlock(temp, 0, bytesRead); - temp.CopyTo(buffer.Span); - } + ProcessBlock(buffer.Span.Slice(0, bytesRead)); // Validate auth code immediately when we've read all encrypted data if (_encryptedDataRemaining <= 0) @@ -526,7 +499,7 @@ private void WriteCore(ReadOnlySpan buffer, byte[] workBuffer) // If full, encrypt and write immediately if (_partialBlockBytes == BlockSize) { - ProcessBlock(_partialBlock, 0, BlockSize); + ProcessBlock(_partialBlock.AsSpan(0, BlockSize)); _baseStream.Write(_partialBlock, 0, BlockSize); _partialBlockBytes = 0; } @@ -539,7 +512,7 @@ private void WriteCore(ReadOnlySpan buffer, byte[] workBuffer) bytesToProcess = (bytesToProcess / BlockSize) * BlockSize; buffer.Slice(inputOffset, bytesToProcess).CopyTo(workBuffer); - ProcessBlock(workBuffer, 0, bytesToProcess); + ProcessBlock(workBuffer.AsSpan(0, bytesToProcess)); _baseStream.Write(workBuffer, 0, bytesToProcess); inputOffset += bytesToProcess; @@ -611,7 +584,7 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella // If full, encrypt and write immediately if (_partialBlockBytes == BlockSize) { - ProcessBlock(_partialBlock, 0, BlockSize); + ProcessBlock(_partialBlock.AsSpan(0, BlockSize)); await _baseStream.WriteAsync(_partialBlock.AsMemory(0, BlockSize), cancellationToken).ConfigureAwait(false); _partialBlockBytes = 0; } @@ -624,7 +597,7 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella bytesToProcess = (bytesToProcess / BlockSize) * BlockSize; buffer.Slice(inputOffset, bytesToProcess).CopyTo(workBuffer); - ProcessBlock(workBuffer, 0, bytesToProcess); + ProcessBlock(workBuffer.AsSpan(0, bytesToProcess)); await _baseStream.WriteAsync(workBuffer.AsMemory(0, bytesToProcess), cancellationToken).ConfigureAwait(false); inputOffset += bytesToProcess; @@ -646,7 +619,7 @@ private async Task FinalizeEncryptionAsync(bool isAsync, CancellationToken cance if (_partialBlockBytes > 0) { // Encrypt the partial block (ProcessBlock handles partials by XORing only available bytes) - ProcessBlock(_partialBlock, 0, _partialBlockBytes); + ProcessBlock(_partialBlock.AsSpan(0, _partialBlockBytes)); if (isAsync) { @@ -684,8 +657,7 @@ protected override void Dispose(bool disposing) { _aesEncryptor?.Dispose(); _aes.Dispose(); - _hmac.Dispose(); - // Removed _encryptionBuffer.Dispose() + _hmac!.Dispose(); if (!_leaveOpen) _baseStream.Dispose(); } @@ -715,7 +687,7 @@ public override async ValueTask DisposeAsync() { _aesEncryptor?.Dispose(); _aes.Dispose(); - _hmac.Dispose(); + _hmac!.Dispose(); if (!_leaveOpen) await _baseStream.DisposeAsync().ConfigureAwait(false); } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 97ab25e7d56755..d66a4bded56087 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -186,11 +186,10 @@ internal async Task WriteCentralDirectoryFileHeaderAsync(bool forceWrite, Cancel // Write WinZip AES extra field AFTER Zip64 (matching sync version order) // Must match the exact check used in the sync version WriteCentralDirectoryFileHeader - if (ForAesEncryption()) + if (UseAesEncryption()) { var aesExtraField = new WinZipAesExtraField { - VendorVersion = 2, // AE-2 AesStrength = Encryption switch { EncryptionMethod.Aes128 => (byte)1, @@ -320,7 +319,7 @@ private async Task OpenInUpdateModeAsync(CancellationToken cancel else if (IsEncrypted && CompressionMethod == ZipCompressionMethod.Aes) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - _aesCompressionMethod = ZipCompressionMethod.Aes; + _headerCompressionMethod = ZipCompressionMethod.Aes; var (success, aesExtraField) = await ZipLocalFileHeader.TrySkipBlockAESAwareAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); if (!success) { @@ -382,11 +381,10 @@ private async Task WriteLocalFileHeaderAsync(bool isEmptyFile, bool forceW // Write WinZip AES extra field if using AES encryption // Must match the exact check used in the sync version WriteLocalFileHeader - if (ForAesEncryption()) + if (UseAesEncryption()) { var aesExtraField = new WinZipAesExtraField { - VendorVersion = 2, // AE-2 AesStrength = Encryption switch { EncryptionMethod.Aes128 => (byte)1, diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index d7ac38b8d47eb3..ef3c97d8987c8d 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -49,7 +49,7 @@ public partial class ZipArchiveEntry private byte[] _fileComment; private EncryptionMethod _encryptionMethod; private readonly CompressionLevel _compressionLevel; - private ZipCompressionMethod _aesCompressionMethod; + private ZipCompressionMethod _headerCompressionMethod; private ushort? _aeVersion; // Initializes a ZipArchiveEntry instance for an existing archive entry. @@ -613,11 +613,10 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 WinZipAesExtraField? aesExtraField = null; int aesExtraFieldSize = 0; - if (ForAesEncryption()) + if (UseAesEncryption()) { aesExtraField = new WinZipAesExtraField { - VendorVersion = 2, // AE-2 AesStrength = Encryption switch { EncryptionMethod.Aes128 => (byte)1, @@ -693,7 +692,7 @@ private void WriteCentralDirectoryFileHeaderPrepare(Span cdStaticHeader, u BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressionMethod..], (ushort)CompressionMethod); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); // when using aes encryption, ae-2 standard dictates crc to be 0 - uint crcToWrite = ForAesEncryption() ? 0 : _crc32; + uint crcToWrite = UseAesEncryption() ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.Crc32..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressedSize..], compressedSizeTruncated); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.UncompressedSize..], uncompressedSizeTruncated); @@ -721,11 +720,10 @@ internal void WriteCentralDirectoryFileHeader(bool forceWrite) zip64ExtraField?.WriteBlock(_archive.ArchiveStream); // Write AES extra field if using AES encryption (add this block) - if (ForAesEncryption()) + if (UseAesEncryption()) { var aesExtraField = new WinZipAesExtraField { - VendorVersion = 2, AesStrength = Encryption switch { EncryptionMethod.Aes128 => (byte)1, @@ -880,16 +878,10 @@ private byte CalculateZipCryptoCheckByte() private bool IsZipCryptoEncrypted() { - return (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0 && !IsAesEncrypted(); + return (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0 && _headerCompressionMethod != ZipCompressionMethod.Aes; } - private bool IsAesEncrypted() - { - // Compression method 99 indicates AES encryption - return _aesCompressionMethod == ZipCompressionMethod.Aes; - } - - private bool ForAesEncryption() + private bool UseAesEncryption() { return _encryptionMethod is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256; } @@ -942,7 +934,7 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read byte expectedCheckByte = CalculateZipCryptoCheckByte(); streamToDecompress = new ZipCryptoStream(compressedStream, password, expectedCheckByte); } - else if (IsAesEncrypted()) + else if (_headerCompressionMethod == ZipCompressionMethod.Aes) { if (password.IsEmpty) throw new InvalidDataException(SR.PasswordRequired); @@ -966,7 +958,7 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read // Get decompressed stream Stream decompressedStream = GetDataDecompressor(streamToDecompress); - if (ForAesEncryption() && _aeVersion == 1) + if (UseAesEncryption() && _aeVersion == 1) { // Wrap with CRC validator for AE-1 return new CrcValidatingReadStream(decompressedStream, _crc32, _uncompressedSize); @@ -1090,7 +1082,7 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st else if (IsEncrypted && CompressionMethod == ZipCompressionMethod.Aes) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - _aesCompressionMethod = ZipCompressionMethod.Aes; + _headerCompressionMethod = ZipCompressionMethod.Aes; // AES case - need to read the extra field to determine actual compression method and encryption strength if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out WinZipAesExtraField? aesExtraField)) { @@ -1281,7 +1273,7 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o uncompressedSizeTruncated = 0; Debug.Assert(_crc32 == 0); } - else if (ForAesEncryption()) + else if (UseAesEncryption()) { _generalPurposeBitFlag |= BitFlagValues.IsEncrypted; _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; @@ -1391,7 +1383,7 @@ private void WriteLocalFileHeaderPrepare(Span lfStaticHeader, uint compres BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressionMethod..], (ushort)CompressionMethod); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); // when using aes encryption, ae-2 standard dictates crc to be 0 - uint crcToWrite = ForAesEncryption() ? 0 : _crc32; + uint crcToWrite = UseAesEncryption() ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.Crc32..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressedSize..], compressedSizeTruncated); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.UncompressedSize..], uncompressedSizeTruncated); @@ -1415,7 +1407,7 @@ private bool WriteLocalFileHeader(bool isEmptyFile, bool forceWrite) zip64ExtraField?.WriteBlock(_archive.ArchiveStream); // Write AES extra field if using AES encryption - if (ForAesEncryption()) + if (UseAesEncryption()) { var aesExtraField = new WinZipAesExtraField { @@ -1594,7 +1586,7 @@ private void WriteCrcAndSizesInLocalHeaderPrepareFor32bitValuesWriting(bool pret int relativeCompressedSizeLocation = ZipLocalFileHeader.FieldLocations.CompressedSize - ZipLocalFileHeader.FieldLocations.Crc32; int relativeUncompressedSizeLocation = ZipLocalFileHeader.FieldLocations.UncompressedSize - ZipLocalFileHeader.FieldLocations.Crc32; // when using aes encryption, ae-2 standard dictates crc to be 0 - uint crcToWrite = ForAesEncryption() ? 0 : _crc32; + uint crcToWrite = UseAesEncryption() ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeCrc32Location..], crcToWrite); BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeCompressedSizeLocation..], compressedSizeTruncated); BinaryPrimitives.WriteUInt32LittleEndian(writeBuffer[relativeUncompressedSizeLocation..], uncompressedSizeTruncated); @@ -1623,7 +1615,7 @@ private void WriteCrcAndSizesInLocalHeaderPrepareForWritingDataDescriptor(Span dataDescriptor) ZipLocalFileHeader.DataDescriptorSignatureConstantBytes.CopyTo(dataDescriptor[ZipLocalFileHeader.ZipDataDescriptor.FieldLocations.Signature..]); // when using aes encryption, ae-2 standard dictates crc to be 0 - uint crcToWrite = ForAesEncryption() ? 0 : _crc32; + uint crcToWrite = UseAesEncryption() ? 0 : _crc32; BinaryPrimitives.WriteUInt32LittleEndian(dataDescriptor[ZipLocalFileHeader.ZipDataDescriptor.FieldLocations.Crc32..], crcToWrite); if (AreSizesTooLarge) From 5410a045032b4083aa2a458cf3d40735dd95511f Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 6 Jan 2026 15:47:27 +0200 Subject: [PATCH 25/39] save key derivation material and replace old ctors + update tests not working yet --- .../tests/ZipFile.Encryption.cs | 406 +++++++++++++++++- .../System/IO/Compression/WinZipAesStream.cs | 167 ++++++- .../System/IO/Compression/ZipArchiveEntry.cs | 132 ++++-- .../src/System/IO/Compression/ZipBlocks.cs | 9 +- .../System/IO/Compression/ZipCryptoStream.cs | 79 +++- 5 files changed, 737 insertions(+), 56 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 50af49563fb800..501c797702bff4 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -317,9 +317,413 @@ private async Task AssertEntryTextEquals(ZipArchiveEntry entry, string expected, actual = await r.ReadToEndAsync(); else actual = r.ReadToEnd(); - + Assert.Equal(expected, actual); } } + + #region Update Mode Tests for Encrypted Entries + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task UpdateMode_ModifyEncryptedEntry_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string entryName = "test.txt"; + string originalContent = "Original Content"; + string modifiedContent = "Modified Content After Update"; + string password = "password123"; + + // Create archive with encrypted entry + var entries = new[] { (entryName, originalContent, (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + // Verify original content + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + await AssertEntryTextEquals(entry, originalContent, password, async); + } + + // Open in Update mode and modify the encrypted entry + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + // Open with password for editing + using (Stream stream = entry.Open(password)) + { + // Clear existing content and write new content + stream.SetLength(0); + byte[] newContentBytes = Encoding.UTF8.GetBytes(modifiedContent); + if (async) + await stream.WriteAsync(newContentBytes, 0, newContentBytes.Length); + else + stream.Write(newContentBytes, 0, newContentBytes.Length); + } + } + + // Verify modified content + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + await AssertEntryTextEquals(entry, modifiedContent, password, async); + } + } + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task UpdateMode_AppendToEncryptedEntry_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string entryName = "test.txt"; + string originalContent = "Original Content"; + string appendedContent = " - Appended Text"; + string password = "password123"; + + // Create archive with encrypted entry + var entries = new[] { (entryName, originalContent, (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + // Open in Update mode and append to the encrypted entry + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using (Stream stream = entry.Open(password)) + { + // Seek to end and append + stream.Seek(0, SeekOrigin.End); + byte[] appendBytes = Encoding.UTF8.GetBytes(appendedContent); + if (async) + await stream.WriteAsync(appendBytes, 0, appendBytes.Length); + else + stream.Write(appendBytes, 0, appendBytes.Length); + } + } + + // Verify content has original + appended + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + await AssertEntryTextEquals(entry, originalContent + appendedContent, password, async); + } + } + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task UpdateMode_ReadOnlyEncryptedEntry_NoModification(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string entryName = "test.txt"; + string content = "Unmodified Content"; + string password = "password123"; + + // Create archive with encrypted entry + var entries = new[] { (entryName, content, (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + // Open in Update mode, read the entry but don't modify it + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using (Stream stream = entry.Open(password)) + using (StreamReader reader = new StreamReader(stream, Encoding.UTF8)) + { + string readContent = async ? await reader.ReadToEndAsync() : reader.ReadToEnd(); + Assert.Equal(content, readContent); + } + } + + // Verify content is still intact after update mode + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + await AssertEntryTextEquals(entry, content, password, async); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task UpdateMode_MultipleEncryptedEntries_ModifyOne(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + var encryptionMethod = ZipArchiveEntry.EncryptionMethod.Aes256; + + var entries = new[] + { + ("file1.txt", "Content 1", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("file2.txt", "Content 2", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("file3.txt", "Content 3", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + // Modify only file2.txt + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + ZipArchiveEntry entry = archive.GetEntry("file2.txt"); + Assert.NotNull(entry); + + using (Stream stream = entry.Open(password)) + { + stream.SetLength(0); + byte[] newContent = Encoding.UTF8.GetBytes("Modified Content 2"); + if (async) + await stream.WriteAsync(newContent, 0, newContent.Length); + else + stream.Write(newContent, 0, newContent.Length); + } + } + + // Verify: file1 and file3 unchanged, file2 modified + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + await AssertEntryTextEquals(archive.GetEntry("file1.txt"), "Content 1", password, async); + await AssertEntryTextEquals(archive.GetEntry("file2.txt"), "Modified Content 2", password, async); + await AssertEntryTextEquals(archive.GetEntry("file3.txt"), "Content 3", password, async); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task UpdateMode_MixedEncryption_ModifyEncrypted(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + var entries = new[] + { + ("plain.txt", "Plain Content", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), + ("encrypted.txt", "Encrypted Content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + // Modify the encrypted entry + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + ZipArchiveEntry entry = archive.GetEntry("encrypted.txt"); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + using (Stream stream = entry.Open(password)) + { + stream.SetLength(0); + byte[] newContent = Encoding.UTF8.GetBytes("Modified Encrypted Content"); + if (async) + await stream.WriteAsync(newContent, 0, newContent.Length); + else + stream.Write(newContent, 0, newContent.Length); + } + } + + // Verify both entries + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + // Plain entry should be unchanged + var plainEntry = archive.GetEntry("plain.txt"); + Assert.NotNull(plainEntry); + Assert.False(plainEntry.IsEncrypted); + await AssertEntryTextEquals(plainEntry, "Plain Content", null, async); + + // Encrypted entry should be modified + var encryptedEntry = archive.GetEntry("encrypted.txt"); + Assert.NotNull(encryptedEntry); + Assert.True(encryptedEntry.IsEncrypted); + await AssertEntryTextEquals(encryptedEntry, "Modified Encrypted Content", password, async); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task UpdateMode_LargeEncryptedEntry_Modify(bool async) + { + string archivePath = GetTempArchivePath(); + string entryName = "large.bin"; + int originalSize = 512 * 1024; // 512KB + int modifiedSize = 768 * 1024; // 768KB + byte[] originalContent = new byte[originalSize]; + byte[] modifiedContent = new byte[modifiedSize]; + new Random(42).NextBytes(originalContent); + new Random(43).NextBytes(modifiedContent); + string password = "password123"; + var encryptionMethod = ZipArchiveEntry.EncryptionMethod.Aes256; + + // Create archive with large encrypted entry + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry(entryName); + using (Stream s = entry.Open(password, encryptionMethod)) + { + if (async) + await s.WriteAsync(originalContent, 0, originalContent.Length); + else + s.Write(originalContent, 0, originalContent.Length); + } + } + + // Update with different content + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using (Stream stream = entry.Open(password)) + { + stream.SetLength(0); + if (async) + await stream.WriteAsync(modifiedContent, 0, modifiedContent.Length); + else + stream.Write(modifiedContent, 0, modifiedContent.Length); + } + } + + // Verify modified content + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + using (Stream s = entry.Open(password)) + using (MemoryStream ms = new MemoryStream()) + { + if (async) + await s.CopyToAsync(ms); + else + s.CopyTo(ms); + + Assert.Equal(modifiedContent, ms.ToArray()); + } + } + } + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task UpdateMode_EncryptedEntry_EmptyAfterModification(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string entryName = "test.txt"; + string originalContent = "Original Content"; + string password = "password123"; + + // Create archive with encrypted entry + var entries = new[] { (entryName, originalContent, (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + // Open in Update mode and clear the content + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + + using (Stream stream = entry.Open(password)) + { + stream.SetLength(0); // Make it empty + } + } + + // Verify entry is now empty + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + await AssertEntryTextEquals(entry, "", password, async); + } + } + + [Fact] + public void UpdateMode_EncryptedEntry_WrongPassword_Throws() + { + string archivePath = GetTempArchivePath(); + string password = "correct"; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) }; + CreateArchiveWithEntries(archivePath, entries, async: false).GetAwaiter().GetResult(); + + using (ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Update)) + { + var entry = archive.GetEntry("test.txt"); + Assert.NotNull(entry); + Assert.Throws(() => entry.Open("wrong")); + } + } + + [Fact] + public void UpdateMode_EncryptedEntry_NoPassword_Throws() + { + string archivePath = GetTempArchivePath(); + string password = "correct"; + var entries = new[] { ("test.txt", "content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) }; + CreateArchiveWithEntries(archivePath, entries, async: false).GetAwaiter().GetResult(); + + using (ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Update)) + { + var entry = archive.GetEntry("test.txt"); + Assert.NotNull(entry); + // Opening an encrypted entry without password in update mode should throw + Assert.ThrowsAny(() => entry.Open()); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task UpdateMode_ZipCryptoToAes_PreservesEncryption(bool async) + { + // This test verifies that modifying a ZipCrypto entry preserves encryption + string archivePath = GetTempArchivePath(); + string entryName = "test.txt"; + string originalContent = "Original ZipCrypto Content"; + string modifiedContent = "Modified Content"; + string password = "password123"; + + // Create archive with ZipCrypto encrypted entry + var entries = new[] { (entryName, originalContent, (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.ZipCrypto) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + // Modify in Update mode + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + using (Stream stream = entry.Open(password)) + { + stream.SetLength(0); + byte[] newContent = Encoding.UTF8.GetBytes(modifiedContent); + if (async) + await stream.WriteAsync(newContent, 0, newContent.Length); + else + stream.Write(newContent, 0, newContent.Length); + } + } + + // Verify entry is still encrypted and can be read with original password + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + await AssertEntryTextEquals(entry, modifiedContent, password, async); + } + } + + #endregion } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 66d112980a505b..639e55a659be8b 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -40,16 +40,105 @@ internal sealed class WinZipAesStream : Stream private readonly byte[] _keystreamBuffer = new byte[KeystreamBufferSize]; private int _keystreamOffset = KeystreamBufferSize; // Start depleted to force initial generation + public static int GetKeyMaterialSize(int keySizeBits) + { + int keySizeBytes = keySizeBits / 8; + // Total = encryption key + HMAC key (same size) + 2-byte password verifier + return keySizeBytes + keySizeBytes + 2; + } + + public static int GetSaltSize(int keySizeBits) => keySizeBits / 16; + + //A byte array containing salt + derived key material + public static byte[] CreateKey(ReadOnlyMemory password, byte[]? salt, int keySizeBits) + { + int saltSize = GetSaltSize(keySizeBits); + int keySizeBytes = keySizeBits / 8; + int totalKeySize = keySizeBytes + keySizeBytes + 2; // encryption key + HMAC key + verifier + + // Generate or validate salt + byte[] saltBytes; + if (salt == null) + { + saltBytes = new byte[saltSize]; + RandomNumberGenerator.Fill(saltBytes); + } + else + { + if (salt.Length != saltSize) + throw new ArgumentException($"Salt must be {saltSize} bytes for AES-{keySizeBits}.", nameof(salt)); + saltBytes = salt; + } + + // Derive keys using PBKDF2 + int maxPasswordByteCount = Encoding.UTF8.GetMaxByteCount(password.Length); + Span passwordBytes = stackalloc byte[maxPasswordByteCount]; + int actualByteCount = Encoding.UTF8.GetBytes(password.Span, passwordBytes); + Span passwordSpan = passwordBytes[..actualByteCount]; + + Span derivedKey = stackalloc byte[totalKeySize]; + + try + { + Rfc2898DeriveBytes.Pbkdf2( + passwordSpan, + saltBytes, + derivedKey, + 1000, + HashAlgorithmName.SHA1); + + // Format: [salt][encryption key][HMAC key][password verifier] + byte[] result = new byte[saltSize + totalKeySize]; + saltBytes.CopyTo(result, 0); + derivedKey.CopyTo(result.AsSpan(saltSize)); - public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool encrypting, int keySizeBits = 256, long totalStreamSize = -1, bool leaveOpen = false) + return result; + } + finally + { + CryptographicOperations.ZeroMemory(passwordBytes); + CryptographicOperations.ZeroMemory(derivedKey); + } + } + + // Parses persisted key material into its components. + private static void ParseKeyMaterial(byte[] keyMaterial, int keySizeBits, + out byte[] salt, out byte[] encryptionKey, out byte[] hmacKey, out byte[] passwordVerifier) + { + int saltSize = GetSaltSize(keySizeBits); + int keySizeBytes = keySizeBits / 8; + int expectedSize = saltSize + keySizeBytes + keySizeBytes + 2; + + Debug.Assert(keyMaterial.Length == expectedSize, "Key material length does not match expected size."); + int offset = 0; + + salt = new byte[saltSize]; + Array.Copy(keyMaterial, offset, salt, 0, saltSize); + offset += saltSize; + + encryptionKey = new byte[keySizeBytes]; + Array.Copy(keyMaterial, offset, encryptionKey, 0, keySizeBytes); + offset += keySizeBytes; + + hmacKey = new byte[keySizeBytes]; + Array.Copy(keyMaterial, offset, hmacKey, 0, keySizeBytes); + offset += keySizeBytes; + + passwordVerifier = new byte[2]; + Array.Copy(keyMaterial, offset, passwordVerifier, 0, 2); + } + + public WinZipAesStream(Stream baseStream, byte[] keyMaterial, bool encrypting, int keySizeBits = 256, long totalStreamSize = -1, bool leaveOpen = false) { ArgumentNullException.ThrowIfNull(baseStream); + ArgumentNullException.ThrowIfNull(keyMaterial); _baseStream = baseStream; _encrypting = encrypting; _keySizeBits = keySizeBits; _totalStreamSize = totalStreamSize; _leaveOpen = leaveOpen; + #pragma warning disable CA1416 // HMACSHA1 is available on all platforms _aes = Aes.Create(); #pragma warning restore CA1416 @@ -59,6 +148,9 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en Array.Clear(_counterBlock, 0, 16); _counterBlock[0] = 1; + // Parse the persisted key material + ParseKeyMaterial(keyMaterial, keySizeBits, out _salt!, out _key!, out _hmacKey!, out _passwordVerifier!); + if (_totalStreamSize > 0) { int saltSize = _keySizeBits / 16; @@ -76,14 +168,7 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en if (_encrypting) { - // 8 for AES-128, 12 for AES-192, 16 for AES-256 - int saltSize = _keySizeBits / 16; - _salt = new byte[saltSize]; - RandomNumberGenerator.Fill(_salt); - - DeriveKeysFromPassword(password, _salt); - - Debug.Assert(_hmacKey is not null, "HMAC key should be derived"); + Debug.Assert(_hmacKey is not null, "HMAC key should be parsed"); #pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms - required by WinZip AES spec _hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA1, _hmacKey!); #pragma warning restore CA5350 @@ -106,9 +191,71 @@ public WinZipAesStream(Stream baseStream, ReadOnlyMemory password, bool en throw new InvalidDataException(SR.InvalidWinZipSize); } - ReadHeader(password); + // Read and validate header using persisted key material + ReadHeaderWithKeyMaterial(); } } + + // Returns key material: [salt][encryption key][HMAC key][password verifier] + internal byte[] GetKeyMaterial() + { + Debug.Assert(_salt != null && _key != null && _hmacKey != null && _passwordVerifier != null, + "Keys should be initialized before getting key material"); + + int saltSize = GetSaltSize(_keySizeBits); + int keySizeBytes = _keySizeBits / 8; + int totalSize = saltSize + keySizeBytes + keySizeBytes + 2; + + byte[] result = new byte[totalSize]; + int offset = 0; + + _salt!.CopyTo(result, offset); + offset += saltSize; + + _key!.CopyTo(result, offset); + offset += keySizeBytes; + + _hmacKey!.CopyTo(result, offset); + offset += keySizeBytes; + + _passwordVerifier!.CopyTo(result, offset); + + return result; + } + + // Reads the header and validates against persisted key material. + private void ReadHeaderWithKeyMaterial() + { + if (_headerRead) return; + + // Salt size depends on AES strength + int saltSize = _keySizeBits / 16; + byte[] fileSalt = new byte[saltSize]; + _baseStream.ReadExactly(fileSalt); + + // Read the 2-byte password verifier from stream + byte[] verifier = new byte[2]; + _baseStream.ReadExactly(verifier); + + // Verify the salt matches + Debug.Assert(fileSalt.AsSpan().SequenceEqual(_salt!), "Salt mismatch - key material does not match this entry."); + + // Verify the password verifier + if (!verifier.AsSpan().SequenceEqual(_passwordVerifier!)) + { + throw new InvalidDataException(SR.InvalidPassword); + } + +#pragma warning disable CA5350 // Do Not Use Weak Cryptographic Algorithms - required by WinZip AES spec + _hmac = IncrementalHash.CreateHMAC(HashAlgorithmName.SHA1, _hmacKey!); +#pragma warning restore CA5350 + InitCipher(); + + Array.Clear(_counterBlock, 0, 16); + _counterBlock[0] = 1; + + _headerRead = true; + } private void DeriveKeysFromPassword(ReadOnlyMemory password, byte[] salt) { // Calculate sizes diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index ef3c97d8987c8d..ce9b646ac34683 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -51,6 +51,11 @@ public partial class ZipArchiveEntry private readonly CompressionLevel _compressionLevel; private ZipCompressionMethod _headerCompressionMethod; private ushort? _aeVersion; + // Cached derived key material for encrypted entries to avoid repeated PBKDF2 derivation. + // For WinZip AES: contains [salt][encryption key][HMAC key][password verifier] + // For ZipCrypto: contains [key0][key1][key2] as 12 bytes + // Invalidated when encryption parameters change (new salt needed) + private byte[]? _derivedEncryptionKeyMaterial; // Initializes a ZipArchiveEntry instance for an existing archive entry. internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) @@ -886,6 +891,73 @@ private bool UseAesEncryption() return _encryptionMethod is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256; } + private void InvalidateKeyMaterialCache() + { + _derivedEncryptionKeyMaterial = null; + } + + private int GetAesKeySizeBits() + { + return _encryptionMethod switch + { + EncryptionMethod.Aes128 => 128, + EncryptionMethod.Aes192 => 192, + EncryptionMethod.Aes256 => 256, + _ => 256 // Default to AES-256 + }; + } + + // Creates the appropriate decryption stream for an encrypted entry. + // Uses cached key material if available; otherwise derives keys from password and caches them. + private Stream CreateDecryptionStream(Stream compressedStream, ReadOnlyMemory password) + { + if (IsZipCryptoEncrypted()) + { + byte expectedCheckByte = CalculateZipCryptoCheckByte(); + + if (_derivedEncryptionKeyMaterial is null) + { + if (password.IsEmpty) + throw new InvalidDataException(SR.PasswordRequired); + + _derivedEncryptionKeyMaterial = ZipCryptoStream.CreateKey(password); + } + + return new ZipCryptoStream(compressedStream, _derivedEncryptionKeyMaterial, expectedCheckByte); + } + else if (_headerCompressionMethod == ZipCompressionMethod.Aes) + { + int keySizeBits = GetAesKeySizeBits(); + + if (_derivedEncryptionKeyMaterial is null) + { + if (password.IsEmpty) + throw new InvalidDataException(SR.PasswordRequired); + + // Read salt from stream to derive keys + int saltSize = WinZipAesStream.GetSaltSize(keySizeBits); + byte[] salt = new byte[saltSize]; + compressedStream.ReadExactly(salt); + + // Seek back so WinZipAesStream can read the header (salt + password verifier) + compressedStream.Seek(-saltSize, SeekOrigin.Current); + + // Derive and cache key material + _derivedEncryptionKeyMaterial = WinZipAesStream.CreateKey(password, salt, keySizeBits); + } + + return new WinZipAesStream( + baseStream: compressedStream, + keyMaterial: _derivedEncryptionKeyMaterial, + encrypting: false, + keySizeBits: keySizeBits, + totalStreamSize: _compressedSize); + } + + // Not encrypted - return as-is + return compressedStream; + } + private Stream GetDataDecompressor(Stream compressedStreamToRead) { Stream? uncompressedStream; @@ -924,35 +996,16 @@ private Stream OpenInReadMode(bool checkOpenable, ReadOnlyMemory password private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, ReadOnlyMemory password = default) { Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offsetOfCompressedData, _compressedSize); - Stream streamToDecompress = compressedStream; + Stream streamToDecompress; - if (IsZipCryptoEncrypted()) + if (IsEncrypted) { - if (password.IsEmpty) - throw new InvalidDataException(SR.PasswordRequired); - - byte expectedCheckByte = CalculateZipCryptoCheckByte(); - streamToDecompress = new ZipCryptoStream(compressedStream, password, expectedCheckByte); + // Use the shared helper that handles key caching + streamToDecompress = CreateDecryptionStream(compressedStream, password); } - else if (_headerCompressionMethod == ZipCompressionMethod.Aes) + else { - if (password.IsEmpty) - throw new InvalidDataException(SR.PasswordRequired); - - int keySizeBits = _encryptionMethod switch - { - EncryptionMethod.Aes128 => 128, - EncryptionMethod.Aes192 => 192, - EncryptionMethod.Aes256 => 256, - _ => 256 // default for aes - }; - - streamToDecompress = new WinZipAesStream( - baseStream: compressedStream, - password: password, - encrypting: false, - keySizeBits: keySizeBits, - totalStreamSize: _compressedSize); + streamToDecompress = compressedStream; } // Get decompressed stream @@ -966,7 +1019,6 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read return decompressedStream; } - private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.None) { if (_everOpenedForWrite) @@ -988,12 +1040,12 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod Encryption = encryptionMethod; - // For ZipCrypto with streaming (bit 3 set), verifier = DOS time low word + byte[] keyMaterial = ZipCryptoStream.CreateKey(password.AsMemory()); ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); targetStream = new ZipCryptoStream( baseStream: _archive.ArchiveStream, - password: password.AsMemory(), + keyBytes: keyMaterial, passwordVerifierLow2Bytes: verifierLow2Bytes, crc32: null, leaveOpen: true); @@ -1005,8 +1057,7 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod Encryption = encryptionMethod; - // use switch to calculate keysizebits based on encryption strength - int keysizebits = encryptionMethod switch + int keySizeBits = encryptionMethod switch { EncryptionMethod.Aes128 => 128, EncryptionMethod.Aes192 => 192, @@ -1014,18 +1065,22 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod _ => 256 // Default to AES-256 }; - // targetstream should be new winzipaesstream for wrting, ae2 + // Derive key material from password with new random salt + byte[] keyMaterial = WinZipAesStream.CreateKey(password.AsMemory(), salt: null, keySizeBits); + targetStream = new WinZipAesStream( - baseStream: _archive.ArchiveStream, - password: password.AsMemory(), - encrypting: true, - keySizeBits: keysizebits, - leaveOpen: true); + baseStream: _archive.ArchiveStream, + keyMaterial: keyMaterial, + encrypting: true, + keySizeBits: keySizeBits, + leaveOpen: true); } + bool isAesEncryption = encryptionMethod is EncryptionMethod.Aes256 or EncryptionMethod.Aes192 or EncryptionMethod.Aes128; + CheckSumAndSizeWriteStream crcSizeStream = GetDataCompressor( targetStream, - encryptionMethod is EncryptionMethod.Aes256 or EncryptionMethod.Aes192 or EncryptionMethod.Aes128 ? false : true, + leaveBackingStreamOpen: !isAesEncryption, (object? o, EventArgs e) => { // release the archive stream @@ -1033,13 +1088,12 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod entry._archive.ReleaseArchiveStream(entry); entry._outstandingWriteStream = null; }, - encryptionMethod != EncryptionMethod.None ? _archive.ArchiveStream : null); + streamForPosition: encryptionMethod != EncryptionMethod.None ? _archive.ArchiveStream : null); _outstandingWriteStream = new DirectToArchiveWriterStream(crcSizeStream, this, encryptionMethod); return new WrappedStream(baseStream: _outstandingWriteStream, closeBaseStream: true); } - private WrappedStream OpenInUpdateMode() { if (_currentlyOpenForWrite) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs index 893e03cab06c95..b4fd9476c32674 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs @@ -693,12 +693,12 @@ public static bool TrySkipBlockAESAware(Stream stream, out WinZipAesExtraField? // Skip file name stream.Seek(nameLength, SeekOrigin.Current); + // Calculate end of extra fields + long extraEnd = stream.Position + extraLength; + // Parse extra fields if present if (extraLength > 0) { - long extraStart = stream.Position; - long extraEnd = extraStart + extraLength; - while (stream.Position < extraEnd) { ushort headerId = reader.ReadUInt16(); @@ -723,6 +723,9 @@ public static bool TrySkipBlockAESAware(Stream stream, out WinZipAesExtraField? } } + // Ensure we're positioned at the end of extra fields (where data begins) + stream.Seek(extraEnd, SeekOrigin.Begin); + return true; } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs index bf20d0250cfb03..40289d320aad6f 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCryptoStream.cs @@ -10,6 +10,8 @@ namespace System.IO.Compression { internal sealed class ZipCryptoStream : Stream { + internal const int KeySize = 12; // 3 * sizeof(uint) + private readonly bool _encrypting; private readonly Stream _base; private readonly bool _leaveOpen; @@ -35,11 +37,15 @@ private static uint[] CreateCrc32Table() return table; } - // Decryption constructor - public ZipCryptoStream(Stream baseStream, ReadOnlyMemory password, byte expectedCheckByte) + // Decryption constructor using persisted key bytes. + public ZipCryptoStream(Stream baseStream, byte[] keyBytes, byte expectedCheckByte) { _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); - InitKeysFromBytes(password.Span); + ArgumentNullException.ThrowIfNull(keyBytes); + if (keyBytes.Length != KeySize) + throw new ArgumentException($"Key bytes must be exactly {KeySize} bytes.", nameof(keyBytes)); + + InitKeysFromKeyBytes(keyBytes); _encrypting = false; ValidateHeader(expectedCheckByte); // reads & consumes 12 bytes } @@ -59,6 +65,73 @@ public ZipCryptoStream(Stream baseStream, InitKeysFromBytes(password.Span); } + // Encryption constructor using persisted key bytes. + public ZipCryptoStream(Stream baseStream, + byte[] keyBytes, + ushort passwordVerifierLow2Bytes, + uint? crc32 = null, + bool leaveOpen = false) + { + _base = baseStream ?? throw new ArgumentNullException(nameof(baseStream)); + ArgumentNullException.ThrowIfNull(keyBytes); + if (keyBytes.Length != KeySize) + throw new ArgumentException($"Key bytes must be exactly {KeySize} bytes.", nameof(keyBytes)); + + _encrypting = true; + _leaveOpen = leaveOpen; + _verifierLow2Bytes = passwordVerifierLow2Bytes; + _crc32ForHeader = crc32; + InitKeysFromKeyBytes(keyBytes); + } + + // Creates the persisted key bytes from a password. + // The returned byte array contains the 3 ZipCrypto keys (key0, key1, key2) + // serialized as 12 bytes in little-endian format. + public static byte[] CreateKey(ReadOnlyMemory password) + { + // Initialize keys with standard ZipCrypto initial values + uint key0 = 305419896; + uint key1 = 591751049; + uint key2 = 878082192; + + // ZipCrypto uses raw bytes; ASCII is the most interoperable + var bytes = password.Span.ToArray(); + foreach (byte b in bytes) + { + key0 = Crc32Update(key0, b); + key1 += (key0 & 0xFF); + key1 = key1 * 134775813 + 1; + key2 = Crc32Update(key2, (byte)(key1 >> 24)); + } + + // Serialize the 3 keys to bytes in little-endian format + byte[] keyBytes = new byte[KeySize]; + BinaryPrimitives.WriteUInt32LittleEndian(keyBytes.AsSpan(0, 4), key0); + BinaryPrimitives.WriteUInt32LittleEndian(keyBytes.AsSpan(4, 4), key1); + BinaryPrimitives.WriteUInt32LittleEndian(keyBytes.AsSpan(8, 4), key2); + + return keyBytes; + } + + // Gets the current key state as a 12-byte array. + // This can be used to persist keys after header validation for update mode. + internal byte[] GetKeyBytes() + { + byte[] keyBytes = new byte[KeySize]; + BinaryPrimitives.WriteUInt32LittleEndian(keyBytes.AsSpan(0, 4), _key0); + BinaryPrimitives.WriteUInt32LittleEndian(keyBytes.AsSpan(4, 4), _key1); + BinaryPrimitives.WriteUInt32LittleEndian(keyBytes.AsSpan(8, 4), _key2); + return keyBytes; + } + + // Initializes keys from persisted key bytes. + private void InitKeysFromKeyBytes(byte[] keyBytes) + { + _key0 = BinaryPrimitives.ReadUInt32LittleEndian(keyBytes.AsSpan(0, 4)); + _key1 = BinaryPrimitives.ReadUInt32LittleEndian(keyBytes.AsSpan(4, 4)); + _key2 = BinaryPrimitives.ReadUInt32LittleEndian(keyBytes.AsSpan(8, 4)); + } + private byte[] CalculateHeader() { byte[] hdrPlain = new byte[12]; From 97a4a2b7247df277fa6dcc70c2ffe34827175188 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 13 Jan 2026 14:39:18 +0100 Subject: [PATCH 26/39] working update mode for winzip & zipcrypto --- .../tests/ZipFile.Extract.cs | 119 +++++++ .../System/IO/Compression/WinZipAesStream.cs | 22 +- .../IO/Compression/ZipArchiveEntry.Async.cs | 48 ++- .../System/IO/Compression/ZipArchiveEntry.cs | 336 +++++++++++++++--- .../System/IO/Compression/ZipBlocks.Async.cs | 21 ++ .../src/System/IO/Compression/ZipBlocks.cs | 37 ++ 6 files changed, 533 insertions(+), 50 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index da6bcc8adf5751..a20a3b351c4f10 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -1899,6 +1899,125 @@ public async Task MixedSyncAsyncOperations_AES192_RoundTrip() } } + [SkipOnCI("Local development test - requires specific file paths")] + [Fact] + public async Task Debug_UpdateMode_MultipleEncryptedEntries_ModifyOne() + { + // Arrange - use a fixed path so you can hexdump it + Directory.CreateDirectory(DownloadsDir); + string archivePath = Path.Combine(DownloadsDir, "debug_update_mode_aes.zip"); + string archiveAfterUpdatePath = Path.Combine(DownloadsDir, "debug_update_mode_aes_after_update.zip"); + + if (File.Exists(archivePath)) File.Delete(archivePath); + if (File.Exists(archiveAfterUpdatePath)) File.Delete(archiveAfterUpdatePath); + + string password = "password123"; + var encryptionMethod = ZipArchiveEntry.EncryptionMethod.Aes256; + + // Step 1: Create initial archive with 3 encrypted entries + using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) + { + var entries = new[] + { + ("file1.txt", "Content 1"), + ("file2.txt", "Content 2"), + ("file3.txt", "Content 3") + }; + + foreach (var (name, content) in entries) + { + var entry = archive.CreateEntry(name); + using var stream = entry.Open(password, encryptionMethod); + using var writer = new StreamWriter(stream, Encoding.UTF8); + await writer.WriteAsync(content); + } + } + + // Copy the original archive for comparison + File.Copy(archivePath, Path.Combine(DownloadsDir, "debug_update_mode_aes_ORIGINAL.zip"), overwrite: true); + + // Step 2: Open in Update mode and modify file2.txt + using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Update)) + { + var entry = archive.GetEntry("file2.txt"); + Assert.NotNull(entry); + + // Log entry state before opening + System.Diagnostics.Debug.WriteLine($"Before Open - Entry: {entry.FullName}"); + System.Diagnostics.Debug.WriteLine($" IsEncrypted: {entry.IsEncrypted}"); + System.Diagnostics.Debug.WriteLine($" CompressionMethod: {entry.CompressionMethod}"); + System.Diagnostics.Debug.WriteLine($" CompressedLength: {entry.CompressedLength}"); + System.Diagnostics.Debug.WriteLine($" Length: {entry.Length}"); + + using (var stream = entry.Open(password)) + { + stream.SetLength(0); + byte[] newContent = Encoding.UTF8.GetBytes("Modified Content 2"); + await stream.WriteAsync(newContent, 0, newContent.Length); + } + + // Log all entries' state after modification + foreach (var e in archive.Entries) + { + System.Diagnostics.Debug.WriteLine($"After Modify - Entry: {e.FullName}"); + System.Diagnostics.Debug.WriteLine($" IsEncrypted: {e.IsEncrypted}"); + System.Diagnostics.Debug.WriteLine($" CompressionMethod: {e.CompressionMethod}"); + } + } + + // Copy the modified archive for comparison + File.Copy(archivePath, archiveAfterUpdatePath, overwrite: true); + + // Step 3: Try to read back all entries + System.Diagnostics.Debug.WriteLine("=== Reading back entries ==="); + + using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Read)) + { + foreach (var entry in archive.Entries) + { + System.Diagnostics.Debug.WriteLine($"Reading Entry: {entry.FullName}"); + System.Diagnostics.Debug.WriteLine($" IsEncrypted: {entry.IsEncrypted}"); + System.Diagnostics.Debug.WriteLine($" CompressionMethod: {entry.CompressionMethod}"); + System.Diagnostics.Debug.WriteLine($" CompressedLength: {entry.CompressedLength}"); + System.Diagnostics.Debug.WriteLine($" Length: {entry.Length}"); + + try + { + using var stream = entry.Open(password); + using var reader = new StreamReader(stream, Encoding.UTF8); + string content = await reader.ReadToEndAsync(); + System.Diagnostics.Debug.WriteLine($" Content: '{content}'"); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($" ERROR: {ex.GetType().Name}: {ex.Message}"); + } + } + } + + // Assert - this is where the original test fails + using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Read)) + { + using (var s1 = archive.GetEntry("file1.txt")!.Open(password)) + using (var r1 = new StreamReader(s1)) + { + Assert.Equal("Content 1", await r1.ReadToEndAsync()); + } + + using (var s2 = archive.GetEntry("file2.txt")!.Open(password)) + using (var r2 = new StreamReader(s2)) + { + Assert.Equal("Modified Content 2", await r2.ReadToEndAsync()); + } + + using (var s3 = archive.GetEntry("file3.txt")!.Open(password)) + using (var r3 = new StreamReader(s3)) + { + Assert.Equal("Content 3", await r3.ReadToEndAsync()); + } + } + } + } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index 639e55a659be8b..2a828281241400 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -789,8 +789,14 @@ protected override void Dispose(bool disposing) { try { - if (_encrypting && !_authCodeValidated && _headerWritten) + if (_encrypting && !_authCodeValidated) { + // Ensure header is written even for empty files + if (!_headerWritten) + { + WriteHeader(); + } + // Encrypt remaining partial data FinalizeEncryptionAsync(false, CancellationToken.None).GetAwaiter().GetResult(); @@ -804,7 +810,7 @@ protected override void Dispose(bool disposing) { _aesEncryptor?.Dispose(); _aes.Dispose(); - _hmac!.Dispose(); + _hmac?.Dispose(); if (!_leaveOpen) _baseStream.Dispose(); } @@ -813,14 +819,21 @@ protected override void Dispose(bool disposing) _disposed = true; base.Dispose(disposing); } + public override async ValueTask DisposeAsync() { if (_disposed) return; try { - if (_encrypting && !_authCodeValidated && _headerWritten) + if (_encrypting && !_authCodeValidated) { + // Ensure header is written even for empty files + if (!_headerWritten) + { + await WriteHeaderAsync(CancellationToken.None).ConfigureAwait(false); + } + // Encrypt remaining partial data await FinalizeEncryptionAsync(true, CancellationToken.None).ConfigureAwait(false); @@ -834,7 +847,7 @@ public override async ValueTask DisposeAsync() { _aesEncryptor?.Dispose(); _aes.Dispose(); - _hmac!.Dispose(); + _hmac?.Dispose(); if (!_leaveOpen) await _baseStream.DisposeAsync().ConfigureAwait(false); } @@ -842,7 +855,6 @@ public override async ValueTask DisposeAsync() _disposed = true; GC.SuppressFinalize(this); } - public override bool CanRead => !_encrypting && !_disposed; public override bool CanSeek => false; public override bool CanWrite => _encrypting && !_disposed; diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index d66a4bded56087..c6c1a8936c60c1 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -202,10 +202,15 @@ internal async Task WriteCentralDirectoryFileHeaderAsync(bool forceWrite, Cancel (ushort)CompressionMethodValues.Deflate }; await aesExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); - } - // write extra fields (and any malformed trailing data). - await ZipGenericExtraField.WriteAllBlocksAsync(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + // write extra fields excluding existing AES extra field (and any malformed trailing data). + await ZipGenericExtraField.WriteAllBlocksExcludingTagAsync(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, WinZipAesExtraField.HeaderId, cancellationToken).ConfigureAwait(false); + } + else + { + // write extra fields (and any malformed trailing data). + await ZipGenericExtraField.WriteAllBlocksAsync(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + } if (_fileComment.Length > 0) { @@ -397,9 +402,14 @@ private async Task WriteLocalFileHeaderAsync(bool isEmptyFile, bool forceW (ushort)CompressionMethodValues.Deflate }; await aesExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); - } - await ZipGenericExtraField.WriteAllBlocksAsync(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + // Write other extra fields, excluding any existing AES extra field to avoid duplication + await ZipGenericExtraField.WriteAllBlocksExcludingTagAsync(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, WinZipAesExtraField.HeaderId, cancellationToken).ConfigureAwait(false); + } + else + { + await ZipGenericExtraField.WriteAllBlocksAsync(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + } } return zip64ExtraField != null; @@ -433,8 +443,30 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can _compressedSize = 0; } + // For unchanged entries, we need to write the header correctly but avoid + // WriteLocalFileHeaderAsync creating NEW encryption structures (which would have + // wrong compression method from _compressionLevel). + // The original AES extra field is preserved in _lhUnknownExtraFields. + BitFlagValues savedFlags = _generalPurposeBitFlag; + EncryptionMethod savedEncryption = _encryptionMethod; + ZipCompressionMethod savedCompressionMethod = CompressionMethod; + + // For AES entries: set CompressionMethod to Aes so header writes method 99, + // but clear _encryptionMethod so WriteLocalFileHeaderAsync doesn't create a new + // AES extra field (the original one in _lhUnknownExtraFields will be used). + if (savedEncryption is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256) + { + CompressionMethod = ZipCompressionMethod.Aes; + _encryptionMethod = EncryptionMethod.None; + } + await WriteLocalFileHeaderAsync(isEmptyFile: _uncompressedSize == 0, forceWrite: true, cancellationToken).ConfigureAwait(false); + // Restore original state + _generalPurposeBitFlag = savedFlags; + _encryptionMethod = savedEncryption; + CompressionMethod = savedCompressionMethod; + // according to ZIP specs, zero-byte files MUST NOT include file data if (_uncompressedSize != 0) { @@ -444,6 +476,12 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can await _archive.ArchiveStream.WriteAsync(compressedBytes, cancellationToken).ConfigureAwait(false); } } + + // Write data descriptor if the original entry had one + if ((savedFlags & BitFlagValues.DataDescriptor) != 0) + { + await WriteDataDescriptorAsync(cancellationToken).ConfigureAwait(false); + } } } else // there is no data in the file (or the data in the file has not been loaded), but if we are in update mode, we may still need to write a header diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index ce9b646ac34683..22e98bba0a30f5 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -76,6 +76,8 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) _generalPurposeBitFlag = (BitFlagValues)cd.GeneralPurposeBitFlag; _isEncrypted = (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0; CompressionMethod = (ZipCompressionMethod)cd.CompressionMethod; + // Initialize _headerCompressionMethod from the central directory + _headerCompressionMethod = (ZipCompressionMethod)cd.CompressionMethod; _lastModified = new DateTimeOffset(ZipHelper.DosTimeToDateTime(cd.LastModified)); _compressedSize = cd.CompressedSize; _uncompressedSize = cd.UncompressedSize; @@ -105,7 +107,6 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) _compressionLevel = MapCompressionLevel(_generalPurposeBitFlag, CompressionMethod); } - // Initializes a ZipArchiveEntry instance for a new archive entry with a specified compression level. internal ZipArchiveEntry(ZipArchive archive, string entryName, CompressionLevel compressionLevel) : this(archive, entryName) @@ -412,12 +413,14 @@ public Stream Open(string? password = null, EncryptionMethod encryptionMethod = default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - if (!_isEncrypted) throw new InvalidDataException(SR.EntryNotEncrypted); - return OpenInReadMode(checkOpenable: true, password.AsMemory()); + if (!_isEncrypted) + { + throw new InvalidDataException(SR.EntryNotEncrypted); + } + return OpenInUpdateModeEncrypted(password); } } - /// /// Returns the FullName of the entry. /// @@ -637,7 +640,10 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 } // determine if we can fit zip64 extra field and original extra fields all in - int currExtraFieldDataLength = ZipGenericExtraField.TotalSize(_cdUnknownExtraFields, _cdTrailingExtraFieldData?.Length ?? 0); + // When using AES encryption, exclude the AES tag from currExtraFieldDataLength since we're writing a new one + int currExtraFieldDataLength = UseAesEncryption() + ? ZipGenericExtraField.TotalSizeExcludingTag(_cdUnknownExtraFields, _cdTrailingExtraFieldData?.Length ?? 0, WinZipAesExtraField.HeaderId) + : ZipGenericExtraField.TotalSize(_cdUnknownExtraFields, _cdTrailingExtraFieldData?.Length ?? 0); int bigExtraFieldLength = (zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) + aesExtraFieldSize + currExtraFieldDataLength; @@ -694,7 +700,12 @@ private void WriteCentralDirectoryFileHeaderPrepare(Span cdStaticHeader, u cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.VersionMadeByCompatibility] = (byte)CurrentZipPlatform; BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.VersionNeededToExtract..], (ushort)_versionToExtract); BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.GeneralPurposeBitFlags..], (ushort)_generalPurposeBitFlag); - BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressionMethod..], (ushort)CompressionMethod); + + // For AES encryption, write compression method 99 (Aes) in the header + // _headerCompressionMethod preserves the original value from the central directory + ushort compressionMethodToWrite = UseAesEncryption() ? (ushort)ZipCompressionMethod.Aes : (ushort)CompressionMethod; + BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressionMethod..], compressionMethodToWrite); + BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); // when using aes encryption, ae-2 standard dictates crc to be 0 uint crcToWrite = UseAesEncryption() ? 0 : _crc32; @@ -724,7 +735,7 @@ internal void WriteCentralDirectoryFileHeader(bool forceWrite) // only write zip64ExtraField if we decided we need it (it's not null) zip64ExtraField?.WriteBlock(_archive.ArchiveStream); - // Write AES extra field if using AES encryption (add this block) + // Write AES extra field if using AES encryption if (UseAesEncryption()) { var aesExtraField = new WinZipAesExtraField @@ -741,10 +752,15 @@ internal void WriteCentralDirectoryFileHeader(bool forceWrite) (ushort)CompressionMethodValues.Deflate }; aesExtraField.WriteBlock(_archive.ArchiveStream); - } - // write extra fields (and any malformed trailing data). - ZipGenericExtraField.WriteAllBlocks(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream); + // write extra fields excluding existing AES extra field (and any malformed trailing data). + ZipGenericExtraField.WriteAllBlocksExcludingTag(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, WinZipAesExtraField.HeaderId); + } + else + { + // write extra fields (and any malformed trailing data). + ZipGenericExtraField.WriteAllBlocks(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream); + } if (_fileComment.Length > 0) { @@ -908,33 +924,36 @@ private int GetAesKeySizeBits() } // Creates the appropriate decryption stream for an encrypted entry. - // Uses cached key material if available; otherwise derives keys from password and caches them. private Stream CreateDecryptionStream(Stream compressedStream, ReadOnlyMemory password) { - if (IsZipCryptoEncrypted()) + bool isAesEncrypted = _headerCompressionMethod == ZipCompressionMethod.Aes; + + if (!isAesEncrypted && IsZipCryptoEncrypted()) { byte expectedCheckByte = CalculateZipCryptoCheckByte(); - if (_derivedEncryptionKeyMaterial is null) + // Password is provided so derive fresh keys + if (!password.IsEmpty) { - if (password.IsEmpty) - throw new InvalidDataException(SR.PasswordRequired); + byte[] freshKeyMaterial = ZipCryptoStream.CreateKey(password); + return new ZipCryptoStream(compressedStream, freshKeyMaterial, expectedCheckByte); + } - _derivedEncryptionKeyMaterial = ZipCryptoStream.CreateKey(password); + if (_derivedEncryptionKeyMaterial is null) + { + throw new InvalidDataException(SR.PasswordRequired); } return new ZipCryptoStream(compressedStream, _derivedEncryptionKeyMaterial, expectedCheckByte); } - else if (_headerCompressionMethod == ZipCompressionMethod.Aes) + else if (isAesEncrypted) { int keySizeBits = GetAesKeySizeBits(); - if (_derivedEncryptionKeyMaterial is null) + // Password is provided so derive fresh keys + if (!password.IsEmpty) { - if (password.IsEmpty) - throw new InvalidDataException(SR.PasswordRequired); - - // Read salt from stream to derive keys + // Generate salt from stream to derive keys int saltSize = WinZipAesStream.GetSaltSize(keySizeBits); byte[] salt = new byte[saltSize]; compressedStream.ReadExactly(salt); @@ -942,8 +961,20 @@ private Stream CreateDecryptionStream(Stream compressedStream, ReadOnlyMemory Array.MaxLength) + { + _currentlyOpenForWrite = false; + _everOpenedForWrite = false; + throw new InvalidOperationException( + "Entry is too large to modify in place. " + + "Read it with Open(password), then delete and recreate the entry with CreateEntry."); + } + + _storedUncompressedData = new MemoryStream((int)_uncompressedSize); + + if (_originallyInArchive) + { + using (Stream decompressor = OpenInReadMode(checkOpenable: false, password.AsMemory())) + { + try + { + decompressor.CopyTo(_storedUncompressedData); + } + catch (InvalidDataException) + { + _storedUncompressedData.Dispose(); + _storedUncompressedData = null; + _currentlyOpenForWrite = false; + _everOpenedForWrite = false; + _derivedEncryptionKeyMaterial = null; + throw; + } + } + } + + // Derive and save key material for re-encryption + // For ZipCrypto: deterministic key from password + // For AES: generate new salt and derive fresh key material + if (IsZipCryptoEncrypted()) + { + _derivedEncryptionKeyMaterial = ZipCryptoStream.CreateKey(password.AsMemory()); + _encryptionMethod = EncryptionMethod.ZipCrypto; + } + else if (UseAesEncryption()) + { + // Generate new salt and derive key material for AES + // This ensures each write uses a fresh random salt for security + int keySizeBits = GetAesKeySizeBits(); + _derivedEncryptionKeyMaterial = WinZipAesStream.CreateKey(password.AsMemory(), salt: null, keySizeBits); + // _encryptionMethod is already set from IsOpenable -> detected from header + } + + // Reset CRC - it will be recalculated when writing + _crc32 = 0; + + // Set the actual compression method for GetDataCompressor + // Note: For AES, CompressionMethod may currently be Aes (99) from reading the header + // We need to set it to Deflate or Stored for the actual compression + // WriteLocalFileHeader will set it back to Aes for the header + if (CompressionMethod == ZipCompressionMethod.Aes || CompressionMethod == ZipCompressionMethod.Deflate || CompressionMethod == ZipCompressionMethod.Deflate64) + { + CompressionMethod = ZipCompressionMethod.Deflate; + } + // else it's Stored, keep it as Stored + + _storedUncompressedData.Seek(0, SeekOrigin.Begin); + return new WrappedStream(_storedUncompressedData, this, thisRef => + { + thisRef!._currentlyOpenForWrite = false; + }); + } private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out string? message) { message = null; @@ -1133,7 +1247,7 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st message = SR.LocalFileHeaderCorrupt; return false; } - else if (IsEncrypted && CompressionMethod == ZipCompressionMethod.Aes) + else if (IsEncrypted && _headerCompressionMethod == ZipCompressionMethod.Aes) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); _headerCompressionMethod = ZipCompressionMethod.Aes; @@ -1196,7 +1310,7 @@ private bool IsOpenableInitialVerifications(bool needToUncompress, out string? m } else { - if (IsEncrypted && CompressionMethod == ZipCompressionMethod.Aes) + if (IsEncrypted && _headerCompressionMethod == ZipCompressionMethod.Aes) { return true; } @@ -1394,8 +1508,11 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o // save offset _offsetOfLocalHeader = _archive.ArchiveStream.Position; - // calculate extra field. if zip64 stuff + original extraField aren't going to fit, dump the original extraField, because this is more important - int currExtraFieldDataLength = ZipGenericExtraField.TotalSize(_lhUnknownExtraFields, _lhTrailingExtraFieldData?.Length ?? 0); + // Calculate extra field + // When using AES encryption, exclude the AES tag from currExtraFieldDataLength since we're writing a new one + int currExtraFieldDataLength = UseAesEncryption() + ? ZipGenericExtraField.TotalSizeExcludingTag(_lhUnknownExtraFields, _lhTrailingExtraFieldData?.Length ?? 0, WinZipAesExtraField.HeaderId) + : ZipGenericExtraField.TotalSize(_lhUnknownExtraFields, _lhTrailingExtraFieldData?.Length ?? 0); int bigExtraFieldLength = (zip64ExtraField != null ? zip64ExtraField.TotalSize : 0) + aesExtraFieldSize + currExtraFieldDataLength; @@ -1434,7 +1551,11 @@ private void WriteLocalFileHeaderPrepare(Span lfStaticHeader, uint compres ZipLocalFileHeader.SignatureConstantBytes.CopyTo(lfStaticHeader[ZipLocalFileHeader.FieldLocations.Signature..]); BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.VersionNeededToExtract..], (ushort)_versionToExtract); BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.GeneralPurposeBitFlags..], (ushort)_generalPurposeBitFlag); - BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressionMethod..], (ushort)CompressionMethod); + + // For AES encryption, write compression method 99 (Aes) in the header + ushort compressionMethodToWrite = UseAesEncryption() ? (ushort)ZipCompressionMethod.Aes : (ushort)CompressionMethod; + BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressionMethod..], compressionMethodToWrite); + BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); // when using aes encryption, ae-2 standard dictates crc to be 0 uint crcToWrite = UseAesEncryption() ? 0 : _crc32; @@ -1477,10 +1598,15 @@ private bool WriteLocalFileHeader(bool isEmptyFile, bool forceWrite) (ushort)CompressionMethodValues.Deflate }; aesExtraField.WriteBlock(_archive.ArchiveStream); - } - // Write other extra fields - ZipGenericExtraField.WriteAllBlocks(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream); + // Write other extra fields, excluding any existing AES extra field to avoid duplication + ZipGenericExtraField.WriteAllBlocksExcludingTag(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, WinZipAesExtraField.HeaderId); + } + else + { + // Write other extra fields + ZipGenericExtraField.WriteAllBlocks(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream); + } } return zip64ExtraField != null; @@ -1495,19 +1621,121 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) { _uncompressedSize = _storedUncompressedData.Length; - //The compressor fills in CRC and sizes - //The DirectToArchiveWriterStream writes headers and such - using (DirectToArchiveWriterStream entryWriter = new( - GetDataCompressor(_archive.ArchiveStream, true, null, null), - this)) + // Check if we need to re-encrypt with ZipCrypto (only if we have cached key material) + if (_encryptionMethod == EncryptionMethod.ZipCrypto && _derivedEncryptionKeyMaterial != null) + { + // Write local file header first (with encryption flag set) + // Pass isEmptyFile: false because even empty encrypted files have the 12-byte header + WriteLocalFileHeader(isEmptyFile: false, forceWrite: true); + + // Record position before encryption data + long startPosition = _archive.ArchiveStream.Position; + + ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); + + using (var encryptionStream = new ZipCryptoStream( + baseStream: _archive.ArchiveStream, + keyBytes: _derivedEncryptionKeyMaterial, + passwordVerifierLow2Bytes: verifierLow2Bytes, + crc32: null, + leaveOpen: true)) + { + // Use GetDataCompressor which handles CRC calculation and compression + using (var crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream)) + { + _storedUncompressedData.Seek(0, SeekOrigin.Begin); + _storedUncompressedData.CopyTo(crcStream); + } + // CRC, uncompressed size are now set by GetDataCompressor callback + // For empty files, ZipCryptoStream.Dispose() will write the 12-byte header + } + + // Calculate compressed size AFTER ZipCryptoStream is disposed + // (includes 12-byte encryption header + compressed data) + _compressedSize = _archive.ArchiveStream.Position - startPosition; + + // Write data descriptor since we used streaming mode + WriteDataDescriptor(); + + _storedUncompressedData.Dispose(); + _storedUncompressedData = null; + } + else if (UseAesEncryption() && _derivedEncryptionKeyMaterial != null) { - _storedUncompressedData.Seek(0, SeekOrigin.Begin); - _storedUncompressedData.CopyTo(entryWriter); + // For AES, we need to: + // 1. Write header with CompressionMethod = Aes (99) + // 2. Compress data with actual compression (Deflate/Stored) + // 3. Keep CompressionMethod = Aes for central directory + + // WriteLocalFileHeader will set CompressionMethod = Aes + WriteLocalFileHeader(isEmptyFile: false, forceWrite: true); + + // Record position before encryption data + long startPosition = _archive.ArchiveStream.Position; + + int keySizeBits = GetAesKeySizeBits(); + + // Determine the actual compression method to use + // The AES extra field stores the real compression method + bool useDeflate = _compressionLevel != CompressionLevel.NoCompression; + + using (var encryptionStream = new WinZipAesStream( + baseStream: _archive.ArchiveStream, + keyMaterial: _derivedEncryptionKeyMaterial, + encrypting: true, + keySizeBits: keySizeBits, + leaveOpen: true)) + { + // Only compress/write if there's data + if (_storedUncompressedData.Length > 0) + { + // Temporarily set CompressionMethod for GetDataCompressor + ZipCompressionMethod savedMethod = CompressionMethod; + CompressionMethod = useDeflate ? ZipCompressionMethod.Deflate : ZipCompressionMethod.Stored; + + using (var crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream)) + { + _storedUncompressedData.Seek(0, SeekOrigin.Begin); + _storedUncompressedData.CopyTo(crcStream); + } + + // Restore CompressionMethod to Aes for central directory + CompressionMethod = ZipCompressionMethod.Aes; + } + else + { + // Empty file: CRC is 0, uncompressed size is 0 + _crc32 = 0; + _uncompressedSize = 0; + } + // WinZipAesStream.Dispose() writes salt + verifier + HMAC even for empty files + } + + // Calculate compressed size AFTER WinZipAesStream is disposed + // (includes salt + password verifier + encrypted data + HMAC) + _compressedSize = _archive.ArchiveStream.Position - startPosition; + + // Write data descriptor since we used streaming mode + WriteDataDescriptor(); + + _storedUncompressedData.Dispose(); + _storedUncompressedData = null; + } + else + { + // Non-encrypted: use standard path + using (DirectToArchiveWriterStream entryWriter = new( + GetDataCompressor(_archive.ArchiveStream, true, null, null), + this)) + { + _storedUncompressedData.Seek(0, SeekOrigin.Begin); + _storedUncompressedData.CopyTo(entryWriter); + } _storedUncompressedData.Dispose(); _storedUncompressedData = null; } } - else + else // _compressedBytes path - copying unchanged entry data { if (_uncompressedSize == 0) { @@ -1515,8 +1743,30 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) _compressedSize = 0; } + // For unchanged entries, we need to write the header correctly but avoid + // WriteLocalFileHeader creating NEW encryption structures (which would have + // wrong compression method from _compressionLevel). + // The original AES extra field is preserved in _lhUnknownExtraFields. + BitFlagValues savedFlags = _generalPurposeBitFlag; + EncryptionMethod savedEncryption = _encryptionMethod; + ZipCompressionMethod savedCompressionMethod = CompressionMethod; + + // For AES entries: set CompressionMethod to Aes so header writes method 99, + // but clear _encryptionMethod so WriteLocalFileHeader doesn't create a new + // AES extra field (the original one in _lhUnknownExtraFields will be used). + if (savedEncryption is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256) + { + CompressionMethod = ZipCompressionMethod.Aes; + _encryptionMethod = EncryptionMethod.None; + } + WriteLocalFileHeader(isEmptyFile: _uncompressedSize == 0, forceWrite: true); + // Restore original state + _generalPurposeBitFlag = savedFlags; + _encryptionMethod = savedEncryption; + CompressionMethod = savedCompressionMethod; + // according to ZIP specs, zero-byte files MUST NOT include file data if (_uncompressedSize != 0) { @@ -1526,6 +1776,12 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) _archive.ArchiveStream.Write(compressedBytes, 0, compressedBytes.Length); } } + + // Write data descriptor if the original entry had one + if ((savedFlags & BitFlagValues.DataDescriptor) != 0) + { + WriteDataDescriptor(); + } } } else // there is no data in the file (or the data in the file has not been loaded), but if we are in update mode, we may still need to write a header diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.Async.cs index d5d8b8c232ebb5..b86a538b62474b 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.Async.cs @@ -38,6 +38,27 @@ public static async Task WriteAllBlocksAsync(List? fields, await stream.WriteAsync(trailingExtraFieldData, cancellationToken).ConfigureAwait(false); } } + + public static async Task WriteAllBlocksExcludingTagAsync(List? fields, ReadOnlyMemory trailingExtraFieldData, Stream stream, ushort excludeTag, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (fields != null) + { + foreach (ZipGenericExtraField field in fields) + { + if (field.Tag != excludeTag) + { + await field.WriteBlockAsync(stream, cancellationToken).ConfigureAwait(false); + } + } + } + + if (!trailingExtraFieldData.IsEmpty) + { + await stream.WriteAsync(trailingExtraFieldData, cancellationToken).ConfigureAwait(false); + } + } } internal sealed partial class Zip64ExtraField diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs index b4fd9476c32674..a2e88a7679853a 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs @@ -116,6 +116,43 @@ public static void WriteAllBlocks(List? fields, ReadOnlySp stream.Write(trailingExtraFieldData); } } + + public static void WriteAllBlocksExcludingTag(List? fields, ReadOnlySpan trailingExtraFieldData, Stream stream, ushort excludeTag) + { + if (fields != null) + { + foreach (ZipGenericExtraField field in fields) + { + if (field.Tag != excludeTag) + { + field.WriteBlock(stream); + } + } + } + + if (!trailingExtraFieldData.IsEmpty) + { + stream.Write(trailingExtraFieldData); + } + } + + public static int TotalSizeExcludingTag(List? fields, int trailingDataLength, ushort excludeTag) + { + int size = trailingDataLength; + + if (fields != null) + { + foreach (ZipGenericExtraField field in fields) + { + if (field.Tag != excludeTag) + { + size += field.Size + ZipGenericExtraField.FieldLengths.Tag + ZipGenericExtraField.FieldLengths.Size; + } + } + } + + return size; + } } internal sealed partial class Zip64ExtraField From 6f34476f403e24c865a0d3cd85d1550046e4ba3d Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Tue, 13 Jan 2026 15:15:57 +0100 Subject: [PATCH 27/39] add more tests related to update mode --- .../tests/ZipFile.Encryption.cs | 375 +++++++++++++++++- 1 file changed, 371 insertions(+), 4 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 501c797702bff4..963912eeb01fd7 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -2,9 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; -using System.IO; -using System.IO.Compression; -using System.Linq; using System.Text; using System.Threading.Tasks; using Xunit; @@ -252,7 +249,7 @@ public async Task ExtractToFile_Encrypted_Success(bool async) { var entry = archive.GetEntry("test.txt"); string destFile = GetTestFilePath(); - + if (async) { await entry.ExtractToFileAsync(destFile, overwrite: true, password: password); @@ -724,6 +721,376 @@ public async Task UpdateMode_ZipCryptoToAes_PreservesEncryption(bool async) } } + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task UpdateMode_MixedEncryption_ModifyAllEntries(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + var entries = new[] + { + ("plain1.txt", "Plain Content 1", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), + ("encrypted1.txt", "Encrypted Content 1", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256), + ("plain2.txt", "Plain Content 2", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), + ("encrypted2.txt", "Encrypted Content 2", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.ZipCrypto) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + // Modify all entries in Update mode + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + // Modify plain1.txt + ZipArchiveEntry plain1 = archive.GetEntry("plain1.txt"); + Assert.NotNull(plain1); + Assert.False(plain1.IsEncrypted); + using (Stream stream = await OpenEntryStream(async, plain1)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes("Modified Plain Content 1"); + if (async) + await stream.WriteAsync(content, 0, content.Length); + else + stream.Write(content, 0, content.Length); + } + + // Modify encrypted1.txt (AES) + ZipArchiveEntry encrypted1 = archive.GetEntry("encrypted1.txt"); + Assert.NotNull(encrypted1); + Assert.True(encrypted1.IsEncrypted); + using (Stream stream = encrypted1.Open(password)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes("Modified Encrypted Content 1"); + if (async) + await stream.WriteAsync(content, 0, content.Length); + else + stream.Write(content, 0, content.Length); + } + + // Modify plain2.txt + ZipArchiveEntry plain2 = archive.GetEntry("plain2.txt"); + Assert.NotNull(plain2); + Assert.False(plain2.IsEncrypted); + using (Stream stream = await OpenEntryStream(async, plain2)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes("Modified Plain Content 2"); + if (async) + await stream.WriteAsync(content, 0, content.Length); + else + stream.Write(content, 0, content.Length); + } + + // Modify encrypted2.txt (ZipCrypto) + ZipArchiveEntry encrypted2 = archive.GetEntry("encrypted2.txt"); + Assert.NotNull(encrypted2); + Assert.True(encrypted2.IsEncrypted); + using (Stream stream = encrypted2.Open(password)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes("Modified Encrypted Content 2"); + if (async) + await stream.WriteAsync(content, 0, content.Length); + else + stream.Write(content, 0, content.Length); + } + } + + // Verify all modifications + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + var plain1 = archive.GetEntry("plain1.txt"); + Assert.NotNull(plain1); + Assert.False(plain1.IsEncrypted); + await AssertEntryTextEquals(plain1, "Modified Plain Content 1", null, async); + + var encrypted1 = archive.GetEntry("encrypted1.txt"); + Assert.NotNull(encrypted1); + Assert.True(encrypted1.IsEncrypted); + await AssertEntryTextEquals(encrypted1, "Modified Encrypted Content 1", password, async); + + var plain2 = archive.GetEntry("plain2.txt"); + Assert.NotNull(plain2); + Assert.False(plain2.IsEncrypted); + await AssertEntryTextEquals(plain2, "Modified Plain Content 2", null, async); + + var encrypted2 = archive.GetEntry("encrypted2.txt"); + Assert.NotNull(encrypted2); + Assert.True(encrypted2.IsEncrypted); + await AssertEntryTextEquals(encrypted2, "Modified Encrypted Content 2", password, async); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task UpdateMode_DeleteEntryAndModifyAnother(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + var entries = new[] + { + ("keep.txt", "Keep This Content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256), + ("delete.txt", "Delete This Content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256), + ("modify.txt", "Original Content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + // Verify initial state + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + Assert.Equal(3, archive.Entries.Count); + } + + // Delete one entry and modify another + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + // Delete delete.txt + ZipArchiveEntry deleteEntry = archive.GetEntry("delete.txt"); + Assert.NotNull(deleteEntry); + deleteEntry.Delete(); + + // Modify modify.txt + ZipArchiveEntry modifyEntry = archive.GetEntry("modify.txt"); + Assert.NotNull(modifyEntry); + using (Stream stream = modifyEntry.Open(password)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes("Modified Content"); + if (async) + await stream.WriteAsync(content, 0, content.Length); + else + stream.Write(content, 0, content.Length); + } + } + + // Verify final state + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + Assert.Equal(2, archive.Entries.Count); + + // Verify deleted entry is gone + Assert.Null(archive.GetEntry("delete.txt")); + + // Verify kept entry is unchanged + var keepEntry = archive.GetEntry("keep.txt"); + Assert.NotNull(keepEntry); + Assert.True(keepEntry.IsEncrypted); + await AssertEntryTextEquals(keepEntry, "Keep This Content", password, async); + + // Verify modified entry + var modifyEntry = archive.GetEntry("modify.txt"); + Assert.NotNull(modifyEntry); + Assert.True(modifyEntry.IsEncrypted); + await AssertEntryTextEquals(modifyEntry, "Modified Content", password, async); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task UpdateMode_DeleteEncryptedAndModifyPlain(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + var entries = new[] + { + ("plain.txt", "Plain Content", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), + ("encrypted.txt", "Encrypted Content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + // Delete encrypted entry and modify plain entry + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + // Delete encrypted entry + ZipArchiveEntry encryptedEntry = archive.GetEntry("encrypted.txt"); + Assert.NotNull(encryptedEntry); + encryptedEntry.Delete(); + + // Modify plain entry + ZipArchiveEntry plainEntry = archive.GetEntry("plain.txt"); + Assert.NotNull(plainEntry); + using (Stream stream = await OpenEntryStream(async, plainEntry)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes("Modified Plain Content"); + if (async) + await stream.WriteAsync(content, 0, content.Length); + else + stream.Write(content, 0, content.Length); + } + } + + // Verify + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + Assert.Single(archive.Entries); + Assert.Null(archive.GetEntry("encrypted.txt")); + + var plainEntry = archive.GetEntry("plain.txt"); + Assert.NotNull(plainEntry); + Assert.False(plainEntry.IsEncrypted); + await AssertEntryTextEquals(plainEntry, "Modified Plain Content", null, async); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task UpdateMode_DeletePlainAndModifyEncrypted(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + var entries = new[] + { + ("plain.txt", "Plain Content", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), + ("encrypted.txt", "Encrypted Content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + // Delete plain entry and modify encrypted entry + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + // Delete plain entry + ZipArchiveEntry plainEntry = archive.GetEntry("plain.txt"); + Assert.NotNull(plainEntry); + plainEntry.Delete(); + + // Modify encrypted entry + ZipArchiveEntry encryptedEntry = archive.GetEntry("encrypted.txt"); + Assert.NotNull(encryptedEntry); + using (Stream stream = encryptedEntry.Open(password)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes("Modified Encrypted Content"); + if (async) + await stream.WriteAsync(content, 0, content.Length); + else + stream.Write(content, 0, content.Length); + } + } + + // Verify + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + Assert.Single(archive.Entries); + Assert.Null(archive.GetEntry("plain.txt")); + + var encryptedEntry = archive.GetEntry("encrypted.txt"); + Assert.NotNull(encryptedEntry); + Assert.True(encryptedEntry.IsEncrypted); + await AssertEntryTextEquals(encryptedEntry, "Modified Encrypted Content", password, async); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task UpdateMode_DeleteMultipleEntriesAndModifyRemaining(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + var entries = new[] + { + ("keep1.txt", "Keep 1", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), + ("delete1.txt", "Delete 1", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256), + ("keep2.txt", "Keep 2", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.ZipCrypto), + ("delete2.txt", "Delete 2", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), + ("modify.txt", "Original", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes128) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + // Delete some entries and modify one + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + archive.GetEntry("delete1.txt")?.Delete(); + archive.GetEntry("delete2.txt")?.Delete(); + + ZipArchiveEntry modifyEntry = archive.GetEntry("modify.txt"); + Assert.NotNull(modifyEntry); + using (Stream stream = modifyEntry.Open(password)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes("Modified"); + if (async) + await stream.WriteAsync(content, 0, content.Length); + else + stream.Write(content, 0, content.Length); + } + } + + // Verify + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + Assert.Equal(3, archive.Entries.Count); + + Assert.Null(archive.GetEntry("delete1.txt")); + Assert.Null(archive.GetEntry("delete2.txt")); + + await AssertEntryTextEquals(archive.GetEntry("keep1.txt"), "Keep 1", null, async); + await AssertEntryTextEquals(archive.GetEntry("keep2.txt"), "Keep 2", password, async); + await AssertEntryTextEquals(archive.GetEntry("modify.txt"), "Modified", password, async); + } + } + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task UpdateMode_AllEncryptionTypes_EditAllEntries(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + // Create archive with multiple entries using the same encryption method + var entries = new[] + { + ("entry1.txt", "Content 1", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("entry2.txt", "Content 2", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("entry3.txt", "Content 3", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + // Edit all entries + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + for (int i = 1; i <= 3; i++) + { + ZipArchiveEntry entry = archive.GetEntry($"entry{i}.txt"); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + using (Stream stream = entry.Open(password)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes($"Modified Content {i}"); + if (async) + await stream.WriteAsync(content, 0, content.Length); + else + stream.Write(content, 0, content.Length); + } + } + } + + // Verify all modifications + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + for (int i = 1; i <= 3; i++) + { + ZipArchiveEntry entry = archive.GetEntry($"entry{i}.txt"); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + await AssertEntryTextEquals(entry, $"Modified Content {i}", password, async); + } + } + } + #endregion } } From 30193225e55f4810c0e45107738cadf514058ed1 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 14 Jan 2026 15:33:03 +0100 Subject: [PATCH 28/39] correctly resolve conflicts --- .../tests/ZipFile.Extract.cs | 72 ++++++++++++++++++- .../IO/Compression/ZipArchiveEntry.Async.cs | 10 +-- .../System/IO/Compression/ZipArchiveEntry.cs | 36 +++------- 3 files changed, 86 insertions(+), 32 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs index a20a3b351c4f10..d559b3f343fb3b 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Extract.cs @@ -2018,7 +2018,77 @@ public async Task Debug_UpdateMode_MultipleEncryptedEntries_ModifyOne() } } - } + [SkipOnCI("Local development test - creates archive for manual inspection with WinRAR")] + [Fact] + public async Task Local_UpdateMode_EditAllEntries_MixedEncryption_ForWinRARInspection() + { + // Arrange + Directory.CreateDirectory(DownloadsDir); + string archivePath = NewPath("update_all_entries_mixed_encryption.zip"); + string archiveBeforeUpdatePath = NewPath("update_all_entries_mixed_encryption_BEFORE.zip"); + + if (File.Exists(archivePath)) File.Delete(archivePath); + if (File.Exists(archiveBeforeUpdatePath)) File.Delete(archiveBeforeUpdatePath); + + string password = "password123"; + + var entries = new[] + { + ("entry_zipcrypto.txt", "Content ZipCrypto", ZipArchiveEntry.EncryptionMethod.ZipCrypto), + ("entry_aes128.txt", "Content AES-128", ZipArchiveEntry.EncryptionMethod.Aes128), + ("entry_aes192.txt", "Content AES-192", ZipArchiveEntry.EncryptionMethod.Aes192), + ("entry_aes256.txt", "Content AES-256", ZipArchiveEntry.EncryptionMethod.Aes256) + }; + + // Step 1: Create archive with entries using different encryption methods + using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) + { + foreach (var (name, content, encryption) in entries) + { + var entry = archive.CreateEntry(name); + using var stream = entry.Open(password, encryption); + using var writer = new StreamWriter(stream, Encoding.UTF8); + await writer.WriteAsync(content); + } + } + // Save a copy before modifications + File.Copy(archivePath, archiveBeforeUpdatePath, overwrite: true); + // Step 2: Open in Update mode and edit ALL entries + using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Update)) + { + foreach (var (name, _, _) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + using (var stream = entry.Open(password)) + { + stream.SetLength(0); + byte[] content = Encoding.UTF8.GetBytes($"Modified {name}"); + await stream.WriteAsync(content, 0, content.Length); + } + } + } + + // Step 3: Verify all entries can be read back + using (var archive = ZipFile.Open(archivePath, ZipArchiveMode.Read)) + { + foreach (var (name, _, _) in entries) + { + var entry = archive.GetEntry(name); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + using var stream = entry.Open(password); + using var reader = new StreamReader(stream, Encoding.UTF8); + string content = await reader.ReadToEndAsync(); + Assert.Equal($"Modified {name}", content); + } + } + + } + } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 9fb2d0b268f832..59e41de97d95c6 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -108,7 +108,7 @@ public async Task OpenAsync(string password, CancellationToken cancellat case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - return await OpenInUpdateModeAsync(cancellationToken).ConfigureAwait(false); + return await OpenInUpdateModeAsync(true, cancellationToken).ConfigureAwait(false); } } @@ -254,8 +254,8 @@ internal async Task WriteCentralDirectoryFileHeaderAsync(bool forceWrite, Cancel _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)CompressionMethodValues.Stored : - (ushort)CompressionMethodValues.Deflate + (ushort)ZipCompressionMethod.Stored : + (ushort)ZipCompressionMethod.Deflate }; await aesExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); @@ -463,8 +463,8 @@ private async Task WriteLocalFileHeaderAsync(bool isEmptyFile, bool forceW _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)CompressionMethodValues.Stored : - (ushort)CompressionMethodValues.Deflate + (ushort)ZipCompressionMethod.Stored : + (ushort)ZipCompressionMethod.Deflate }; await aesExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index 92168f2b34483b..c841647daaad5a 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -687,8 +687,8 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)CompressionMethodValues.Stored : - (ushort)CompressionMethodValues.Deflate + (ushort)ZipCompressionMethod.Stored : + (ushort)ZipCompressionMethod.Deflate }; aesExtraFieldSize = WinZipAesExtraField.TotalSize; } @@ -802,8 +802,8 @@ internal void WriteCentralDirectoryFileHeader(bool forceWrite) _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)CompressionMethodValues.Stored : - (ushort)CompressionMethodValues.Deflate + (ushort)ZipCompressionMethod.Stored : + (ushort)ZipCompressionMethod.Deflate }; aesExtraField.WriteBlock(_archive.ArchiveStream); @@ -961,11 +961,6 @@ private bool UseAesEncryption() return _encryptionMethod is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256; } - private void InvalidateKeyMaterialCache() - { - _derivedEncryptionKeyMaterial = null; - } - private int GetAesKeySizeBits() { return _encryptionMethod switch @@ -1112,10 +1107,10 @@ private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod // we assume that if another entry grabbed the archive stream, that it set this entry's _everOpenedForWrite property to true by calling WriteLocalFileHeaderAndDataIfNeeded _archive.DebugAssertIsStillArchiveStreamOwner(this); - return OpenInWriteModeCore(); + return OpenInWriteModeCore(password, encryptionMethod); } - private WrappedStream OpenInWriteModeCore() + private WrappedStream OpenInWriteModeCore(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.None) { _everOpenedForWrite = true; Changes |= ZipArchive.ChangeState.StoredData; @@ -1529,8 +1524,8 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)CompressionMethodValues.Stored : - (ushort)CompressionMethodValues.Deflate + (ushort)ZipCompressionMethod.Stored : + (ushort)ZipCompressionMethod.Deflate }; aesExtraFieldSize = WinZipAesExtraField.TotalSize; } @@ -1663,8 +1658,8 @@ private bool WriteLocalFileHeader(bool isEmptyFile, bool forceWrite) _ /* EncryptionMethod.Aes256 */ => (byte)3 }, CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)CompressionMethodValues.Stored : - (ushort)CompressionMethodValues.Deflate + (ushort)ZipCompressionMethod.Stored : + (ushort)ZipCompressionMethod.Deflate }; aesExtraField.WriteBlock(_archive.ArchiveStream); @@ -2351,17 +2346,6 @@ public enum EncryptionMethod : byte Aes256 = 4 } - - internal enum CompressionMethodValues : ushort - { - Stored = 0x0, - Deflate = 0x8, - Deflate64 = 0x9, - BZip2 = 0xC, - LZMA = 0xE, - Aes = 99 - } - internal sealed class LocalHeaderOffsetComparer : Comparer { private static readonly LocalHeaderOffsetComparer s_instance = new LocalHeaderOffsetComparer(); From af36c2adf2107db3b333b8b68f3c37dd92ec4268 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Thu, 15 Jan 2026 12:28:27 +0100 Subject: [PATCH 29/39] initial parsing of aes extra metadata --- .../tests/ZipFile.Encryption.cs | 44 ++++++++++ .../IO/Compression/ZipArchiveEntry.Async.cs | 38 ++++----- .../System/IO/Compression/ZipArchiveEntry.cs | 81 +++++++++--------- .../src/System/IO/Compression/ZipBlocks.cs | 84 +++++++++++++++++++ 4 files changed, 185 insertions(+), 62 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 963912eeb01fd7..964b994a09b0f8 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -1092,5 +1092,49 @@ public async Task UpdateMode_AllEncryptionTypes_EditAllEntries(ZipArchiveEntry.E } #endregion + + #region CompressionMethod Property Tests for Encrypted Entries + + #region CompressionMethod Property Tests for Encrypted Entries + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task CompressionMethod_AesEncryptedEntries_ReturnsActualCompressionMethod(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "password123"; + + // Create archive with entries using different AES strengths + var entries = new[] + { + ("aes128.txt", "AES-128 content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes128), + ("aes192.txt", "AES-192 content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes192), + ("aes256.txt", "AES-256 content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256), + ("zipcrypto.txt", "ZipCrypto content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.ZipCrypto), + ("plain.txt", "Plain content", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null) + }; + + await CreateArchiveWithEntries(archivePath, entries, async); + + // Verify CompressionMethod without opening entry streams + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + // AES entries should report the actual compression method from the AES extra field (Deflate) + Assert.Equal(ZipCompressionMethod.Deflate, archive.GetEntry("aes128.txt")!.CompressionMethod); + Assert.Equal(ZipCompressionMethod.Deflate, archive.GetEntry("aes192.txt")!.CompressionMethod); + Assert.Equal(ZipCompressionMethod.Deflate, archive.GetEntry("aes256.txt")!.CompressionMethod); + + // ZipCrypto uses actual compression method (Deflate) + Assert.Equal(ZipCompressionMethod.Deflate, archive.GetEntry("zipcrypto.txt")!.CompressionMethod); + + // Plain entry uses actual compression method (Deflate) + Assert.Equal(ZipCompressionMethod.Deflate, archive.GetEntry("plain.txt")!.CompressionMethod); + } + } + + #endregion + + #endregion + } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 59e41de97d95c6..b38d381cb8fabd 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -386,36 +386,32 @@ private async Task OpenInUpdateModeAsync(bool loadExistingContent message = SR.LocalFileHeaderCorrupt; return (false, message); } - else if (IsEncrypted && CompressionMethod == ZipCompressionMethod.Aes) + else if (IsEncrypted && _headerCompressionMethod == ZipCompressionMethod.Aes) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - _headerCompressionMethod = ZipCompressionMethod.Aes; - var (success, aesExtraField) = await ZipLocalFileHeader.TrySkipBlockAESAwareAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + // AES case - skip the local file header and validate it exists. + // The AES metadata (encryption strength, actual compression method) was already + // parsed from the central directory in the constructor, so we don't mutate + // _encryptionMethod or CompressionMethod here. + var (success, localAesField) = await ZipLocalFileHeader.TrySkipBlockAESAwareAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); if (!success) { message = SR.LocalFileHeaderCorrupt; return (false, message); } - if (aesExtraField.HasValue) + // Optionally validate that local header AES info matches central directory. + // If local header has AES field but with mismatched strength, that's corruption. + if (localAesField.HasValue && localAesField.Value.AesStrength != (_encryptionMethod switch { - EncryptionMethod detectedEncryption = aesExtraField.Value.AesStrength switch - { - 1 => EncryptionMethod.Aes128, - 2 => EncryptionMethod.Aes192, - 3 => EncryptionMethod.Aes256, - _ => throw new InvalidDataException("Unknown AES strength") - }; - - // Store the detected encryption method - _encryptionMethod = detectedEncryption; - - _aeVersion = aesExtraField.Value.VendorVersion; - - // Store the actual compression method that will be used after decryption - // This is needed for GetDataDecompressor to work correctly - // Set the compression method to the actual method for decompression - CompressionMethod = (ZipCompressionMethod)aesExtraField.Value.CompressionMethod; + EncryptionMethod.Aes128 => 1, + EncryptionMethod.Aes192 => 2, + EncryptionMethod.Aes256 => 3, + _ => 0 + })) + { + // Local and central directory AES strengths don't match - could be corruption + // For now, we trust the central directory as authoritative (already set in constructor) } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index c841647daaad5a..6bd3ec1c8ff572 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -75,9 +75,33 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) _versionToExtract = (ZipVersionNeededValues)cd.VersionNeededToExtract; _generalPurposeBitFlag = (BitFlagValues)cd.GeneralPurposeBitFlag; _isEncrypted = (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0; - CompressionMethod = (ZipCompressionMethod)cd.CompressionMethod; - // Initialize _headerCompressionMethod from the central directory + // Initialize _headerCompressionMethod from the central directory. + // For AES entries, this will be 99 (WinZip AES wrapper indicator) and never changes. _headerCompressionMethod = (ZipCompressionMethod)cd.CompressionMethod; + // For AES-encrypted entries, the real compression method is stored in the AES extra field (0x9901) + // Parse it now so that people can see the actual value before opening the entry. + if (_isEncrypted && cd.AesExtraField.HasValue) + { + WinZipAesExtraField aesField = cd.AesExtraField.Value; + // Set the real compression method from the AES extra field + CompressionMethod = (ZipCompressionMethod)aesField.CompressionMethod; + + // Also parse remaining needed metadata now + _aeVersion = aesField.VendorVersion; + _encryptionMethod = aesField.AesStrength switch + { + 1 => EncryptionMethod.Aes128, + 2 => EncryptionMethod.Aes192, + 3 => EncryptionMethod.Aes256, + _ => throw new InvalidDataException(SR.InvalidAesStrength) + }; + } + else + { + // Non-AES entry: compression method from CD is the real method + CompressionMethod = (ZipCompressionMethod)cd.CompressionMethod; + } + _lastModified = new DateTimeOffset(ZipHelper.DosTimeToDateTime(cd.LastModified)); _compressedSize = cd.CompressedSize; _uncompressedSize = cd.UncompressedSize; @@ -1273,17 +1297,17 @@ private WrappedStream OpenInUpdateModeEncrypted(string? password) // This ensures each write uses a fresh random salt for security int keySizeBits = GetAesKeySizeBits(); _derivedEncryptionKeyMaterial = WinZipAesStream.CreateKey(password.AsMemory(), salt: null, keySizeBits); - // _encryptionMethod is already set from IsOpenable -> detected from header + // _encryptionMethod is already set from constructor (parsed from central directory AES extra field) } // Reset CRC - it will be recalculated when writing _crc32 = 0; - // Set the actual compression method for GetDataCompressor - // Note: For AES, CompressionMethod may currently be Aes (99) from reading the header - // We need to set it to Deflate or Stored for the actual compression - // WriteLocalFileHeader will set it back to Aes for the header - if (CompressionMethod == ZipCompressionMethod.Aes || CompressionMethod == ZipCompressionMethod.Deflate || CompressionMethod == ZipCompressionMethod.Deflate64) + // Set the compression method for GetDataCompressor. + // CompressionMethod now contains the actual method (Stored/Deflate/etc.) from the + // central directory AES extra field, not the wrapper value 99. + // For re-compression, we use Deflate unless the original was Stored. + if (CompressionMethod == ZipCompressionMethod.Deflate || CompressionMethod == ZipCompressionMethod.Deflate64) { CompressionMethod = ZipCompressionMethod.Deflate; } @@ -1314,35 +1338,15 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st else if (IsEncrypted && _headerCompressionMethod == ZipCompressionMethod.Aes) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - _headerCompressionMethod = ZipCompressionMethod.Aes; - // AES case - need to read the extra field to determine actual compression method and encryption strength - if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out WinZipAesExtraField? aesExtraField)) + // AES case - skip the local file header and validate it exists. + // The AES metadata (encryption strength, actual compression method) was already + // parsed from the central directory in the constructor + if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _)) { message = SR.LocalFileHeaderCorrupt; return false; } - if (aesExtraField.HasValue) - { - - EncryptionMethod detectedEncryption = aesExtraField.Value.AesStrength switch - { - 1 => EncryptionMethod.Aes128, - 2 => EncryptionMethod.Aes192, - 3 => EncryptionMethod.Aes256, - _ => throw new InvalidDataException(SR.InvalidAesStrength) - }; - - // Store the detected encryption method - _encryptionMethod = detectedEncryption; - - _aeVersion = aesExtraField.Value.VendorVersion; - - // Store the actual compression method that will be used after decryption - // This is needed for GetDataDecompressor to work correctly - // Set the compression method to the actual method for decompression - CompressionMethod = (ZipCompressionMethod)aesExtraField.Value.CompressionMethod; - } } // Pass the detected encryption method to GetOffsetOfCompressedData @@ -1364,21 +1368,16 @@ private bool IsOpenableInitialVerifications(bool needToUncompress, out string? m message = null; if (needToUncompress) { - if (!IsEncrypted && - CompressionMethod != ZipCompressionMethod.Stored && + // For AES-encrypted entries, CompressionMethod now contains the actual compression + // method (from the AES extra field), not the wrapper value 99. So we can use + // the same validation logic for both encrypted and non-encrypted entries. + if (CompressionMethod != ZipCompressionMethod.Stored && CompressionMethod != ZipCompressionMethod.Deflate && CompressionMethod != ZipCompressionMethod.Deflate64) { message = SR.UnsupportedCompression; return false; } - else - { - if (IsEncrypted && _headerCompressionMethod == ZipCompressionMethod.Aes) - { - return true; - } - } } if (_diskNumberStart != _archive.NumberOfThisDisk) { diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs index a2e88a7679853a..2ffa656a8ab88a 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs @@ -769,6 +769,7 @@ public static bool TrySkipBlockAESAware(Stream stream, out WinZipAesExtraField? internal struct WinZipAesExtraField { public const ushort HeaderId = 0x9901; + private const int DataSize = 7; // Vendor version (2) + Vendor ID (2) + AES strength (1) + Compression method (2) private ushort _vendorVersion = 2; private byte _aesStrength; private ushort _compressionMethod; @@ -786,6 +787,73 @@ public WinZipAesExtraField(ushort VendorVersion, byte AesStrength, ushort Compre public static int TotalSize => 11; // 2 (header) + 2 (size) + 7 (data) + /// + /// Tries to find and parse the WinZip AES extra field (0x9901) from a list of generic extra fields. + /// + /// The list of extra fields to search. + /// When this method returns true, contains the parsed AES extra field. + /// true if the AES extra field was found and parsed; otherwise, false. + public static bool TryGetFromExtraFields(List? extraFields, out WinZipAesExtraField aesExtraField) + { + aesExtraField = default; + + if (extraFields == null) + return false; + + foreach (ZipGenericExtraField field in extraFields) + { + if (field.Tag == HeaderId && field.Size >= DataSize) + { + byte[] data = field.Data; + aesExtraField = new WinZipAesExtraField( + VendorVersion: BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(0, 2)), + AesStrength: data[4], + CompressionMethod: BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(5, 2)) + ); + return true; + } + } + + return false; + } + + /// + /// Tries to find and parse the WinZip AES extra field (0x9901) from raw extra field data bytes. + /// This is used when ExtraFields are not saved (Read mode) but we still need to parse the AES field. + /// + /// The raw extra field data bytes. + /// When this method returns true, contains the parsed AES extra field. + /// true if the AES extra field was found and parsed; otherwise, false. + public static bool TryGetFromRawExtraFieldData(ReadOnlySpan extraFieldData, out WinZipAesExtraField aesExtraField) + { + aesExtraField = default; + int offset = 0; + + while (offset + 4 <= extraFieldData.Length) // Need at least 4 bytes for header ID and size + { + ushort headerId = BinaryPrimitives.ReadUInt16LittleEndian(extraFieldData.Slice(offset, 2)); + ushort fieldSize = BinaryPrimitives.ReadUInt16LittleEndian(extraFieldData.Slice(offset + 2, 2)); + + if (offset + 4 + fieldSize > extraFieldData.Length) + break; // Not enough data for this field + + if (headerId == HeaderId && fieldSize >= DataSize) + { + ReadOnlySpan data = extraFieldData.Slice(offset + 4, fieldSize); + aesExtraField = new WinZipAesExtraField( + VendorVersion: BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(0, 2)), + AesStrength: data[4], + CompressionMethod: BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(5, 2)) + ); + return true; + } + + offset += 4 + fieldSize; + } + + return false; + } + public void WriteBlock(Stream stream) { Span buffer = new byte[TotalSize]; @@ -847,6 +915,14 @@ internal sealed partial class ZipCentralDirectoryFileHeader public List? ExtraFields; public byte[]? TrailingExtraFieldData; + /// + /// The WinZip AES extra field (0x9901) if present in the central directory. + /// This is always parsed (regardless of saveExtraFieldsAndComments) so that + /// ZipArchiveEntry can determine the real compression method for AES-encrypted entries + /// without needing to read the local file header. + /// + public WinZipAesExtraField? AesExtraField; + private static bool TryReadBlockInitialize(ReadOnlySpan buffer, [NotNullWhen(returnValue: true)] out ZipCentralDirectoryFileHeader? header, out int bytesRead, out uint compressedSizeSmall, out uint uncompressedSizeSmall, out ushort diskNumberStartSmall, out uint relativeOffsetOfLocalHeaderSmall) { // the buffer will always be large enough for at least the constant section to be verified @@ -898,6 +974,14 @@ private static void TryReadBlockFinalize(ZipCentralDirectoryFileHeader header, R ReadOnlySpan zipExtraFields = dynamicHeader.Slice(header.FilenameLength, header.ExtraFieldLength); + // Always parse AES extra field (0x9901) from the central directory if present. + // This is needed so ZipArchiveEntry can determine the real compression method + // for AES-encrypted entries without requiring Open() to be called. + if (WinZipAesExtraField.TryGetFromRawExtraFieldData(zipExtraFields, out WinZipAesExtraField aesField)) + { + header.AesExtraField = aesField; + } + if (saveExtraFieldsAndComments) { header.ExtraFields = ZipGenericExtraField.ParseExtraField(zipExtraFields, out ReadOnlySpan trailingDataSpan); From 38dc088569aacf271597951fa83c5965ae686b3f Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Fri, 16 Jan 2026 15:04:14 +0100 Subject: [PATCH 30/39] address comments --- .../System/IO/Compression/ZipTestHelper.cs | 2 +- ...xtensions.ZipArchiveEntry.Extract.Async.cs | 2 +- .../tests/ZipFile.Encryption.cs | 397 ++++++++---------- .../ref/System.IO.Compression.cs | 4 +- .../src/System.IO.Compression.csproj | 1 - .../IO/Compression/ZipArchiveEntry.Async.cs | 259 +++++++----- .../System/IO/Compression/ZipArchiveEntry.cs | 303 ++++--------- .../System/IO/Compression/ZipBlocks.Async.cs | 67 +-- .../src/System/IO/Compression/ZipBlocks.cs | 78 +--- 9 files changed, 438 insertions(+), 675 deletions(-) diff --git a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs index a3ccd52901601e..57782feb62b8e2 100644 --- a/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs +++ b/src/libraries/Common/tests/System/IO/Compression/ZipTestHelper.cs @@ -559,7 +559,7 @@ public static async Task DisposeZipArchive(bool async, ZipArchive archive) public static async Task OpenEntryStream(bool async, ZipArchiveEntry entry) { - return async ? await entry.OpenAsync() : entry.Open(); + return async ? await entry.OpenAsync(cancellationToken: default) : entry.Open(); } public static async Task DisposeStream(bool async, Stream stream) diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs index 5e4a4604db53aa..f097e93b36ba01 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs @@ -100,7 +100,7 @@ public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string { Stream es; if (!string.IsNullOrEmpty(password)) - es = await source.OpenAsync(password, cancellationToken).ConfigureAwait(false); + es = await source.OpenAsync(password, cancellationToken: cancellationToken).ConfigureAwait(false); else es = await source.OpenAsync(cancellationToken).ConfigureAwait(false); await using (es) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 964b994a09b0f8..9aa43bbe8d617d 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -72,30 +72,6 @@ public async Task Encryption_MultipleEntries_SamePassword_RoundTrip(ZipArchiveEn } } - [Theory] - [MemberData(nameof(EncryptionMethodAndBoolTestData))] - public async Task Encryption_MultipleEntries_DifferentPasswords_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) - { - string archivePath = GetTempArchivePath(); - var entries = new[] - { - ("file1.txt", "Content 1", (string?)"pass1", (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), - ("file2.txt", "Content 2", (string?)"pass2", (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) - }; - - await CreateArchiveWithEntries(archivePath, entries, async); - - using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) - { - foreach (var (name, content, pwd, _) in entries) - { - var entry = archive.GetEntry(name); - Assert.NotNull(entry); - await AssertEntryTextEquals(entry, content, pwd, async); - } - } - } - [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] public async Task Encryption_MixedPlainAndEncrypted_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) @@ -193,7 +169,7 @@ public async Task Encryption_LargeFile_RoundTrip(bool async) } [Fact] - public void Negative_WrongPassword_Throws_InvalidDataException() + public void WrongPassword_Throws_InvalidDataException() { string archivePath = GetTempArchivePath(); string password = "correct"; @@ -222,17 +198,19 @@ public void Negative_MissingPassword_Throws_InvalidDataException() } } - [Fact] - public void Negative_OpeningPlainEntryWithPassword_Throws() + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task Negative_OpeningPlainEntryWithPassword_Throws(bool async) { string archivePath = GetTempArchivePath(); var entries = new[] { ("plain.txt", "content", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null) }; - CreateArchiveWithEntries(archivePath, entries, async: false).GetAwaiter().GetResult(); + await CreateArchiveWithEntries(archivePath, entries, async); - using (ZipArchive archive = ZipFile.OpenRead(archivePath)) + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) { - var entry = archive.GetEntry("plain.txt"); - Assert.ThrowsAny(() => entry.Open("password")); + ZipArchiveEntry entry = archive.GetEntry("plain.txt"); + Assert.NotNull(entry); + Assert.Throws(() => entry.Open("password")); } } @@ -374,48 +352,6 @@ public async Task UpdateMode_ModifyEncryptedEntry_RoundTrip(ZipArchiveEntry.Encr } } - [Theory] - [MemberData(nameof(EncryptionMethodAndBoolTestData))] - public async Task UpdateMode_AppendToEncryptedEntry_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) - { - string archivePath = GetTempArchivePath(); - string entryName = "test.txt"; - string originalContent = "Original Content"; - string appendedContent = " - Appended Text"; - string password = "password123"; - - // Create archive with encrypted entry - var entries = new[] { (entryName, originalContent, (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) }; - await CreateArchiveWithEntries(archivePath, entries, async); - - // Open in Update mode and append to the encrypted entry - using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) - { - ZipArchiveEntry entry = archive.GetEntry(entryName); - Assert.NotNull(entry); - - using (Stream stream = entry.Open(password)) - { - // Seek to end and append - stream.Seek(0, SeekOrigin.End); - byte[] appendBytes = Encoding.UTF8.GetBytes(appendedContent); - if (async) - await stream.WriteAsync(appendBytes, 0, appendBytes.Length); - else - stream.Write(appendBytes, 0, appendBytes.Length); - } - } - - // Verify content has original + appended - using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) - { - ZipArchiveEntry entry = archive.GetEntry(entryName); - Assert.NotNull(entry); - Assert.True(entry.IsEncrypted); - await AssertEntryTextEquals(entry, originalContent + appendedContent, password, async); - } - } - [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] public async Task UpdateMode_ReadOnlyEncryptedEntry_NoModification(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) @@ -674,152 +610,7 @@ public void UpdateMode_EncryptedEntry_NoPassword_Throws() var entry = archive.GetEntry("test.txt"); Assert.NotNull(entry); // Opening an encrypted entry without password in update mode should throw - Assert.ThrowsAny(() => entry.Open()); - } - } - - [Theory] - [MemberData(nameof(Get_Booleans_Data))] - public async Task UpdateMode_ZipCryptoToAes_PreservesEncryption(bool async) - { - // This test verifies that modifying a ZipCrypto entry preserves encryption - string archivePath = GetTempArchivePath(); - string entryName = "test.txt"; - string originalContent = "Original ZipCrypto Content"; - string modifiedContent = "Modified Content"; - string password = "password123"; - - // Create archive with ZipCrypto encrypted entry - var entries = new[] { (entryName, originalContent, (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.ZipCrypto) }; - await CreateArchiveWithEntries(archivePath, entries, async); - - // Modify in Update mode - using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) - { - ZipArchiveEntry entry = archive.GetEntry(entryName); - Assert.NotNull(entry); - Assert.True(entry.IsEncrypted); - - using (Stream stream = entry.Open(password)) - { - stream.SetLength(0); - byte[] newContent = Encoding.UTF8.GetBytes(modifiedContent); - if (async) - await stream.WriteAsync(newContent, 0, newContent.Length); - else - stream.Write(newContent, 0, newContent.Length); - } - } - - // Verify entry is still encrypted and can be read with original password - using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) - { - ZipArchiveEntry entry = archive.GetEntry(entryName); - Assert.NotNull(entry); - Assert.True(entry.IsEncrypted); - await AssertEntryTextEquals(entry, modifiedContent, password, async); - } - } - - [Theory] - [MemberData(nameof(Get_Booleans_Data))] - public async Task UpdateMode_MixedEncryption_ModifyAllEntries(bool async) - { - string archivePath = GetTempArchivePath(); - string password = "password123"; - - var entries = new[] - { - ("plain1.txt", "Plain Content 1", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), - ("encrypted1.txt", "Encrypted Content 1", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256), - ("plain2.txt", "Plain Content 2", (string?)null, (ZipArchiveEntry.EncryptionMethod?)null), - ("encrypted2.txt", "Encrypted Content 2", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.ZipCrypto) - }; - - await CreateArchiveWithEntries(archivePath, entries, async); - - // Modify all entries in Update mode - using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) - { - // Modify plain1.txt - ZipArchiveEntry plain1 = archive.GetEntry("plain1.txt"); - Assert.NotNull(plain1); - Assert.False(plain1.IsEncrypted); - using (Stream stream = await OpenEntryStream(async, plain1)) - { - stream.SetLength(0); - byte[] content = Encoding.UTF8.GetBytes("Modified Plain Content 1"); - if (async) - await stream.WriteAsync(content, 0, content.Length); - else - stream.Write(content, 0, content.Length); - } - - // Modify encrypted1.txt (AES) - ZipArchiveEntry encrypted1 = archive.GetEntry("encrypted1.txt"); - Assert.NotNull(encrypted1); - Assert.True(encrypted1.IsEncrypted); - using (Stream stream = encrypted1.Open(password)) - { - stream.SetLength(0); - byte[] content = Encoding.UTF8.GetBytes("Modified Encrypted Content 1"); - if (async) - await stream.WriteAsync(content, 0, content.Length); - else - stream.Write(content, 0, content.Length); - } - - // Modify plain2.txt - ZipArchiveEntry plain2 = archive.GetEntry("plain2.txt"); - Assert.NotNull(plain2); - Assert.False(plain2.IsEncrypted); - using (Stream stream = await OpenEntryStream(async, plain2)) - { - stream.SetLength(0); - byte[] content = Encoding.UTF8.GetBytes("Modified Plain Content 2"); - if (async) - await stream.WriteAsync(content, 0, content.Length); - else - stream.Write(content, 0, content.Length); - } - - // Modify encrypted2.txt (ZipCrypto) - ZipArchiveEntry encrypted2 = archive.GetEntry("encrypted2.txt"); - Assert.NotNull(encrypted2); - Assert.True(encrypted2.IsEncrypted); - using (Stream stream = encrypted2.Open(password)) - { - stream.SetLength(0); - byte[] content = Encoding.UTF8.GetBytes("Modified Encrypted Content 2"); - if (async) - await stream.WriteAsync(content, 0, content.Length); - else - stream.Write(content, 0, content.Length); - } - } - - // Verify all modifications - using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) - { - var plain1 = archive.GetEntry("plain1.txt"); - Assert.NotNull(plain1); - Assert.False(plain1.IsEncrypted); - await AssertEntryTextEquals(plain1, "Modified Plain Content 1", null, async); - - var encrypted1 = archive.GetEntry("encrypted1.txt"); - Assert.NotNull(encrypted1); - Assert.True(encrypted1.IsEncrypted); - await AssertEntryTextEquals(encrypted1, "Modified Encrypted Content 1", password, async); - - var plain2 = archive.GetEntry("plain2.txt"); - Assert.NotNull(plain2); - Assert.False(plain2.IsEncrypted); - await AssertEntryTextEquals(plain2, "Modified Plain Content 2", null, async); - - var encrypted2 = archive.GetEntry("encrypted2.txt"); - Assert.NotNull(encrypted2); - Assert.True(encrypted2.IsEncrypted); - await AssertEntryTextEquals(encrypted2, "Modified Encrypted Content 2", password, async); + Assert.ThrowsAny(() => entry.Open()); } } @@ -1136,5 +927,173 @@ public async Task CompressionMethod_AesEncryptedEntries_ReturnsActualCompression #endregion + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task Encryption_TrueZip64_LargeEntry_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + + try + { + // Clean up any leftover file from previous failed runs + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + + // Skip if insufficient disk space + long requiredSpace = 5L * 1024 * 1024 * 1024; // 5GB + DriveInfo drive = new DriveInfo(Path.GetPathRoot(Path.GetFullPath(archivePath))!); + if (drive.AvailableFreeSpace < requiredSpace * 2) // Need space for archive + verification + { + return; // Skip test - insufficient disk space + } + + string entryName = "zip64_true_large.bin"; + long size = (long)uint.MaxValue + (1024 * 1024); // Just over 4GB + string password = "Zip64Password!"; + int bufferSize = 64 * 1024 * 1024; // 64MB buffer + + // Create a deterministic buffer for writing and verification + byte[] buffer = new byte[bufferSize]; + new Random(42).NextBytes(buffer); + + // Create archive with entry > 4GB to force Zip64 + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry(entryName, CompressionLevel.NoCompression); + using (Stream s = entry.Open(password, encryptionMethod)) + { + long written = 0; + while (written < size) + { + int toWrite = (int)Math.Min(buffer.Length, size - written); + if (async) + await s.WriteAsync(buffer.AsMemory(0, toWrite)); + else + s.Write(buffer, 0, toWrite); + written += toWrite; + } + } + } + + // Verify the archive was created and can be read + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + Assert.Equal(size, entry.Length); + + // Verify content by reading in chunks and checking pattern + using (Stream s = entry.Open(password)) + { + byte[] readBuffer = new byte[bufferSize]; + long totalRead = 0; + + while (totalRead < size) + { + int expectedToRead = (int)Math.Min(readBuffer.Length, size - totalRead); + int actualRead = 0; + while (actualRead < expectedToRead) + { + int bytesRead = async + ? await s.ReadAsync(readBuffer.AsMemory(actualRead, expectedToRead - actualRead)) + : s.Read(readBuffer, actualRead, expectedToRead - actualRead); + if (bytesRead == 0) break; + actualRead += bytesRead; + } + + Assert.Equal(expectedToRead, actualRead); + Assert.True(readBuffer.AsSpan(0, actualRead).SequenceEqual(buffer.AsSpan(0, actualRead))); + totalRead += actualRead; + } + + Assert.Equal(size, totalRead); + } + } + } + finally + { + // Clean up the large file + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + } + } + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task Encryption_TrueZip64_LargeEntry_UpdateMode_Throws(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + + try + { + // Clean up any leftover file from previous failed runs + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + + // Skip if insufficient disk space + long requiredSpace = 5L * 1024 * 1024 * 1024; // 5GB + DriveInfo drive = new DriveInfo(Path.GetPathRoot(Path.GetFullPath(archivePath))!); + if (drive.AvailableFreeSpace < requiredSpace * 2) + { + return; // Skip test - insufficient disk space + } + + string entryName = "zip64_true_large.bin"; + long size = (long)uint.MaxValue + (1024 * 1024); // Just over 4GB + string password = "Zip64Password!"; + int bufferSize = 64 * 1024 * 1024; // 64MB buffer + + byte[] buffer = new byte[bufferSize]; + new Random(42).NextBytes(buffer); + + // Create archive with entry > 4GB to force Zip64 + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry(entryName, CompressionLevel.NoCompression); + using (Stream s = entry.Open(password, encryptionMethod)) + { + long written = 0; + while (written < size) + { + int toWrite = (int)Math.Min(buffer.Length, size - written); + if (async) + await s.WriteAsync(buffer.AsMemory(0, toWrite)); + else + s.Write(buffer, 0, toWrite); + written += toWrite; + } + } + } + + // Update mode should fail for entries larger than int.MaxValue + // because the implementation loads the entire decrypted content into a MemoryStream + using (ZipArchive archive = await CallZipFileOpen(async, archivePath, ZipArchiveMode.Update)) + { + ZipArchiveEntry entry = archive.GetEntry(entryName); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + // Opening the entry in update mode requires decrypting into memory, + // which should fail for entries larger than memorystream MaxValue (~2GB) + Assert.ThrowsAny(() => entry.Open(password)); + } + } + finally + { + // Clean up the large file + if (File.Exists(archivePath)) + { + File.Delete(archivePath); + } + } + } + } } diff --git a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs index 38d4cf63b7e0e0..31bb46529441da 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -127,8 +127,8 @@ internal ZipArchiveEntry() { } public string Name { get { throw null; } } public void Delete() { } public System.IO.Stream Open() { throw null; } - public System.IO.Stream Open(string? password = null, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod = System.IO.Compression.ZipArchiveEntry.EncryptionMethod.ZipCrypto) { throw null; } - public System.Threading.Tasks.Task OpenAsync(string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.IO.Stream Open(string? password = null, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod = System.IO.Compression.ZipArchiveEntry.EncryptionMethod.Aes256) { throw null; } + public System.Threading.Tasks.Task OpenAsync(string? password = null, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod = System.IO.Compression.ZipArchiveEntry.EncryptionMethod.Aes256, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.IO.Stream Open(FileAccess access) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index 994062f96a0a40..0948379d4e1cf6 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -81,7 +81,6 @@ - \ No newline at end of file diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index b38d381cb8fabd..2502b91c24fd1d 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -94,7 +94,7 @@ public async Task OpenAsync(FileAccess access, CancellationToken cancell } } - public async Task OpenAsync(string password, CancellationToken cancellationToken = default) + public async Task OpenAsync(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.Aes256, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); @@ -102,13 +102,21 @@ public async Task OpenAsync(string password, CancellationToken cancellat switch (_archive.Mode) { case ZipArchiveMode.Read: + if (!IsEncrypted) + { + throw new InvalidDataException(SR.EntryNotEncrypted); + } return await OpenInReadModeAsync(checkOpenable: true, cancellationToken, password.AsMemory()).ConfigureAwait(false); case ZipArchiveMode.Create: - return OpenInWriteMode(); + return OpenInWriteMode(password, encryptionMethod); case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - return await OpenInUpdateModeAsync(true, cancellationToken).ConfigureAwait(false); + if (!IsEncrypted) + { + throw new InvalidDataException(SR.EntryNotEncrypted); + } + return await OpenInUpdateModeAsync(loadExistingContent: true, cancellationToken, password).ConfigureAwait(false); } } @@ -120,58 +128,36 @@ internal async Task GetOffsetOfCompressedDataAsync(CancellationToken cance // Seek to local header _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - long baseOffset; - - if (!IsEncrypted || IsZipCryptoEncrypted()) - { - // Non-AES case: just skip the local header - if (!await ZipLocalFileHeader.TrySkipBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false)) - throw new InvalidDataException(SR.LocalFileHeaderCorrupt); - - baseOffset = _archive.ArchiveStream.Position; - } - else - { - // AES case - need to parse the AES extra field to find the actual compression method and skip the correct number of bytes - var (success, _) = await ZipLocalFileHeader.TrySkipBlockAESAwareAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); - if (!success) - throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + // Skip the local file header to get to the compressed data + // TrySkipBlockAsync handles both AES and non-AES cases correctly + if (!await ZipLocalFileHeader.TrySkipBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false)) + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); - baseOffset = _archive.ArchiveStream.Position; - } - - _storedOffsetOfCompressedData = baseOffset; + _storedOffsetOfCompressedData = _archive.ArchiveStream.Position; } return _storedOffsetOfCompressedData.Value; } - private async Task GetUncompressedDataAsync(CancellationToken cancellationToken) + private async Task GetUncompressedDataAsync(CancellationToken cancellationToken, string? password = null) { cancellationToken.ThrowIfCancellationRequested(); if (_storedUncompressedData == null) { // this means we have never opened it before - // if _uncompressedSize > int.MaxValue, it's still okay, because MemoryStream will just - // grow as data is copied into it + if (_uncompressedSize > Array.MaxLength) + { + throw new InvalidDataException(SR.EntryTooLarge); + } + _storedUncompressedData = new MemoryStream((int)_uncompressedSize); if (_originallyInArchive) { - if (_isEncrypted) - { - // We don't support edit-in-place for encrypted entries without an explicit password flow. - // Tell the caller to do the safe pattern: read with Open(password), then delete+recreate. - await _storedUncompressedData.DisposeAsync().ConfigureAwait(false); - _storedUncompressedData = null; - _currentlyOpenForWrite = false; - _everOpenedForWrite = false; - throw new InvalidOperationException( - "Editing an encrypted entry in-place is not supported. " + - "Read it with Open(password), then delete and recreate the entry with CreateEntry(..., password, ...)."); - } + Stream decompressor = password != null + ? await OpenInReadModeAsync(checkOpenable: false, cancellationToken, password.AsMemory()).ConfigureAwait(false) + : await OpenInReadModeAsync(checkOpenable: false, cancellationToken).ConfigureAwait(false); - Stream decompressor = await OpenInReadModeAsync(false, cancellationToken).ConfigureAwait(false); await using (decompressor) { try @@ -189,6 +175,7 @@ private async Task GetUncompressedDataAsync(CancellationToken canc _storedUncompressedData = null; _currentlyOpenForWrite = false; _everOpenedForWrite = false; + _derivedEncryptionKeyMaterial = null; throw; } } @@ -244,20 +231,7 @@ internal async Task WriteCentralDirectoryFileHeaderAsync(bool forceWrite, Cancel // Must match the exact check used in the sync version WriteCentralDirectoryFileHeader if (UseAesEncryption()) { - var aesExtraField = new WinZipAesExtraField - { - AesStrength = Encryption switch - { - EncryptionMethod.Aes128 => (byte)1, - EncryptionMethod.Aes192 => (byte)2, - EncryptionMethod.Aes256 => (byte)3, - _ /* EncryptionMethod.Aes256 */ => (byte)3 - }, - CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)ZipCompressionMethod.Stored : - (ushort)ZipCompressionMethod.Deflate - }; - await aesExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + await CreateAesExtraField().WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); // write extra fields excluding existing AES extra field (and any malformed trailing data). await ZipGenericExtraField.WriteAllBlocksExcludingTagAsync(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, WinZipAesExtraField.HeaderId, cancellationToken).ConfigureAwait(false); @@ -333,12 +307,16 @@ private async Task OpenInReadModeAsync(bool checkOpenable, CancellationT await GetOffsetOfCompressedDataAsync(cancellationToken).ConfigureAwait(false), password); } - private async Task OpenInUpdateModeAsync(bool loadExistingContent = true, CancellationToken cancellationToken = default) + private async Task OpenInUpdateModeAsync(bool loadExistingContent = true, CancellationToken cancellationToken = default, string? password = null) { cancellationToken.ThrowIfCancellationRequested(); if (_currentlyOpenForWrite) throw new IOException(SR.UpdateModeOneStream); + // Validate password requirement for encrypted entries + if (loadExistingContent && IsEncrypted && string.IsNullOrEmpty(password)) + throw new ArgumentException(SR.PasswordRequired, nameof(password)); + if (loadExistingContent) { await ThrowIfNotOpenableAsync(needToUncompress: true, needToLoadIntoMemory: true, cancellationToken).ConfigureAwait(false); @@ -350,7 +328,13 @@ private async Task OpenInUpdateModeAsync(bool loadExistingContent if (loadExistingContent) { - _storedUncompressedData = await GetUncompressedDataAsync(cancellationToken).ConfigureAwait(false); + _storedUncompressedData = await GetUncompressedDataAsync(cancellationToken, password).ConfigureAwait(false); + + // For encrypted entries, set up key material for re-encryption + if (IsEncrypted) + { + SetupEncryptionKeyMaterial(password!); + } } else { @@ -391,28 +375,12 @@ private async Task OpenInUpdateModeAsync(bool loadExistingContent _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); // AES case - skip the local file header and validate it exists. // The AES metadata (encryption strength, actual compression method) was already - // parsed from the central directory in the constructor, so we don't mutate - // _encryptionMethod or CompressionMethod here. - var (success, localAesField) = await ZipLocalFileHeader.TrySkipBlockAESAwareAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); - if (!success) + // parsed from the central directory in the constructor. + if (!await ZipLocalFileHeader.TrySkipBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false)) { message = SR.LocalFileHeaderCorrupt; return (false, message); } - - // Optionally validate that local header AES info matches central directory. - // If local header has AES field but with mismatched strength, that's corruption. - if (localAesField.HasValue && localAesField.Value.AesStrength != (_encryptionMethod switch - { - EncryptionMethod.Aes128 => 1, - EncryptionMethod.Aes192 => 2, - EncryptionMethod.Aes256 => 3, - _ => 0 - })) - { - // Local and central directory AES strengths don't match - could be corruption - // For now, we trust the central directory as authoritative (already set in constructor) - } } // when this property gets called, some duplicated work @@ -449,20 +417,7 @@ private async Task WriteLocalFileHeaderAsync(bool isEmptyFile, bool forceW // Must match the exact check used in the sync version WriteLocalFileHeader if (UseAesEncryption()) { - var aesExtraField = new WinZipAesExtraField - { - AesStrength = Encryption switch - { - EncryptionMethod.Aes128 => (byte)1, - EncryptionMethod.Aes192 => (byte)2, - EncryptionMethod.Aes256 => (byte)3, - _ /* EncryptionMethod.Aes256 */ => (byte)3 - }, - CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)ZipCompressionMethod.Stored : - (ushort)ZipCompressionMethod.Deflate - }; - await aesExtraField.WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); + await CreateAesExtraField().WriteBlockAsync(_archive.ArchiveStream, cancellationToken).ConfigureAwait(false); // Write other extra fields, excluding any existing AES extra field to avoid duplication await ZipGenericExtraField.WriteAllBlocksExcludingTagAsync(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, WinZipAesExtraField.HeaderId, cancellationToken).ConfigureAwait(false); @@ -485,18 +440,126 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can { _uncompressedSize = _storedUncompressedData.Length; - //The compressor fills in CRC and sizes - //The DirectToArchiveWriterStream writes headers and such - DirectToArchiveWriterStream entryWriter = new(GetDataCompressor(_archive.ArchiveStream, true, null, null), this); - await using (entryWriter) + // Check if we need to re-encrypt with ZipCrypto (only if we have cached key material) + if (Encryption == EncryptionMethod.ZipCrypto && _derivedEncryptionKeyMaterial != null) { - _storedUncompressedData.Seek(0, SeekOrigin.Begin); - await _storedUncompressedData.CopyToAsync(entryWriter, cancellationToken).ConfigureAwait(false); + // Write local file header first (with encryption flag set) + // Pass isEmptyFile: false because even empty encrypted files have the 12-byte header + await WriteLocalFileHeaderAsync(isEmptyFile: false, forceWrite: true, cancellationToken).ConfigureAwait(false); + + // Record position before encryption data + long startPosition = _archive.ArchiveStream.Position; + + ushort verifierLow2Bytes = (ushort)ZipHelper.DateTimeToDosTime(_lastModified.DateTime); + + var encryptionStream = new ZipCryptoStream( + baseStream: _archive.ArchiveStream, + keyBytes: _derivedEncryptionKeyMaterial, + passwordVerifierLow2Bytes: verifierLow2Bytes, + crc32: null, + leaveOpen: true); + await using (encryptionStream.ConfigureAwait(false)) + { + // Use GetDataCompressor which handles CRC calculation and compression + var crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream); + await using (crcStream.ConfigureAwait(false)) + { + _storedUncompressedData.Seek(0, SeekOrigin.Begin); + await _storedUncompressedData.CopyToAsync(crcStream, cancellationToken).ConfigureAwait(false); + } + // CRC, uncompressed size are now set by GetDataCompressor callback + // For empty files, ZipCryptoStream.Dispose() will write the 12-byte header + } + + // Calculate compressed size AFTER ZipCryptoStream is disposed + // (includes 12-byte encryption header + compressed data) + _compressedSize = _archive.ArchiveStream.Position - startPosition; + + // Write data descriptor since we used streaming mode + await WriteDataDescriptorAsync(cancellationToken).ConfigureAwait(false); + + await _storedUncompressedData.DisposeAsync().ConfigureAwait(false); + _storedUncompressedData = null; + } + else if (UseAesEncryption() && _derivedEncryptionKeyMaterial != null) + { + // For AES, we need to: + // 1. Write header with CompressionMethod = Aes (99) + // 2. Compress data with actual compression (Deflate/Stored) + // 3. Keep CompressionMethod = Aes for central directory + + // WriteLocalFileHeaderAsync will set CompressionMethod = Aes + await WriteLocalFileHeaderAsync(isEmptyFile: false, forceWrite: true, cancellationToken).ConfigureAwait(false); + + // Record position before encryption data + long startPosition = _archive.ArchiveStream.Position; + + int keySizeBits = GetAesKeySizeBits(); + + // Determine the actual compression method to use + // The AES extra field stores the real compression method + bool useDeflate = _compressionLevel != CompressionLevel.NoCompression; + + var encryptionStream = new WinZipAesStream( + baseStream: _archive.ArchiveStream, + keyMaterial: _derivedEncryptionKeyMaterial, + encrypting: true, + keySizeBits: keySizeBits, + leaveOpen: true); + await using (encryptionStream.ConfigureAwait(false)) + { + // Only compress/write if there's data + if (_storedUncompressedData.Length > 0) + { + // Temporarily set CompressionMethod for GetDataCompressor + ZipCompressionMethod savedMethod = CompressionMethod; + CompressionMethod = useDeflate ? ZipCompressionMethod.Deflate : ZipCompressionMethod.Stored; + + var crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream); + await using (crcStream.ConfigureAwait(false)) + { + _storedUncompressedData.Seek(0, SeekOrigin.Begin); + await _storedUncompressedData.CopyToAsync(crcStream, cancellationToken).ConfigureAwait(false); + } + + // Restore CompressionMethod to Aes for central directory + CompressionMethod = ZipCompressionMethod.Aes; + } + else + { + // Empty file: CRC is 0, uncompressed size is 0 + _crc32 = 0; + _uncompressedSize = 0; + } + // WinZipAesStream.Dispose() writes salt + verifier + HMAC even for empty files + } + + // Calculate compressed size AFTER WinZipAesStream is disposed + // (includes salt + password verifier + encrypted data + HMAC) + _compressedSize = _archive.ArchiveStream.Position - startPosition; + + // Write data descriptor since we used streaming mode + await WriteDataDescriptorAsync(cancellationToken).ConfigureAwait(false); + + await _storedUncompressedData.DisposeAsync().ConfigureAwait(false); + _storedUncompressedData = null; + } + else + { + // Non-encrypted: use standard path + //The compressor fills in CRC and sizes + //The DirectToArchiveWriterStream writes headers and such + DirectToArchiveWriterStream entryWriter = new(GetDataCompressor(_archive.ArchiveStream, true, null, null), this); + await using (entryWriter.ConfigureAwait(false)) + { + _storedUncompressedData.Seek(0, SeekOrigin.Begin); + await _storedUncompressedData.CopyToAsync(entryWriter, cancellationToken).ConfigureAwait(false); + } await _storedUncompressedData.DisposeAsync().ConfigureAwait(false); _storedUncompressedData = null; } } - else + else // _compressedBytes path - copying unchanged entry data { if (_uncompressedSize == 0) { @@ -509,23 +572,23 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can // wrong compression method from _compressionLevel). // The original AES extra field is preserved in _lhUnknownExtraFields. BitFlagValues savedFlags = _generalPurposeBitFlag; - EncryptionMethod savedEncryption = _encryptionMethod; + EncryptionMethod savedEncryption = Encryption; ZipCompressionMethod savedCompressionMethod = CompressionMethod; // For AES entries: set CompressionMethod to Aes so header writes method 99, - // but clear _encryptionMethod so WriteLocalFileHeaderAsync doesn't create a new + // but clear Encryption so WriteLocalFileHeaderAsync doesn't create a new // AES extra field (the original one in _lhUnknownExtraFields will be used). if (savedEncryption is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256) { CompressionMethod = ZipCompressionMethod.Aes; - _encryptionMethod = EncryptionMethod.None; + Encryption = EncryptionMethod.None; } await WriteLocalFileHeaderAsync(isEmptyFile: _uncompressedSize == 0, forceWrite: true, cancellationToken).ConfigureAwait(false); // Restore original state _generalPurposeBitFlag = savedFlags; - _encryptionMethod = savedEncryption; + Encryption = savedEncryption; CompressionMethod = savedCompressionMethod; // according to ZIP specs, zero-byte files MUST NOT include file data diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index 6bd3ec1c8ff572..2cbefbba894a77 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -51,10 +51,9 @@ public partial class ZipArchiveEntry private readonly CompressionLevel _compressionLevel; private ZipCompressionMethod _headerCompressionMethod; private ushort? _aeVersion; - // Cached derived key material for encrypted entries to avoid repeated PBKDF2 derivation. + // Cached derived key material for encrypted entries to allow updating in place // For WinZip AES: contains [salt][encryption key][HMAC key][password verifier] // For ZipCrypto: contains [key0][key1][key2] as 12 bytes - // Invalidated when encryption parameters change (new salt needed) private byte[]? _derivedEncryptionKeyMaterial; // Initializes a ZipArchiveEntry instance for an existing archive entry. @@ -88,7 +87,7 @@ internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) // Also parse remaining needed metadata now _aeVersion = aesField.VendorVersion; - _encryptionMethod = aesField.AesStrength switch + Encryption = aesField.AesStrength switch { 1 => EncryptionMethod.Aes128, 2 => EncryptionMethod.Aes192, @@ -420,7 +419,7 @@ public Stream Open() /// The entry is already currently open for writing. -or- The entry has been deleted from the archive. -or- The archive that this entry belongs to was opened in ZipArchiveMode.Create, and this entry has already been written to once. /// The entry is missing from the archive or is corrupt and cannot be read. -or- The entry has been compressed using a compression method that is not supported. /// The ZipArchive that this entry belongs to has been disposed. - public Stream Open(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.ZipCrypto) + public Stream Open(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.Aes256) { ThrowIfInvalidArchive(); switch (_archive.Mode) @@ -436,13 +435,11 @@ public Stream Open(string? password = null, EncryptionMethod encryptionMethod = case ZipArchiveMode.Update: default: Debug.Assert(_archive.Mode == ZipArchiveMode.Update); - - if (!_isEncrypted) + if (!IsEncrypted) { throw new InvalidDataException(SR.EntryNotEncrypted); } - - return OpenInUpdateModeEncrypted(password); + return OpenInUpdateMode(loadExistingContent: true, password); } } /// @@ -534,27 +531,12 @@ internal long GetOffsetOfCompressedData() // Seek to local header _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); - long baseOffset; - - if (!IsEncrypted || IsZipCryptoEncrypted()) - { - // Non-AES case: just skip the local header - if (!ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveStream)) - throw new InvalidDataException(SR.LocalFileHeaderCorrupt); - - baseOffset = _archive.ArchiveStream.Position; - } - else - { - // AES case - need to also parse the AES extra field and skip the correct number of bytes - if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _)) - throw new InvalidDataException(SR.LocalFileHeaderCorrupt); - - baseOffset = _archive.ArchiveStream.Position; - - } + // Skip the local file header to get to the compressed data + // TrySkipBlock handles both AES and non-AES cases correctly + if (!ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveStream)) + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); - _storedOffsetOfCompressedData = baseOffset; + _storedOffsetOfCompressedData = _archive.ArchiveStream.Position; } return _storedOffsetOfCompressedData.Value; @@ -566,27 +548,21 @@ private MemoryStream GetUncompressedData(string? password = null) { // this means we have never opened it before - // if _uncompressedSize > int.MaxValue, it's still okay, because MemoryStream will just - // grow as data is copied into it + + if (_uncompressedSize > Array.MaxLength) + { + throw new InvalidDataException(SR.EntryTooLarge); + } + _storedUncompressedData = new MemoryStream((int)_uncompressedSize); if (_originallyInArchive) { + Stream decompressor = password != null + ? OpenInReadMode(checkOpenable: false, password.AsMemory()) + : OpenInReadMode(checkOpenable: false); - if (_isEncrypted) - { - // We don’t support edit-in-place for encrypted entries without an explicit password flow. - // Tell the caller to do the safe pattern: read with Open(password), then delete+recreate. - _storedUncompressedData.Dispose(); - _storedUncompressedData = null; - _currentlyOpenForWrite = false; - _everOpenedForWrite = false; - throw new InvalidOperationException( - "Editing an encrypted entry in-place is not supported. " + - "Read it with Open(password), then delete and recreate the entry with CreateEntry(..., password, ...)."); - } - - using (Stream decompressor = OpenInReadMode(false, password.AsMemory())) + using (decompressor) { try { @@ -603,6 +579,7 @@ private MemoryStream GetUncompressedData(string? password = null) _storedUncompressedData = null; _currentlyOpenForWrite = false; _everOpenedForWrite = false; + _derivedEncryptionKeyMaterial = null; throw; } } @@ -701,19 +678,7 @@ private bool WriteCentralDirectoryFileHeaderInitialize(bool forceWrite, out Zip6 if (UseAesEncryption()) { - aesExtraField = new WinZipAesExtraField - { - AesStrength = Encryption switch - { - EncryptionMethod.Aes128 => (byte)1, - EncryptionMethod.Aes192 => (byte)2, - EncryptionMethod.Aes256 => (byte)3, - _ /* EncryptionMethod.Aes256 */ => (byte)3 - }, - CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)ZipCompressionMethod.Stored : - (ushort)ZipCompressionMethod.Deflate - }; + aesExtraField = CreateAesExtraField(); aesExtraFieldSize = WinZipAesExtraField.TotalSize; } @@ -816,20 +781,7 @@ internal void WriteCentralDirectoryFileHeader(bool forceWrite) // Write AES extra field if using AES encryption if (UseAesEncryption()) { - var aesExtraField = new WinZipAesExtraField - { - AesStrength = Encryption switch - { - EncryptionMethod.Aes128 => (byte)1, - EncryptionMethod.Aes192 => (byte)2, - EncryptionMethod.Aes256 => (byte)3, - _ /* EncryptionMethod.Aes256 */ => (byte)3 - }, - CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)ZipCompressionMethod.Stored : - (ushort)ZipCompressionMethod.Deflate - }; - aesExtraField.WriteBlock(_archive.ArchiveStream); + CreateAesExtraField().WriteBlock(_archive.ArchiveStream); // write extra fields excluding existing AES extra field (and any malformed trailing data). ZipGenericExtraField.WriteAllBlocksExcludingTag(_cdUnknownExtraFields, _cdTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, WinZipAesExtraField.HeaderId); @@ -982,12 +934,12 @@ private bool IsZipCryptoEncrypted() private bool UseAesEncryption() { - return _encryptionMethod is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256; + return Encryption is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256; } private int GetAesKeySizeBits() { - return _encryptionMethod switch + return Encryption switch { EncryptionMethod.Aes128 => 128, EncryptionMethod.Aes192 => 192, @@ -997,62 +949,37 @@ private int GetAesKeySizeBits() } // Creates the appropriate decryption stream for an encrypted entry. - private Stream CreateDecryptionStream(Stream compressedStream, ReadOnlyMemory password) + private Stream WrapWithDecryptionIfNeeded(Stream compressedStream, ReadOnlyMemory password) { + if (password.IsEmpty) + throw new InvalidDataException(SR.PasswordRequired); + bool isAesEncrypted = _headerCompressionMethod == ZipCompressionMethod.Aes; if (!isAesEncrypted && IsZipCryptoEncrypted()) { byte expectedCheckByte = CalculateZipCryptoCheckByte(); - - // Password is provided so derive fresh keys - if (!password.IsEmpty) - { - byte[] freshKeyMaterial = ZipCryptoStream.CreateKey(password); - return new ZipCryptoStream(compressedStream, freshKeyMaterial, expectedCheckByte); - } - - if (_derivedEncryptionKeyMaterial is null) - { - throw new InvalidDataException(SR.PasswordRequired); - } - - return new ZipCryptoStream(compressedStream, _derivedEncryptionKeyMaterial, expectedCheckByte); + byte[] keyMaterial = ZipCryptoStream.CreateKey(password); + return new ZipCryptoStream(compressedStream, keyMaterial, expectedCheckByte); } else if (isAesEncrypted) { int keySizeBits = GetAesKeySizeBits(); - // Password is provided so derive fresh keys - if (!password.IsEmpty) - { - // Generate salt from stream to derive keys - int saltSize = WinZipAesStream.GetSaltSize(keySizeBits); - byte[] salt = new byte[saltSize]; - compressedStream.ReadExactly(salt); - - // Seek back so WinZipAesStream can read the header (salt + password verifier) - compressedStream.Seek(-saltSize, SeekOrigin.Current); - - // Derive fresh key material from the provided password - byte[] freshKeyMaterial = WinZipAesStream.CreateKey(password, salt, keySizeBits); - - return new WinZipAesStream( - baseStream: compressedStream, - keyMaterial: freshKeyMaterial, - encrypting: false, - keySizeBits: keySizeBits, - totalStreamSize: _compressedSize); - } + // Read salt from stream to derive keys + int saltSize = WinZipAesStream.GetSaltSize(keySizeBits); + byte[] salt = new byte[saltSize]; + compressedStream.ReadExactly(salt); - if (_derivedEncryptionKeyMaterial is null) - { - throw new InvalidDataException(SR.PasswordRequired); - } + // Seek back so WinZipAesStream can read the header (salt + password verifier) + compressedStream.Seek(-saltSize, SeekOrigin.Current); + + // Derive key material from the provided password + byte[] keyMaterial = WinZipAesStream.CreateKey(password, salt, keySizeBits); return new WinZipAesStream( baseStream: compressedStream, - keyMaterial: _derivedEncryptionKeyMaterial, + keyMaterial: keyMaterial, encrypting: false, keySizeBits: keySizeBits, totalStreamSize: _compressedSize); @@ -1061,7 +988,6 @@ private Stream CreateDecryptionStream(Stream compressedStream, ReadOnlyMemory + /// Sets up encryption key material for re-encryption when writing back to the archive. + /// + private void SetupEncryptionKeyMaterial(string password) { - if (_currentlyOpenForWrite) - throw new IOException(SR.UpdateModeOneStream); - - ThrowIfNotOpenable(needToUncompress: true, needToLoadIntoMemory: true); - - if (string.IsNullOrEmpty(password)) - throw new ArgumentException(SR.PasswordRequired, nameof(password)); - - _everOpenedForWrite = true; - Changes |= ZipArchive.ChangeState.StoredData; - _currentlyOpenForWrite = true; - - // Load and decrypt the data into memory - // MemoryStream has a maximum capacity of int.MaxValue bytes - if (_uncompressedSize > Array.MaxLength) - { - _currentlyOpenForWrite = false; - _everOpenedForWrite = false; - throw new InvalidOperationException( - "Entry is too large to modify in place. " + - "Read it with Open(password), then delete and recreate the entry with CreateEntry."); - } - - _storedUncompressedData = new MemoryStream((int)_uncompressedSize); - - if (_originallyInArchive) - { - using (Stream decompressor = OpenInReadMode(checkOpenable: false, password.AsMemory())) - { - try - { - decompressor.CopyTo(_storedUncompressedData); - } - catch (InvalidDataException) - { - _storedUncompressedData.Dispose(); - _storedUncompressedData = null; - _currentlyOpenForWrite = false; - _everOpenedForWrite = false; - _derivedEncryptionKeyMaterial = null; - throw; - } - } - } - // Derive and save key material for re-encryption - // For ZipCrypto: deterministic key from password - // For AES: generate new salt and derive fresh key material if (IsZipCryptoEncrypted()) { _derivedEncryptionKeyMaterial = ZipCryptoStream.CreateKey(password.AsMemory()); - _encryptionMethod = EncryptionMethod.ZipCrypto; + Encryption = EncryptionMethod.ZipCrypto; } else if (UseAesEncryption()) { @@ -1297,28 +1189,33 @@ private WrappedStream OpenInUpdateModeEncrypted(string? password) // This ensures each write uses a fresh random salt for security int keySizeBits = GetAesKeySizeBits(); _derivedEncryptionKeyMaterial = WinZipAesStream.CreateKey(password.AsMemory(), salt: null, keySizeBits); - // _encryptionMethod is already set from constructor (parsed from central directory AES extra field) + // Encryption is already set from constructor (parsed from central directory AES extra field) } // Reset CRC - it will be recalculated when writing _crc32 = 0; + } - // Set the compression method for GetDataCompressor. - // CompressionMethod now contains the actual method (Stored/Deflate/etc.) from the - // central directory AES extra field, not the wrapper value 99. - // For re-compression, we use Deflate unless the original was Stored. - if (CompressionMethod == ZipCompressionMethod.Deflate || CompressionMethod == ZipCompressionMethod.Deflate64) - { - CompressionMethod = ZipCompressionMethod.Deflate; - } - // else it's Stored, keep it as Stored - - _storedUncompressedData.Seek(0, SeekOrigin.Begin); - return new WrappedStream(_storedUncompressedData, this, thisRef => + /// + /// Creates a WinZip AES extra field for writing to local/central directory headers. + /// + private WinZipAesExtraField CreateAesExtraField() + { + return new WinZipAesExtraField { - thisRef!._currentlyOpenForWrite = false; - }); + AesStrength = Encryption switch + { + EncryptionMethod.Aes128 => (byte)1, + EncryptionMethod.Aes192 => (byte)2, + EncryptionMethod.Aes256 => (byte)3, + _ => (byte)3 // Default to AES-256 + }, + CompressionMethod = _compressionLevel == CompressionLevel.NoCompression + ? (ushort)ZipCompressionMethod.Stored + : (ushort)ZipCompressionMethod.Deflate + }; } + private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out string? message) { message = null; @@ -1341,12 +1238,11 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st // AES case - skip the local file header and validate it exists. // The AES metadata (encryption strength, actual compression method) was already // parsed from the central directory in the constructor - if (!ZipLocalFileHeader.TrySkipBlockAESAware(_archive.ArchiveStream, out _)) + if (!ZipLocalFileHeader.TrySkipBlock(_archive.ArchiveStream)) { message = SR.LocalFileHeaderCorrupt; return false; } - } // Pass the detected encryption method to GetOffsetOfCompressedData @@ -1466,7 +1362,7 @@ private static BitFlagValues MapDeflateCompressionOption(BitFlagValues generalPu private bool IsOffsetTooLarge => _offsetOfLocalHeader > uint.MaxValue; private bool ShouldUseZIP64 => AreSizesTooLarge || IsOffsetTooLarge; - internal EncryptionMethod Encryption { get => _encryptionMethod; set => _encryptionMethod = value; } + internal EncryptionMethod Encryption { get => _encryptionMethod; private set => _encryptionMethod = value; } private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, out Zip64ExtraField? zip64ExtraField, out uint compressedSizeTruncated, out uint uncompressedSizeTruncated, out ushort extraFieldLength) { @@ -1513,19 +1409,7 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o CompressionMethod = ZipCompressionMethod.Aes; compressedSizeTruncated = 0; uncompressedSizeTruncated = 0; - aesExtraField = new WinZipAesExtraField - { - AesStrength = Encryption switch - { - EncryptionMethod.Aes128 => (byte)1, - EncryptionMethod.Aes192 => (byte)2, - EncryptionMethod.Aes256 => (byte)3, - _ /* EncryptionMethod.Aes256 */ => (byte)3 - }, - CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)ZipCompressionMethod.Stored : - (ushort)ZipCompressionMethod.Deflate - }; + aesExtraField = CreateAesExtraField(); aesExtraFieldSize = WinZipAesExtraField.TotalSize; } // if we have a non-seekable stream, don't worry about sizes at all, and just set the right bit @@ -1647,20 +1531,7 @@ private bool WriteLocalFileHeader(bool isEmptyFile, bool forceWrite) // Write AES extra field if using AES encryption if (UseAesEncryption()) { - var aesExtraField = new WinZipAesExtraField - { - AesStrength = Encryption switch - { - EncryptionMethod.Aes128 => (byte)1, - EncryptionMethod.Aes192 => (byte)2, - EncryptionMethod.Aes256 => (byte)3, - _ /* EncryptionMethod.Aes256 */ => (byte)3 - }, - CompressionMethod = _compressionLevel == CompressionLevel.NoCompression ? - (ushort)ZipCompressionMethod.Stored : - (ushort)ZipCompressionMethod.Deflate - }; - aesExtraField.WriteBlock(_archive.ArchiveStream); + CreateAesExtraField().WriteBlock(_archive.ArchiveStream); // Write other extra fields, excluding any existing AES extra field to avoid duplication ZipGenericExtraField.WriteAllBlocksExcludingTag(_lhUnknownExtraFields, _lhTrailingExtraFieldData ?? Array.Empty(), _archive.ArchiveStream, WinZipAesExtraField.HeaderId); @@ -1685,7 +1556,7 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) _uncompressedSize = _storedUncompressedData.Length; // Check if we need to re-encrypt with ZipCrypto (only if we have cached key material) - if (_encryptionMethod == EncryptionMethod.ZipCrypto && _derivedEncryptionKeyMaterial != null) + if (Encryption == EncryptionMethod.ZipCrypto && _derivedEncryptionKeyMaterial != null) { // Write local file header first (with encryption flag set) // Pass isEmptyFile: false because even empty encrypted files have the 12-byte header @@ -1811,7 +1682,7 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) // wrong compression method from _compressionLevel). // The original AES extra field is preserved in _lhUnknownExtraFields. BitFlagValues savedFlags = _generalPurposeBitFlag; - EncryptionMethod savedEncryption = _encryptionMethod; + EncryptionMethod savedEncryption = Encryption; ZipCompressionMethod savedCompressionMethod = CompressionMethod; // For AES entries: set CompressionMethod to Aes so header writes method 99, @@ -1820,14 +1691,14 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) if (savedEncryption is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256) { CompressionMethod = ZipCompressionMethod.Aes; - _encryptionMethod = EncryptionMethod.None; + Encryption = EncryptionMethod.None; } WriteLocalFileHeader(isEmptyFile: _uncompressedSize == 0, forceWrite: true); // Restore original state _generalPurposeBitFlag = savedFlags; - _encryptionMethod = savedEncryption; + Encryption = savedEncryption; CompressionMethod = savedCompressionMethod; // according to ZIP specs, zero-byte files MUST NOT include file data diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.Async.cs index b86a538b62474b..a11d7833778555 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.Async.cs @@ -168,79 +168,14 @@ public static async Task TrySkipBlockAsync(Stream stream, CancellationToke cancellationToken.ThrowIfCancellationRequested(); byte[] blockBytes = new byte[FieldLengths.Signature]; - long currPosition = stream.Position; int bytesRead = await stream.ReadAtLeastAsync(blockBytes, blockBytes.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false); - if (!TrySkipBlockCore(stream, blockBytes, bytesRead, currPosition)) + if (!TrySkipBlockCore(stream, blockBytes, bytesRead)) { return false; } bytesRead = await stream.ReadAtLeastAsync(blockBytes, blockBytes.Length, throwOnEndOfStream: false, cancellationToken).ConfigureAwait(false); return TrySkipBlockFinalize(stream, blockBytes, bytesRead); } - - public static async Task<(bool success, WinZipAesExtraField? aesExtraField)> TrySkipBlockAESAwareAsync(Stream stream, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - WinZipAesExtraField? aesExtraField = null; - - // Read the first 4 bytes (local file header signature) - byte[] signatureBytes = new byte[4]; - await stream.ReadExactlyAsync(signatureBytes, cancellationToken).ConfigureAwait(false); - if (!signatureBytes.AsSpan().SequenceEqual(SignatureConstantBytes)) - { - return (false, null); // Not a valid local file header - } - - // Read fixed-size fields after signature - // Skip version through mod date (10 bytes), then skip CRC32 + sizes (12 bytes) - byte[] skipBuffer = new byte[22]; - await stream.ReadExactlyAsync(skipBuffer, cancellationToken).ConfigureAwait(false); - - byte[] lengthBuffer = new byte[4]; - await stream.ReadExactlyAsync(lengthBuffer, cancellationToken).ConfigureAwait(false); - ushort nameLength = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(lengthBuffer.AsSpan(0, 2)); - ushort extraLength = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(lengthBuffer.AsSpan(2, 2)); - - // Skip file name - stream.Seek(nameLength, SeekOrigin.Current); - - // Parse extra fields if present - if (extraLength > 0) - { - long extraStart = stream.Position; - long extraEnd = extraStart + extraLength; - - byte[] fieldHeader = new byte[4]; - while (stream.Position < extraEnd) - { - await stream.ReadExactlyAsync(fieldHeader, cancellationToken).ConfigureAwait(false); - ushort headerId = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(fieldHeader.AsSpan(0, 2)); - ushort dataSize = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(fieldHeader.AsSpan(2, 2)); - - if (headerId == WinZipAesExtraField.HeaderId) // 0x9901 - { - // AES extra field structure: - // Vendor version (2) + Vendor ID (2) + AES strength (1) + Original compression (2) - byte[] aesData = new byte[7]; - await stream.ReadExactlyAsync(aesData, cancellationToken).ConfigureAwait(false); - ushort vendorVersion = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(aesData.AsSpan(0, 2)); - // Skip vendor ID 'AE' (bytes 2-3) - byte aesStrength = aesData[4]; // 1, 2, or 3 - ushort compressionMethod = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(aesData.AsSpan(5, 2)); - - aesExtraField = new WinZipAesExtraField(vendorVersion, aesStrength, compressionMethod); - break; - } - else - { - stream.Seek(dataSize, SeekOrigin.Current); // Skip unknown extra field - } - } - } - - return (true, aesExtraField); - } } internal sealed partial class ZipCentralDirectoryFileHeader diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs index 2ffa656a8ab88a..47b34e5a4ce755 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipBlocks.cs @@ -650,18 +650,13 @@ public static List GetExtraFields(Stream stream, out byte[ } } - private static bool TrySkipBlockCore(Stream stream, Span blockBytes, int bytesRead, long currPosition) + private static bool TrySkipBlockCore(Stream stream, Span blockBytes, int bytesRead) { if (bytesRead != FieldLengths.Signature || !blockBytes.SequenceEqual(SignatureConstantBytes)) { return false; } - if (stream.Length < currPosition + FieldLocations.FilenameLength) - { - return false; - } - // Already read the signature, so make the filename length field location relative to that stream.Seek(FieldLocations.FilenameLength - FieldLengths.Signature, SeekOrigin.Current); @@ -684,12 +679,11 @@ private static bool TrySkipBlockFinalize(Stream stream, Span blockBytes, i ushort filenameLength = BinaryPrimitives.ReadUInt16LittleEndian(blockBytes[relativeFilenameLengthLocation..]); ushort extraFieldLength = BinaryPrimitives.ReadUInt16LittleEndian(blockBytes[relativeExtraFieldLengthLocation..]); - if (stream.Length < stream.Position + filenameLength + extraFieldLength) - { - return false; - } - - stream.Seek(filenameLength + extraFieldLength, SeekOrigin.Current); + // Calculate absolute position of compressed data and seek there + // Using SeekOrigin.Begin ensures we end up at the correct position + // regardless of any edge cases during header parsing + long dataStart = stream.Position + filenameLength + extraFieldLength; + stream.Seek(dataStart, SeekOrigin.Begin); return true; } @@ -698,9 +692,8 @@ private static bool TrySkipBlockFinalize(Stream stream, Span blockBytes, i public static bool TrySkipBlock(Stream stream) { Span blockBytes = stackalloc byte[FieldLengths.Signature]; - long currPosition = stream.Position; int bytesRead = stream.ReadAtLeast(blockBytes, blockBytes.Length, throwOnEndOfStream: false); - if (!TrySkipBlockCore(stream, blockBytes, bytesRead, currPosition)) + if (!TrySkipBlockCore(stream, blockBytes, bytesRead)) { return false; } @@ -708,63 +701,6 @@ public static bool TrySkipBlock(Stream stream) return TrySkipBlockFinalize(stream, blockBytes, bytesRead); } - public static bool TrySkipBlockAESAware(Stream stream, out WinZipAesExtraField? aesExtraField) - { - aesExtraField = null; - BinaryReader reader = new BinaryReader(stream); - - // Read the first 4 bytes (local file header signature) - byte[] signatureBytes = reader.ReadBytes(4); - if (!signatureBytes.AsSpan().SequenceEqual(SignatureConstantBytes)) - { - return false; // Not a valid local file header - } - - // Read fixed-size fields after signature - // Skip version through mod date (10 bytes), then skip CRC32 + sizes (12 bytes) - reader.ReadBytes(22); // Skip 22 bytes total - - ushort nameLength = reader.ReadUInt16(); - ushort extraLength = reader.ReadUInt16(); - - // Skip file name - stream.Seek(nameLength, SeekOrigin.Current); - - // Calculate end of extra fields - long extraEnd = stream.Position + extraLength; - - // Parse extra fields if present - if (extraLength > 0) - { - while (stream.Position < extraEnd) - { - ushort headerId = reader.ReadUInt16(); - ushort dataSize = reader.ReadUInt16(); - - if (headerId == WinZipAesExtraField.HeaderId) // 0x9901 - { - // AES extra field structure: - // Vendor version (2) + Vendor ID (2) + AES strength (1) + Original compression (2) - ushort vendorVersion = reader.ReadUInt16(); - reader.ReadBytes(2); // Skip vendor ID 'AE' - byte aesStrength = reader.ReadByte(); // 1, 2, or 3 - ushort compressionMethod = reader.ReadUInt16(); - - aesExtraField = new WinZipAesExtraField(vendorVersion, aesStrength, compressionMethod); - break; - } - else - { - stream.Seek(dataSize, SeekOrigin.Current); // Skip unknown extra field - } - } - } - - // Ensure we're positioned at the end of extra fields (where data begins) - stream.Seek(extraEnd, SeekOrigin.Begin); - - return true; - } } internal struct WinZipAesExtraField { From 16d8a279f2d2c16be5adb17d0c82381c8103f24e Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 19 Jan 2026 12:45:19 +0100 Subject: [PATCH 31/39] fix refs and remove crc validation --- .../tests/ZipFile.Encryption.cs | 9 +- .../ref/System.IO.Compression.cs | 7 +- .../IO/Compression/ZipArchiveEntry.Async.cs | 14 +- .../System/IO/Compression/ZipArchiveEntry.cs | 26 ++- .../IO/Compression/ZipCompressionMethod.cs | 7 +- .../System/IO/Compression/ZipCustomStreams.cs | 155 ------------------ 6 files changed, 24 insertions(+), 194 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 9aa43bbe8d617d..71bcfb47d03b73 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -886,8 +886,6 @@ public async Task UpdateMode_AllEncryptionTypes_EditAllEntries(ZipArchiveEntry.E #region CompressionMethod Property Tests for Encrypted Entries - #region CompressionMethod Property Tests for Encrypted Entries - [Theory] [MemberData(nameof(Get_Booleans_Data))] public async Task CompressionMethod_AesEncryptedEntries_ReturnsActualCompressionMethod(bool async) @@ -925,10 +923,11 @@ public async Task CompressionMethod_AesEncryptedEntries_ReturnsActualCompression #endregion - #endregion + #region Zip64 Tests for Encrypted Entries [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnCI("Skipping large disk space test on CI machines.")] public async Task Encryption_TrueZip64_LargeEntry_RoundTrip(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -1025,6 +1024,7 @@ public async Task Encryption_TrueZip64_LargeEntry_RoundTrip(ZipArchiveEntry.Encr [Theory] [MemberData(nameof(EncryptionMethodAndBoolTestData))] + [SkipOnCI("Skipping large disk space test on CI machines.")] public async Task Encryption_TrueZip64_LargeEntry_UpdateMode_Throws(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) { string archivePath = GetTempArchivePath(); @@ -1082,7 +1082,7 @@ public async Task Encryption_TrueZip64_LargeEntry_UpdateMode_Throws(ZipArchiveEn // Opening the entry in update mode requires decrypting into memory, // which should fail for entries larger than memorystream MaxValue (~2GB) - Assert.ThrowsAny(() => entry.Open(password)); + Assert.Throws(() => entry.Open(password)); } } finally @@ -1095,5 +1095,6 @@ public async Task Encryption_TrueZip64_LargeEntry_UpdateMode_Throws(ZipArchiveEn } } + #endregion } } diff --git a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs index 31bb46529441da..ed13e75e386017 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -127,11 +127,11 @@ internal ZipArchiveEntry() { } public string Name { get { throw null; } } public void Delete() { } public System.IO.Stream Open() { throw null; } + public System.IO.Stream Open(System.IO.FileAccess access) { throw null; } public System.IO.Stream Open(string? password = null, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod = System.IO.Compression.ZipArchiveEntry.EncryptionMethod.Aes256) { throw null; } - public System.Threading.Tasks.Task OpenAsync(string? password = null, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod = System.IO.Compression.ZipArchiveEntry.EncryptionMethod.Aes256, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public System.IO.Stream Open(FileAccess access) { throw null; } - public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task OpenAsync(string? password, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod = System.IO.Compression.ZipArchiveEntry.EncryptionMethod.Aes256, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public override string ToString() { throw null; } public enum EncryptionMethod : byte { @@ -153,7 +153,6 @@ public enum ZipCompressionMethod Stored = 0, Deflate = 8, Deflate64 = 9, - Aes = 99 } public sealed partial class ZLibCompressionOptions { diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 2502b91c24fd1d..764eafd51e3c59 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -94,7 +94,7 @@ public async Task OpenAsync(FileAccess access, CancellationToken cancell } } - public async Task OpenAsync(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.Aes256, CancellationToken cancellationToken = default) + public async Task OpenAsync(string? password, EncryptionMethod encryptionMethod = EncryptionMethod.Aes256, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); ThrowIfInvalidArchive(); @@ -370,7 +370,7 @@ private async Task OpenInUpdateModeAsync(bool loadExistingContent message = SR.LocalFileHeaderCorrupt; return (false, message); } - else if (IsEncrypted && _headerCompressionMethod == ZipCompressionMethod.Aes) + else if (IsEncrypted && (ushort)_headerCompressionMethod == AesEncryptionMarker) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); // AES case - skip the local file header and validate it exists. @@ -522,8 +522,8 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can await _storedUncompressedData.CopyToAsync(crcStream, cancellationToken).ConfigureAwait(false); } - // Restore CompressionMethod to Aes for central directory - CompressionMethod = ZipCompressionMethod.Aes; + // Restore CompressionMethod - AesCompressionMethodValue is used directly when writing headers + CompressionMethod = savedMethod; } else { @@ -573,14 +573,11 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can // The original AES extra field is preserved in _lhUnknownExtraFields. BitFlagValues savedFlags = _generalPurposeBitFlag; EncryptionMethod savedEncryption = Encryption; - ZipCompressionMethod savedCompressionMethod = CompressionMethod; - // For AES entries: set CompressionMethod to Aes so header writes method 99, - // but clear Encryption so WriteLocalFileHeaderAsync doesn't create a new + // For AES entries: clear Encryption so WriteLocalFileHeaderAsync doesn't create a new // AES extra field (the original one in _lhUnknownExtraFields will be used). if (savedEncryption is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256) { - CompressionMethod = ZipCompressionMethod.Aes; Encryption = EncryptionMethod.None; } @@ -589,7 +586,6 @@ private async Task WriteLocalFileHeaderAndDataIfNeededAsync(bool forceWrite, Can // Restore original state _generalPurposeBitFlag = savedFlags; Encryption = savedEncryption; - CompressionMethod = savedCompressionMethod; // according to ZIP specs, zero-byte files MUST NOT include file data if (_uncompressedSize != 0) diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index 2cbefbba894a77..fb9a768bf0eb46 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -55,7 +55,7 @@ public partial class ZipArchiveEntry // For WinZip AES: contains [salt][encryption key][HMAC key][password verifier] // For ZipCrypto: contains [key0][key1][key2] as 12 bytes private byte[]? _derivedEncryptionKeyMaterial; - + internal const ushort AesEncryptionMarker = 99; // Initializes a ZipArchiveEntry instance for an existing archive entry. internal ZipArchiveEntry(ZipArchive archive, ZipCentralDirectoryFileHeader cd) { @@ -746,7 +746,7 @@ private void WriteCentralDirectoryFileHeaderPrepare(Span cdStaticHeader, u // For AES encryption, write compression method 99 (Aes) in the header // _headerCompressionMethod preserves the original value from the central directory - ushort compressionMethodToWrite = UseAesEncryption() ? (ushort)ZipCompressionMethod.Aes : (ushort)CompressionMethod; + ushort compressionMethodToWrite = UseAesEncryption() ? (ushort)AesEncryptionMarker : (ushort)CompressionMethod; BinaryPrimitives.WriteUInt16LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.CompressionMethod..], compressionMethodToWrite); BinaryPrimitives.WriteUInt32LittleEndian(cdStaticHeader[ZipCentralDirectoryFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); @@ -929,7 +929,7 @@ private byte CalculateZipCryptoCheckByte() private bool IsZipCryptoEncrypted() { - return (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0 && _headerCompressionMethod != ZipCompressionMethod.Aes; + return (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0 && (ushort)_headerCompressionMethod != AesEncryptionMarker; } private bool UseAesEncryption() @@ -954,7 +954,7 @@ private Stream WrapWithDecryptionIfNeeded(Stream compressedStream, ReadOnlyMemor if (password.IsEmpty) throw new InvalidDataException(SR.PasswordRequired); - bool isAesEncrypted = _headerCompressionMethod == ZipCompressionMethod.Aes; + bool isAesEncrypted = (ushort)_headerCompressionMethod == AesEncryptionMarker; if (!isAesEncrypted && IsZipCryptoEncrypted()) { @@ -1005,7 +1005,7 @@ private Stream GetDataDecompressor(Stream compressedStreamToRead) default: // We should not get here with Aes as CompressionMethod anymore // as it should have been replaced with the actual compression method - Debug.Assert(CompressionMethod != ZipCompressionMethod.Aes, + Debug.Assert((ushort)CompressionMethod != AesEncryptionMarker, "AES compression method should have been replaced with actual compression method"); // Fallback to stored if we somehow get here @@ -1041,12 +1041,6 @@ private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, Read // Get decompressed stream Stream decompressedStream = GetDataDecompressor(streamToDecompress); - if (UseAesEncryption() && _aeVersion == 1) - { - // Wrap with CRC validator for AE-1 - return new CrcValidatingReadStream(decompressedStream, _crc32, _uncompressedSize); - } - return decompressedStream; } private WrappedStream OpenInWriteMode(string? password = null, EncryptionMethod encryptionMethod = EncryptionMethod.None) @@ -1232,7 +1226,7 @@ private bool IsOpenable(bool needToUncompress, bool needToLoadIntoMemory, out st message = SR.LocalFileHeaderCorrupt; return false; } - else if (IsEncrypted && _headerCompressionMethod == ZipCompressionMethod.Aes) + else if (IsEncrypted && (ushort)_headerCompressionMethod == AesEncryptionMarker) { _archive.ArchiveStream.Seek(_offsetOfLocalHeader, SeekOrigin.Begin); // AES case - skip the local file header and validate it exists. @@ -1406,7 +1400,7 @@ private bool WriteLocalFileHeaderInitialize(bool isEmptyFile, bool forceWrite, o _generalPurposeBitFlag |= BitFlagValues.DataDescriptor; // Set compression method to 99 (AES indicator) in the header - CompressionMethod = ZipCompressionMethod.Aes; + CompressionMethod = (ZipCompressionMethod)AesEncryptionMarker; compressedSizeTruncated = 0; uncompressedSizeTruncated = 0; aesExtraField = CreateAesExtraField(); @@ -1500,7 +1494,7 @@ private void WriteLocalFileHeaderPrepare(Span lfStaticHeader, uint compres BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.GeneralPurposeBitFlags..], (ushort)_generalPurposeBitFlag); // For AES encryption, write compression method 99 (Aes) in the header - ushort compressionMethodToWrite = UseAesEncryption() ? (ushort)ZipCompressionMethod.Aes : (ushort)CompressionMethod; + ushort compressionMethodToWrite = UseAesEncryption() ? (ushort)AesEncryptionMarker : (ushort)CompressionMethod; BinaryPrimitives.WriteUInt16LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.CompressionMethod..], compressionMethodToWrite); BinaryPrimitives.WriteUInt32LittleEndian(lfStaticHeader[ZipLocalFileHeader.FieldLocations.LastModified..], ZipHelper.DateTimeToDosTime(_lastModified.DateTime)); @@ -1634,7 +1628,7 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) } // Restore CompressionMethod to Aes for central directory - CompressionMethod = ZipCompressionMethod.Aes; + CompressionMethod = (ZipCompressionMethod)AesEncryptionMarker; } else { @@ -1690,7 +1684,7 @@ private void WriteLocalFileHeaderAndDataIfNeeded(bool forceWrite) // AES extra field (the original one in _lhUnknownExtraFields will be used). if (savedEncryption is EncryptionMethod.Aes128 or EncryptionMethod.Aes192 or EncryptionMethod.Aes256) { - CompressionMethod = ZipCompressionMethod.Aes; + CompressionMethod = (ZipCompressionMethod)AesEncryptionMarker; Encryption = EncryptionMethod.None; } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCompressionMethod.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCompressionMethod.cs index 4ad210316bd4f0..644b41714b04d9 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCompressionMethod.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCompressionMethod.cs @@ -24,11 +24,6 @@ public enum ZipCompressionMethod /// /// The entry is compressed using the Deflate64 algorithm. /// - Deflate64 = 0x9, - - /// - /// The entry is encrypted using AES standard. - /// - Aes = 99 + Deflate64 = 0x9 } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs index acff9c9aa60179..d0c0c55dccc5e4 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs @@ -694,159 +694,4 @@ public override async ValueTask DisposeAsync() } } - internal sealed class CrcValidatingReadStream : Stream - { - private readonly Stream _baseStream; - private uint _runningCrc; - private readonly uint _expectedCrc; - private long _totalBytesRead; - private readonly long _expectedLength; - private bool _isDisposed; - private bool _crcValidated; - - public CrcValidatingReadStream(Stream baseStream, uint expectedCrc, long expectedLength) - { - _baseStream = baseStream; - _expectedCrc = expectedCrc; - _expectedLength = expectedLength; - _runningCrc = 0; - _totalBytesRead = 0; - _crcValidated = false; - } - - public override bool CanRead => !_isDisposed && _baseStream.CanRead; - public override bool CanSeek => false; - public override bool CanWrite => false; - - public override long Length => _baseStream.Length; - - public override long Position - { - get => _baseStream.Position; - set => throw new NotSupportedException(SR.SeekingNotSupported); - } - - public override int Read(byte[] buffer, int offset, int count) - { - ThrowIfDisposed(); - ValidateBufferArguments(buffer, offset, count); - - int bytesRead = _baseStream.Read(buffer, offset, count); - ProcessBytesRead(buffer.AsSpan(offset, bytesRead)); - - return bytesRead; - } - - public override int Read(Span buffer) - { - ThrowIfDisposed(); - - int bytesRead = _baseStream.Read(buffer); - ProcessBytesRead(buffer.Slice(0, bytesRead)); - - return bytesRead; - } - - public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - ThrowIfDisposed(); - ValidateBufferArguments(buffer, offset, count); - - int bytesRead = await _baseStream.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); - ProcessBytesRead(buffer.AsSpan(offset, bytesRead)); - - return bytesRead; - } - - public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) - { - ThrowIfDisposed(); - - int bytesRead = await _baseStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - ProcessBytesRead(buffer.Span.Slice(0, bytesRead)); - - return bytesRead; - } - - private void ProcessBytesRead(ReadOnlySpan data) - { - if (data.Length > 0) - { - _runningCrc = Crc32Helper.UpdateCrc32(_runningCrc, data); - _totalBytesRead += data.Length; - - if (_totalBytesRead >= _expectedLength) - { - ValidateCrc(); - } - } - } - - private void ValidateCrc() - { - if (_crcValidated) - return; - - _crcValidated = true; - - if (_totalBytesRead == _expectedLength && _runningCrc != _expectedCrc) - { - throw new InvalidDataException(SR.CrcMismatch); - } - } - - public override void Write(byte[] buffer, int offset, int count) - { - ThrowIfDisposed(); - throw new NotSupportedException(SR.WritingNotSupported); - } - - public override void Flush() - { - ThrowIfDisposed(); - } - - public override Task FlushAsync(CancellationToken cancellationToken) - { - ThrowIfDisposed(); - return Task.CompletedTask; - } - - public override long Seek(long offset, SeekOrigin origin) - { - ThrowIfDisposed(); - throw new NotSupportedException(SR.SeekingNotSupported); - } - - public override void SetLength(long value) - { - ThrowIfDisposed(); - throw new NotSupportedException(SR.SetLengthRequiresSeekingAndWriting); - } - - private void ThrowIfDisposed() - { - ObjectDisposedException.ThrowIf(_isDisposed, this); - } - - protected override void Dispose(bool disposing) - { - if (disposing && !_isDisposed) - { - _baseStream.Dispose(); - _isDisposed = true; - } - base.Dispose(disposing); - } - - public override async ValueTask DisposeAsync() - { - if (!_isDisposed) - { - await _baseStream.DisposeAsync().ConfigureAwait(false); - _isDisposed = true; - } - await base.DisposeAsync().ConfigureAwait(false); - } - } } From 3e5dd7967effbb29ffecb351f790f6d3ca2724e5 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Wed, 21 Jan 2026 11:11:08 +0100 Subject: [PATCH 32/39] update open overloads and extracttofile/directory calls --- .../ref/System.IO.Compression.ZipFile.cs | 15 +- .../System/IO/Compression/ZipFile.Extract.cs | 328 ++++++++++++++++++ ...FileExtensions.ZipArchive.Extract.Async.cs | 77 ++++ .../ZipFileExtensions.ZipArchive.Extract.cs | 11 + ...xtensions.ZipArchiveEntry.Extract.Async.cs | 17 +- ...pFileExtensions.ZipArchiveEntry.Extract.cs | 7 +- .../tests/ZipFile.Encryption.cs | 238 +++++++++++++ .../ref/System.IO.Compression.cs | 6 +- .../src/Resources/Strings.resx | 12 +- .../IO/Compression/ZipArchiveEntry.Async.cs | 28 +- .../System/IO/Compression/ZipArchiveEntry.cs | 29 +- 11 files changed, 755 insertions(+), 13 deletions(-) diff --git a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs index b678c368782f50..ff4e40804392e6 100644 --- a/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs +++ b/src/libraries/System.IO.Compression.ZipFile/ref/System.IO.Compression.ZipFile.cs @@ -22,12 +22,20 @@ public static void CreateFromDirectory(string sourceDirectoryName, string destin public static System.Threading.Tasks.Task CreateFromDirectoryAsync(string sourceDirectoryName, string destinationArchiveFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName) { } public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, bool overwriteFiles) { } + public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, bool overwriteFiles, string password) { } + public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, string password) { } public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding) { } public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles) { } + public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, string password) { } + public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, string password) { } public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName) { } public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles) { } + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, string password) { } + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, string password) { } public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding) { } public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles) { } + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, string password) { } + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, string password) { } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(System.IO.Stream source, string destinationDirectoryName, System.Text.Encoding? entryNameEncoding, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } @@ -53,15 +61,18 @@ public static partial class ZipFileExtensions public static System.Threading.Tasks.Task CreateEntryFromFileAsync(this System.IO.Compression.ZipArchive destination, string sourceFileName, string entryName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName) { } public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles) { } + public static void ExtractToDirectory(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles, string password) { } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, bool overwriteFiles, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToDirectoryAsync(this System.IO.Compression.ZipArchive source, string destinationDirectoryName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName) { } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite) { } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, string password) { } public static void ExtractToFile(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, string password) { } - public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, string? password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, bool overwrite, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, string? password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public static System.Threading.Tasks.Task ExtractToFileAsync(this System.IO.Compression.ZipArchiveEntry source, string destinationFileName, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs index cd2b78e64eb558..7da2b74ee2f91f 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Extract.cs @@ -188,6 +188,189 @@ public static void ExtractToDirectory(string sourceArchiveFileName, string desti } } + /// + /// Extracts all of the files in the specified password-protected archive to a directory on the file system. + /// The specified directory must not exist. This method will create all subdirectories and the specified directory. + /// If there is an error while extracting the archive, the archive will remain partially extracted. Each entry will + /// be extracted such that the extracted file has the same relative path to the destinationDirectoryName as the entry + /// has to the archive. The path is permitted to specify relative or absolute path information. Relative path information + /// is interpreted as relative to the current working directory. If a file to be archived has an invalid last modified + /// time, the first datetime representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// + /// + /// sourceArchive or destinationDirectoryName is a zero-length string, contains only whitespace, + /// or contains one or more invalid characters as defined by InvalidPathChars. + /// sourceArchive or destinationDirectoryName is null. + /// sourceArchive or destinationDirectoryName specifies a path, file name, + /// or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, + /// and file names must be less than 260 characters. + /// The path specified by sourceArchive or destinationDirectoryName is invalid, + /// (for example, it is on an unmapped drive). + /// An I/O error has occurred. -or- An archive entry's name is zero-length, contains only whitespace, or contains one or + /// more invalid characters as defined by InvalidPathChars. -or- Extracting an archive entry would result in a file destination that is outside the destination directory (for example, because of parent directory accessors). -or- An archive entry has the same name as an already extracted entry from the same archive. + /// The caller does not have the required permission. + /// sourceArchive or destinationDirectoryName is in an invalid format. + /// sourceArchive was not found. + /// The archive specified by sourceArchive: Is not a valid ZipArchive + /// -or- An archive entry was not found or was corrupt. -or- An archive entry has been compressed using a compression method + /// that is not supported. + /// + /// The path to the archive on the file system that is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// The password used to decrypt the encrypted entries in the archive. + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, string password) => + ExtractToDirectory(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: false, password: password); + + /// + /// Extracts all of the files in the specified password-protected archive to a directory on the file system. + /// The specified directory must not exist. This method will create all subdirectories and the specified directory. + /// If there is an error while extracting the archive, the archive will remain partially extracted. Each entry will + /// be extracted such that the extracted file has the same relative path to the destinationDirectoryName as the entry + /// has to the archive. The path is permitted to specify relative or absolute path information. Relative path information + /// is interpreted as relative to the current working directory. If a file to be archived has an invalid last modified + /// time, the first datetime representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// + /// + /// sourceArchive or destinationDirectoryName is a zero-length string, contains only whitespace, + /// or contains one or more invalid characters as defined by InvalidPathChars. + /// sourceArchive or destinationDirectoryName is null. + /// sourceArchive or destinationDirectoryName specifies a path, file name, + /// or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, + /// and file names must be less than 260 characters. + /// The path specified by sourceArchive or destinationDirectoryName is invalid, + /// (for example, it is on an unmapped drive). + /// An I/O error has occurred. -or- An archive entry's name is zero-length, contains only whitespace, or contains one or + /// more invalid characters as defined by InvalidPathChars. -or- Extracting an archive entry would result in a file destination that is outside the destination directory (for example, because of parent directory accessors). -or- An archive entry has the same name as an already extracted entry from the same archive. + /// The caller does not have the required permission. + /// sourceArchive or destinationDirectoryName is in an invalid format. + /// sourceArchive was not found. + /// The archive specified by sourceArchive: Is not a valid ZipArchive + /// -or- An archive entry was not found or was corrupt. -or- An archive entry has been compressed using a compression method + /// that is not supported. + /// + /// The path to the archive on the file system that is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// True to indicate overwrite. + /// The password used to decrypt the encrypted entries in the archive. + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, bool overwriteFiles, string password) => + ExtractToDirectory(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: overwriteFiles, password: password); + + /// + /// Extracts all of the files in the specified password-protected archive to a directory on the file system. + /// The specified directory must not exist. This method will create all subdirectories and the specified directory. + /// If there is an error while extracting the archive, the archive will remain partially extracted. Each entry will + /// be extracted such that the extracted file has the same relative path to the destinationDirectoryName as the entry + /// has to the archive. The path is permitted to specify relative or absolute path information. Relative path information + /// is interpreted as relative to the current working directory. If a file to be archived has an invalid last modified + /// time, the first datetime representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// + /// + /// sourceArchive or destinationDirectoryName is a zero-length string, contains only whitespace, + /// or contains one or more invalid characters as defined by InvalidPathChars. + /// sourceArchive or destinationDirectoryName is null. + /// sourceArchive or destinationDirectoryName specifies a path, file name, + /// or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, + /// and file names must be less than 260 characters. + /// The path specified by sourceArchive or destinationDirectoryName is invalid, + /// (for example, it is on an unmapped drive). + /// An I/O error has occurred. -or- An archive entry's name is zero-length, contains only whitespace, or contains one or + /// more invalid characters as defined by InvalidPathChars. -or- Extracting an archive entry would result in a file destination that is outside the destination directory (for example, because of parent directory accessors). -or- An archive entry has the same name as an already extracted entry from the same archive. + /// The caller does not have the required permission. + /// sourceArchive or destinationDirectoryName is in an invalid format. + /// sourceArchive was not found. + /// The archive specified by sourceArchive: Is not a valid ZipArchive + /// -or- An archive entry was not found or was corrupt. -or- An archive entry has been compressed using a compression method + /// that is not supported. + /// + /// The path to the archive on the file system that is to be extracted. + /// The path to the directory on the file system. The directory specified must not exist, but the directory that it is contained in must exist. + /// The encoding to use when reading or writing entry names and comments in this ZipArchive. + /// /// NOTE: Specifying this parameter to values other than null is discouraged. + /// However, this may be necessary for interoperability with ZIP archive tools and libraries that do not correctly support + /// UTF-8 encoding for entry names or comments.
+ /// This value is used as follows:
+ /// If entryNameEncoding is not specified (== null): + /// + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is not set, + /// use the current system default code page (Encoding.Default) in order to decode the entry name and comment. + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is set, + /// use UTF-8 (Encoding.UTF8) in order to decode the entry name and comment. + /// + /// If entryNameEncoding is specified (!= null): + /// + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is not set, + /// use the specified entryNameEncoding in order to decode the entry name and comment. + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is set, + /// use UTF-8 (Encoding.UTF8) in order to decode the entry name and comment. + /// + /// Note that Unicode encodings other than UTF-8 may not be currently used for the entryNameEncoding, + /// otherwise an is thrown. + /// + /// The password used to decrypt the encrypted entries in the archive. + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, string password) => + ExtractToDirectory(sourceArchiveFileName, destinationDirectoryName, entryNameEncoding: entryNameEncoding, overwriteFiles: false, password: password); + + /// + /// Extracts all of the files in the specified password-protected archive to a directory on the file system. + /// The specified directory must not exist. This method will create all subdirectories and the specified directory. + /// If there is an error while extracting the archive, the archive will remain partially extracted. Each entry will + /// be extracted such that the extracted file has the same relative path to the destinationDirectoryName as the entry + /// has to the archive. The path is permitted to specify relative or absolute path information. Relative path information + /// is interpreted as relative to the current working directory. If a file to be archived has an invalid last modified + /// time, the first datetime representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// + /// + /// sourceArchive or destinationDirectoryName is a zero-length string, contains only whitespace, + /// or contains one or more invalid characters as defined by InvalidPathChars. + /// sourceArchive or destinationDirectoryName is null. + /// sourceArchive or destinationDirectoryName specifies a path, file name, + /// or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, + /// and file names must be less than 260 characters. + /// The path specified by sourceArchive or destinationDirectoryName is invalid, + /// (for example, it is on an unmapped drive). + /// An I/O error has occurred. -or- An archive entry's name is zero-length, contains only whitespace, or contains one or + /// more invalid characters as defined by InvalidPathChars. -or- Extracting an archive entry would result in a file destination that is outside the destination directory (for example, because of parent directory accessors). -or- An archive entry has the same name as an already extracted entry from the same archive. + /// The caller does not have the required permission. + /// sourceArchive or destinationDirectoryName is in an invalid format. + /// sourceArchive was not found. + /// The archive specified by sourceArchive: Is not a valid ZipArchive + /// -or- An archive entry was not found or was corrupt. -or- An archive entry has been compressed using a compression method + /// that is not supported. + /// + /// The path to the archive on the file system that is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// True to indicate overwrite. + /// The encoding to use when reading or writing entry names and comments in this ZipArchive. + /// /// NOTE: Specifying this parameter to values other than null is discouraged. + /// However, this may be necessary for interoperability with ZIP archive tools and libraries that do not correctly support + /// UTF-8 encoding for entry names or comments.
+ /// This value is used as follows:
+ /// If entryNameEncoding is not specified (== null): + /// + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is not set, + /// use the current system default code page (Encoding.Default) in order to decode the entry name and comment. + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is set, + /// use UTF-8 (Encoding.UTF8) in order to decode the entry name and comment. + /// + /// If entryNameEncoding is specified (!= null): + /// + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is not set, + /// use the specified entryNameEncoding in order to decode the entry name and comment. + /// For entries where the language encoding flag (EFS) in the general purpose bit flag of the local file header is set, + /// use UTF-8 (Encoding.UTF8) in order to decode the entry name and comment. + /// + /// Note that Unicode encodings other than UTF-8 may not be currently used for the entryNameEncoding, + /// otherwise an is thrown. + /// + /// The password used to decrypt the encrypted entries in the archive. + public static void ExtractToDirectory(string sourceArchiveFileName, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, string password) + { + ArgumentNullException.ThrowIfNull(sourceArchiveFileName); + + using ZipArchive archive = Open(sourceArchiveFileName, ZipArchiveMode.Read, entryNameEncoding); + archive.ExtractToDirectory(destinationDirectoryName, overwriteFiles, password); + } + /// /// Extracts all the files from the zip archive stored in the specified stream and places them in the specified destination directory on the file system. /// @@ -328,5 +511,150 @@ public static void ExtractToDirectory(Stream source, string destinationDirectory using ZipArchive archive = new ZipArchive(source, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding); archive.ExtractToDirectory(destinationDirectoryName, overwriteFiles); } + + /// + /// Extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system. + /// + /// The stream from which the zip archive is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// The password used to decrypt the encrypted entries in the archive. + /// This method creates the specified directory and all subdirectories. The destination directory cannot already exist. + /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. + /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. + /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// > is , contains only white space, or contains at least one invalid character. + /// or is . + /// The specified path in exceeds the system-defined maximum length. + /// The specified path is invalid (for example, it is on an unmapped drive). + /// The name of an entry in the archive is , contains only white space, or contains at least one invalid character. + /// -or- + /// Extracting an archive entry would create a file that is outside the directory specified by . (For example, this might happen if the entry name contains parent directory accessors.) + /// -or- + /// An archive entry to extract has the same name as an entry that has already been extracted or that exists in . + /// The caller does not have the required permission to access the archive or the destination directory. + /// contains an invalid format. + /// The archive contained in the stream is not a valid zip archive. + /// -or- + /// An archive entry was not found or was corrupt. + /// -or- + /// An archive entry was compressed by using a compression method that is not supported. + public static void ExtractToDirectory(Stream source, string destinationDirectoryName, string password) => + ExtractToDirectory(source, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: false, password: password); + + /// + /// Extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system, and optionally allows choosing if the files in the destination directory should be overwritten. + /// + /// The stream from which the zip archive is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// to overwrite files; otherwise. + /// The password used to decrypt the encrypted entries in the archive. + /// This method creates the specified directory and all subdirectories. The destination directory cannot already exist. + /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. + /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. + /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// > is , contains only white space, or contains at least one invalid character. + /// or is . + /// The specified path in exceeds the system-defined maximum length. + /// The specified path is invalid (for example, it is on an unmapped drive). + /// The name of an entry in the archive is , contains only white space, or contains at least one invalid character. + /// -or- + /// Extracting an archive entry would create a file that is outside the directory specified by . (For example, this might happen if the entry name contains parent directory accessors.) + /// -or- + /// is and an archive entry to extract has the same name as an entry that has already been extracted or that exists in . + /// The caller does not have the required permission to access the archive or the destination directory. + /// contains an invalid format. + /// The archive contained in the stream is not a valid zip archive. + /// -or- + /// An archive entry was not found or was corrupt. + /// -or- + /// An archive entry was compressed by using a compression method that is not supported. + public static void ExtractToDirectory(Stream source, string destinationDirectoryName, bool overwriteFiles, string password) => + ExtractToDirectory(source, destinationDirectoryName, entryNameEncoding: null, overwriteFiles: overwriteFiles, password: password); + + /// + /// Extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system and uses the specified character encoding for entry names. + /// + /// The stream from which the zip archive is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// The encoding to use when reading or writing entry names and comments in this archive. Specify a value for this parameter only when an encoding is required for interoperability with zip archive tools and libraries that do not support UTF-8 encoding for entry names or comments. + /// The password used to decrypt the encrypted entries in the archive. + /// This method creates the specified directory and all subdirectories. The destination directory cannot already exist. + /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. + /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. + /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// If is set to a value other than , entry names and comments are decoded according to the following rules: + /// - For entry names and comments where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, the entry names and comments are decoded by using the specified encoding. + /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. + /// If is set to , entry names and comments are decoded according to the following rules: + /// - For entries where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, entry names and comments are decoded by using the current system default code page. + /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. + /// > is , contains only white space, or contains at least one invalid character. + /// -or- + /// is set to a Unicode encoding other than UTF-8. + /// or is . + /// The specified path in exceeds the system-defined maximum length. + /// The specified path is invalid (for example, it is on an unmapped drive). + /// The name of an entry in the archive is , contains only white space, or contains at least one invalid character. + /// -or- + /// Extracting an archive entry would create a file that is outside the directory specified by . (For example, this might happen if the entry name contains parent directory accessors.) + /// -or- + /// An archive entry to extract has the same name as an entry that has already been extracted or that exists in . + /// The caller does not have the required permission to access the archive or the destination directory. + /// contains an invalid format. + /// The archive contained in the stream is not a valid zip archive. + /// -or- + /// An archive entry was not found or was corrupt. + /// -or- + /// An archive entry was compressed by using a compression method that is not supported. + public static void ExtractToDirectory(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, string password) => + ExtractToDirectory(source, destinationDirectoryName, entryNameEncoding: entryNameEncoding, overwriteFiles: false, password: password); + + /// + /// Extracts all the files from the password-protected zip archive stored in the specified stream and places them in the specified destination directory on the file system, uses the specified character encoding for entry names, and optionally allows choosing if the files in the destination directory should be overwritten. + /// + /// The stream from which the zip archive is to be extracted. + /// The path to the directory in which to place the extracted files, specified as a relative or absolute path. A relative path is interpreted as relative to the current working directory. + /// The encoding to use when reading or writing entry names and comments in this archive. Specify a value for this parameter only when an encoding is required for interoperability with zip archive tools and libraries that do not support UTF-8 encoding for entry names or comments. + /// to overwrite files; otherwise. + /// The password used to decrypt the encrypted entries in the archive. + /// This method creates the specified directory and all subdirectories. The destination directory cannot already exist. + /// Exceptions related to validating the paths in the or the files in the zip archive contained in parameters are thrown before extraction. Otherwise, if an error occurs during extraction, the archive remains partially extracted. + /// Each extracted file has the same relative path to the directory specified by as its source entry has to the root of the archive. + /// If a file to be archived has an invalid last modified time, the first date and time representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// If is set to a value other than , entry names and comments are decoded according to the following rules: + /// - For entry names and comments where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, the entry names and comments are decoded by using the specified encoding. + /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. + /// If is set to , entry names and comments are decoded according to the following rules: + /// - For entries where the language encoding flag (in the general-purpose bit flag of the local file header) is not set, entry names are decoded by using the current system default code page. + /// - For entries where the language encoding flag is set, the entry names and comments are decoded by using UTF-8. + /// > is , contains only white space, or contains at least one invalid character. + /// -or- + /// is set to a Unicode encoding other than UTF-8. + /// or is . + /// The specified path in exceeds the system-defined maximum length. + /// The specified path is invalid (for example, it is on an unmapped drive). + /// The name of an entry in the archive is , contains only white space, or contains at least one invalid character. + /// -or- + /// Extracting an archive entry would create a file that is outside the directory specified by . (For example, this might happen if the entry name contains parent directory accessors.) + /// -or- + /// is and an archive entry to extract has the same name as an entry that has already been extracted or that exists in . + /// The caller does not have the required permission to access the archive or the destination directory. + /// contains an invalid format. + /// The archive contained in the stream is not a valid zip archive. + /// -or- + /// An archive entry was not found or was corrupt. + /// -or- + /// An archive entry was compressed by using a compression method that is not supported. + public static void ExtractToDirectory(Stream source, string destinationDirectoryName, Encoding? entryNameEncoding, bool overwriteFiles, string password) + { + ArgumentNullException.ThrowIfNull(source); + if (!source.CanRead) + { + throw new ArgumentException(SR.UnreadableStream, nameof(source)); + } + + using ZipArchive archive = new ZipArchive(source, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding); + archive.ExtractToDirectory(destinationDirectoryName, overwriteFiles, password); + } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs index 8b42376813a2da..0aac6b396a03fc 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.Async.cs @@ -82,4 +82,81 @@ public static async Task ExtractToDirectoryAsync(this ZipArchive source, string await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwriteFiles, cancellationToken).ConfigureAwait(false); } } + + /// + /// Asynchronously extracts all of the files in the password-protected archive to a directory on the file system. The specified directory may already exist. + /// This method will create all subdirectories and the specified directory if necessary. + /// If there is an error while extracting the archive, the archive will remain partially extracted. + /// Each entry will be extracted such that the extracted file has the same relative path to destinationDirectoryName as the + /// entry has to the root of the archive. If a file to be archived has an invalid last modified time, the first datetime + /// representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// + /// destinationDirectoryName is a zero-length string, contains only whitespace, + /// or contains one or more invalid characters as defined by InvalidPathChars. + /// destinationDirectoryName is null. + /// The specified path, file name, or both exceed the system-defined maximum length. + /// For example, on Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 characters. + /// The specified path is invalid, (for example, it is on an unmapped drive). + /// An archive entry?s name is zero-length, contains only whitespace, or contains one or more invalid + /// characters as defined by InvalidPathChars. -or- Extracting an archive entry would have resulted in a destination + /// file that is outside destinationDirectoryName (for example, if the entry name contains parent directory accessors). + /// -or- An archive entry has the same name as an already extracted entry from the same archive. + /// The caller does not have the required permission. + /// destinationDirectoryName is in an invalid format. + /// An archive entry was not found or was corrupt. + /// -or- An archive entry has been compressed using a compression method that is not supported. + /// An asynchronous operation is cancelled. + /// The zip archive to extract files from. + /// The path to the directory on the file system. + /// The directory specified must not exist. The path is permitted to specify relative or absolute path information. + /// Relative path information is interpreted as relative to the current working directory. + /// The password used to decrypt the encrypted entries in the archive. + /// The cancellation token to monitor for cancellation requests. + /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. + public static Task ExtractToDirectoryAsync(this ZipArchive source, string destinationDirectoryName, string password, CancellationToken cancellationToken = default) => + ExtractToDirectoryAsync(source, destinationDirectoryName, overwriteFiles: false, password, cancellationToken); + + /// + /// Asynchronously extracts all of the files in the password-protected archive to a directory on the file system. The specified directory may already exist. + /// This method will create all subdirectories and the specified directory if necessary. + /// If there is an error while extracting the archive, the archive will remain partially extracted. + /// Each entry will be extracted such that the extracted file has the same relative path to destinationDirectoryName as the + /// entry has to the root of the archive. If a file to be archived has an invalid last modified time, the first datetime + /// representable in the Zip timestamp format (midnight on January 1, 1980) will be used. + /// + /// destinationDirectoryName is a zero-length string, contains only whitespace, + /// or contains one or more invalid characters as defined by InvalidPathChars. + /// destinationDirectoryName is null. + /// The specified path, file name, or both exceed the system-defined maximum length. + /// For example, on Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 characters. + /// The specified path is invalid, (for example, it is on an unmapped drive). + /// An archive entry?s name is zero-length, contains only whitespace, or contains one or more invalid + /// characters as defined by InvalidPathChars. -or- Extracting an archive entry would have resulted in a destination + /// file that is outside destinationDirectoryName (for example, if the entry name contains parent directory accessors). + /// -or- An archive entry has the same name as an already extracted entry from the same archive. + /// The caller does not have the required permission. + /// destinationDirectoryName is in an invalid format. + /// An archive entry was not found or was corrupt. + /// -or- An archive entry has been compressed using a compression method that is not supported. + /// An asynchronous operation is cancelled. + /// The zip archive to extract files from. + /// The path to the directory on the file system. + /// The directory specified must not exist. The path is permitted to specify relative or absolute path information. + /// Relative path information is interpreted as relative to the current working directory. + /// True to indicate overwrite. + /// The password used to decrypt the encrypted entries in the archive. + /// The cancellation token to monitor for cancellation requests. + /// A task that represents the asynchronous extract operation. The task completes when all entries have been extracted or an error occurs. + public static async Task ExtractToDirectoryAsync(this ZipArchive source, string destinationDirectoryName, bool overwriteFiles, string password, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(destinationDirectoryName); + + foreach (ZipArchiveEntry entry in source.Entries) + { + await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryName, overwriteFiles, password, cancellationToken).ConfigureAwait(false); + } + } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs index ad9cfd4a6c2e63..c42472e65cc4d0 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Extract.cs @@ -73,5 +73,16 @@ public static void ExtractToDirectory(this ZipArchive source, string destination entry.ExtractRelativeToDirectory(destinationDirectoryName, overwriteFiles); } } + + public static void ExtractToDirectory(this ZipArchive source, string destinationDirectoryName, bool overwriteFiles, string password) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(destinationDirectoryName); + + foreach (ZipArchiveEntry entry in source.Entries) + { + entry.ExtractRelativeToDirectory(destinationDirectoryName, overwriteFiles); + } + } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs index f097e93b36ba01..06b0f6531c310c 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.Async.cs @@ -86,10 +86,10 @@ public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string ExtractToFileFinalize(source, destinationFileName); } - public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, string? password, CancellationToken cancellationToken = default) => + public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, string password, CancellationToken cancellationToken = default) => await ExtractToFileAsync(source, destinationFileName, false, password, cancellationToken).ConfigureAwait(false); - public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, bool overwrite, string? password, CancellationToken cancellationToken = default) + public static async Task ExtractToFileAsync(this ZipArchiveEntry source, string destinationFileName, bool overwrite, string password, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -124,4 +124,17 @@ internal static async Task ExtractRelativeToDirectoryAsync(this ZipArchiveEntry await source.ExtractToFileAsync(fileDestinationPath, overwrite: overwrite, cancellationToken).ConfigureAwait(false); } } + + internal static async Task ExtractRelativeToDirectoryAsync(this ZipArchiveEntry source, string destinationDirectoryName, bool overwrite, string password, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (ExtractRelativeToDirectoryCheckIfFile(source, destinationDirectoryName, out string fileDestinationPath)) + { + // If it is a file: + // Create containing directory: + Directory.CreateDirectory(Path.GetDirectoryName(fileDestinationPath)!); + await source.ExtractToFileAsync(fileDestinationPath, overwrite: overwrite, password:password, cancellationToken).ConfigureAwait(false); + } + } } diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs index 8d252fc6a0b89b..1818c98d7acfc7 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs @@ -160,14 +160,17 @@ private static bool ExtractRelativeToDirectoryCheckIfFile(ZipArchiveEntry source return true; // It is a file } - internal static void ExtractRelativeToDirectory(this ZipArchiveEntry source, string destinationDirectoryName, bool overwrite) + internal static void ExtractRelativeToDirectory(this ZipArchiveEntry source, string destinationDirectoryName, bool overwrite, string? password = null) { if (ExtractRelativeToDirectoryCheckIfFile(source, destinationDirectoryName, out string fileDestinationPath)) { // If it is a file: // Create containing directory: Directory.CreateDirectory(Path.GetDirectoryName(fileDestinationPath)!); - source.ExtractToFile(fileDestinationPath, overwrite: overwrite); + if (!string.IsNullOrEmpty(password)) + source.ExtractToFile(fileDestinationPath, overwrite: overwrite, password: password); + else + source.ExtractToFile(fileDestinationPath, overwrite: overwrite); } } } diff --git a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs index 71bcfb47d03b73..6acb4d35cc51e7 100644 --- a/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs +++ b/src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Encryption.cs @@ -1096,5 +1096,243 @@ public async Task Encryption_TrueZip64_LargeEntry_UpdateMode_Throws(ZipArchiveEn } #endregion + + #region ExtractToFile Tests for Encrypted Entries + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task ExtractToFile_EncryptedEntry_Success(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string password = "TestPassword123"; + string content = "Encrypted content for ExtractToFile test"; + var entries = new[] { ("encrypted.txt", content, (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry("encrypted.txt"); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + string destFile = GetTestFilePath(); + + if (async) + { + await entry.ExtractToFileAsync(destFile, overwrite: false, password: password); + Assert.Equal(content, await File.ReadAllTextAsync(destFile)); + } + else + { + entry.ExtractToFile(destFile, overwrite: false, password: password); + Assert.Equal(content, File.ReadAllText(destFile)); + } + } + } + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task ExtractToFile_EncryptedEntry_Overwrite_Success(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string password = "TestPassword123"; + string content = "Updated encrypted content"; + var entries = new[] { ("encrypted.txt", content, (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + string destFile = GetTestFilePath(); + // Create an existing file to be overwritten + File.WriteAllText(destFile, "Original content to be overwritten"); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry("encrypted.txt"); + Assert.NotNull(entry); + + if (async) + { + await entry.ExtractToFileAsync(destFile, overwrite: true, password: password); + Assert.Equal(content, await File.ReadAllTextAsync(destFile)); + } + else + { + entry.ExtractToFile(destFile, overwrite: true, password: password); + Assert.Equal(content, File.ReadAllText(destFile)); + } + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task ExtractToFile_EncryptedEntry_WrongPassword_Throws(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "CorrectPassword"; + var entries = new[] { ("encrypted.txt", "content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) }; + await CreateArchiveWithEntries(archivePath, entries, async); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + ZipArchiveEntry entry = archive.GetEntry("encrypted.txt"); + Assert.NotNull(entry); + string destFile = GetTestFilePath(); + + if (async) + { + await Assert.ThrowsAsync(() => entry.ExtractToFileAsync(destFile, overwrite: false, password: "WrongPassword")); + } + else + { + Assert.Throws(() => entry.ExtractToFile(destFile, overwrite: false, password: "WrongPassword")); + } + } + } + + #endregion + + #region ExtractToDirectory Tests for Encrypted Entries + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task ExtractToDirectory_MultipleEncryptedEntries_SamePassword_Success(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string password = "SharedPassword"; + var entries = new[] + { + ("file1.txt", "Content 1", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("file2.txt", "Content 2", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("subfolder/file3.txt", "Content 3", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("subfolder/nested/file4.txt", "Content 4", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) + }; + await CreateArchiveWithEntries(archivePath, entries, async); + + using TempDirectory tempDir = new TempDirectory(GetTestFilePath()); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + if (async) + { + await archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: false, password); + } + else + { + archive.ExtractToDirectory(tempDir.Path, overwriteFiles: false, password); + } + } + + // Verify all files were extracted correctly + foreach (var (name, content, _, _) in entries) + { + string extractedPath = Path.Combine(tempDir.Path, name.Replace('/', Path.DirectorySeparatorChar)); + Assert.True(File.Exists(extractedPath), $"File {name} should exist"); + Assert.Equal(content, File.ReadAllText(extractedPath)); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task ExtractToDirectory_MultipleEntries_DifferentPasswords_Throws(bool async) + { + string archivePath = GetTempArchivePath(); + // Create entries with different passwords + var entries = new[] + { + ("file1.txt", "Content 1", (string?)"Password1", (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256), + ("file2.txt", "Content 2", (string?)"Password2", (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256), + ("file3.txt", "Content 3", (string?)"Password3", (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) + }; + await CreateArchiveWithEntries(archivePath, entries, async); + + using TempDirectory tempDir = new TempDirectory(GetTestFilePath()); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + // Using Password1 should fail for file2 and file3 + if (async) + { + await Assert.ThrowsAsync(() => + archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: false, "Password1")); + } + else + { + Assert.Throws(() => + archive.ExtractToDirectory(tempDir.Path, overwriteFiles: false, "Password1")); + } + } + } + + [Theory] + [MemberData(nameof(EncryptionMethodAndBoolTestData))] + public async Task ExtractToDirectory_EncryptedWithOverwrite_Success(ZipArchiveEntry.EncryptionMethod encryptionMethod, bool async) + { + string archivePath = GetTempArchivePath(); + string password = "TestPassword"; + var entries = new[] + { + ("file1.txt", "New Content 1", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod), + ("file2.txt", "New Content 2", (string?)password, (ZipArchiveEntry.EncryptionMethod?)encryptionMethod) + }; + await CreateArchiveWithEntries(archivePath, entries, async); + + using TempDirectory tempDir = new TempDirectory(GetTestFilePath()); + + // Create existing files to be overwritten + File.WriteAllText(Path.Combine(tempDir.Path, "file1.txt"), "Old Content 1"); + File.WriteAllText(Path.Combine(tempDir.Path, "file2.txt"), "Old Content 2"); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + if (async) + { + await archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: true, password); + } + else + { + archive.ExtractToDirectory(tempDir.Path, overwriteFiles: true, password); + } + } + + // Verify files were overwritten with new content + Assert.Equal("New Content 1", File.ReadAllText(Path.Combine(tempDir.Path, "file1.txt"))); + Assert.Equal("New Content 2", File.ReadAllText(Path.Combine(tempDir.Path, "file2.txt"))); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task ExtractToDirectory_EncryptedWithoutOverwrite_ExistingFile_Throws(bool async) + { + string archivePath = GetTempArchivePath(); + string password = "TestPassword"; + var entries = new[] + { + ("file1.txt", "New Content", (string?)password, (ZipArchiveEntry.EncryptionMethod?)ZipArchiveEntry.EncryptionMethod.Aes256) + }; + await CreateArchiveWithEntries(archivePath, entries, async); + + using TempDirectory tempDir = new TempDirectory(GetTestFilePath()); + + // Create existing file that should not be overwritten + File.WriteAllText(Path.Combine(tempDir.Path, "file1.txt"), "Existing Content"); + + using (ZipArchive archive = await CallZipFileOpenRead(async, archivePath)) + { + if (async) + { + await Assert.ThrowsAsync(() => + archive.ExtractToDirectoryAsync(tempDir.Path, overwriteFiles: false, password)); + } + else + { + Assert.Throws(() => + archive.ExtractToDirectory(tempDir.Path, overwriteFiles: false, password)); + } + } + + // Verify existing file was not modified + Assert.Equal("Existing Content", File.ReadAllText(Path.Combine(tempDir.Path, "file1.txt"))); + } + + #endregion } } diff --git a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs index ed13e75e386017..9f2e098bb43348 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -128,9 +128,11 @@ internal ZipArchiveEntry() { } public void Delete() { } public System.IO.Stream Open() { throw null; } public System.IO.Stream Open(System.IO.FileAccess access) { throw null; } - public System.IO.Stream Open(string? password = null, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod = System.IO.Compression.ZipArchiveEntry.EncryptionMethod.Aes256) { throw null; } + public System.IO.Stream Open(string password) { throw null; } + public System.IO.Stream Open(string password, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public System.Threading.Tasks.Task OpenAsync(string? password, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod = System.IO.Compression.ZipArchiveEntry.EncryptionMethod.Aes256, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task OpenAsync(string password, System.IO.Compression.ZipArchiveEntry.EncryptionMethod encryptionMethod, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + public System.Threading.Tasks.Task OpenAsync(string password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public override string ToString() { throw null; } public enum EncryptionMethod : byte diff --git a/src/libraries/System.IO.Compression/src/Resources/Strings.resx b/src/libraries/System.IO.Compression/src/Resources/Strings.resx index bc35f91fe908d7..c418f2c05a806e 100644 --- a/src/libraries/System.IO.Compression/src/Resources/Strings.resx +++ b/src/libraries/System.IO.Compression/src/Resources/Strings.resx @@ -1,3 +1,4 @@ +