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