Fix repeated RAR5 encrypted header key derivation

This commit is contained in:
Adam Hathcock
2026-10-02 09:39:38 +01:00
parent c083c6efd8
commit c90997e12e
5 changed files with 193 additions and 26 deletions

View File

@@ -12,8 +12,11 @@ internal class CryptKey5 : ICryptKey
const int DERIVED_KEY_LENGTH = 0x10;
const int SHA256_DIGEST_SIZE = 32;
private string _password;
private Rar5CryptoInfo _cryptoInfo;
private readonly string _password;
private readonly Rar5CryptoInfo _cryptoInfo;
private byte[]? _aesKey;
private byte[] _derivedSalt = [];
private int _derivedLg2Count;
private byte[] _pswCheck = [];
private byte[] _hashKey = [];
@@ -69,23 +72,29 @@ internal class CryptKey5 : ICryptKey
public ICryptoTransform Transformer(byte[] salt)
{
var iterations = (1 << _cryptoInfo.LG2Count); // Adjust the number of iterations as needed
var salt_rar5 = salt.Concat(new byte[] { 0, 0, 0, 1 });
var derivedKey = GenerateRarPBKDF2Key(
_password,
salt_rar5.ToArray(),
iterations,
DERIVED_KEY_LENGTH
);
_hashKey = derivedKey[1];
_pswCheck = new byte[EncryptionConstV5.SIZE_PSWCHECK];
for (var i = 0; i < SHA256_DIGEST_SIZE; i++)
// Header IVs change for each block, but the expensive KDF inputs do not.
// Snapshot the salt so in-place changes cannot reuse an unrelated key.
if (
_aesKey is null
|| _derivedLg2Count != _cryptoInfo.LG2Count
|| !_derivedSalt.SequenceEqual(salt)
)
{
_pswCheck[i % EncryptionConstV5.SIZE_PSWCHECK] ^= derivedKey[2][i];
var derivedKey = GenerateRarPBKDF2Key(
_password,
salt.Concat(new byte[] { 0, 0, 0, 1 }).ToArray(),
1 << _cryptoInfo.LG2Count,
DERIVED_KEY_LENGTH
);
_hashKey = derivedKey[1];
_pswCheck = new byte[EncryptionConstV5.SIZE_PSWCHECK];
for (var i = 0; i < SHA256_DIGEST_SIZE; i++)
{
_pswCheck[i % EncryptionConstV5.SIZE_PSWCHECK] ^= derivedKey[2][i];
}
_derivedSalt = (byte[])salt.Clone();
_derivedLg2Count = _cryptoInfo.LG2Count;
_aesKey = derivedKey[0];
}
if (_cryptoInfo.UsePswCheck && !_cryptoInfo.PswCheck.SequenceEqual(_pswCheck))
@@ -93,11 +102,11 @@ internal class CryptKey5 : ICryptKey
throw new CryptographicException("The password did not match.");
}
var aes = Aes.Create();
using var aes = Aes.Create();
aes.KeySize = AES_256;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.None;
aes.Key = derivedKey[0];
aes.Key = _aesKey;
aes.IV = _cryptoInfo.InitV;
return aes.CreateDecryptor();
}

View File

@@ -67,10 +67,8 @@ public partial class RarHeaderFactory
await _cryptInfo
.ReadInitVAsync(new AsyncMarkingBinaryReader(stream))
.ConfigureAwait(false);
var _headerKey = new CryptKey5(Options.Password!, _cryptInfo);
reader = await AsyncRarCryptoBinaryReader
.Create(stream, _headerKey, _cryptInfo.Salt)
.Create(stream, GetHeaderKey(), _cryptInfo.Salt)
.ConfigureAwait(false);
}
else
@@ -215,6 +213,7 @@ public partial class RarHeaderFactory
.ConfigureAwait(false);
IsEncrypted = true;
_cryptInfo = cryptoHeader.CryptInfo;
_headerKey = null;
return cryptoHeader;
}

View File

