From 2cd70d019fa739314055904b528cc2a57826d83c Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Mon, 5 Oct 2026 11:05:33 +0100 Subject: [PATCH] Reuse RAR5 derived keys across encrypted file entries --- .../Archives/Rar/FileInfoRarFilePart.cs | 2 +- .../Archives/Rar/SeekableFilePart.cs | 7 ++- .../Archives/Rar/StreamRarArchiveVolume.cs | 9 +++- src/SharpCompress/Common/Rar/CryptKey5.cs | 20 +++------ .../Rar/Headers/RarHeaderFactory.Async.cs | 3 +- .../Common/Rar/Headers/RarHeaderFactory.cs | 8 ++-- src/SharpCompress/Common/Rar/Rar5KeyCache.cs | 40 +++++++++++++++++ src/SharpCompress/Common/Rar/RarVolume.cs | 1 + .../SharpCompress.Test/Rar/CryptKey5Tests.cs | 45 +++++++++++++++++++ 9 files changed, 113 insertions(+), 22 deletions(-) create mode 100644 src/SharpCompress/Common/Rar/Rar5KeyCache.cs diff --git a/src/SharpCompress/Archives/Rar/FileInfoRarFilePart.cs b/src/SharpCompress/Archives/Rar/FileInfoRarFilePart.cs index 02d2f769..bda245d6 100644 --- a/src/SharpCompress/Archives/Rar/FileInfoRarFilePart.cs +++ b/src/SharpCompress/Archives/Rar/FileInfoRarFilePart.cs @@ -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; } diff --git a/src/SharpCompress/Archives/Rar/SeekableFilePart.cs b/src/SharpCompress/Archives/Rar/SeekableFilePart.cs index 97822d90..1cf70a15 100644 --- a/src/SharpCompress/Archives/Rar/SeekableFilePart.cs +++ b/src/SharpCompress/Archives/Rar/SeekableFilePart.cs @@ -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); } diff --git a/src/SharpCompress/Archives/Rar/StreamRarArchiveVolume.cs b/src/SharpCompress/Archives/Rar/StreamRarArchiveVolume.cs index b7ffc67e..95885ae5 100644 --- a/src/SharpCompress/Archives/Rar/StreamRarArchiveVolume.cs +++ b/src/SharpCompress/Archives/Rar/StreamRarArchiveVolume.cs @@ -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 + ); } diff --git a/src/SharpCompress/Common/Rar/CryptKey5.cs b/src/SharpCompress/Common/Rar/CryptKey5.cs index ba34d61d..ee5f0c3b 100644 --- a/src/SharpCompress/Common/Rar/CryptKey5.cs +++ b/src/SharpCompress/Common/Rar/CryptKey5.cs @@ -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 GenerateRarPBKDF2Key( - string password, - byte[] salt, - int iterations, - int keyLength - ) + internal static List 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++) diff --git a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs index 7010ed96..640e2b90 100644 --- a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs +++ b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs @@ -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) ); diff --git a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs index 42615b58..67ee8c69 100644 --- a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs +++ b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs @@ -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) ); } diff --git a/src/SharpCompress/Common/Rar/Rar5KeyCache.cs b/src/SharpCompress/Common/Rar/Rar5KeyCache.cs new file mode 100644 index 00000000..5208c1e7 --- /dev/null +++ b/src/SharpCompress/Common/Rar/Rar5KeyCache.cs @@ -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 Keys)> entries = + new(); + + internal List 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; + } + } +} diff --git a/src/SharpCompress/Common/Rar/RarVolume.cs b/src/SharpCompress/Common/Rar/RarVolume.cs index aaf7c0e8..642466e6 100644 --- a/src/SharpCompress/Common/Rar/RarVolume.cs +++ b/src/SharpCompress/Common/Rar/RarVolume.cs @@ -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) diff --git a/tests/SharpCompress.Test/Rar/CryptKey5Tests.cs b/tests/SharpCompress.Test/Rar/CryptKey5Tests.cs index c7e4577b..73102f5b 100644 --- a/tests/SharpCompress.Test/Rar/CryptKey5Tests.cs +++ b/tests/SharpCompress.Test/Rar/CryptKey5Tests.cs @@ -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(() => + new CryptKey5("test", secondInfo, cache).Transformer(secondInfo.Salt) + ); + Assert.Throws(() => + 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() {