Compare commits

...

8 Commits

Author SHA1 Message Date
Adam Hathcock
0b6831b3f2 minor update 2026-01-12 10:23:22 +00:00
Adam Hathcock
c64cc98ca7 back to simpler include 2026-01-11 12:15:42 +00:00
copilot-swe-agent[bot]
4910d6b567 Fix .NET Standard 2.0 build error - use compatible Rfc2898DeriveBytes constructor
Co-authored-by: adamhathcock <527620+adamhathcock@users.noreply.github.com>
2026-01-10 20:21:01 +00:00
Adam Hathcock
68279e0a2b Merge branch 'master' into copilot/add-password-support-zip-files
# Conflicts:
#	src/SharpCompress/Writers/Zip/ZipWriter.cs
2026-01-09 10:55:16 +00:00
copilot-swe-agent[bot]
0d6b0aa1ab Merge from master and resolve conflicts - update ZStandard API and ArchiveEncoding interface
Co-authored-by: adamhathcock <527620+adamhathcock@users.noreply.github.com>
2026-01-09 10:11:42 +00:00
copilot-swe-agent[bot]
42bbfa08a9 Add documentation for NonDisposingStream class
Co-authored-by: adamhathcock <527620+adamhathcock@users.noreply.github.com>
2025-12-02 09:37:51 +00:00
copilot-swe-agent[bot]
6efb8be03a Add ZIP password encryption support with WinZip AES-128/AES-256
Co-authored-by: adamhathcock <527620+adamhathcock@users.noreply.github.com>
2025-12-02 09:34:57 +00:00
copilot-swe-agent[bot]
94b4100a88 Initial plan 2025-12-02 09:01:56 +00:00
8 changed files with 798 additions and 29 deletions

View File

