diff --git a/docs/API.md b/docs/API.md index 09c89005..7d359c36 100644 --- a/docs/API.md +++ b/docs/API.md @@ -837,6 +837,16 @@ using (var reader = ReaderFactory.OpenReader(stream)) - [USAGE.md](USAGE.md) - Complete code examples - [FORMATS.md](FORMATS.md) - Supported formats - [PERFORMANCE.md](PERFORMANCE.md) - API selection guide + +## 7z Encrypted Decoding Errors + +AES-encrypted 7z codec data errors and encrypted-header CRC failures throw `SharpCompress.Common.CryptographicException`, +with the original decoder error in `InnerException`. The exception can indicate either an +incorrect password or corrupt encrypted data; 7z AES has no independent password verifier. +`CryptographicException(string message, Exception inner)` preserves an underlying failure. +Unencrypted codec errors retain their existing behavior. This does not guarantee detection +of every incorrect password, particularly for data without compression or integrity checking. + ## 7z Encryption Detection For 7z archives, `IsEncrypted` is true when any entry has an AES-encrypted data stream. diff --git a/src/SharpCompress/Common/SevenZip/ArchiveReader.Async.cs b/src/SharpCompress/Common/SevenZip/ArchiveReader.Async.cs index ed29e81c..30c5ad40 100644 --- a/src/SharpCompress/Common/SevenZip/ArchiveReader.Async.cs +++ b/src/SharpCompress/Common/SevenZip/ArchiveReader.Async.cs @@ -231,9 +231,12 @@ internal sealed partial class ArchiveReader != folder._unpackCrc ) { - throw new InvalidFormatException( + var exception = new InvalidFormatException( "Decoded stream does not match expected CRC." ); + throw folder.IsEncrypted() + ? EncryptedDecoderStream.DecodingException(exception) + : exception; } } } diff --git a/src/SharpCompress/Common/SevenZip/ArchiveReader.cs b/src/SharpCompress/Common/SevenZip/ArchiveReader.cs index 8ce543c2..3c9ddee9 100644 --- a/src/SharpCompress/Common/SevenZip/ArchiveReader.cs +++ b/src/SharpCompress/Common/SevenZip/ArchiveReader.cs @@ -674,9 +674,12 @@ internal partial class ArchiveReader != folder._unpackCrc ) { - throw new InvalidFormatException( + var exception = new InvalidFormatException( "Decoded stream does not match expected CRC." ); + throw folder.IsEncrypted() + ? EncryptedDecoderStream.DecodingException(exception) + : exception; } } } diff --git a/src/SharpCompress/Common/SharpCompressException.cs b/src/SharpCompress/Common/SharpCompressException.cs index 7dc7cdda..cbbbd37b 100644 --- a/src/SharpCompress/Common/SharpCompressException.cs +++ b/src/SharpCompress/Common/SharpCompressException.cs @@ -28,7 +28,14 @@ public class ArchiveOperationException : SharpCompressException public class IncompleteArchiveException(string message) : ArchiveException(message); -public class CryptographicException(string message) : SharpCompressException(message); +public class CryptographicException : SharpCompressException +{ + public CryptographicException(string message) + : base(message) { } + + public CryptographicException(string message, Exception inner) + : base(message, inner) { } +} public class ReaderCancelledException(string message) : SharpCompressException(message); diff --git a/src/SharpCompress/Compressors/LZMA/DecoderStream.cs b/src/SharpCompress/Compressors/LZMA/DecoderStream.cs index ac50ac52..611561d4 100644 --- a/src/SharpCompress/Compressors/LZMA/DecoderStream.cs +++ b/src/SharpCompress/Compressors/LZMA/DecoderStream.cs @@ -303,14 +303,23 @@ internal static class DecoderStreamHelper out var primaryCoderIndex, out var primaryOutStreamIndex ); - return CreateDecoderStream( - inStreams, - packSizes, - outStreams, - folderInfo, - primaryCoderIndex, - pass - ); + var encrypted = folderInfo.IsEncrypted(); + try + { + var decoder = CreateDecoderStream( + inStreams, + packSizes, + outStreams, + folderInfo, + primaryCoderIndex, + pass + ); + return encrypted ? new EncryptedDecoderStream(decoder) : decoder; + } + catch (DataErrorException ex) when (encrypted) + { + throw EncryptedDecoderStream.DecodingException(ex); + } } internal static async ValueTask CreateDecoderStreamAsync( @@ -341,15 +350,24 @@ internal static class DecoderStreamHelper out var primaryCoderIndex, out var primaryOutStreamIndex ); - return await CreateDecoderStreamAsync( - inStreams, - packSizes, - outStreams, - folderInfo, - primaryCoderIndex, - pass, - cancellationToken - ) - .ConfigureAwait(false); + var encrypted = folderInfo.IsEncrypted(); + try + { + var decoder = await CreateDecoderStreamAsync( + inStreams, + packSizes, + outStreams, + folderInfo, + primaryCoderIndex, + pass, + cancellationToken + ) + .ConfigureAwait(false); + return encrypted ? new EncryptedDecoderStream(decoder) : decoder; + } + catch (DataErrorException ex) when (encrypted) + { + throw EncryptedDecoderStream.DecodingException(ex); + } } } diff --git a/src/SharpCompress/Compressors/LZMA/EncryptedDecoderStream.cs b/src/SharpCompress/Compressors/LZMA/EncryptedDecoderStream.cs new file mode 100644 index 00000000..4c1c94dc --- /dev/null +++ b/src/SharpCompress/Compressors/LZMA/EncryptedDecoderStream.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Common; + +namespace SharpCompress.Compressors.LZMA; + +// AES has no password verifier. Decoder errors can mean either a wrong password +// or damaged ciphertext; retain the codec error without claiming certainty. +internal sealed class EncryptedDecoderStream(Stream decoder) : DecoderStream2 +{ + internal static CryptographicException DecodingException(Exception exception) => + new("Incorrect password or corrupt encrypted 7z data.", exception); + + public override long Length => decoder.Length; + public override long Position => decoder.Position; + + public override int Read(byte[] buffer, int offset, int count) + { + try + { + return decoder.Read(buffer, offset, count); + } + catch (DataErrorException ex) + { + throw DecodingException(ex); + } + } + + public override async Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken + ) + { + try + { + return await decoder + .ReadAsync(buffer, offset, count, cancellationToken) + .ConfigureAwait(false); + } + catch (DataErrorException ex) + { + throw DecodingException(ex); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + decoder.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/tests/SharpCompress.Test/SevenZip/EncryptedDecoderStreamTests.cs b/tests/SharpCompress.Test/SevenZip/EncryptedDecoderStreamTests.cs new file mode 100644 index 00000000..7f942045 --- /dev/null +++ b/tests/SharpCompress.Test/SevenZip/EncryptedDecoderStreamTests.cs @@ -0,0 +1,78 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Common; +using SharpCompress.Compressors.LZMA; +using Xunit; + +namespace SharpCompress.Test.SevenZip; + +public class EncryptedDecoderStreamTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task EncryptedDecoderStream_DataError_PreservesInnerException(bool async) + { + var error = new DataErrorException(); + using var decoder = new FailingStream(error); + using var stream = new EncryptedDecoderStream(decoder); + var exception = await Assert.ThrowsAsync(async () => + { + if (async) + { + await stream.ReadAsync(new byte[1], 0, 1, CancellationToken.None); + } + else + { + stream.Read(new byte[1], 0, 1); + } + }); + Assert.Same(error, exception.InnerException); + } + + [Fact] + public async Task EncryptedDecoderStream_OtherErrors_AreNotTranslated() + { + var error = new IOException("read failed"); + using var decoder = new FailingStream(error); + using var stream = new EncryptedDecoderStream(decoder); + Assert.Same(error, Assert.Throws(() => stream.ReadByte())); + Assert.Same( + error, + await Assert.ThrowsAsync(() => stream.ReadAsync(new byte[1], 0, 1)) + ); + using var canceled = new CancellationTokenSource(); + canceled.Cancel(); + await Assert.ThrowsAnyAsync(() => + stream.ReadAsync(new byte[1], 0, 1, canceled.Token) + ); + stream.Dispose(); + Assert.True(decoder.Disposed); + } + + private sealed class FailingStream(Exception exception) : MemoryStream + { + public bool Disposed { get; private set; } + + public override int Read(byte[] buffer, int offset, int count) => throw exception; + + public override Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + throw exception; + } + + protected override void Dispose(bool disposing) + { + Disposed = true; + base.Dispose(disposing); + } + } +} diff --git a/tests/SharpCompress.Test/SevenZip/SevenZipPasswordExceptionTests.cs b/tests/SharpCompress.Test/SevenZip/SevenZipPasswordExceptionTests.cs new file mode 100644 index 00000000..8e9715b0 --- /dev/null +++ b/tests/SharpCompress.Test/SevenZip/SevenZipPasswordExceptionTests.cs @@ -0,0 +1,113 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Archives.SevenZip; +using SharpCompress.Common; +using SharpCompress.Compressors.LZMA; +using SharpCompress.Readers; +using Xunit; + +namespace SharpCompress.Test.SevenZip; + +public class SevenZipPasswordExceptionTests : ArchiveTests +{ + // 206-byte fixture generated with 7zz a -psecret -mhe=on -m0=LZMA2 + // encrypted-header.7z data.txt (data.txt contains "hello"). No external tool needed in CI. + private const string EncryptedHeader = + "N3q8ryccAAQx1ES9gAAAAAAAAAAuAAAAAAAAADjCO/gWFzzAowVAn1/zBdIMi+5r2AyhxUL1SKmLFzNxH6VJqIWJTTzQDTAhcddAjSIgK/e92hRdPfRIZYJaEPZcue3MFygwZTMOKvgnqcH5B+k/o2345MpVIuA4n2yg4ag99CRyj4LF52P188ujA4nEMXCEsl5lKfQmln5QFOXywSf2ShcGEAEJcAAHCwEAASQG8QcBElMPq2bT4KLY1zYjazQMOzZJ1AxqCgGTarjeAAA="; + + [Theory] + [InlineData(false, "secret")] + [InlineData(true, "secret")] + [InlineData(false, "wrong")] + [InlineData(true, "wrong")] + public async Task SevenZipArchive_EncryptedHeader_PasswordErrors(bool async, string password) + { + using var input = new MemoryStream(Convert.FromBase64String(EncryptedHeader)); + async Task ReadArchive() + { + if (async) + { + await using var archive = await SevenZipArchive.OpenAsyncArchive( + input, + new ReaderOptions { Password = password, LeaveStreamOpen = true }, + CancellationToken.None + ); + var count = 0; + await foreach (var entry in archive.EntriesAsync) + { + Assert.Equal("data.txt", entry.Key); + count++; + } + Assert.Equal(1, count); + } + else + { + using var archive = SevenZipArchive.OpenArchive( + input, + new ReaderOptions { Password = password, LeaveStreamOpen = true } + ); + Assert.Equal("data.txt", archive.Entries.Single().Key); + } + } + if (password == "secret") + { + await ReadArchive(); + } + else + { + var exception = await Assert.ThrowsAsync(ReadArchive); + Assert.IsType(exception.InnerException); + } + } + + [Fact] + public async Task SevenZipArchive_WrongPassword_AsyncOpenPublicException() + { + await using var archive = await SevenZipArchive.OpenAsyncArchive( + Path.Combine(TEST_ARCHIVES_PATH, "7Zip.LZMA.Aes.7z"), + new ReaderOptions { Password = "wrong" }, + CancellationToken.None + ); + var exception = await Assert.ThrowsAsync(async () => + { + await foreach (var entry in archive.EntriesAsync) + { + if (!entry.IsDirectory) + { + using var stream = await entry.OpenEntryStreamAsync(CancellationToken.None); + await stream.CopyToAsync(Stream.Null, 81920, CancellationToken.None); + } + } + }); + Assert.IsType(exception.InnerException); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SevenZipArchive_WrongPassword_PublicException(bool async) + { + using var archive = SevenZipArchive.OpenArchive( + Path.Combine(TEST_ARCHIVES_PATH, "7Zip.LZMA.Aes.7z"), + new ReaderOptions { Password = "wrong" } + ); + var entry = archive.Entries.First(e => !e.IsDirectory); + var exception = await Assert.ThrowsAsync(async () => + { + using var stream = entry.OpenEntryStream(); + if (async) + { + await stream.CopyToAsync(Stream.Null, 81920, CancellationToken.None); + } + else + { + stream.CopyTo(Stream.Null); + } + }); + Assert.IsType(exception.InnerException); + Assert.Contains("corrupt encrypted", exception.Message); + } +}