mirror of
https://github.com/adamhathcock/sharpcompress.git
synced 2026-11-15 17:02:03 +00:00
Merge pull request #1476 from jaysanati/feature/7z-write-encryption-master
SevenZipWriter: password protection (7zAES) and encrypted headers
This commit is contained in:
58
src/SharpCompress/Common/SevenZip/SevenZipAesEncryption.cs
Normal file
58
src/SharpCompress/Common/SevenZip/SevenZipAesEncryption.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
201
src/SharpCompress/Compressors/LZMA/AesEncoderStream.cs
Normal file
201
src/SharpCompress/Compressors/LZMA/AesEncoderStream.cs
Normal 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
60
src/SharpCompress/Compressors/LZMA/SevenZipAesKey.cs
Normal file
60
src/SharpCompress/Compressors/LZMA/SevenZipAesKey.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user