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.
This commit is contained in:
Adam Hathcock
2026-10-20 10:10:29 +01:00
parent 542ed974d4
commit e61a335d54
2 changed files with 73 additions and 6 deletions

View File

@@ -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<byte> block = stackalloc byte[SHA256_DIGEST_SIZE];
Span<byte> nextBlock = stackalloc byte[SHA256_DIGEST_SIZE];
Span<byte> 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<byte[]> { };
@@ -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;

View File

@@ -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)]