@@ -13,6 +13,20 @@ public partial class RarHeaderFactory
private bool _isRar5;
private Rar5CryptoInfo? _cryptInfo;
private CryptKey5? _headerKey;
private string? _headerPassword;
private CryptKey5 GetHeaderKey()
{
// ReaderOptions is mutable; changing its password must not keep using
// a key derived from the previous password.
if (_headerKey is null || _headerPassword != Options.Password)
{
_headerPassword = Options.Password;
_headerKey = new CryptKey5(_headerPassword, _cryptInfo.NotNull());
}
return _headerKey;
}
public RarHeaderFactory(StreamingMode mode, ReaderOptions options)
{
@@ -62,9 +76,7 @@ public partial class RarHeaderFactory
if (_isRar5 && _cryptInfo != null)
{
_cryptInfo.ReadInitV(new MarkingBinaryReader(stream));
var _headerKey = new CryptKey5(Options.Password!, _cryptInfo);
reader = RarCryptoBinaryReader.Create(stream, _headerKey, _cryptInfo.Salt);
reader = RarCryptoBinaryReader.Create(stream, GetHeaderKey(), _cryptInfo.Salt);
}
else
{
@@ -191,6 +203,7 @@ public partial class RarHeaderFactory
var cryptoHeader = ArchiveCryptHeader.Create(header, reader);
IsEncrypted = true;
_cryptInfo = cryptoHeader.CryptInfo;
_headerKey = null;
return cryptoHeader;
}

View File

@@ -0,0 +1,84 @@
using System.IO;
using System.Linq;
using SharpCompress.Common.Rar;
using SharpCompress.Common.Rar.Headers;
using SharpCompress.IO;
using SharpCompress.Readers;
using Xunit;
namespace SharpCompress.Test.Rar;
public class CryptKey5Tests : TestBase
{
private Rar5CryptoInfo ReadCryptoInfo()
{
using var stream = File.OpenRead(
Path.Combine(TEST_ARCHIVES_PATH, "Rar5.encrypted_filesAndHeader.rar")
);
var factory = new RarHeaderFactory(StreamingMode.Seekable, ReaderOptions.ForExternalStream);
var info = factory.ReadHeaders(stream).OfType<ArchiveCryptHeader>().First().CryptInfo;
info.InitV = new byte[16];
return info;
}
[Fact]
public void Rar5_Key_ReusesDerivedMaterial_WithDifferentIVs()
{
var info = ReadCryptoInfo();
var key = new CryptKey5("test", info);
using var first = key.Transformer(info.Salt);
var firstBlock = first.TransformFinalBlock(new byte[16], 0, 16);
var hashKey = key.HashKey;
var passwordCheck = key.PswCheck;
info.InitV[0] = 1;
using var second = key.Transformer((byte[])info.Salt.Clone());
var secondBlock = second.TransformFinalBlock(new byte[16], 0, 16);
Assert.Same(hashKey, key.HashKey);
Assert.Same(passwordCheck, key.PswCheck);
Assert.False(firstBlock.SequenceEqual(secondBlock));
using var fresh = new CryptKey5("test", info).Transformer(info.Salt);
Assert.Equal(fresh.TransformFinalBlock(new byte[16], 0, 16), secondBlock);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Rar5_Key_InvalidatesDerivedMaterial_WhenInputsChange(bool changeIterations)
{
var info = ReadCryptoInfo();
var key = new CryptKey5("test", info);
using var first = key.Transformer(info.Salt);
var hashKey = key.HashKey;
info.UsePswCheck = false;
if (changeIterations)
{
info.LG2Count--;
}
else
{
info.Salt[0] ^= 1;
}
using var second = key.Transformer(info.Salt);
using var fresh = new CryptKey5("test", info).Transformer(info.Salt);
Assert.NotSame(hashKey, key.HashKey);
Assert.Equal(
fresh.TransformFinalBlock(new byte[16], 0, 16),
second.TransformFinalBlock(new byte[16], 0, 16)
);
}
[Fact]
public void Rar5_Key_ValidatesPasswordCheck_OnCacheHit()
{
var info = ReadCryptoInfo();
var key = new CryptKey5("test", info);
using var first = key.Transformer(info.Salt);
info.PswCheck[0] ^= 1;
Assert.Throws<SharpCompress.Common.CryptographicException>(() =>
key.Transformer(info.Salt)
);
}
}

View File

@@ -1,4 +1,6 @@
using System.IO;
using System.Threading.Tasks;
using SharpCompress.Common;
using SharpCompress.Common.Rar.Headers;
using SharpCompress.IO;
using SharpCompress.Readers;
@@ -52,4 +54,64 @@ public class RarHeaderFactoryTest : TestBase
}
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Rar5_ReadHeaders_WithCachedKey(bool useAsync)
{
var options = ReaderOptions.ForExternalStream.WithPassword("test");
Assert.Equal(6, await CountEncryptedFileHeaders(useAsync, options, false));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Rar5_ReadHeaders_RejectChangedPassword(bool useAsync)
{
var options = ReaderOptions.ForExternalStream.WithPassword("test");
await Assert.ThrowsAsync<CryptographicException>(async () =>
await CountEncryptedFileHeaders(useAsync, options, true)
);
}
private async Task<int> CountEncryptedFileHeaders(
bool useAsync,
ReaderOptions options,
bool changePassword
)
{
using var stream = File.OpenRead(
Path.Combine(TEST_ARCHIVES_PATH, "Rar5.encrypted_filesAndHeader.rar")
);
var factory = new RarHeaderFactory(StreamingMode.Seekable, options);
var count = 0;
if (useAsync)
{
await foreach (var header in factory.ReadHeadersAsync(stream))
{
CountHeader(header);
}
}
else
{
foreach (var header in factory.ReadHeaders(stream))
{
CountHeader(header);
}
}
return count;
void CountHeader(IRarHeader header)
{
if (header.HeaderType == HeaderType.File)
{
count++;
if (changePassword)
{
options.Password = "failed";
}
}
}
}
}