From e61a335d54fd473a2358cc85a779efd476070d55 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 20 Oct 2026 10:10:29 +0100 Subject: [PATCH] Reduce allocations in RAR5 password key derivation Reuse keyed HMAC state and digest buffers on modern .NET targets while preserving RAR5's three PBKDF2 snapshots. Add independent derivation checks and an allocation regression test. Profiling on macOS ARM64 with .NET 10 reduced allocations by 99.96%, but showed slower throughput; Alpine-specific timing remains unverified. Full .NET 10 test suite passes. --- src/SharpCompress/Common/Rar/CryptKey5.cs | 21 +++++-- .../SharpCompress.Test/Rar/CryptKey5Tests.cs | 58 +++++++++++++++++++ 2 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/SharpCompress/Common/Rar/CryptKey5.cs b/src/SharpCompress/Common/Rar/CryptKey5.cs index ee5f0c3b..c68fbc8a 100644 --- a/src/SharpCompress/Common/Rar/CryptKey5.cs +++ b/src/SharpCompress/Common/Rar/CryptKey5.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; @@ -35,14 +36,21 @@ internal class CryptKey5 : ICryptKey { var passwordBytes = Encoding.UTF8.GetBytes(password); salt = salt.Concat(new byte[] { 0, 0, 0, 1 }).ToArray(); -#if LEGACY_DOTNET + // Reuse keyed HMAC state: recreating it for every iteration is especially + // expensive on some platforms. Modern targets also reuse digest buffers. using var hmac = new HMACSHA256(passwordBytes); +#if LEGACY_DOTNET var block = hmac.ComputeHash(salt); -#else - var block = HMACSHA256.HashData(passwordBytes, salt); -#endif var finalHash = (byte[])block.Clone(); +#else + Span block = stackalloc byte[SHA256_DIGEST_SIZE]; + Span nextBlock = stackalloc byte[SHA256_DIGEST_SIZE]; + Span finalHash = stackalloc byte[SHA256_DIGEST_SIZE]; + hmac.TryComputeHash(salt, block, out _); + block.CopyTo(finalHash); +#endif + // RAR5 takes snapshots of the same PBKDF2 chain at N, N+16, and N+32. var loop = new int[] { iterations, 17, 17 }; var res = new List { }; @@ -53,7 +61,8 @@ internal class CryptKey5 : ICryptKey #if LEGACY_DOTNET block = hmac.ComputeHash(block); #else - block = HMACSHA256.HashData(passwordBytes, block); + hmac.TryComputeHash(block, nextBlock, out _); + nextBlock.CopyTo(block); #endif for (var j = 0; j < finalHash.Length; j++) { @@ -61,7 +70,7 @@ internal class CryptKey5 : ICryptKey } } - res.Add((byte[])finalHash.Clone()); + res.Add(finalHash.ToArray()); } return res; diff --git a/tests/SharpCompress.Test/Rar/CryptKey5Tests.cs b/tests/SharpCompress.Test/Rar/CryptKey5Tests.cs index 45665b2c..648c156a 100644 --- a/tests/SharpCompress.Test/Rar/CryptKey5Tests.cs +++ b/tests/SharpCompress.Test/Rar/CryptKey5Tests.cs @@ -1,5 +1,8 @@ +using System; using System.IO; using System.Linq; +using System.Security.Cryptography; +using System.Text; using System.Threading.Tasks; using SharpCompress.Common.Rar; using SharpCompress.Common.Rar.Headers; @@ -11,6 +14,61 @@ namespace SharpCompress.Test.Rar; public class CryptKey5Tests : TestBase { + [Theory] + [InlineData("", 1)] + [InlineData("password", 2)] + [InlineData("password", 4096)] + [InlineData("pässwörd🔐", 32)] + [InlineData( + "a password longer than the SHA256 block size of sixty-four bytes to exercise HMAC key hashing", + 128 + )] + public void Rar5_DerivedKeys_MatchPbkdf2_Snapshots(string password, int iterations) + { + var salt = Enumerable.Range(0, 16).Select(i => (byte)i).ToArray(); + var originalSalt = (byte[])salt.Clone(); + var keys = CryptKey5.GenerateRarPBKDF2Key(password, salt, iterations); + + Assert.Equal(3, keys.Count); + for (var i = 0; i < keys.Count; i++) + { +#if LEGACY_DOTNET + using var reference = new Rfc2898DeriveBytes( + Encoding.UTF8.GetBytes(password), + salt, + iterations + i * 16, + HashAlgorithmName.SHA256 + ); + Assert.Equal(reference.GetBytes(32), keys[i]); +#else + var expected = Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(password), + salt, + iterations + i * 16, + HashAlgorithmName.SHA256, + 32 + ); + Assert.Equal(expected, keys[i]); +#endif + } + Assert.Equal(originalSalt, salt); + } + +#if !LEGACY_DOTNET + [Fact] + public void Rar5_DerivedKeys_DoNotAllocate_PerIteration() + { + var salt = new byte[16]; + CryptKey5.GenerateRarPBKDF2Key("password", salt, 4096); + var before = GC.GetAllocatedBytesForCurrentThread(); + CryptKey5.GenerateRarPBKDF2Key("password", salt, 4096); + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + // Allow fixed setup costs, but not a digest array for each iteration. + Assert.True(allocated < 16 * 1024, $"Allocated {allocated} bytes."); + } +#endif + [Theory] [InlineData(0)] [InlineData(24)]