diff --git a/src/SharpCompress/Common/Rar/CryptKey5.cs b/src/SharpCompress/Common/Rar/CryptKey5.cs index 490a3c54..ba34d61d 100644 --- a/src/SharpCompress/Common/Rar/CryptKey5.cs +++ b/src/SharpCompress/Common/Rar/CryptKey5.cs @@ -12,8 +12,11 @@ internal class CryptKey5 : ICryptKey const int DERIVED_KEY_LENGTH = 0x10; const int SHA256_DIGEST_SIZE = 32; - private string _password; - private Rar5CryptoInfo _cryptoInfo; + private readonly string _password; + private readonly Rar5CryptoInfo _cryptoInfo; + private byte[]? _aesKey; + private byte[] _derivedSalt = []; + private int _derivedLg2Count; private byte[] _pswCheck = []; private byte[] _hashKey = []; @@ -69,23 +72,29 @@ internal class CryptKey5 : ICryptKey public ICryptoTransform Transformer(byte[] salt) { - var iterations = (1 << _cryptoInfo.LG2Count); // Adjust the number of iterations as needed - - var salt_rar5 = salt.Concat(new byte[] { 0, 0, 0, 1 }); - var derivedKey = GenerateRarPBKDF2Key( - _password, - salt_rar5.ToArray(), - iterations, - DERIVED_KEY_LENGTH - ); - - _hashKey = derivedKey[1]; - - _pswCheck = new byte[EncryptionConstV5.SIZE_PSWCHECK]; - - for (var i = 0; i < SHA256_DIGEST_SIZE; i++) + // Header IVs change for each block, but the expensive KDF inputs do not. + // Snapshot the salt so in-place changes cannot reuse an unrelated key. + if ( + _aesKey is null + || _derivedLg2Count != _cryptoInfo.LG2Count + || !_derivedSalt.SequenceEqual(salt) + ) { - _pswCheck[i % EncryptionConstV5.SIZE_PSWCHECK] ^= derivedKey[2][i]; + var derivedKey = GenerateRarPBKDF2Key( + _password, + salt.Concat(new byte[] { 0, 0, 0, 1 }).ToArray(), + 1 << _cryptoInfo.LG2Count, + DERIVED_KEY_LENGTH + ); + _hashKey = derivedKey[1]; + _pswCheck = new byte[EncryptionConstV5.SIZE_PSWCHECK]; + for (var i = 0; i < SHA256_DIGEST_SIZE; i++) + { + _pswCheck[i % EncryptionConstV5.SIZE_PSWCHECK] ^= derivedKey[2][i]; + } + _derivedSalt = (byte[])salt.Clone(); + _derivedLg2Count = _cryptoInfo.LG2Count; + _aesKey = derivedKey[0]; } if (_cryptoInfo.UsePswCheck && !_cryptoInfo.PswCheck.SequenceEqual(_pswCheck)) @@ -93,11 +102,11 @@ internal class CryptKey5 : ICryptKey throw new CryptographicException("The password did not match."); } - var aes = Aes.Create(); + using var aes = Aes.Create(); aes.KeySize = AES_256; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.None; - aes.Key = derivedKey[0]; + aes.Key = _aesKey; aes.IV = _cryptoInfo.InitV; return aes.CreateDecryptor(); } diff --git a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs index d020add9..2e38caf0 100644 --- a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs +++ b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs @@ -67,10 +67,8 @@ public partial class RarHeaderFactory await _cryptInfo .ReadInitVAsync(new AsyncMarkingBinaryReader(stream)) .ConfigureAwait(false); - var _headerKey = new CryptKey5(Options.Password!, _cryptInfo); - reader = await AsyncRarCryptoBinaryReader - .Create(stream, _headerKey, _cryptInfo.Salt) + .Create(stream, GetHeaderKey(), _cryptInfo.Salt) .ConfigureAwait(false); } else @@ -215,6 +213,7 @@ public partial class RarHeaderFactory .ConfigureAwait(false); IsEncrypted = true; _cryptInfo = cryptoHeader.CryptInfo; + _headerKey = null; return cryptoHeader; } diff --git a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs index e9694c2a..030b2eb2 100644 --- a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs +++ b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs @@ -13,6 +13,20 @@ public partial class RarHeaderFactory private bool _isRar5; private Rar5CryptoInfo? _cryptInfo; + private CryptKey5? _headerKey; + private string? _headerPassword; + + private CryptKey5 GetHeaderKey() + { + // ReaderOptions is mutable; changing its password must not keep using + // a key derived from the previous password. + if (_headerKey is null || _headerPassword != Options.Password) + { + _headerPassword = Options.Password; + _headerKey = new CryptKey5(_headerPassword, _cryptInfo.NotNull()); + } + return _headerKey; + } public RarHeaderFactory(StreamingMode mode, ReaderOptions options) { @@ -62,9 +76,7 @@ public partial class RarHeaderFactory if (_isRar5 && _cryptInfo != null) { _cryptInfo.ReadInitV(new MarkingBinaryReader(stream)); - var _headerKey = new CryptKey5(Options.Password!, _cryptInfo); - - reader = RarCryptoBinaryReader.Create(stream, _headerKey, _cryptInfo.Salt); + reader = RarCryptoBinaryReader.Create(stream, GetHeaderKey(), _cryptInfo.Salt); } else { @@ -191,6 +203,7 @@ public partial class RarHeaderFactory var cryptoHeader = ArchiveCryptHeader.Create(header, reader); IsEncrypted = true; _cryptInfo = cryptoHeader.CryptInfo; + _headerKey = null; return cryptoHeader; } diff --git a/tests/SharpCompress.Test/Rar/CryptKey5Tests.cs b/tests/SharpCompress.Test/Rar/CryptKey5Tests.cs new file mode 100644 index 00000000..c7e4577b --- /dev/null +++ b/tests/SharpCompress.Test/Rar/CryptKey5Tests.cs @@ -0,0 +1,84 @@ +using System.IO; +using System.Linq; +using SharpCompress.Common.Rar; +using SharpCompress.Common.Rar.Headers; +using SharpCompress.IO; +using SharpCompress.Readers; +using Xunit; + +namespace SharpCompress.Test.Rar; + +public class CryptKey5Tests : TestBase +{ + private Rar5CryptoInfo ReadCryptoInfo() + { + using var stream = File.OpenRead( + Path.Combine(TEST_ARCHIVES_PATH, "Rar5.encrypted_filesAndHeader.rar") + ); + var factory = new RarHeaderFactory(StreamingMode.Seekable, ReaderOptions.ForExternalStream); + var info = factory.ReadHeaders(stream).OfType().First().CryptInfo; + info.InitV = new byte[16]; + return info; + } + + [Fact] + public void Rar5_Key_ReusesDerivedMaterial_WithDifferentIVs() + { + var info = ReadCryptoInfo(); + var key = new CryptKey5("test", info); + using var first = key.Transformer(info.Salt); + var firstBlock = first.TransformFinalBlock(new byte[16], 0, 16); + var hashKey = key.HashKey; + var passwordCheck = key.PswCheck; + + info.InitV[0] = 1; + using var second = key.Transformer((byte[])info.Salt.Clone()); + var secondBlock = second.TransformFinalBlock(new byte[16], 0, 16); + Assert.Same(hashKey, key.HashKey); + Assert.Same(passwordCheck, key.PswCheck); + Assert.False(firstBlock.SequenceEqual(secondBlock)); + + using var fresh = new CryptKey5("test", info).Transformer(info.Salt); + Assert.Equal(fresh.TransformFinalBlock(new byte[16], 0, 16), secondBlock); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Rar5_Key_InvalidatesDerivedMaterial_WhenInputsChange(bool changeIterations) + { + var info = ReadCryptoInfo(); + var key = new CryptKey5("test", info); + using var first = key.Transformer(info.Salt); + var hashKey = key.HashKey; + info.UsePswCheck = false; + if (changeIterations) + { + info.LG2Count--; + } + else + { + info.Salt[0] ^= 1; + } + + using var second = key.Transformer(info.Salt); + using var fresh = new CryptKey5("test", info).Transformer(info.Salt); + Assert.NotSame(hashKey, key.HashKey); + Assert.Equal( + fresh.TransformFinalBlock(new byte[16], 0, 16), + second.TransformFinalBlock(new byte[16], 0, 16) + ); + } + + [Fact] + public void Rar5_Key_ValidatesPasswordCheck_OnCacheHit() + { + var info = ReadCryptoInfo(); + var key = new CryptKey5("test", info); + using var first = key.Transformer(info.Salt); + info.PswCheck[0] ^= 1; + Assert.Throws(() => + key.Transformer(info.Salt) + ); + } +} diff --git a/tests/SharpCompress.Test/Rar/RarHeaderFactoryTest.cs b/tests/SharpCompress.Test/Rar/RarHeaderFactoryTest.cs index 6fd42ba9..0f8e92c7 100644 --- a/tests/SharpCompress.Test/Rar/RarHeaderFactoryTest.cs +++ b/tests/SharpCompress.Test/Rar/RarHeaderFactoryTest.cs @@ -1,4 +1,6 @@ using System.IO; +using System.Threading.Tasks; +using SharpCompress.Common; using SharpCompress.Common.Rar.Headers; using SharpCompress.IO; using SharpCompress.Readers; @@ -52,4 +54,64 @@ public class RarHeaderFactoryTest : TestBase } } } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Rar5_ReadHeaders_WithCachedKey(bool useAsync) + { + var options = ReaderOptions.ForExternalStream.WithPassword("test"); + Assert.Equal(6, await CountEncryptedFileHeaders(useAsync, options, false)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Rar5_ReadHeaders_RejectChangedPassword(bool useAsync) + { + var options = ReaderOptions.ForExternalStream.WithPassword("test"); + await Assert.ThrowsAsync(async () => + await CountEncryptedFileHeaders(useAsync, options, true) + ); + } + + private async Task CountEncryptedFileHeaders( + bool useAsync, + ReaderOptions options, + bool changePassword + ) + { + using var stream = File.OpenRead( + Path.Combine(TEST_ARCHIVES_PATH, "Rar5.encrypted_filesAndHeader.rar") + ); + var factory = new RarHeaderFactory(StreamingMode.Seekable, options); + var count = 0; + if (useAsync) + { + await foreach (var header in factory.ReadHeadersAsync(stream)) + { + CountHeader(header); + } + } + else + { + foreach (var header in factory.ReadHeaders(stream)) + { + CountHeader(header); + } + } + return count; + + void CountHeader(IRarHeader header) + { + if (header.HeaderType == HeaderType.File) + { + count++; + if (changePassword) + { + options.Password = "failed"; + } + } + } + } }