Reuse RAR5 derived keys across encrypted file entries

This commit is contained in:
Adam Hathcock
2026-10-05 11:05:33 +01:00
parent 915183320c
commit 2cd70d019f
9 changed files with 113 additions and 22 deletions

View File

@@ -12,7 +12,7 @@ internal sealed class FileInfoRarFilePart : SeekableFilePart
FileHeader fh,
FileInfo fi
)
: base(mh, fh, volume.Index, volume.Stream, password) => FileInfo = fi;
: base(mh, fh, volume.Index, volume.Stream, password, volume.KeyCache) => FileInfo = fi;
internal FileInfo FileInfo { get; }

View File

@@ -8,18 +8,21 @@ internal class SeekableFilePart : RarFilePart
{
private readonly Stream _stream;
private readonly string? _password;
private readonly Rar5KeyCache _keyCache;
internal SeekableFilePart(
MarkHeader mh,
FileHeader fh,
int index,
Stream stream,
string? password
string? password,
Rar5KeyCache keyCache
)
: base(mh, fh, index)
{
_stream = stream;
_password = password;
_keyCache = keyCache;
}
internal override Stream GetCompressedStream()
@@ -34,7 +37,7 @@ internal class SeekableFilePart : RarFilePart
if (FileHeader.Rar5CryptoInfo != null)
{
var cryptKey = new CryptKey5(_password!, FileHeader.Rar5CryptoInfo);
var cryptKey = new CryptKey5(_password!, FileHeader.Rar5CryptoInfo, _keyCache);
return new RarCryptoWrapper(_stream, FileHeader.Rar5CryptoInfo.Salt, cryptKey);
}

View File

@@ -18,5 +18,12 @@ internal class StreamRarArchiveVolume : RarVolume
GetVolumeFilePartsAsync();
internal override RarFilePart CreateFilePart(MarkHeader markHeader, FileHeader fileHeader) =>
new SeekableFilePart(markHeader, fileHeader, Index, Stream, ReaderOptions.Password);
new SeekableFilePart(
markHeader,
fileHeader,
Index,
Stream,
ReaderOptions.Password,
KeyCache
);
}

View File

@@ -9,35 +9,32 @@ namespace SharpCompress.Common.Rar;
internal class CryptKey5 : ICryptKey
{
const int AES_256 = 256;
const int DERIVED_KEY_LENGTH = 0x10;
const int SHA256_DIGEST_SIZE = 32;
private readonly string _password;
private readonly Rar5CryptoInfo _cryptoInfo;
private readonly Rar5KeyCache _keyCache;
private byte[]? _aesKey;
private byte[] _derivedSalt = [];
private int _derivedLg2Count;
private byte[] _pswCheck = [];
private byte[] _hashKey = [];
public CryptKey5(string? password, Rar5CryptoInfo rar5CryptoInfo)
public CryptKey5(string? password, Rar5CryptoInfo rar5CryptoInfo, Rar5KeyCache? keyCache = null)
{
_password = password ?? "";
_cryptoInfo = rar5CryptoInfo;
_keyCache = keyCache ?? new Rar5KeyCache();
}
public byte[] PswCheck => _pswCheck;
public byte[] HashKey => _hashKey;
private static List<byte[]> GenerateRarPBKDF2Key(
string password,
byte[] salt,
int iterations,
int keyLength
)
internal static List<byte[]> GenerateRarPBKDF2Key(string password, byte[] salt, int iterations)
{
var passwordBytes = Encoding.UTF8.GetBytes(password);
salt = salt.Concat(new byte[] { 0, 0, 0, 1 }).ToArray();
#if LEGACY_DOTNET
using var hmac = new HMACSHA256(passwordBytes);
var block = hmac.ComputeHash(salt);
@@ -80,12 +77,7 @@ internal class CryptKey5 : ICryptKey
|| !_derivedSalt.SequenceEqual(salt)
)
{
var derivedKey = GenerateRarPBKDF2Key(
_password,
salt.Concat(new byte[] { 0, 0, 0, 1 }).ToArray(),
1 << _cryptoInfo.LG2Count,
DERIVED_KEY_LENGTH
);
var derivedKey = _keyCache.GetKeys(_password, salt, _cryptoInfo.LG2Count);
_hashKey = derivedKey[1];
_pswCheck = new byte[EncryptionConstV5.SIZE_PSWCHECK];
for (var i = 0; i < SHA256_DIGEST_SIZE; i++)

View File

@@ -185,7 +185,8 @@ public partial class RarHeaderFactory
fh.R4Salt is null
? new CryptKey5(
Options.Password,
fh.Rar5CryptoInfo.NotNull()
fh.Rar5CryptoInfo.NotNull(),
KeyCache
)
: new CryptKey3(Options.Password)
);

View File

@@ -15,6 +15,7 @@ public partial class RarHeaderFactory
private Rar5CryptoInfo? _cryptInfo;
private CryptKey5? _headerKey;
private string? _headerPassword;
internal Rar5KeyCache KeyCache { get; } = new();
private CryptKey5 GetHeaderKey()
{
@@ -23,7 +24,7 @@ public partial class RarHeaderFactory
if (_headerKey is null || _headerPassword != Options.Password)
{
_headerPassword = Options.Password;
_headerKey = new CryptKey5(_headerPassword, _cryptInfo.NotNull());
_headerKey = new CryptKey5(_headerPassword, _cryptInfo.NotNull(), KeyCache);
}
return _headerKey;
}
@@ -180,7 +181,8 @@ public partial class RarHeaderFactory
fh.R4Salt is null
? new CryptKey5(
Options.Password,
fh.Rar5CryptoInfo.NotNull()
fh.Rar5CryptoInfo.NotNull(),
KeyCache
)
: new CryptKey3(Options.Password)
);
@@ -247,7 +249,7 @@ public partial class RarHeaderFactory
return new RarCryptoWrapper(
packedStream,
header.Rar5CryptoInfo.Salt,
new CryptKey5(Options.Password, header.Rar5CryptoInfo)
new CryptKey5(Options.Password, header.Rar5CryptoInfo, KeyCache)
);
}