@@ -1,4 +1,5 @@
using System;
using System.Security.Cryptography;
using SharpCompress.Common.Zip.Headers;
using SharpCompress.Compressors.Deflate;
@@ -47,6 +48,44 @@ internal class PkwareTraditionalEncryptionData
return encryptor;
}
/// <summary>
/// Creates a new PkwareTraditionalEncryptionData instance for writing encrypted data.
/// </summary>
/// <param name="password">The password to use for encryption.</param>
/// <param name="archiveEncoding">The archive encoding.</param>
/// <returns>A new encryption data instance.</returns>
public static PkwareTraditionalEncryptionData ForWrite(
string password,
IArchiveEncoding archiveEncoding
)
{
return new PkwareTraditionalEncryptionData(password, archiveEncoding);
}
/// <summary>
/// Generates the 12-byte encryption header required for PKWARE traditional encryption.
/// </summary>
/// <param name="crc">The CRC32 of the uncompressed file data, or the last modified time high byte if using data descriptors.</param>
/// <param name="lastModifiedTime">The last modified time (used as verification byte when CRC is unknown).</param>
/// <returns>The encrypted 12-byte header.</returns>
public byte[] GenerateEncryptionHeader(uint crc, ushort lastModifiedTime)
{
var header = new byte[12];
// Fill first 11 bytes with random data
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(header, 0, 11);
}
// The last byte is the verification byte - high byte of CRC, or high byte of lastModifiedTime
// When streaming (UsePostDataDescriptor), we use the time as verification
header[11] = (byte)((crc >> 24) & 0xff);
// Encrypt the header
return Encrypt(header, header.Length);
}
public byte[] Decrypt(byte[] cipherText, int length)
{
if (length > cipherText.Length)

View File

@@ -0,0 +1,205 @@
using System;
using System.Buffers.Binary;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using SharpCompress.IO;
namespace SharpCompress.Common.Zip;
/// <summary>
/// Stream that encrypts data using WinZip AES encryption and writes to an underlying stream.
/// </summary>
internal class WinzipAesEncryptionStream : Stream
{
private const int BLOCK_SIZE_IN_BYTES = 16;
private const int RFC2898_ITERATIONS = 1000;
private const int AUTH_CODE_LENGTH = 10;
private readonly Stream _stream;
private readonly SymmetricAlgorithm _cipher;
private readonly ICryptoTransform _transform;
private readonly HMACSHA1 _hmac;
private readonly byte[] _counter = new byte[BLOCK_SIZE_IN_BYTES];
private readonly byte[] _counterOut = new byte[BLOCK_SIZE_IN_BYTES];
private int _nonce = 1;
private bool _isDisposed;
internal WinzipAesEncryptionStream(Stream stream, string password, WinzipAesKeySize keySize)
{
_stream = stream;
// Generate salt
var saltLength = GetSaltLength(keySize);
var salt = new byte[saltLength];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(salt);
}
// Derive keys using PBKDF2
var keyLength = GetKeyLength(keySize);
#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
// Initialize cipher
_cipher = CreateCipher(keyBytes);
var iv = new byte[BLOCK_SIZE_IN_BYTES];
_transform = _cipher.CreateEncryptor(keyBytes, iv);
// Initialize HMAC for authentication
_hmac = new HMACSHA1(ivBytes);
// Write salt and password verification value
_stream.Write(salt, 0, salt.Length);
_stream.Write(passwordVerifyValue, 0, passwordVerifyValue.Length);
}
private static int GetSaltLength(WinzipAesKeySize keySize) =>
keySize switch
{
WinzipAesKeySize.KeySize128 => 8,
WinzipAesKeySize.KeySize192 => 12,
WinzipAesKeySize.KeySize256 => 16,
_ => throw new InvalidOperationException(),
};
private static int GetKeyLength(WinzipAesKeySize keySize) =>
keySize switch
{
WinzipAesKeySize.KeySize128 => 16,
WinzipAesKeySize.KeySize192 => 24,
WinzipAesKeySize.KeySize256 => 32,
_ => throw new InvalidOperationException(),
};
private static SymmetricAlgorithm CreateCipher(byte[] keyBytes)
{
var cipher = Aes.Create();
cipher.BlockSize = BLOCK_SIZE_IN_BYTES * 8;
cipher.KeySize = keyBytes.Length * 8;
cipher.Mode = CipherMode.ECB;
cipher.Padding = PaddingMode.None;
return cipher;
}
public override bool CanRead => false;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override void Flush() => _stream.Flush();
public override int Read(byte[] buffer, int offset, int count) =>
throw new NotSupportedException();
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 (count == 0)
{
return;
}
var encrypted = EncryptData(buffer, offset, count);
_hmac.TransformBlock(encrypted, 0, encrypted.Length, encrypted, 0);
_stream.Write(encrypted, 0, encrypted.Length);
}
private byte[] EncryptData(byte[] buffer, int offset, int count)
{
var result = new byte[count];
var posn = 0;
while (posn < count)
{
var blockSize = Math.Min(BLOCK_SIZE_IN_BYTES, count - posn);
// Update counter
BinaryPrimitives.WriteInt32LittleEndian(_counter, _nonce++);
// Encrypt counter to get key stream
_transform.TransformBlock(_counter, 0, BLOCK_SIZE_IN_BYTES, _counterOut, 0);
// XOR with plaintext
for (var i = 0; i < blockSize; i++)
{
result[posn + i] = (byte)(_counterOut[i] ^ buffer[offset + posn + i]);
}
posn += blockSize;
}
return result;
}
protected override void Dispose(bool disposing)
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
if (disposing)
{
// Finalize HMAC and write authentication code
_hmac.TransformFinalBlock([], 0, 0);
var authCode = _hmac.Hash!;
_stream.Write(authCode, 0, AUTH_CODE_LENGTH);
_transform.Dispose();
_cipher.Dispose();
_hmac.Dispose();
_stream.Dispose();
}
base.Dispose(disposing);
}
/// <summary>
/// Gets the overhead bytes added by encryption (salt + password verification + auth code).
/// </summary>
internal static int GetEncryptionOverhead(WinzipAesKeySize keySize) =>
GetSaltLength(keySize) + 2 + AUTH_CODE_LENGTH;
}

View File

@@ -0,0 +1,30 @@
namespace SharpCompress.Common.Zip;
/// <summary>
/// Specifies the encryption method to use when creating encrypted ZIP archives.
/// </summary>
public enum ZipEncryptionType
{
/// <summary>
/// No encryption.
/// </summary>
None = 0,
/// <summary>
/// PKWARE Traditional (ZipCrypto) encryption.
/// This is the older, less secure encryption method but is widely compatible.
/// </summary>
PkwareTraditional = 1,
/// <summary>
/// WinZip AES-256 encryption.
/// This is the more secure encryption method using AES-256.
/// </summary>
Aes256 = 2,
/// <summary>
/// WinZip AES-128 encryption.
/// This uses AES-128 for encryption.
/// </summary>
Aes128 = 3,
}

View File

