diff --git a/src/SharpCompress/Common/Zip/PkwareTraditionalEncryptionData.cs b/src/SharpCompress/Common/Zip/PkwareTraditionalEncryptionData.cs
index c0c64524b..70320d84a 100644
--- a/src/SharpCompress/Common/Zip/PkwareTraditionalEncryptionData.cs
+++ b/src/SharpCompress/Common/Zip/PkwareTraditionalEncryptionData.cs
@@ -1,4 +1,5 @@
using System;
+using System.Security.Cryptography;
using SharpCompress.Common.Zip.Headers;
using SharpCompress.Compressors.Deflate;
@@ -47,6 +48,44 @@ byte[] encryptionHeader
return encryptor;
}
+ ///
+ /// Creates a new PkwareTraditionalEncryptionData instance for writing encrypted data.
+ ///
+ /// The password to use for encryption.
+ /// The archive encoding.
+ /// A new encryption data instance.
+ public static PkwareTraditionalEncryptionData ForWrite(
+ string password,
+ IArchiveEncoding archiveEncoding
+ )
+ {
+ return new PkwareTraditionalEncryptionData(password, archiveEncoding);
+ }
+
+ ///
+ /// Generates the 12-byte encryption header required for PKWARE traditional encryption.
+ ///
+ /// The CRC32 of the uncompressed file data, or the last modified time high byte if using data descriptors.
+ /// The last modified time (used as verification byte when CRC is unknown).
+ /// The encrypted 12-byte header.
+ 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)
diff --git a/src/SharpCompress/Common/Zip/WinzipAesEncryptionStream.cs b/src/SharpCompress/Common/Zip/WinzipAesEncryptionStream.cs
new file mode 100644
index 000000000..55f2d00ca
--- /dev/null
+++ b/src/SharpCompress/Common/Zip/WinzipAesEncryptionStream.cs
@@ -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;
+
+///
+/// Stream that encrypts data using WinZip AES encryption and writes to an underlying stream.
+///
+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);
+ }
+
+ ///
+ /// Gets the overhead bytes added by encryption (salt + password verification + auth code).
+ ///
+ internal static int GetEncryptionOverhead(WinzipAesKeySize keySize) =>
+ GetSaltLength(keySize) + 2 + AUTH_CODE_LENGTH;
+}
diff --git a/src/SharpCompress/Common/Zip/ZipEncryptionType.cs b/src/SharpCompress/Common/Zip/ZipEncryptionType.cs
new file mode 100644
index 000000000..4efccebde
--- /dev/null
+++ b/src/SharpCompress/Common/Zip/ZipEncryptionType.cs
@@ -0,0 +1,30 @@
+namespace SharpCompress.Common.Zip;
+
+///
+/// Specifies the encryption method to use when creating encrypted ZIP archives.
+///
+public enum ZipEncryptionType
+{
+ ///
+ /// No encryption.
+ ///
+ None = 0,
+
+ ///
+ /// PKWARE Traditional (ZipCrypto) encryption.
+ /// This is the older, less secure encryption method but is widely compatible.
+ ///
+ PkwareTraditional = 1,
+
+ ///
+ /// WinZip AES-256 encryption.
+ /// This is the more secure encryption method using AES-256.
+ ///
+ Aes256 = 2,
+
+ ///
+ /// WinZip AES-128 encryption.
+ /// This uses AES-128 for encryption.
+ ///
+ Aes128 = 3,
+}
diff --git a/src/SharpCompress/Common/Zip/ZipHeaderFactory.cs b/src/SharpCompress/Common/Zip/ZipHeaderFactory.cs
index 865aba44a..69232c8d9 100644
--- a/src/SharpCompress/Common/Zip/ZipHeaderFactory.cs
+++ b/src/SharpCompress/Common/Zip/ZipHeaderFactory.cs
@@ -157,8 +157,8 @@ private void LoadHeader(ZipFileEntry entryHeader, Stream stream)
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,
diff --git a/src/SharpCompress/Writers/Zip/ZipCentralDirectoryEntry.cs b/src/SharpCompress/Writers/Zip/ZipCentralDirectoryEntry.cs
index b866b5356..b5a68b8f7 100644
--- a/src/SharpCompress/Writers/Zip/ZipCentralDirectoryEntry.cs
+++ b/src/SharpCompress/Writers/Zip/ZipCentralDirectoryEntry.cs
@@ -35,6 +35,16 @@ IArchiveEncoding archiveEncoding
internal ushort Zip64HeaderOffset { get; set; }
internal ulong HeaderOffset { get; }
+ ///
+ /// The encryption type used for this entry.
+ ///
+ internal ZipEncryptionType EncryptionType { get; set; } = ZipEncryptionType.None;
+
+ ///
+ /// The actual compression method (used when compression is WinzipAes).
+ ///
+ internal ZipCompressionMethod ActualCompression { get; set; }
+
internal uint Write(Stream outputStream)
{
var encodedFilename = archiveEncoding.Encode(fileName);
@@ -49,12 +59,29 @@ internal uint Write(Stream outputStream)
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 uint Write(Stream outputStream)
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 uint Write(Stream outputStream)
}
}
- // 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 uint Write(Stream outputStream)
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 uint Write(Stream outputStream)
outputStream.Write(intBuf.Slice(0, 4)); // VolumeNumber = 0
}
+ // Write WinZip AES extra field
+ if (
+ EncryptionType == ZipEncryptionType.Aes128
+ || EncryptionType == ZipEncryptionType.Aes256
+ )
+ {
+ Span 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)(
diff --git a/src/SharpCompress/Writers/Zip/ZipWriter.cs b/src/SharpCompress/Writers/Zip/ZipWriter.cs
index 8c4b96b6e..fcc311699 100644
--- a/src/SharpCompress/Writers/Zip/ZipWriter.cs
+++ b/src/SharpCompress/Writers/Zip/ZipWriter.cs
@@ -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 ZipWriter(Stream destination, ZipWriterOptions zipWriterOptions)
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 Stream WriteToStream(string entryPath, ZipWriterEntryOptions options)
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 Stream WriteToStream(string entryPath, ZipWriterEntryOptions options)
{
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 Stream WriteToStream(string entryPath, ZipWriterEntryOptions options)
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 @@ private void WriteDirectoryEntry(string directoryPath, ZipWriterEntryOptions opt
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 @@ private int WriteHeader(
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 @@ bool useZip64
);
}
- 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 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 @@ bool useZip64
{
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 @@ bool useZip64
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 @@ bool useZip64
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 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 @@ internal class ZipWritingStream : Stream
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 @@ internal ZipWritingStream(
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 @@ private Stream GetWriteStream(Stream writeStream)
{
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 @@ private Stream GetWriteStream(Stream writeStream)
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 @@ private Stream GetWriteStream(Stream writeStream)
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 @@ private Stream GetWriteStream(Stream writeStream)
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 @@ protected override void Dispose(bool disposing)
{
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 @@ protected override void Dispose(bool disposing)
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 override void Write(byte[] buffer, int offset, int count)
}
}
+ ///
+ /// 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.
+ ///
+ 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
}
diff --git a/src/SharpCompress/Writers/Zip/ZipWriterOptions.cs b/src/SharpCompress/Writers/Zip/ZipWriterOptions.cs
index 9aa80dd17..5b6c4687a 100644
--- a/src/SharpCompress/Writers/Zip/ZipWriterOptions.cs
+++ b/src/SharpCompress/Writers/Zip/ZipWriterOptions.cs
@@ -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 @@ internal ZipWriterOptions(WriterOptions options)
{
UseZip64 = writerOptions.UseZip64;
ArchiveComment = writerOptions.ArchiveComment;
+ Password = writerOptions.Password;
+ EncryptionType = writerOptions.EncryptionType;
}
}
@@ -80,4 +83,19 @@ public CompressionLevel DeflateCompressionLevel
/// are less than 4GiB in length.
///
public bool UseZip64 { get; set; }
+
+ ///
+ /// The password to use for encrypting the ZIP archive entries.
+ /// When set, entries will be encrypted using the specified .
+ /// If null or empty, no encryption is applied.
+ ///
+ public string? Password { get; set; }
+
+ ///
+ /// The encryption type to use when a password is set.
+ /// Defaults to .
+ /// When is set and this is ,
+ /// will be used automatically.
+ ///
+ public ZipEncryptionType EncryptionType { get; set; } = ZipEncryptionType.None;
}
diff --git a/tests/SharpCompress.Test/Zip/ZipWriterTests.cs b/tests/SharpCompress.Test/Zip/ZipWriterTests.cs
index 82e7caa2f..9bbebfa30 100644
--- a/tests/SharpCompress.Test/Zip/ZipWriterTests.cs
+++ b/tests/SharpCompress.Test/Zip/ZipWriterTests.cs
@@ -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 void Zip_Rar_Write() =>
Assert.Throws(() =>
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);
+ }
}