View File

@@ -0,0 +1,40 @@
using System.Collections.Generic;
using System.Linq;
namespace SharpCompress.Common.Rar;
// Cache only KDF output, never entry-specific IVs, checks, or AES transforms.
internal sealed class Rar5KeyCache
{
private const int Capacity = 4;
private readonly List<(string Password, byte[] Salt, int Lg2Count, List<byte[]> Keys)> entries =
new();
internal List<byte[]> GetKeys(string password, byte[] salt, int lg2Count)
{
lock (entries)
{
foreach (var (cachedPassword, cachedSalt, cachedCount, cachedKeys) in entries)
{
if (
cachedPassword == password
&& cachedCount == lg2Count
&& cachedSalt.SequenceEqual(salt)
)
{
return cachedKeys;
}
}
// Snapshot mutable metadata so later changes cannot corrupt cache identity.
var snapshot = (byte[])salt.Clone();
var keys = CryptKey5.GenerateRarPBKDF2Key(password, snapshot, 1 << lg2Count);
if (entries.Count == Capacity)
{
entries.RemoveAt(0);
}
entries.Add((password, snapshot, lg2Count, keys));
return keys;
}
}
}

View File

@@ -21,6 +21,7 @@ public abstract class RarVolume : Volume
private const int MaxCommentSize = 16 * 1024 * 1024;
private readonly RarHeaderFactory _headerFactory;
internal Rar5KeyCache KeyCache => _headerFactory.KeyCache;
private int _maxCompressionAlgorithm;
internal RarVolume(StreamingMode mode, Stream stream, ReaderOptions options, int index)

View File

@@ -21,6 +21,51 @@ public class CryptKey5Tests : TestBase
return info;
}
[Fact]
public void Rar5_Key_SharesDerivedMaterial_AcrossEntries()
{
var cache = new Rar5KeyCache();
var firstInfo = ReadCryptoInfo();
var secondInfo = ReadCryptoInfo();
secondInfo.InitV[0] = 1;
var first = new CryptKey5("test", firstInfo, cache);
var second = new CryptKey5("test", secondInfo, cache);
using var firstTransform = first.Transformer(firstInfo.Salt);
using var secondTransform = second.Transformer(secondInfo.Salt);
Assert.Same(first.HashKey, second.HashKey);
Assert.Equal(first.PswCheck, second.PswCheck);
var firstBlock = firstTransform.TransformFinalBlock(new byte[16], 0, 16);
var secondBlock = secondTransform.TransformFinalBlock(new byte[16], 0, 16);
Assert.False(firstBlock.SequenceEqual(secondBlock));
using var fresh = new CryptKey5("test", secondInfo).Transformer(secondInfo.Salt);
Assert.Equal(fresh.TransformFinalBlock(new byte[16], 0, 16), secondBlock);
secondInfo.PswCheck[0] ^= 1;
Assert.Throws<SharpCompress.Common.CryptographicException>(() =>
new CryptKey5("test", secondInfo, cache).Transformer(secondInfo.Salt)
);
Assert.Throws<SharpCompress.Common.CryptographicException>(() =>
new CryptKey5("failed", firstInfo, cache).Transformer(firstInfo.Salt)
);
}
[Fact]
public void Rar5_KeyCache_MatchesInputs_AndBoundsRetention()
{
var cache = new Rar5KeyCache();
var salt = new byte[16];
var original = cache.GetKeys("test", salt, 0);
Assert.Same(original, cache.GetKeys("test", (byte[])salt.Clone(), 0));
Assert.NotSame(original, cache.GetKeys("other", salt, 0));
Assert.NotSame(original, cache.GetKeys("test", salt, 1));
salt[0] = 1;
Assert.NotSame(original, cache.GetKeys("test", salt, 0));
Assert.Same(original, cache.GetKeys("test", new byte[16], 0));
salt[0] = 2;
cache.GetKeys("test", salt, 0);
Assert.NotSame(original, cache.GetKeys("test", new byte[16], 0));
}
[Fact]
public void Rar5_Key_ReusesDerivedMaterial_WithDifferentIVs()
{