Add ZIP password encryption support for writing archives#1059
Conversation
Co-authored-by: adamhathcock <527620+adamhathcock@users.noreply.github.com>
Co-authored-by: adamhathcock <527620+adamhathcock@users.noreply.github.com>
|
When will it be merged into the master |
|
@copilot update this branch and validate the requirements |
Branch is up to date and all requirements validated: ✅ Build: Clean build for net10.0 (0 warnings, 0 errors)
✅ API: Works as documented in PR description - password defaults to AES-256 when EncryptionType not specified |
|
@copilot update from master and resolve the conflict |
…chiveEncoding interface Co-authored-by: adamhathcock <527620+adamhathcock@users.noreply.github.com>
Updated from master and resolved conflict in commit 0d6b0aa. Conflict Resolution:
Verification: |
# Conflicts: # src/SharpCompress/Writers/Zip/ZipWriter.cs
|
@copilot fix the build issue for net standard |
… constructor Co-authored-by: adamhathcock <527620+adamhathcock@users.noreply.github.com>
Fixed .NET Standard 2.0 build error in commit 4910d6b. Issue: Solution: Updated conditional compilation directives to use the 3-parameter constructor (password, salt, iterations) for .NET Standard 2.0 and .NET Framework, while using the more modern API for newer frameworks. Verification: |
There was a problem hiding this comment.
Pull request overview
This pull request adds comprehensive support for password-based encryption when writing ZIP archives. The implementation supports three encryption methods: PKWARE Traditional (ZipCrypto), WinZip AES-128, and WinZip AES-256.
Changes:
- Added
PasswordandEncryptionTypeproperties toZipWriterOptionsto enable encryption configuration - Implemented
WinzipAesEncryptionStreamfor AES encryption using CTR mode with HMAC-SHA1 authentication - Extended
PkwareTraditionalEncryptionDatawithForWritefactory method and encryption header generation - Modified
ZipWriterto integrate encryption into the stream composition pipeline - Added comprehensive tests for AES-128, AES-256, and multiple file scenarios
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/SharpCompress/Common/Zip/ZipEncryptionType.cs | New enum defining encryption types (None, PkwareTraditional, Aes128, Aes256) |
| src/SharpCompress/Common/Zip/WinzipAesEncryptionStream.cs | New encryption stream implementing WinZip AES encryption with CTR mode and HMAC-SHA1 |
| src/SharpCompress/Common/Zip/PkwareTraditionalEncryptionData.cs | Added ForWrite factory method and GenerateEncryptionHeader for PKWARE encryption |
| src/SharpCompress/Common/Zip/ZipHeaderFactory.cs | Minor refactoring to use ReadFully for salt and password verification |
| src/SharpCompress/Writers/Zip/ZipWriterOptions.cs | Added Password and EncryptionType properties with documentation |
| src/SharpCompress/Writers/Zip/ZipWriter.cs | Core encryption implementation including stream composition, header updates, and flag management |
| src/SharpCompress/Writers/Zip/ZipCentralDirectoryEntry.cs | Added encryption metadata (EncryptionType, ActualCompression) and AES extra field writing |
| tests/SharpCompress.Test/Zip/ZipWriterTests.cs | Added 5 comprehensive tests covering AES-128, AES-256, no compression, multiple files, and default encryption |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Dispose encryption stream to finalize encryption (e.g., write auth code for AES) | ||
| encryptionStream?.Dispose(); | ||
|
|
There was a problem hiding this comment.
The disposal order is incorrect. The code disposes writeStream first (which is the compression stream), then tries to dispose encryptionStream separately. However, writeStream wraps encryptionStream, so disposing writeStream will already dispose encryptionStream as its underlying stream. This causes a double-dispose issue.
The correct approach would be to only dispose writeStream, which will properly cascade the disposal down through the stream chain. Remove the explicit disposal of encryptionStream at line 648.
| // Dispose encryption stream to finalize encryption (e.g., write auth code for AES) | |
| encryptionStream?.Dispose(); |
| originalStream.Position = (long)(entry.HeaderOffset + 6); | ||
| originalStream.WriteByte(0); | ||
| // Only the Encrypted flag should be in the low byte for seekable streams | ||
| originalStream.WriteByte( | ||
| encryptionType != ZipEncryptionType.None | ||
| ? (byte)HeaderFlags.Encrypted | ||
| : (byte)0 | ||
| ); |
There was a problem hiding this comment.
The EFS (UTF-8 encoding) flag is not being preserved when updating the local header for seekable streams. The code only writes the Encrypted flag (bit 0) to the low byte, but HeaderFlags is a 16-bit value and the EFS flag is bit 11 (0x0800) in the high byte. This means that if UTF-8 encoding is used, the EFS flag will be cleared when the header is updated.
The fix should write both bytes of the flags value, preserving both the Encrypted flag and the EFS flag. The flags should be calculated similarly to how they're set in WriteHeader (lines 313-321), considering the original encoding setting.
| var content = reader.ReadToEnd(); | ||
|
|
||
| Assert.Equal(testContent, content); | ||
| } |
There was a problem hiding this comment.
No test coverage for PkwareTraditional encryption. While the implementation includes support for PKWARE Traditional encryption (as seen in lines 556-575 of ZipWriter.cs), there are no tests that verify this encryption type works correctly. This is a significant gap in test coverage since it's one of the three encryption types supported by this feature.
Add a test case similar to the existing AES tests that writes and reads a ZIP file using ZipEncryptionType.PkwareTraditional to ensure this encryption method works correctly.
| #if NETFRAMEWORK || NETSTANDARD2_0 | ||
| var rfc2898 = new Rfc2898DeriveBytes(password, salt, RFC2898_ITERATIONS); | ||
| var keyBytes = rfc2898.GetBytes(keyLength); | ||
| var ivBytes = rfc2898.GetBytes(keyLength); | ||
| var passwordVerifyValue = rfc2898.GetBytes(2); | ||
| #elif NET10_0_OR_GREATER | ||
| var derivedKeySize = (keyLength * 2) + 2; | ||
| var passwordBytes = Encoding.UTF8.GetBytes(password); | ||
| var derivedKey = Rfc2898DeriveBytes.Pbkdf2( | ||
| passwordBytes, | ||
| salt, | ||
| RFC2898_ITERATIONS, | ||
| HashAlgorithmName.SHA1, | ||
| derivedKeySize | ||
| ); | ||
| var keyBytes = derivedKey.AsSpan(0, keyLength).ToArray(); | ||
| var ivBytes = derivedKey.AsSpan(keyLength, keyLength).ToArray(); | ||
| var passwordVerifyValue = derivedKey.AsSpan(keyLength * 2, 2).ToArray(); | ||
| #else | ||
| var rfc2898 = new Rfc2898DeriveBytes( | ||
| password, | ||
| salt, | ||
| RFC2898_ITERATIONS, | ||
| HashAlgorithmName.SHA1 | ||
| ); | ||
| var keyBytes = rfc2898.GetBytes(keyLength); | ||
| var ivBytes = rfc2898.GetBytes(keyLength); | ||
| var passwordVerifyValue = rfc2898.GetBytes(2); | ||
| #endif |
There was a problem hiding this comment.
The Rfc2898DeriveBytes instance is not being disposed in the NETFRAMEWORK/NETSTANDARD2_0 and the else branch. Rfc2898DeriveBytes implements IDisposable and should be disposed after use to properly release cryptographic resources.
Wrap the Rfc2898DeriveBytes instantiation in a using statement for both branches (lines 44 and 62-67) to ensure proper resource disposal.
| ); | ||
| // Write the encryption header (12 bytes) | ||
| // CRC is not known yet, so we use 0 for now (it gets verified with time for streaming) | ||
| var header = encryptor.GenerateEncryptionHeader(0, 0); |
There was a problem hiding this comment.
The PKWARE Traditional encryption header is generated with incorrect parameters. The second parameter (lastModifiedTime) is hardcoded as 0, but according to the PKZIP specification, when CRC is unknown (streaming mode), the verification byte should be the high byte of the file's last modification time.
The code should extract the DOS time from entry.ModificationTime and pass the low 16 bits (time portion) as the lastModifiedTime parameter. For example: (ushort)(entry.ModificationTime.DateTimeToDosTime() & 0xFFFF)
| var header = encryptor.GenerateEncryptionHeader(0, 0); | |
| ushort lastModifiedTime = (ushort)(entry.ModificationTime.DateTimeToDosTime() & 0xFFFF); | |
| var header = encryptor.GenerateEncryptionHeader(0, lastModifiedTime); |
| foreach (var entry in entries) | ||
| { | ||
| using var entryStream = entry.OpenEntryStream(); | ||
| using var reader = new StreamReader(entryStream); | ||
| var content = reader.ReadToEnd(); | ||
| Assert.Contains("Content of file", content); | ||
| } |
There was a problem hiding this comment.
This foreach loop immediately maps its iteration variable to another variable - consider mapping the sequence explicitly using '.Select(...)'.
| var rfc2898 = new Rfc2898DeriveBytes( | ||
| password, | ||
| salt, | ||
| RFC2898_ITERATIONS, | ||
| HashAlgorithmName.SHA1 | ||
| ); |
There was a problem hiding this comment.
Disposable 'Rfc2898DeriveBytes' is created but not disposed.
| if (!string.IsNullOrEmpty(password)) | ||
| { | ||
| // If password is set but encryption type is None, default to AES-256 | ||
| encryptionType = | ||
| zipWriterOptions.EncryptionType == ZipEncryptionType.None | ||
| ? ZipEncryptionType.Aes256 | ||
| : zipWriterOptions.EncryptionType; | ||
| } | ||
| else | ||
| { | ||
| encryptionType = ZipEncryptionType.None; | ||
| } |
There was a problem hiding this comment.
Both branches of this 'if' statement write to the same variable - consider using '?' to express intent better.
| if (!string.IsNullOrEmpty(password)) | |
| { | |
| // If password is set but encryption type is None, default to AES-256 | |
| encryptionType = | |
| zipWriterOptions.EncryptionType == ZipEncryptionType.None | |
| ? ZipEncryptionType.Aes256 | |
| : zipWriterOptions.EncryptionType; | |
| } | |
| else | |
| { | |
| encryptionType = ZipEncryptionType.None; | |
| } | |
| encryptionType = !string.IsNullOrEmpty(password) | |
| ? (zipWriterOptions.EncryptionType == ZipEncryptionType.None | |
| ? ZipEncryptionType.Aes256 | |
| : zipWriterOptions.EncryptionType) | |
| : ZipEncryptionType.None; |
|
Eyoo! Any update on this? |
Original prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.