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); + } }