Add Zip Archives password support#122093
Conversation
…assword and unprotected
… with cryptography lib
| } | ||
| else | ||
| { | ||
| // For decryption: HMAC first (on ciphertext), then XOR |
There was a problem hiding this comment.
Since this data protocol correctly uses Encrypt-then-MAC (EtM), best practice (and internal security requirements) dictates that you should verify the MAC entirely before doing any decryption. As far as I can tell, this implementation allows decrypted data to be processed by a caller before the MAC is verified.
While that is the more performant approach, it will require a security process exception (ideally before the code is committed). Alternatively, change to verifying the MAC beforehand.
Maybe that discussion was already and and I'm not privy to it, but it's a flaw in implementation that I see here. cc: @blowdart @GrabYourPitchforks
| private const string TestPassword = "test-password"; | ||
| private const ushort PasswordVerifier = 0x1234; |
| private const string TestPassword = "test-password"; | ||
| private const ushort PasswordVerifier = 0x1234; |
| private const string TestPassword = "test-password"; | ||
|
|
| public async Task DecryptEntries_SamePassword_7Zip(bool async) | ||
| { | ||
| string password = "S3cur3P@ssw0rd"; | ||
| using Stream archiveStream = await StreamHelpers.CreateTempCopyStream(passwordProtected("PasswordProtected_7ZIP_SamePassword.zip")); |
| public System.Threading.Tasks.Task<System.IO.Stream> OpenAsync(System.IO.FileAccess access, System.ReadOnlySpan<char> password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } | ||
| public System.Threading.Tasks.Task<System.IO.Stream> OpenAsync(System.IO.FileAccess access, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } | ||
| public System.Threading.Tasks.Task<System.IO.Stream> OpenAsync(System.ReadOnlySpan<char> password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } |
| var crcStream = GetDataCompressor(encryptionStream, leaveBackingStreamOpen: true, onClose: null, streamForPosition: _archive.ArchiveStream); | ||
| await using (crcStream.ConfigureAwait(false)) | ||
| { | ||
| _storedUncompressedData.Seek(0, SeekOrigin.Begin); | ||
| await _storedUncompressedData.CopyToAsync(crcStream, cancellationToken).ConfigureAwait(false); | ||
| } | ||
|
|
||
| // Restore CompressionMethod - AesCompressionMethodValue is used directly when writing headers | ||
| CompressionMethod = savedMethod; |
| BitFlagValues savedFlags = _generalPurposeBitFlag; | ||
| ZipEncryptionMethod savedEncryption = Encryption; | ||
| ZipCompressionMethod savedCompressionMethod = CompressionMethod; | ||
|
|
||
| // For AES entries: set CompressionMethod to Aes so header writes method 99, | ||
| // but clear _encryptionMethod so WriteLocalFileHeaderAsync doesn't create a new | ||
| // AES extra field (the original one in _lhUnknownExtraFields will be used). | ||
| if (savedEncryption is ZipEncryptionMethod.Aes128 or ZipEncryptionMethod.Aes192 or ZipEncryptionMethod.Aes256) | ||
| { | ||
| CompressionMethod = (ZipCompressionMethod)WinZipAesMethod; | ||
| Encryption = ZipEncryptionMethod.None; | ||
| } |
| if (overwrite && File.Exists(destinationFileName)) | ||
| { | ||
| tempPath = Path.GetTempFileName(); | ||
| extractPath = tempPath; | ||
| } |
| // When overwriting, extract to a temporary file first to avoid corrupting the destination file | ||
| // if an exception occurs during extraction (e.g., password-protected archive, corrupted data). | ||
| string extractPath = destinationFileName; | ||
| string? tempPath = null; | ||
|
|
||
| if (overwrite && File.Exists(destinationFileName)) | ||
| { | ||
| tempPath = Path.GetTempFileName(); | ||
| extractPath = tempPath; | ||
| } |
| ThrowIfInvalidArchive(); | ||
| ValidateAccessForMode(access); | ||
|
|
||
| if (IsEncrypted && password.IsEmpty) |
|
|
||
| ZipArchiveEntry lastEntry = _entries[_entries.Count - 1]; | ||
| if (lastEntry.IsEncrypted) | ||
| { | ||
| await lastEntry.ReadEncryptionSaltIfNeededAsync(cancellationToken).ConfigureAwait(false); | ||
| } |
| private byte[] CopyToRentedArray(ReadOnlySpan<byte> bytes) | ||
| { | ||
| byte[] buffer = ArrayPool<byte>.Shared.Rent(bytes.Length); | ||
| try | ||
| { | ||
| bytes.CopyTo(buffer); | ||
| return buffer; | ||
| } | ||
| catch | ||
| { | ||
| ArrayPool<byte>.Shared.Return(buffer); | ||
| throw; | ||
| } | ||
| } |
There was a problem hiding this comment.
| private byte[] CopyToRentedArray(ReadOnlySpan<byte> bytes) | |
| { | |
| byte[] buffer = ArrayPool<byte>.Shared.Rent(bytes.Length); | |
| try | |
| { | |
| bytes.CopyTo(buffer); | |
| return buffer; | |
| } | |
| catch | |
| { | |
| ArrayPool<byte>.Shared.Return(buffer); | |
| throw; | |
| } | |
| } | |
| private static byte[] CopyToRentedArray(ReadOnlySpan<byte> bytes) | |
| { | |
| byte[] buffer = ArrayPool<byte>.Shared.Rent(bytes.Length); | |
| bytes.CopyTo(buffer); | |
| return buffer; | |
| } |
CopyTo won't throw here, and if it did the cleanup doesn't matter since the fuzzer would stop anyway.
| private byte[] CopyToRentedArray(ReadOnlySpan<byte> bytes) | ||
| { | ||
| byte[] buffer = ArrayPool<byte>.Shared.Rent(bytes.Length); | ||
| try | ||
| { | ||
| bytes.CopyTo(buffer); | ||
| return buffer; | ||
| } | ||
| catch | ||
| { | ||
| ArrayPool<byte>.Shared.Return(buffer); | ||
| throw; | ||
| } | ||
| } |
There was a problem hiding this comment.
| private byte[] CopyToRentedArray(ReadOnlySpan<byte> bytes) | |
| { | |
| byte[] buffer = ArrayPool<byte>.Shared.Rent(bytes.Length); | |
| try | |
| { | |
| bytes.CopyTo(buffer); | |
| return buffer; | |
| } | |
| catch | |
| { | |
| ArrayPool<byte>.Shared.Return(buffer); | |
| throw; | |
| } | |
| } | |
| private static byte[] CopyToRentedArray(ReadOnlySpan<byte> bytes) | |
| { | |
| byte[] buffer = ArrayPool<byte>.Shared.Rent(bytes.Length); | |
| bytes.CopyTo(buffer); | |
| return buffer; | |
| } |
| } | ||
|
|
||
| ZipArchiveEntry entry; | ||
| if (!password.IsEmpty && encryption != ZipEncryptionMethod.None) |
There was a problem hiding this comment.
| if (!password.IsEmpty && encryption != ZipEncryptionMethod.None) | |
| if (encryption != ZipEncryptionMethod.None) |
Already checked above
| if (!password.IsEmpty) | ||
| ExtractToFile(source, fileDestinationPath, overwrite, password); | ||
| else | ||
| source.ExtractToFile(fileDestinationPath, overwrite: overwrite); |
There was a problem hiding this comment.
Nit: Please use blocks with {} in such if/else cases.
| /// The key material layout is: [salt][encryption key][HMAC key][password verifier (2 bytes)]. | ||
| /// </summary> | ||
| [UnsupportedOSPlatform("browser")] | ||
| internal readonly struct WinZipAesKeyMaterial |
There was a problem hiding this comment.
If using struct here is about perf, it may actually be worse since it's quite large and you're passing it around in arguments a bunch
| int inputOffset = 0; | ||
| int inputCount = buffer.Length; |
There was a problem hiding this comment.
Instead of tracking the offsets and count, you can slice the buffer (same for the memory in async variant).
You get to drop the extra locals, and the loop condition is !IsEmpty.
| if (isAsync) | ||
| await WriteHeaderAsync(cancellationToken).ConfigureAwait(false); | ||
| else | ||
| WriteHeader(); |
There was a problem hiding this comment.
Nit: {} blocks (same in all other places)
| // Only flush if the base stream supports writing | ||
| if (_baseStream.CanWrite) |
There was a problem hiding this comment.
Why are we checking this? Wouldn't it be the caller's error / the responsibility of the base stream to check?
We aren't doing the same in ZipCryptoStream for example
| private bool IsZipCryptoEncrypted => (_generalPurposeBitFlag & BitFlagValues.IsEncrypted) != 0 && (ushort)_headerCompressionMethod != WinZipAesMethod; | ||
|
|
||
| private bool UseAesEncryption() | ||
| { | ||
| return Encryption is ZipEncryptionMethod.Aes128 or ZipEncryptionMethod.Aes192 or ZipEncryptionMethod.Aes256; | ||
| } | ||
| private bool IsAesEncrypted => (ushort)_headerCompressionMethod == WinZipAesMethod; |
There was a problem hiding this comment.
Nit: Any reason between property vs method here?
| int offset = 0; | ||
|
|
||
| while (offset < buffer.Length) |
There was a problem hiding this comment.
Same here re: slicing instead of tracking the offset
Fixes #1545
Big Milestones and status: