From a7d3b100a0b30dd51b69a3e9295d321d180feec9 Mon Sep 17 00:00:00 2001 From: Jay Sanati Date: Fri, 23 Oct 2026 22:36:26 -0700 Subject: [PATCH 1/2] Report the codec, not 7zAES, as an encrypted 7z entry's CompressionType 7-Zip writes 7zAES as the first coder of an encrypted folder, in front of the codec. SevenZipFilePart.GetCompression() looked at the first coder only, so CompressionType threw InvalidFormatException for every entry of a 7-Zip-made encrypted archive. Skip the AES filter and report the first real coder. Co-Authored-By: Claude Opus 5.5 --- .../Common/SevenZip/SevenZipFilePart.cs | 6 ++++- .../SevenZip/SevenZipArchiveTests.cs | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs b/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs index 2b3f6af3..2ab660e6 100644 --- a/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs +++ b/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs @@ -119,7 +119,11 @@ internal class SevenZipFilePart : FilePart return CompressionType.None; } - var coder = Folder.NotNull()._coders.First(); + // 7zAES is a filter in front of the codec (7-Zip writes it as coder 0), so it says + // nothing about the compression; report the first real coder instead. + var coder = + Folder.NotNull()._coders.FirstOrDefault(c => c._methodId._id != CMethodId.K_AES_ID) + ?? throw new InvalidFormatException(); return coder._methodId._id switch { K_COPY => CompressionType.None, diff --git a/tests/SharpCompress.Test/SevenZip/SevenZipArchiveTests.cs b/tests/SharpCompress.Test/SevenZip/SevenZipArchiveTests.cs index dc3b1c93..05aea802 100644 --- a/tests/SharpCompress.Test/SevenZip/SevenZipArchiveTests.cs +++ b/tests/SharpCompress.Test/SevenZip/SevenZipArchiveTests.cs @@ -105,6 +105,29 @@ public class SevenZipArchiveTests : ArchiveTests new SevenZipFactory() ); + [Theory] + [InlineData("7Zip.LZMA.Aes.7z")] + [InlineData("7Zip.LZMA2.Aes.7z")] + public void SevenZipArchive_Aes_ReportsCompressionType(string testArchive) + { + // 7-Zip writes 7zAES as the folder's first coder, in front of the codec. + using var stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, testArchive)); + using var archive = SevenZipArchive.OpenArchive( + stream, + ReaderOptions.ForExternalStream with + { + Password = "testpassword", + } + ); + var files = archive.Entries.Where(entry => !entry.IsDirectory && entry.Size > 0).ToList(); + Assert.NotEmpty(files); + foreach (var entry in files) + { + Assert.True(entry.IsEncrypted, entry.Key); + Assert.Equal(CompressionType.LZMA, entry.CompressionType); + } + } + [Fact] public void SevenZipArchive_LZMA2AES_StreamRead() => ArchiveStreamRead( From 521a33688ae3e66f4421cd97c29e7b9d8e3f38b5 Mon Sep 17 00:00:00 2001 From: Jay Sanati Date: Fri, 23 Oct 2026 22:36:26 -0700 Subject: [PATCH 2/2] Add 7z write encryption (7zAES) and encrypted headers SevenZipWriterOptions gains Password and EncryptHeader (#1472). With a password, each folder is written as 7zAES -> LZMA/LZMA2 in the same layout 7-Zip uses: AES is coder 0 and reads the packed stream, the codec is coder 1 and reads AES's output. AES properties match 7-Zip's defaults (NumCyclesPower 19, no salt, random 16-byte IV per folder), so the key is derived once per archive. EncryptHeader runs the header through the same chain; an encrypted header is always LZMA-compressed. Empty files have no stream and stay unencrypted, as in 7-Zip. - SevenZipAesKey: the 7zAES KDF, moved unchanged from AesDecoderStream so the encoder and decoder share it. - AesEncoderStream: AES-256-CBC, zero-fills the last block on Finish. - SevenZipAesEncryption: per-archive key and per-folder IV/properties. - EncryptHeader without a password, or an empty password, throws ArgumentException. Uses only Aes (CBC) and SHA256, available on every target framework and Native AOT safe. Verified with 7-Zip 26.05 (7z t, 7z x, 7z l -slt) and WinRAR for LZMA and LZMA2, with and without header encryption. Co-Authored-By: Claude Opus 5.5 --- .../Common/SevenZip/SevenZipAesEncryption.cs | 58 ++++ .../SevenZip/SevenZipStreamsCompressor.cs | 106 ++++++-- .../Compressors/LZMA/AesDecoderStream.cs | 48 +--- .../Compressors/LZMA/AesEncoderStream.cs | 201 ++++++++++++++ .../Compressors/LZMA/SevenZipAesKey.cs | 60 ++++ .../Writers/SevenZip/SevenZipWriter.Async.cs | 14 +- .../Writers/SevenZip/SevenZipWriter.cs | 38 ++- .../Writers/SevenZip/SevenZipWriterOptions.cs | 15 + .../SevenZip/SevenZipWriterEncryptionTests.cs | 257 ++++++++++++++++++ 9 files changed, 723 insertions(+), 74 deletions(-) create mode 100644 src/SharpCompress/Common/SevenZip/SevenZipAesEncryption.cs create mode 100644 src/SharpCompress/Compressors/LZMA/AesEncoderStream.cs create mode 100644 src/SharpCompress/Compressors/LZMA/SevenZipAesKey.cs create mode 100644 tests/SharpCompress.Test/SevenZip/SevenZipWriterEncryptionTests.cs diff --git a/src/SharpCompress/Common/SevenZip/SevenZipAesEncryption.cs b/src/SharpCompress/Common/SevenZip/SevenZipAesEncryption.cs new file mode 100644 index 00000000..e136c662 --- /dev/null +++ b/src/SharpCompress/Common/SevenZip/SevenZipAesEncryption.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using SharpCompress.Compressors.LZMA; + +namespace SharpCompress.Common.SevenZip; + +/// +/// 7zAES settings for one archive being written. The key is derived once, without a salt, and +/// every folder gets its own random IV, which is what 7-Zip does by default. +/// +internal sealed class SevenZipAesEncryption +{ + private const int IvSize = 16; + + private readonly byte[] _key; + private readonly int _numCyclesPower; + + private SevenZipAesEncryption(byte[] key, int numCyclesPower) + { + _key = key; + _numCyclesPower = numCyclesPower; + } + + public static SevenZipAesEncryption Create( + string password, + int numCyclesPower = SevenZipAesKey.DefaultNumCyclesPower + ) + { + // 7-Zip hashes the UTF-16LE password, as AesDecoderStream expects. + var key = + SevenZipAesKey.Derive(numCyclesPower, [], Encoding.Unicode.GetBytes(password)) + ?? throw new ArchiveOperationException("7z AES key derivation failed."); + return new SevenZipAesEncryption(key, numCyclesPower); + } + + /// + /// Creates an encoder for one folder and returns the AES coder properties to store with it. + /// + public AesEncoderStream CreateEncoder(Stream output, out byte[] coderProperties) + { + var iv = new byte[IvSize]; + using (var random = RandomNumberGenerator.Create()) + { + random.GetBytes(iv); + } + + // Byte 0: 0x40 = IV present, low six bits = NumCyclesPower; no salt bit. + // Byte 1: high nibble = salt size beyond the salt bit (0), low nibble = IV size beyond + // the IV bit (15), giving a 16-byte IV. See AesDecoderStream.Init. + coderProperties = new byte[2 + IvSize]; + coderProperties[0] = (byte)(0x40 | _numCyclesPower); + coderProperties[1] = IvSize - 1; + Buffer.BlockCopy(iv, 0, coderProperties, 2, IvSize); + return new AesEncoderStream(output, _key, iv); + } +} diff --git a/src/SharpCompress/Common/SevenZip/SevenZipStreamsCompressor.cs b/src/SharpCompress/Common/SevenZip/SevenZipStreamsCompressor.cs index 61b4f45e..a12cacf1 100644 --- a/src/SharpCompress/Common/SevenZip/SevenZipStreamsCompressor.cs +++ b/src/SharpCompress/Common/SevenZip/SevenZipStreamsCompressor.cs @@ -22,7 +22,8 @@ internal sealed class PackedStream /// /// Compresses a single input stream using LZMA or LZMA2, writing compressed output /// to the archive stream. Builds the CFolder metadata describing the compression. -/// Uses SharpCompress's existing LzmaStream encoder. +/// Uses SharpCompress's existing LzmaStream encoder. With , +/// the compressed data is also encrypted (LZMA/LZMA2 -> 7zAES), as 7-Zip does. /// internal sealed class SevenZipStreamsCompressor(Stream outputStream) { @@ -33,10 +34,12 @@ internal sealed class SevenZipStreamsCompressor(Stream outputStream) /// Uncompressed data to compress. /// Compression method (LZMA or LZMA2). /// LZMA encoder properties (null for defaults). + /// 7zAES settings, or null to store the data unencrypted. public PackedStream Compress( Stream inputStream, CompressionType compressionType, - LzmaEncoderProperties? encoderProperties = null + LzmaEncoderProperties? encoderProperties = null, + SevenZipAesEncryption? encryption = null ) { var isLzma2 = compressionType == CompressionType.LZMA2; @@ -47,19 +50,25 @@ internal sealed class SevenZipStreamsCompressor(Stream outputStream) // Wrap the output stream in CRC calculator using var outCrcStream = new Crc32Stream(outputStream); + // The packed stream is the encrypted data; the codec writes into the encoder. + byte[]? aesProperties = null; + using var aesStream = encryption?.CreateEncoder(outCrcStream, out aesProperties); + Stream codecOutput = aesStream ?? (Stream)outCrcStream; + byte[] properties; if (isLzma2) { // LZMA2: use Lzma2EncoderStream for chunk-based framing using var lzma2Stream = new Lzma2EncoderStream( - outCrcStream, + codecOutput, encoderProperties.DictionarySize, encoderProperties.NumFastBytes ); CopyWithCrc(inputStream, lzma2Stream, out var inputCrc2, out var inputSize2); lzma2Stream.Dispose(); + aesStream?.Finish(); properties = lzma2Stream.Properties; @@ -69,16 +78,19 @@ internal sealed class SevenZipStreamsCompressor(Stream outputStream) (ulong)(outputStream.Position - outStartOffset), (ulong)inputSize2, inputCrc2, - outCrcStream.Crc + outCrcStream.Crc, + aesProperties, + aesStream?.InputLength ); } // LZMA - using var lzmaStream = LzmaStream.Create(encoderProperties, false, outCrcStream); + using var lzmaStream = LzmaStream.Create(encoderProperties, false, codecOutput); properties = lzmaStream.Properties; CopyWithCrc(inputStream, lzmaStream, out var inputCrc, out var inputSize); lzmaStream.Dispose(); + aesStream?.Finish(); return BuildPackedStream( isLzma2: false, @@ -86,7 +98,9 @@ internal sealed class SevenZipStreamsCompressor(Stream outputStream) (ulong)(outputStream.Position - outStartOffset), (ulong)inputSize, inputCrc, - outCrcStream.Crc + outCrcStream.Crc, + aesProperties, + aesStream?.InputLength ); } @@ -97,11 +111,13 @@ internal sealed class SevenZipStreamsCompressor(Stream outputStream) /// Uncompressed data to compress. /// Compression method (LZMA or LZMA2). /// LZMA encoder properties (null for defaults). + /// 7zAES settings, or null to store the data unencrypted. /// Cancellation token. public async ValueTask CompressAsync( Stream inputStream, CompressionType compressionType, LzmaEncoderProperties? encoderProperties = null, + SevenZipAesEncryption? encryption = null, CancellationToken cancellationToken = default ) { @@ -115,6 +131,11 @@ internal sealed class SevenZipStreamsCompressor(Stream outputStream) // Wrap the output stream in CRC calculator using var outCrcStream = new Crc32Stream(outputStream); + // The packed stream is the encrypted data; the codec writes into the encoder. + byte[]? aesProperties = null; + using var aesStream = encryption?.CreateEncoder(outCrcStream, out aesProperties); + Stream codecOutput = aesStream ?? (Stream)outCrcStream; + byte[] properties; if (isLzma2) @@ -124,7 +145,7 @@ internal sealed class SevenZipStreamsCompressor(Stream outputStream) long inputSize2; { await using var lzma2Stream = new Lzma2EncoderStream( - outCrcStream, + codecOutput, encoderProperties.DictionarySize, encoderProperties.NumFastBytes ); @@ -139,13 +160,20 @@ internal sealed class SevenZipStreamsCompressor(Stream outputStream) properties = lzma2Stream.Properties; } + if (aesStream is not null) + { + await aesStream.FinishAsync(cancellationToken).ConfigureAwait(false); + } + return BuildPackedStream( isLzma2: true, properties, (ulong)(outputStream.Position - outStartOffset), (ulong)inputSize2, inputCrc2, - outCrcStream.Crc + outCrcStream.Crc, + aesProperties, + aesStream?.InputLength ); } @@ -153,7 +181,7 @@ internal sealed class SevenZipStreamsCompressor(Stream outputStream) uint inputCrc; long inputSize; { - await using var lzmaStream = LzmaStream.Create(encoderProperties, false, outCrcStream); + await using var lzmaStream = LzmaStream.Create(encoderProperties, false, codecOutput); properties = lzmaStream.Properties; (inputCrc, inputSize) = await CopyWithCrcAsync( @@ -164,13 +192,20 @@ internal sealed class SevenZipStreamsCompressor(Stream outputStream) .ConfigureAwait(false); } + if (aesStream is not null) + { + await aesStream.FinishAsync(cancellationToken).ConfigureAwait(false); + } + return BuildPackedStream( isLzma2: false, properties, (ulong)(outputStream.Position - outStartOffset), (ulong)inputSize, inputCrc, - outCrcStream.Crc + outCrcStream.Crc, + aesProperties, + aesStream?.InputLength ); } @@ -273,23 +308,50 @@ internal sealed class SevenZipStreamsCompressor(Stream outputStream) ulong compressedSize, ulong uncompressedSize, uint inputCrc, - uint? outputCrc + uint? outputCrc, + byte[]? aesProperties, + long? codecOutputSize ) { var methodId = isLzma2 ? CMethodId.K_LZMA2 : CMethodId.K_LZMA; var folder = new CFolder(); - folder._coders.Add( - new CCoderInfo - { - _methodId = methodId, - _numInStreams = 1, - _numOutStreams = 1, - _props = properties, - } - ); - folder._packStreams.Add(0); - folder._unpackSizes.Add((long)uncompressedSize); + var codec = new CCoderInfo + { + _methodId = methodId, + _numInStreams = 1, + _numOutStreams = 1, + _props = properties, + }; + + if (aesProperties is null) + { + folder._coders.Add(codec); + folder._packStreams.Add(0); + folder._unpackSizes.Add((long)uncompressedSize); + } + else + { + // Same layout as 7-Zip: coder 0 is 7zAES and reads the packed stream (in-stream 0); + // coder 1 is LZMA/LZMA2, reads coder 0's output (in-stream 1 <- out-stream 0) and + // produces the file data. Unpack sizes are per out-stream, in coder order; the AES + // size excludes the CBC padding. + folder._coders.Add( + new CCoderInfo + { + _methodId = CMethodId.K_AES, + _numInStreams = 1, + _numOutStreams = 1, + _props = aesProperties, + } + ); + folder._coders.Add(codec); + folder._bindPairs.Add(new CBindPair { _inIndex = 1, _outIndex = 0 }); + folder._packStreams.Add(0); + folder._unpackSizes.Add(codecOutputSize.NotNull()); + folder._unpackSizes.Add((long)uncompressedSize); + } + folder._unpackCrc = inputCrc; return new PackedStream diff --git a/src/SharpCompress/Compressors/LZMA/AesDecoderStream.cs b/src/SharpCompress/Compressors/LZMA/AesDecoderStream.cs index 72f05db6..539be3b5 100644 --- a/src/SharpCompress/Compressors/LZMA/AesDecoderStream.cs +++ b/src/SharpCompress/Compressors/LZMA/AesDecoderStream.cs @@ -42,7 +42,7 @@ internal sealed partial class AesDecoderStream : DecoderStream2 Init(info, out var numCyclesPower, out var salt, out var seed); var passwordBytes = Encoding.Unicode.GetBytes(password); - var key = InitKey(numCyclesPower, salt, passwordBytes); + var key = SevenZipAesKey.Derive(numCyclesPower, salt, passwordBytes); if (key is null) { throw new ArchiveOperationException("Initialized with null key"); @@ -196,52 +196,6 @@ internal sealed partial class AesDecoderStream : DecoderStream2 } } - private byte[]? InitKey(int mNumCyclesPower, byte[] salt, byte[] pass) - { - if (mNumCyclesPower == 0x3F) - { - var key = new byte[32]; - - int pos; - for (pos = 0; pos < salt.Length; pos++) - { - key[pos] = salt[pos]; - } - - for (var i = 0; i < pass.Length && pos < 32; i++) - { - key[pos++] = pass[i]; - } - - return key; - } - else - { - using var sha = SHA256.Create(); - var counter = new byte[8]; - var numRounds = 1L << mNumCyclesPower; - for (long round = 0; round < numRounds; round++) - { - sha.TransformBlock(salt, 0, salt.Length, null, 0); - sha.TransformBlock(pass, 0, pass.Length, null, 0); - sha.TransformBlock(counter, 0, 8, null, 0); - - // This mirrors the counter so we don't have to convert long to byte[] each round. - // (It also ensures the counter is little endian, which BitConverter does not.) - for (var i = 0; i < 8; i++) - { - if (++counter[i] != 0) - { - break; - } - } - } - - sha.TransformFinalBlock(counter, 0, 0); - return sha.Hash; - } - } - private int HandleUnderflow(byte[] buffer, int offset, int count) { // If this is zero we were called to create a new underflow buffer. diff --git a/src/SharpCompress/Compressors/LZMA/AesEncoderStream.cs b/src/SharpCompress/Compressors/LZMA/AesEncoderStream.cs new file mode 100644 index 00000000..5f381b42 --- /dev/null +++ b/src/SharpCompress/Compressors/LZMA/AesEncoderStream.cs @@ -0,0 +1,201 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; + +namespace SharpCompress.Compressors.LZMA; + +/// +/// Write-only 7zAES encoder: AES-256-CBC without padding mode. The final partial block is +/// zero-filled to 16 bytes by ; readers trim it using the coder's unpack +/// size, as does. Callers must call or +/// ; disposing never writes, so it can't hide an earlier exception. +/// Does not dispose the output stream. +/// +internal sealed class AesEncoderStream : Stream +{ + private const int BlockSize = 16; + + private readonly Stream _output; + private readonly ICryptoTransform _encryptor; + private readonly byte[] _pending = new byte[BlockSize]; + private readonly byte[] _buffer = new byte[4 << 10]; + private int _pendingCount; + private bool _finished; + private bool _isDisposed; + + public AesEncoderStream(Stream output, byte[] key, byte[] iv) + { + _output = output; + using var aes = Aes.Create(); + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.None; + _encryptor = aes.CreateEncryptor(key, iv); + } + + /// + /// Bytes written to this stream before padding: the unpack size of the AES coder. + /// + public long InputLength { get; private set; } + + public override bool CanRead => false; + + public override bool CanSeek => false; + + public override bool CanWrite => true; + + public override long Length => InputLength; + + public override long Position + { + get => InputLength; + set => throw new NotSupportedException(); + } + + public override void Flush() => _output.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) => + _output.FlushAsync(cancellationToken); + + 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) + { + ThrowIfFinished(); + InputLength += count; + while (count > 0) + { + var encrypted = Encrypt(buffer, ref offset, ref count); + if (encrypted > 0) + { + _output.Write(_buffer, 0, encrypted); + } + } + } + + public override async Task WriteAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken + ) + { + ThrowIfFinished(); + InputLength += count; + while (count > 0) + { + var encrypted = Encrypt(buffer, ref offset, ref count); + if (encrypted > 0) + { + await _output + .WriteAsync(_buffer, 0, encrypted, cancellationToken) + .ConfigureAwait(false); + } + } + } + + /// + /// Pads and writes the final partial block. Must be called once all data has been written. + /// + public void Finish() + { + var encrypted = FinishBlock(); + if (encrypted > 0) + { + _output.Write(_buffer, 0, encrypted); + } + } + + /// + /// Asynchronously pads and writes the final partial block. + /// + public async ValueTask FinishAsync(CancellationToken cancellationToken) + { + var encrypted = FinishBlock(); + if (encrypted > 0) + { + await _output + .WriteAsync(_buffer, 0, encrypted, cancellationToken) + .ConfigureAwait(false); + } + } + + protected override void Dispose(bool disposing) + { + if (!_isDisposed) + { + _isDisposed = true; + if (disposing) + { + _encryptor.Dispose(); + } + } + base.Dispose(disposing); + } + + /// + /// Encrypts as many whole blocks as fit into from the pending block + /// and the caller's data, advancing and . + /// Returns the number of encrypted bytes now in . + /// + private int Encrypt(byte[] buffer, ref int offset, ref int count) + { + // Complete a partially filled block first, so CBC chaining stays in input order. + if (_pendingCount > 0 || count < BlockSize) + { + var take = Math.Min(BlockSize - _pendingCount, count); + Buffer.BlockCopy(buffer, offset, _pending, _pendingCount, take); + _pendingCount += take; + offset += take; + count -= take; + if (_pendingCount < BlockSize) + { + return 0; + } + + _pendingCount = 0; + return _encryptor.TransformBlock(_pending, 0, BlockSize, _buffer, 0); + } + + var whole = Math.Min(count & ~(BlockSize - 1), _buffer.Length); + var written = _encryptor.TransformBlock(buffer, offset, whole, _buffer, 0); + offset += whole; + count -= whole; + return written; + } + + private int FinishBlock() + { + if (_finished) + { + return 0; + } + + _finished = true; + if (_pendingCount == 0) + { + return 0; + } + + Array.Clear(_pending, _pendingCount, BlockSize - _pendingCount); + _pendingCount = 0; + return _encryptor.TransformBlock(_pending, 0, BlockSize, _buffer, 0); + } + + private void ThrowIfFinished() + { + if (_finished) + { + throw new ObjectDisposedException( + nameof(AesEncoderStream), + "Cannot write after the final block has been written." + ); + } + } +} diff --git a/src/SharpCompress/Compressors/LZMA/SevenZipAesKey.cs b/src/SharpCompress/Compressors/LZMA/SevenZipAesKey.cs new file mode 100644 index 00000000..7757891f --- /dev/null +++ b/src/SharpCompress/Compressors/LZMA/SevenZipAesKey.cs @@ -0,0 +1,60 @@ +using System.Security.Cryptography; + +namespace SharpCompress.Compressors.LZMA; + +/// +/// 7zAES key derivation, shared by and the 7z writer. +/// +internal static class SevenZipAesKey +{ + /// + /// 7-Zip's default for new archives: 2^19 SHA-256 rounds. + /// + public const int DefaultNumCyclesPower = 19; + + public static byte[]? Derive(int numCyclesPower, byte[] salt, byte[] pass) + { + if (numCyclesPower == 0x3F) + { + var key = new byte[32]; + + int pos; + for (pos = 0; pos < salt.Length; pos++) + { + key[pos] = salt[pos]; + } + + for (var i = 0; i < pass.Length && pos < 32; i++) + { + key[pos++] = pass[i]; + } + + return key; + } + else + { + using var sha = SHA256.Create(); + var counter = new byte[8]; + var numRounds = 1L << numCyclesPower; + for (long round = 0; round < numRounds; round++) + { + sha.TransformBlock(salt, 0, salt.Length, null, 0); + sha.TransformBlock(pass, 0, pass.Length, null, 0); + sha.TransformBlock(counter, 0, 8, null, 0); + + // This mirrors the counter so we don't have to convert long to byte[] each round. + // (It also ensures the counter is little endian, which BitConverter does not.) + for (var i = 0; i < 8; i++) + { + if (++counter[i] != 0) + { + break; + } + } + } + + sha.TransformFinalBlock(counter, 0, 0); + return sha.Hash; + } + } +} diff --git a/src/SharpCompress/Writers/SevenZip/SevenZipWriter.Async.cs b/src/SharpCompress/Writers/SevenZip/SevenZipWriter.Async.cs index c1cbaf6f..c8b5c3bc 100644 --- a/src/SharpCompress/Writers/SevenZip/SevenZipWriter.Async.cs +++ b/src/SharpCompress/Writers/SevenZip/SevenZipWriter.Async.cs @@ -59,7 +59,11 @@ public partial class SevenZipWriter ArchiveHeaderWriter.WriteRawHeader(headerStream, mainStreamsInfo, filesInfo); // Optionally compress the header - if (sevenZipOptions.CompressHeader && headerStream.Length > 0) + // An encrypted header travels as an encoded (compressed) header. + if ( + (sevenZipOptions.CompressHeader || sevenZipOptions.EncryptHeader) + && headerStream.Length > 0 + ) { await WriteCompressedHeaderAsync(headerStream, endOfPackedData).ConfigureAwait(false); } @@ -80,7 +84,12 @@ public partial class SevenZipWriter rawHeaderStream.Position = 0; var headerCompressor = new SevenZipStreamsCompressor(output); var headerPacked = await headerCompressor - .CompressAsync(rawHeaderStream, CompressionType.LZMA, sevenZipOptions.LzmaProperties) + .CompressAsync( + rawHeaderStream, + CompressionType.LZMA, + sevenZipOptions.LzmaProperties, + HeaderEncryption + ) .ConfigureAwait(false); // Build EncodedHeader StreamsInfo (describes how to decompress the header) @@ -197,6 +206,7 @@ public partial class SevenZipWriter progressStream, sevenZipOptions.CompressionType, sevenZipOptions.LzmaProperties, + Encryption, cancellationToken ) .ConfigureAwait(false); diff --git a/src/SharpCompress/Writers/SevenZip/SevenZipWriter.cs b/src/SharpCompress/Writers/SevenZip/SevenZipWriter.cs index cfc96cd1..e2e798cc 100644 --- a/src/SharpCompress/Writers/SevenZip/SevenZipWriter.cs +++ b/src/SharpCompress/Writers/SevenZip/SevenZipWriter.cs @@ -24,6 +24,7 @@ public partial class SevenZipWriter : AbstractWriter private readonly List packedStreams = []; private bool finalized; private bool _placeholderWritten; + private SevenZipAesEncryption? _encryption; /// /// Creates a new SevenZipWriter writing to the specified stream. @@ -40,6 +41,19 @@ public partial class SevenZipWriter : AbstractWriter ); } + if (options.Password is { Length: 0 }) + { + throw new ArgumentException( + "The 7z password must not be empty. Use null for an unencrypted archive.", + nameof(options) + ); + } + + if (options.EncryptHeader && options.Password is null) + { + throw new ArgumentException("EncryptHeader requires a Password.", nameof(options)); + } + sevenZipOptions = options; if (options.LeaveStreamOpen) @@ -50,6 +64,18 @@ public partial class SevenZipWriter : AbstractWriter InitializeStream(destination); } + /// + /// 7zAES settings for this archive, or null when no password is set. The key is derived on + /// first use, once per archive, since deriving it takes 2^19 SHA-256 rounds. + /// + private SevenZipAesEncryption? Encryption => + sevenZipOptions.Password is null + ? null + : _encryption ??= SevenZipAesEncryption.Create(sevenZipOptions.Password); + + private SevenZipAesEncryption? HeaderEncryption => + sevenZipOptions.EncryptHeader ? Encryption : null; + /// /// Ensures the placeholder signature header has been written synchronously. /// Called before the first sync write. @@ -122,7 +148,8 @@ public partial class SevenZipWriter : AbstractWriter var packed = compressor.Compress( progressStream, sevenZipOptions.CompressionType, - sevenZipOptions.LzmaProperties + sevenZipOptions.LzmaProperties, + Encryption ); // Check if the stream was actually empty (handles non-seekable streams with no data) @@ -209,7 +236,11 @@ public partial class SevenZipWriter : AbstractWriter ArchiveHeaderWriter.WriteRawHeader(headerStream, mainStreamsInfo, filesInfo); // Optionally compress the header - if (sevenZipOptions.CompressHeader && headerStream.Length > 0) + // An encrypted header travels as an encoded (compressed) header. + if ( + (sevenZipOptions.CompressHeader || sevenZipOptions.EncryptHeader) + && headerStream.Length > 0 + ) { WriteCompressedHeader(headerStream, endOfPackedData); } @@ -229,7 +260,8 @@ public partial class SevenZipWriter : AbstractWriter var headerPacked = headerCompressor.Compress( rawHeaderStream, CompressionType.LZMA, - sevenZipOptions.LzmaProperties + sevenZipOptions.LzmaProperties, + HeaderEncryption ); // Build EncodedHeader StreamsInfo (describes how to decompress the header) diff --git a/src/SharpCompress/Writers/SevenZip/SevenZipWriterOptions.cs b/src/SharpCompress/Writers/SevenZip/SevenZipWriterOptions.cs index 2a286434..0a234b90 100644 --- a/src/SharpCompress/Writers/SevenZip/SevenZipWriterOptions.cs +++ b/src/SharpCompress/Writers/SevenZip/SevenZipWriterOptions.cs @@ -86,6 +86,21 @@ public sealed record SevenZipWriterOptions : IWriterOptions /// public LzmaEncoderProperties? LzmaProperties { get; set; } + /// + /// Password for 7zAES encryption (AES-256-CBC with SHA-256 key derivation, as 7-Zip uses). + /// When set, every file's data is encrypted. Null (the default) writes an unencrypted archive. + /// Empty files have no data stream, so they stay unencrypted, as in 7-Zip. + /// + public string? Password { get; set; } + + /// + /// Also encrypts the archive header (file names, sizes, dates and folder structure), like + /// 7-Zip's -mhe=on, so nothing is visible without the password. Requires . + /// The encrypted header is always LZMA-compressed, whatever says. + /// Default is false, matching the 7-Zip command line. + /// + public bool EncryptHeader { get; set; } + /// /// Creates a new SevenZipWriterOptions instance with LZMA2 compression (default). /// diff --git a/tests/SharpCompress.Test/SevenZip/SevenZipWriterEncryptionTests.cs b/tests/SharpCompress.Test/SevenZip/SevenZipWriterEncryptionTests.cs new file mode 100644 index 00000000..e6688ae6 --- /dev/null +++ b/tests/SharpCompress.Test/SevenZip/SevenZipWriterEncryptionTests.cs @@ -0,0 +1,257 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using SharpCompress.Archives; +using SharpCompress.Archives.SevenZip; +using SharpCompress.Common; +using SharpCompress.Readers; +using SharpCompress.Test.Mocks; +using SharpCompress.Writers.SevenZip; +using Xunit; + +namespace SharpCompress.Test.SevenZip; + +public class SevenZipWriterEncryptionTests : TestBase +{ + private const string Password = "secret pässword"; + + // Not a multiple of 16 bytes and larger than the encoder's 4 KB buffer, so the final + // partial AES block and multi-chunk writes are both exercised. + private static readonly byte[] LargeContent = CreateRandom(100_003); + + private static readonly byte[] SmallContent = Encoding.UTF8.GetBytes("small file"); + + [Theory] + [InlineData(CompressionType.LZMA2, false, false)] + [InlineData(CompressionType.LZMA2, true, false)] + [InlineData(CompressionType.LZMA, false, false)] + [InlineData(CompressionType.LZMA, true, false)] + [InlineData(CompressionType.LZMA2, false, true)] + [InlineData(CompressionType.LZMA2, true, true)] + public async ValueTask SevenZipWriter_Encrypted_RoundTrip( + CompressionType compressionType, + bool encryptHeader, + bool useAsync + ) + { + var archive = await WriteArchive( + new SevenZipWriterOptions(compressionType) + { + Password = Password, + EncryptHeader = encryptHeader, + }, + useAsync + ); + + using var reader = SevenZipArchive.OpenArchive( + new MemoryStream(archive), + new ReaderOptions { Password = Password } + ); + var entries = reader.Entries.ToDictionary(entry => entry.Key!); + Assert.Equal(4, entries.Count); + Assert.True(entries["dir"].IsDirectory); + + Assert.True(entries["dir/large.bin"].IsEncrypted); + // SharpCompress reports both LZMA and LZMA2 as LZMA; the AES filter must not hide it. + Assert.Equal(CompressionType.LZMA, entries["dir/large.bin"].CompressionType); + Assert.Equal(LargeContent, Read(entries["dir/large.bin"])); + Assert.True(entries["small.txt"].IsEncrypted); + Assert.Equal(SmallContent, Read(entries["small.txt"])); + + // Empty files have no data stream, so there is nothing to encrypt (as in 7-Zip). + Assert.False(entries["empty.txt"].IsEncrypted); + Assert.Empty(Read(entries["empty.txt"])); + } + + [Fact] + public async ValueTask SevenZipWriter_Encrypted_ReadsWithForwardOnlyReader() + { + var archive = await WriteArchive( + new SevenZipWriterOptions { Password = Password, EncryptHeader = true }, + useAsync: false + ); + + using var archiveReader = SevenZipArchive.OpenArchive( + new MemoryStream(archive), + new ReaderOptions { Password = Password } + ); + using var reader = archiveReader.ExtractAllEntries(); + var contents = new Dictionary(); + while (reader.MoveToNextEntry()) + { + if (!reader.Entry.IsDirectory) + { + using var output = new MemoryStream(); + reader.WriteEntryTo(output); + contents[reader.Entry.Key!] = output.ToArray(); + } + } + + Assert.Equal(LargeContent, contents["dir/large.bin"]); + Assert.Equal(SmallContent, contents["small.txt"]); + Assert.Empty(contents["empty.txt"]); + } + + [Fact] + public async ValueTask SevenZipWriter_EncryptedData_ListsWithoutPasswordButCannotRead() + { + var archive = await WriteArchive( + new SevenZipWriterOptions { Password = Password }, + useAsync: false + ); + + using var reader = SevenZipArchive.OpenArchive(new MemoryStream(archive)); + var large = reader.Entries.Single(entry => entry.Key == "dir/large.bin"); + Assert.Equal(LargeContent.Length, large.Size); + Assert.Throws(() => Read(large)); + } + + [Fact] + public async ValueTask SevenZipWriter_EncryptedHeader_HidesEntriesWithoutPassword() + { + var archive = await WriteArchive( + new SevenZipWriterOptions { Password = Password, EncryptHeader = true }, + useAsync: false + ); + + Assert.Throws(() => + { + using var reader = SevenZipArchive.OpenArchive(new MemoryStream(archive)); + _ = reader.Entries.ToList(); + }); + + // The names are not stored in plain text anywhere in the file. + Assert.DoesNotContain("large.bin", Encoding.Unicode.GetString(archive)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async ValueTask SevenZipWriter_Encrypted_WrongPasswordFails(bool encryptHeader) + { + var archive = await WriteArchive( + new SevenZipWriterOptions { Password = Password, EncryptHeader = encryptHeader }, + useAsync: false + ); + + Assert.ThrowsAny(() => + { + using var reader = SevenZipArchive.OpenArchive( + new MemoryStream(archive), + new ReaderOptions { Password = "wrong" } + ); + foreach (var entry in reader.Entries.Where(entry => !entry.IsDirectory)) + { + Read(entry); + } + }); + } + + [Fact] + public void SevenZipWriter_EncryptedHeader_DirectoryOnlyArchive() + { + using var archiveStream = new MemoryStream(); + using ( + var writer = new SevenZipWriter( + archiveStream, + new SevenZipWriterOptions { Password = Password, EncryptHeader = true } + ) + ) + { + writer.WriteDirectory("a", DateTime.UtcNow); + writer.WriteDirectory("a/b", DateTime.UtcNow); + } + + archiveStream.Position = 0; + using var reader = SevenZipArchive.OpenArchive( + archiveStream, + new ReaderOptions { Password = Password, LeaveStreamOpen = true } + ); + Assert.Equal( + new[] { "a", "a/b" }, + reader.Entries.Select(entry => entry.Key!.TrimEnd('/')).OrderBy(key => key) + ); + } + + [Fact] + public async ValueTask SevenZipWriter_Encrypted_UsesFreshInitializationVectors() + { + var options = new SevenZipWriterOptions { Password = Password }; + var first = await WriteArchive(options, useAsync: false); + var second = await WriteArchive(options, useAsync: false); + + // Same input, password and settings: only the random IVs differ. + Assert.NotEqual(first, second); + } + + [Fact] + public void SevenZipWriter_EncryptHeaderWithoutPassword_Throws() + { + using var output = new MemoryStream(); + Assert.Throws(() => + new SevenZipWriter(output, new SevenZipWriterOptions { EncryptHeader = true }) + ); + } + + [Fact] + public void SevenZipWriter_EmptyPassword_Throws() + { + using var output = new MemoryStream(); + Assert.Throws(() => + new SevenZipWriter(output, new SevenZipWriterOptions { Password = "" }) + ); + } + + private static async ValueTask WriteArchive( + SevenZipWriterOptions options, + bool useAsync + ) + { + using var archiveStream = new MemoryStream(); + if (useAsync) + { + await using var writer = new SevenZipWriter( + new AsyncOnlyStream(archiveStream), + options + ); + await writer.WriteDirectoryAsync("dir", DateTime.UtcNow); + await writer.WriteAsync( + "dir/large.bin", + new MemoryStream(LargeContent), + DateTime.UtcNow + ); + await writer.WriteAsync("small.txt", new MemoryStream(SmallContent), DateTime.UtcNow); + await writer.WriteAsync("empty.txt", new MemoryStream(), DateTime.UtcNow); + } + else + { + using var writer = new SevenZipWriter(archiveStream, options); + writer.WriteDirectory("dir", DateTime.UtcNow); + writer.Write("dir/large.bin", new MemoryStream(LargeContent), DateTime.UtcNow); + writer.Write("small.txt", new MemoryStream(SmallContent), DateTime.UtcNow); + writer.Write("empty.txt", new MemoryStream(), DateTime.UtcNow); + } + + return archiveStream.ToArray(); + } + + private static byte[] Read(IArchiveEntry entry) + { + using var output = new MemoryStream(); + using (var content = entry.OpenEntryStream()) + { + content.CopyTo(output); + } + return output.ToArray(); + } + + private static byte[] CreateRandom(int length) + { + var bytes = new byte[length]; + new Random(1234).NextBytes(bytes); + return bytes; + } +}