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