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