@@ -157,8 +157,8 @@ internal class ZipHeaderFactory
var salt = new byte[WinzipAesEncryptionData.KeyLengthInBytes(keySize) / 2];
var passwordVerifyValue = new byte[2];
stream.Read(salt, 0, salt.Length);
stream.Read(passwordVerifyValue, 0, 2);
stream.ReadFully(salt);
stream.ReadFully(passwordVerifyValue);
entryHeader.WinzipAesEncryptionData = new WinzipAesEncryptionData(
keySize,
salt,

View File

@@ -35,6 +35,16 @@ internal class ZipCentralDirectoryEntry
internal ushort Zip64HeaderOffset { get; set; }
internal ulong HeaderOffset { get; }
/// <summary>
/// The encryption type used for this entry.
/// </summary>
internal ZipEncryptionType EncryptionType { get; set; } = ZipEncryptionType.None;
/// <summary>
/// The actual compression method (used when compression is WinzipAes).
/// </summary>
internal ZipCompressionMethod ActualCompression { get; set; }
internal uint Write(Stream outputStream)
{
var encodedFilename = archiveEncoding.Encode(fileName);
@@ -49,12 +59,29 @@ internal class ZipCentralDirectoryEntry
var headeroffsetvalue = zip64 ? uint.MaxValue : (uint)HeaderOffset;
var extralength = zip64 ? (2 + 2 + 8 + 8 + 8 + 4) : 0;
// Add AES extra field length if encrypted with AES
if (
EncryptionType == ZipEncryptionType.Aes128
|| EncryptionType == ZipEncryptionType.Aes256
)
{
extralength += 2 + 2 + 7; // ID + size + data
}
// Determine version needed to extract:
// - Version 63 for LZMA, PPMd, BZip2, ZStandard (advanced compression methods)
// - Version 51 for WinZip AES encryption
// - Version 45 for Zip64 extensions (when Zip64HeaderOffset != 0 or actual sizes require it)
// - Version 20 for standard Deflate/None compression
byte version;
if (
EncryptionType == ZipEncryptionType.Aes128
|| EncryptionType == ZipEncryptionType.Aes256
)
{
version = 51;
}
else if (
compression == ZipCompressionMethod.LZMA
|| compression == ZipCompressionMethod.PPMd
|| compression == ZipCompressionMethod.BZip2
@@ -75,6 +102,13 @@ internal class ZipCentralDirectoryEntry
var flags = Equals(archiveEncoding.GetEncoding(), Encoding.UTF8)
? HeaderFlags.Efs
: HeaderFlags.None;
// Add encryption flag
if (EncryptionType != ZipEncryptionType.None)
{
flags |= HeaderFlags.Encrypted;
}
if (!outputStream.CanSeek)
{
// Cannot use data descriptors with zip64:
@@ -94,8 +128,8 @@ internal class ZipCentralDirectoryEntry
}
}
// Support for zero byte files
if (Decompressed == 0 && Compressed == 0)
// Support for zero byte files (but not for encrypted files which always have encryption overhead)
if (Decompressed == 0 && Compressed == 0 && EncryptionType == ZipEncryptionType.None)
{
usedCompression = ZipCompressionMethod.None;
}
@@ -153,11 +187,13 @@ internal class ZipCentralDirectoryEntry
outputStream.Write(intBuf.Slice(0, 4)); // Offset of header
outputStream.Write(encodedFilename, 0, encodedFilename.Length);
// Write Zip64 extra field
if (zip64)
{
BinaryPrimitives.WriteUInt16LittleEndian(intBuf, 0x0001);
outputStream.Write(intBuf.Slice(0, 2));
BinaryPrimitives.WriteUInt16LittleEndian(intBuf, (ushort)(extralength - 4));
BinaryPrimitives.WriteUInt16LittleEndian(intBuf, (ushort)(2 + 2 + 8 + 8 + 8 + 4 - 4));
outputStream.Write(intBuf.Slice(0, 2));
BinaryPrimitives.WriteUInt64LittleEndian(intBuf, Decompressed);
@@ -170,6 +206,28 @@ internal class ZipCentralDirectoryEntry
outputStream.Write(intBuf.Slice(0, 4)); // VolumeNumber = 0
}
// Write WinZip AES extra field
if (
EncryptionType == ZipEncryptionType.Aes128
|| EncryptionType == ZipEncryptionType.Aes256
)
{
Span<byte> aesExtra = stackalloc byte[11];
// Extra field ID: 0x9901 (WinZip AES)
BinaryPrimitives.WriteUInt16LittleEndian(aesExtra, 0x9901);
// Extra field data size: 7 bytes
BinaryPrimitives.WriteUInt16LittleEndian(aesExtra.Slice(2), 7);
// AES encryption version: 2 (AE-2)
BinaryPrimitives.WriteUInt16LittleEndian(aesExtra.Slice(4), 0x0002);
// Vendor ID: "AE" = 0x4541
BinaryPrimitives.WriteUInt16LittleEndian(aesExtra.Slice(6), 0x4541);
// AES encryption strength: 1=128-bit, 3=256-bit
aesExtra[8] = EncryptionType == ZipEncryptionType.Aes128 ? (byte)1 : (byte)3;
// Actual compression method
BinaryPrimitives.WriteUInt16LittleEndian(aesExtra.Slice(9), (ushort)ActualCompression);
outputStream.Write(aesExtra);
}
outputStream.Write(encodedComment, 0, encodedComment.Length);
return (uint)(

View File

@@ -27,6 +27,8 @@ public class ZipWriter : AbstractWriter
private long streamPosition;
private PpmdProperties? ppmdProps;
private readonly bool isZip64;
private readonly string? password;
private readonly ZipEncryptionType encryptionType;
public ZipWriter(Stream destination, ZipWriterOptions zipWriterOptions)
: base(ArchiveType.Zip, zipWriterOptions)
@@ -41,6 +43,21 @@ public class ZipWriter : AbstractWriter
compressionType = zipWriterOptions.CompressionType;
compressionLevel = zipWriterOptions.CompressionLevel;
// Initialize encryption settings
password = zipWriterOptions.Password;
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;
}
if (WriterOptions.LeaveStreamOpen)
{
destination = SharpCompressStream.Create(destination, leaveOpen: true);
@@ -97,8 +114,20 @@ public class ZipWriter : AbstractWriter
entryPath = NormalizeFilename(entryPath);
options.ModificationDateTime ??= DateTime.Now;
options.EntryComment ??= string.Empty;
// Determine the effective encryption type for this entry
var effectiveEncryption = encryptionType;
// For WinZip AES, the compression method in the header is set to WinzipAes,
// and the actual compression method is stored in the extra field
var headerCompression =
effectiveEncryption == ZipEncryptionType.Aes128
|| effectiveEncryption == ZipEncryptionType.Aes256
? ZipCompressionMethod.WinzipAes
: compression;
var entry = new ZipCentralDirectoryEntry(
compression,
headerCompression,
entryPath,
(ulong)streamPosition,
WriterOptions.ArchiveEncoding
@@ -106,6 +135,8 @@ public class ZipWriter : AbstractWriter
{
Comment = options.EntryComment,
ModificationTime = options.ModificationDateTime,
EncryptionType = effectiveEncryption,
ActualCompression = compression,
};
// Use the archive default setting for zip64 and allow overrides
@@ -115,14 +146,23 @@ public class ZipWriter : AbstractWriter
useZip64 = options.EnableZip64.Value;
}
var headersize = (uint)WriteHeader(entryPath, options, entry, useZip64);
var headersize = (uint)WriteHeader(
entryPath,
options,
entry,
useZip64,
effectiveEncryption,
compression
);
streamPosition += headersize;
return new ZipWritingStream(
this,
OutputStream.NotNull(),
entry,
compression,
options.CompressionLevel ?? compressionLevel
options.CompressionLevel ?? compressionLevel,
effectiveEncryption,
password
);
}
@@ -201,7 +241,15 @@ public class ZipWriter : AbstractWriter
useZip64 = options.EnableZip64.Value;
}
var headersize = (uint)WriteHeader(directoryPath, options, entry, useZip64);
// Directory entries are never encrypted
var headersize = (uint)WriteHeader(
directoryPath,
options,
entry,
useZip64,
ZipEncryptionType.None,
compression
);
streamPosition += headersize;
entries.Add(entry);
}
@@ -210,7 +258,9 @@ public class ZipWriter : AbstractWriter
string filename,
ZipWriterEntryOptions zipWriterEntryOptions,
ZipCentralDirectoryEntry entry,
bool useZip64
bool useZip64,
ZipEncryptionType encryption,
ZipCompressionMethod actualCompression
)
{
// We err on the side of caution until the zip specification clarifies how to support this
@@ -221,15 +271,30 @@ public class ZipWriter : AbstractWriter
);
}
var explicitZipCompressionInfo = ToZipCompressionMethod(
zipWriterEntryOptions.CompressionType ?? compressionType
);
// Encryption is only supported with seekable streams for now
if (!OutputStream.CanSeek && encryption != ZipEncryptionType.None)
{
throw new NotSupportedException("Encryption is not supported on non-seekable streams");
}
// Determine the compression method to write in the header
var headerCompression =
encryption == ZipEncryptionType.Aes128 || encryption == ZipEncryptionType.Aes256
? ZipCompressionMethod.WinzipAes
: actualCompression;
var encodedFilename = WriterOptions.ArchiveEncoding.Encode(filename);
Span<byte> intBuf = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(intBuf, ZipHeaderFactory.ENTRY_HEADER_BYTES);
OutputStream.Write(intBuf);
if (explicitZipCompressionInfo == ZipCompressionMethod.Deflate)
// Determine version needed
if (encryption == ZipEncryptionType.Aes128 || encryption == ZipEncryptionType.Aes256)
{
OutputStream.Write(stackalloc byte[] { 51, 0 }); // WinZip AES requires version 5.1
}
else if (actualCompression == ZipCompressionMethod.Deflate)
{
if (OutputStream.CanSeek && useZip64)
{
@@ -244,14 +309,22 @@ public class ZipWriter : AbstractWriter
{
OutputStream.Write(stackalloc byte[] { 63, 0 }); //version says we used PPMd or LZMA
}
var flags = Equals(WriterOptions.ArchiveEncoding.GetEncoding(), Encoding.UTF8)
? HeaderFlags.Efs
: 0;
: HeaderFlags.None;
// Add encryption flag
if (encryption != ZipEncryptionType.None)
{
flags |= HeaderFlags.Encrypted;
}
if (!OutputStream.CanSeek)
{
flags |= HeaderFlags.UsePostDataDescriptor;
if (explicitZipCompressionInfo == ZipCompressionMethod.LZMA)
if (actualCompression == ZipCompressionMethod.LZMA)
{
flags |= HeaderFlags.Bit1; // eos marker
}
@@ -259,7 +332,7 @@ public class ZipWriter : AbstractWriter
BinaryPrimitives.WriteUInt16LittleEndian(intBuf, (ushort)flags);
OutputStream.Write(intBuf.Slice(0, 2));
BinaryPrimitives.WriteUInt16LittleEndian(intBuf, (ushort)explicitZipCompressionInfo);
BinaryPrimitives.WriteUInt16LittleEndian(intBuf, (ushort)headerCompression);
OutputStream.Write(intBuf.Slice(0, 2)); // zipping method
BinaryPrimitives.WriteUInt32LittleEndian(
intBuf,
@@ -274,22 +347,49 @@ public class ZipWriter : AbstractWriter
BinaryPrimitives.WriteUInt16LittleEndian(intBuf, (ushort)encodedFilename.Length);
OutputStream.Write(intBuf.Slice(0, 2)); // filename length
// Calculate extra field length
var extralength = 0;
if (OutputStream.CanSeek && useZip64)
{
extralength = 2 + 2 + 8 + 8;
extralength += 2 + 2 + 8 + 8; // Zip64 extra field
}
// WinZip AES extra field: 2 (id) + 2 (size) + 2 (version) + 2 (vendor) + 1 (strength) + 2 (actual compression)
if (encryption == ZipEncryptionType.Aes128 || encryption == ZipEncryptionType.Aes256)
{
extralength += 2 + 2 + 7;
}
BinaryPrimitives.WriteUInt16LittleEndian(intBuf, (ushort)extralength);
OutputStream.Write(intBuf.Slice(0, 2)); // extra length
OutputStream.Write(encodedFilename, 0, encodedFilename.Length);
if (extralength != 0)
// Write Zip64 extra field
if (OutputStream.CanSeek && useZip64)
{
OutputStream.Write(new byte[extralength], 0, extralength); // reserve space for zip64 data
OutputStream.Write(new byte[2 + 2 + 8 + 8], 0, 2 + 2 + 8 + 8); // reserve space for zip64 data
entry.Zip64HeaderOffset = (ushort)(6 + 2 + 2 + 4 + 12 + 2 + 2 + encodedFilename.Length);
}
// Write WinZip AES extra field
if (encryption == ZipEncryptionType.Aes128 || encryption == ZipEncryptionType.Aes256)
{
Span<byte> aesExtra = stackalloc byte[11];
// Extra field ID: 0x9901 (WinZip AES)
BinaryPrimitives.WriteUInt16LittleEndian(aesExtra, 0x9901);
// Extra field data size: 7 bytes
BinaryPrimitives.WriteUInt16LittleEndian(aesExtra.Slice(2), 7);
// AES encryption version: 2 (AE-2)
BinaryPrimitives.WriteUInt16LittleEndian(aesExtra.Slice(4), 0x0002);
// Vendor ID: "AE" = 0x4541
BinaryPrimitives.WriteUInt16LittleEndian(aesExtra.Slice(6), 0x4541);
// AES encryption strength: 1=128-bit, 3=256-bit
aesExtra[8] = encryption == ZipEncryptionType.Aes128 ? (byte)1 : (byte)3;
// Actual compression method
BinaryPrimitives.WriteUInt16LittleEndian(aesExtra.Slice(9), (ushort)actualCompression);
OutputStream.Write(aesExtra);
}
return 6 + 2 + 2 + 4 + 12 + 2 + 2 + encodedFilename.Length + extralength;
}
@@ -385,7 +485,10 @@ public class ZipWriter : AbstractWriter
private readonly ZipWriter writer;
private readonly ZipCompressionMethod zipCompressionMethod;
private readonly int compressionLevel;
private readonly ZipEncryptionType encryptionType;
private readonly string? password;
private SharpCompressStream? counting;
private Stream? encryptionStream;
private ulong decompressed;
// Flag to prevent throwing exceptions on Dispose
@@ -397,15 +500,18 @@ public class ZipWriter : AbstractWriter
Stream originalStream,
ZipCentralDirectoryEntry entry,
ZipCompressionMethod zipCompressionMethod,
int compressionLevel
int compressionLevel,
ZipEncryptionType encryptionType,
string? password
)
{
this.writer = writer;
this.originalStream = originalStream;
this.writer = writer;
this.entry = entry;
this.zipCompressionMethod = zipCompressionMethod;
this.compressionLevel = compressionLevel;
this.encryptionType = encryptionType;
this.password = password;
writeStream = GetWriteStream(originalStream);
}
@@ -427,6 +533,47 @@ public class ZipWriter : AbstractWriter
{
counting = new SharpCompressStream(writeStream, leaveOpen: true);
Stream output = counting;
// Wrap with encryption stream if needed
if (encryptionType == ZipEncryptionType.Aes128)
{
encryptionStream = new WinzipAesEncryptionStream(
counting,
password!,
WinzipAesKeySize.KeySize128
);
output = encryptionStream;
}
else if (encryptionType == ZipEncryptionType.Aes256)
{
encryptionStream = new WinzipAesEncryptionStream(
counting,
password!,
WinzipAesKeySize.KeySize256
);
output = encryptionStream;
}
else if (encryptionType == ZipEncryptionType.PkwareTraditional)
{
// For PKWARE traditional encryption, we need to write the encryption header
// and wrap the stream in the crypto stream
var encryptor = PkwareTraditionalEncryptionData.ForWrite(
password!,
writer.WriterOptions.ArchiveEncoding
);
// 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);
counting.Write(header, 0, header.Length);
encryptionStream = new PkwareTraditionalCryptoStream(
new NonDisposingStream(counting),
encryptor,
CryptoMode.Encrypt
);
output = encryptionStream;
}
switch (zipCompressionMethod)
{
case ZipCompressionMethod.None:
@@ -436,17 +583,24 @@ public class ZipWriter : AbstractWriter
case ZipCompressionMethod.Deflate:
{
return new DeflateStream(
counting,
output,
CompressionMode.Compress,
(CompressionLevel)compressionLevel
);
}
case ZipCompressionMethod.BZip2:
{
return new BZip2Stream(counting, CompressionMode.Compress, false);
return new BZip2Stream(output, CompressionMode.Compress, false);
}
case ZipCompressionMethod.LZMA:
{
// LZMA with encryption is not supported per ZIP spec
if (encryptionType != ZipEncryptionType.None)
{
throw new NotSupportedException(
"LZMA compression with encryption is not supported"
);
}
counting.WriteByte(9);
counting.WriteByte(20);
counting.WriteByte(5);
@@ -455,7 +609,7 @@ public class ZipWriter : AbstractWriter
var lzmaStream = new LzmaStream(
new LzmaEncoderProperties(!originalStream.CanSeek),
false,
counting
output
);
counting.Write(lzmaStream.Properties, 0, lzmaStream.Properties.Length);
return lzmaStream;
@@ -463,11 +617,11 @@ public class ZipWriter : AbstractWriter
case ZipCompressionMethod.PPMd:
{
counting.Write(writer.PpmdProperties.Properties, 0, 2);
return new PpmdStream(writer.PpmdProperties, counting, true);
return new PpmdStream(writer.PpmdProperties, output, true);
}
case ZipCompressionMethod.ZStandard:
{
return new CompressionStream(counting, compressionLevel);
return new CompressionStream(output, compressionLevel);
}
default:
{
@@ -490,6 +644,9 @@ public class ZipWriter : AbstractWriter
{
writeStream.Dispose();
// Dispose encryption stream to finalize encryption (e.g., write auth code for AES)
encryptionStream?.Dispose();
if (limitsExceeded)
{
// We have written invalid data into the archive,
@@ -511,12 +668,24 @@ public class ZipWriter : AbstractWriter
if (originalStream.CanSeek)
{
// Clear UsePostDataDescriptor flag (bit 3) since we're updating sizes in place
// But preserve the Encrypted flag (bit 0) if encryption is enabled
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
);
if (countingCount == 0 && entry.Decompressed == 0)
if (
countingCount == 0
&& entry.Decompressed == 0
&& encryptionType == ZipEncryptionType.None
)
{
// set compression to STORED for zero byte files (no compression data)
// But not if encrypted, as encrypted files always have some data
originalStream.Position = (long)(entry.HeaderOffset + 8);
originalStream.WriteByte(0);
originalStream.WriteByte(0);
@@ -638,5 +807,46 @@ public class ZipWriter : AbstractWriter
}
}
/// <summary>
/// A stream wrapper that doesn't dispose the underlying stream when disposed.
/// This is used in encryption scenarios where the crypto stream would otherwise
/// dispose the counting stream prematurely, before we can read the final count.
/// </summary>
private class NonDisposingStream : Stream
{
private readonly Stream _stream;
public NonDisposingStream(Stream stream) => _stream = stream;
public override bool CanRead => _stream.CanRead;
public override bool CanSeek => _stream.CanSeek;
public override bool CanWrite => _stream.CanWrite;
public override long Length => _stream.Length;
public override long Position
{
get => _stream.Position;
set => _stream.Position = value;
}
public override void Flush() => _stream.Flush();
public override int Read(byte[] buffer, int offset, int count) =>
_stream.Read(buffer, offset, count);
public override long Seek(long offset, SeekOrigin origin) => _stream.Seek(offset, origin);
public override void SetLength(long value) => _stream.SetLength(value);
public override void Write(byte[] buffer, int offset, int count) =>
_stream.Write(buffer, offset, count);
protected override void Dispose(bool disposing)
{
// Don't dispose the underlying stream
base.Dispose(disposing);
}
}
#endregion Nested type: ZipWritingStream
}

View File

@@ -1,5 +1,6 @@
using System;
using SharpCompress.Common;
using SharpCompress.Common.Zip;
using SharpCompress.Compressors.Deflate;
using D = SharpCompress.Compressors.Deflate;
@@ -24,6 +25,8 @@ public class ZipWriterOptions : WriterOptions
{
UseZip64 = writerOptions.UseZip64;
ArchiveComment = writerOptions.ArchiveComment;
Password = writerOptions.Password;
EncryptionType = writerOptions.EncryptionType;
}
}
@@ -80,4 +83,19 @@ public class ZipWriterOptions : WriterOptions
/// are less than 4GiB in length.
/// </summary>
public bool UseZip64 { get; set; }
/// <summary>
/// The password to use for encrypting the ZIP archive entries.
/// When set, entries will be encrypted using the specified <see cref="EncryptionType"/>.
/// If null or empty, no encryption is applied.
/// </summary>
public string? Password { get; set; }
/// <summary>
/// The encryption type to use when a password is set.
/// Defaults to <see cref="ZipEncryptionType.None"/>.
/// When <see cref="Password"/> is set and this is <see cref="ZipEncryptionType.None"/>,
/// <see cref="ZipEncryptionType.Aes256"/> will be used automatically.
/// </summary>
public ZipEncryptionType EncryptionType { get; set; } = ZipEncryptionType.None;
}

View File

@@ -1,7 +1,12 @@
using System.IO;
using System.Linq;
using System.Text;
using SharpCompress.Archives.Zip;
using SharpCompress.Common;
using SharpCompress.Common.Zip;
using SharpCompress.Readers;
using SharpCompress.Writers;
using SharpCompress.Writers.Zip;
using Xunit;
namespace SharpCompress.Test.Zip;
@@ -97,4 +102,208 @@ public class ZipWriterTests : WriterTests
Assert.Throws<InvalidFormatException>(() =>
Write(CompressionType.Rar, "Zip.ppmd.noEmptyDirs.zip", "Zip.ppmd.noEmptyDirs.zip")
);
[Fact]
public void Zip_Deflate_Encrypted_Aes256_WriteAndRead()
{
const string password = "test_password";
const string testContent = "Hello, this is a test file for encrypted ZIP.";
using var memoryStream = new MemoryStream();
// Write encrypted ZIP
var options = new ZipWriterOptions(CompressionType.Deflate)
{
Password = password,
EncryptionType = ZipEncryptionType.Aes256,
};
using (var writer = new ZipWriter(memoryStream, options))
{
var contentBytes = Encoding.UTF8.GetBytes(testContent);
writer.Write("test.txt", new MemoryStream(contentBytes));
}
// Read back the encrypted ZIP
memoryStream.Position = 0;
using var archive = ZipArchive.Open(
memoryStream,
new ReaderOptions { Password = password }
);
var entry = archive.Entries.First(e => !e.IsDirectory);
Assert.Equal("test.txt", entry.Key);
using var entryStream = entry.OpenEntryStream();
using var reader = new StreamReader(entryStream);
var content = reader.ReadToEnd();
Assert.Equal(testContent, content);
}
[Fact]
public void Zip_Deflate_Encrypted_Aes128_WriteAndRead()
{
const string password = "test_password";
const string testContent = "Hello, this is a test file for encrypted ZIP with AES-128.";
using var memoryStream = new MemoryStream();
// Write encrypted ZIP
var options = new ZipWriterOptions(CompressionType.Deflate)
{
Password = password,
EncryptionType = ZipEncryptionType.Aes128,
};
using (var writer = new ZipWriter(memoryStream, options))
{
var contentBytes = Encoding.UTF8.GetBytes(testContent);
writer.Write("test.txt", new MemoryStream(contentBytes));
}
// Read back the encrypted ZIP
memoryStream.Position = 0;
using var archive = ZipArchive.Open(
memoryStream,
new ReaderOptions { Password = password }
);
var entry = archive.Entries.First(e => !e.IsDirectory);
Assert.Equal("test.txt", entry.Key);
using var entryStream = entry.OpenEntryStream();
using var reader = new StreamReader(entryStream);
var content = reader.ReadToEnd();
Assert.Equal(testContent, content);
}
[Fact]
public void Zip_None_Encrypted_Aes256_WriteAndRead()
{
const string password = "test_password";
const string testContent = "Uncompressed but encrypted content.";
using var memoryStream = new MemoryStream();
// Write encrypted ZIP with no compression
var options = new ZipWriterOptions(CompressionType.None)
{
Password = password,
EncryptionType = ZipEncryptionType.Aes256,
};
using (var writer = new ZipWriter(memoryStream, options))
{
var contentBytes = Encoding.UTF8.GetBytes(testContent);
writer.Write("uncompressed.txt", new MemoryStream(contentBytes));
}
// Read back the encrypted ZIP
memoryStream.Position = 0;
using var archive = ZipArchive.Open(
memoryStream,
new ReaderOptions { Password = password }
);
var entry = archive.Entries.First(e => !e.IsDirectory);
Assert.Equal("uncompressed.txt", entry.Key);
using var entryStream = entry.OpenEntryStream();
using var reader = new StreamReader(entryStream);
var content = reader.ReadToEnd();
Assert.Equal(testContent, content);
}
[Fact]
public void Zip_Encrypted_MultipleFiles_WriteAndRead()
{
const string password = "multi_file_password";
using var memoryStream = new MemoryStream();
// Write encrypted ZIP with multiple files
var options = new ZipWriterOptions(CompressionType.Deflate)
{
Password = password,
EncryptionType = ZipEncryptionType.Aes256,
};
using (var writer = new ZipWriter(memoryStream, options))
{
writer.Write(
"file1.txt",
new MemoryStream(Encoding.UTF8.GetBytes("Content of file 1"))
);
writer.Write(
"file2.txt",
new MemoryStream(Encoding.UTF8.GetBytes("Content of file 2"))
);
writer.Write(
"folder/file3.txt",
new MemoryStream(Encoding.UTF8.GetBytes("Content of file 3"))
);
}
// Read back the encrypted ZIP
memoryStream.Position = 0;
using var archive = ZipArchive.Open(
memoryStream,
new ReaderOptions { Password = password }
);
var entries = archive.Entries.Where(e => !e.IsDirectory).ToList();
Assert.Equal(3, entries.Count);
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);
}
}
[Fact]
public void Zip_Encrypted_DefaultEncryption_WhenPasswordSet()
{
const string password = "auto_encryption";
const string testContent = "Auto encryption type test.";
using var memoryStream = new MemoryStream();
// Write ZIP with password but no explicit encryption type
// Should default to AES-256
var options = new ZipWriterOptions(CompressionType.Deflate)
{
Password = password,
// EncryptionType not set, should default to AES-256 when password is provided
};
using (var writer = new ZipWriter(memoryStream, options))
{
writer.Write("auto.txt", new MemoryStream(Encoding.UTF8.GetBytes(testContent)));
}
// Read back the encrypted ZIP
memoryStream.Position = 0;
using var archive = ZipArchive.Open(
memoryStream,
new ReaderOptions { Password = password }
);
var entry = archive.Entries.First(e => !e.IsDirectory);
using var entryStream = entry.OpenEntryStream();
using var reader = new StreamReader(entryStream);
var content = reader.ReadToEnd();
Assert.Equal(testContent, content);
}
}