Merge pull request #1476 from jaysanati/feature/7z-write-encryption-master

SevenZipWriter: password protection (7zAES) and encrypted headers
This commit is contained in:
Adam Hathcock
2026-10-26 13:17:32 +00:00
committed by GitHub
9 changed files with 723 additions and 74 deletions

View File

@@ -0,0 +1,58 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using SharpCompress.Compressors.LZMA;
namespace SharpCompress.Common.SevenZip;
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Creates an encoder for one folder and returns the AES coder properties to store with it.
/// </summary>
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);
}
}

View File

@@ -22,7 +22,8 @@ internal sealed class PackedStream
/// <summary>
/// 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 <see cref="SevenZipAesEncryption" />,
/// the compressed data is also encrypted (LZMA/LZMA2 -> 7zAES), as 7-Zip does.
/// </summary>
internal sealed class SevenZipStreamsCompressor(Stream outputStream)
{
@@ -33,10 +34,12 @@ internal sealed class SevenZipStreamsCompressor(Stream outputStream)
/// <param name="inputStream">Uncompressed data to compress.</param>
/// <param name="compressionType">Compression method (LZMA or LZMA2).</param>
/// <param name="encoderProperties">LZMA encoder properties (null for defaults).</param>
/// <param name="encryption">7zAES settings, or null to store the data unencrypted.</param>
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)
/// <param name="inputStream">Uncompressed data to compress.</param>
/// <param name="compressionType">Compression method (LZMA or LZMA2).</param>
/// <param name="encoderProperties">LZMA encoder properties (null for defaults).</param>
/// <param name="encryption">7zAES settings, or null to store the data unencrypted.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async ValueTask<PackedStream> 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

View File

@@ -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.

View File

@@ -0,0 +1,201 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
namespace SharpCompress.Compressors.LZMA;
/// <summary>
/// Write-only 7zAES encoder: AES-256-CBC without padding mode. The final partial block is
/// zero-filled to 16 bytes by <see cref="Finish" />; readers trim it using the coder's unpack
/// size, as <see cref="AesDecoderStream" /> does. Callers must call <see cref="Finish" /> or
/// <see cref="FinishAsync" />; disposing never writes, so it can't hide an earlier exception.
/// Does not dispose the output stream.
/// </summary>
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);
}
/// <summary>
/// Bytes written to this stream before padding: the unpack size of the AES coder.
/// </summary>
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);
}
}
}
/// <summary>
/// Pads and writes the final partial block. Must be called once all data has been written.
/// </summary>
public void Finish()
{
var encrypted = FinishBlock();
if (encrypted > 0)
{
_output.Write(_buffer, 0, encrypted);
}
}
/// <summary>
/// Asynchronously pads and writes the final partial block.
/// </summary>
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);
}
/// <summary>
/// Encrypts as many whole blocks as fit into <see cref="_buffer" /> from the pending block
/// and the caller's data, advancing <paramref name="offset" /> and <paramref name="count" />.
/// Returns the number of encrypted bytes now in <see cref="_buffer" />.
/// </summary>
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."
);
}
}
}

View File

@@ -0,0 +1,60 @@
using System.Security.Cryptography;
namespace SharpCompress.Compressors.LZMA;
/// <summary>
/// 7zAES key derivation, shared by <see cref="AesDecoderStream" /> and the 7z writer.
/// </summary>
internal static class SevenZipAesKey
{
/// <summary>
/// 7-Zip's default for new archives: 2^19 SHA-256 rounds.
/// </summary>
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;
}
}
}

View File

@@ -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);

View File

@@ -24,6 +24,7 @@ public partial class SevenZipWriter : AbstractWriter
private readonly List<PackedStream> packedStreams = [];
private bool finalized;
private bool _placeholderWritten;
private SevenZipAesEncryption? _encryption;
/// <summary>
/// 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);
}
/// <summary>
/// 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.
/// </summary>
private SevenZipAesEncryption? Encryption =>
sevenZipOptions.Password is null
? null
: _encryption ??= SevenZipAesEncryption.Create(sevenZipOptions.Password);
private SevenZipAesEncryption? HeaderEncryption =>
sevenZipOptions.EncryptHeader ? Encryption : null;
/// <summary>
/// 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)

View File

@@ -86,6 +86,21 @@ public sealed record SevenZipWriterOptions : IWriterOptions
/// </summary>
public LzmaEncoderProperties? LzmaProperties { get; set; }
/// <summary>
/// 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.
/// </summary>
public string? Password { get; set; }
/// <summary>
/// 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 <see cref="Password" />.
/// The encrypted header is always LZMA-compressed, whatever <see cref="CompressHeader" /> says.
/// Default is false, matching the 7-Zip command line.
/// </summary>
public bool EncryptHeader { get; set; }
/// <summary>
/// Creates a new SevenZipWriterOptions instance with LZMA2 compression (default).
/// </summary>

View File

@@ -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<string, byte[]>();
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<CryptographicException>(() => Read(large));
}
[Fact]
public async ValueTask SevenZipWriter_EncryptedHeader_HidesEntriesWithoutPassword()
{
var archive = await WriteArchive(
new SevenZipWriterOptions { Password = Password, EncryptHeader = true },
useAsync: false
);
Assert.Throws<CryptographicException>(() =>
{
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<CryptographicException>(() =>
{
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<ArgumentException>(() =>
new SevenZipWriter(output, new SevenZipWriterOptions { EncryptHeader = true })
);
}
[Fact]
public void SevenZipWriter_EmptyPassword_Throws()
{
using var output = new MemoryStream();
Assert.Throws<ArgumentException>(() =>
new SevenZipWriter(output, new SevenZipWriterOptions { Password = "" })
);
}
private static async ValueTask<byte[]> 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;
}
}