Add Zip Archives password support#122093
Add Zip Archives password support#122093alinpahontu2912 wants to merge 89 commits intodotnet:mainfrom
Conversation
…assword and unprotected
… with cryptography lib
rzikm
left a comment
There was a problem hiding this comment.
The implementation has some duplication that I would like to see removed and streamlined, but otherwise looks reasonable.
|
Hey @bartonjs can you take a look again ? |
| // Verify the salt matches — use constant-time comparison because the salt is | ||
| // derived from secret key material and a timing side-channel could leak information. |
| @@ -1,4 +1,4 @@ | |||
| <Project Sdk="Microsoft.NET.Sdk"> | |||
| <Project Sdk="Microsoft.NET.Sdk"> | |||
| @@ -1,4 +1,4 @@ | |||
| <Project Sdk="Microsoft.NET.Sdk"> | |||
| <Project Sdk="Microsoft.NET.Sdk"> | |||
| @@ -1,4 +1,4 @@ | |||
| <Project Sdk="Microsoft.NET.Sdk"> | |||
| <Project Sdk="Microsoft.NET.Sdk"> | |||
| @@ -1,4 +1,4 @@ | |||
| <Project Sdk="Microsoft.NET.Sdk"> | |||
| <Project Sdk="Microsoft.NET.Sdk"> | |||
| @@ -1,4 +1,4 @@ | |||
| <Project Sdk="Microsoft.NET.Sdk"> | |||
| <Project Sdk="Microsoft.NET.Sdk"> | |||
| @@ -1,4 +1,4 @@ | |||
| <Project Sdk="Microsoft.NET.Sdk"> | |||
| <Project Sdk="Microsoft.NET.Sdk"> | |||
| public override int Read(Span<byte> destination) | ||
| { | ||
| if (_encrypting) | ||
| { | ||
| throw new NotSupportedException(SR.ReadingNotSupported); | ||
| } | ||
| int n = _base.Read(destination); | ||
| for (int i = 0; i < n; i++) | ||
| destination[i] = DecryptAndUpdateKeys(destination[i]); | ||
| return n; |
| foreach (ZipGenericExtraField field in extraFields) | ||
| { | ||
| if (field.Tag == HeaderId && field.Size >= DataSize) | ||
| { | ||
| byte[] data = field.Data; | ||
| aesExtraField = new WinZipAesExtraField( | ||
| vendorVersion: BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(0, 2)), | ||
| aesStrength: data[4], | ||
| compressionMethod: BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(5, 2)) | ||
| ); |
| ushort headerId = BinaryPrimitives.ReadUInt16LittleEndian(extraFieldData.Slice(offset, 2)); | ||
| ushort fieldSize = BinaryPrimitives.ReadUInt16LittleEndian(extraFieldData.Slice(offset + 2, 2)); | ||
|
|
||
| if (offset + 4 + fieldSize > extraFieldData.Length) | ||
| break; // Not enough data for this field | ||
|
|
||
| if (headerId == HeaderId && fieldSize >= DataSize) | ||
| { | ||
| ReadOnlySpan<byte> data = extraFieldData.Slice(offset + 4, fieldSize); | ||
| aesExtraField = new WinZipAesExtraField( | ||
| vendorVersion: BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(0, 2)), | ||
| aesStrength: data[4], | ||
| compressionMethod: BinaryPrimitives.ReadUInt16LittleEndian(data.Slice(5, 2)) | ||
| ); | ||
| return true; |
| if (overwrite && File.Exists(destinationFileName)) | ||
| { | ||
| tempPath = Path.GetTempFileName(); | ||
| extractPath = tempPath; | ||
| } | ||
|
|
||
| try | ||
| { | ||
| FileStream fs = new FileStream(extractPath, fileStreamOptions); | ||
| await using (fs.ConfigureAwait(false)) | ||
| { | ||
| Stream es = await source.OpenAsync(password.Span, cancellationToken: cancellationToken).ConfigureAwait(false); | ||
| await using (es.ConfigureAwait(false)) | ||
| { | ||
| await es.CopyToAsync(fs, cancellationToken).ConfigureAwait(false); | ||
| } | ||
| } | ||
|
|
||
| // Move the temporary file to the destination only after successful extraction | ||
| if (tempPath is not null) | ||
| { | ||
| File.Move(tempPath, destinationFileName, overwrite: true); | ||
| } |
| { | ||
| // WriteAsync requires Memory<byte>, so we must copy to a heap buffer for the async path | ||
| byte[] authCodeArray = authCode.Slice(0, 10).ToArray(); | ||
| await _baseStream.WriteAsync(authCodeArray.AsMemory(0, 10), cancellationToken).ConfigureAwait(false); |
There was a problem hiding this comment.
It seems weird that you're doing bounded AsMemory after doing a bounded Slice.ToArray
| } | ||
| 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 async Task FinalizeEncryptionAsync(bool isAsync, CancellationToken cancellationToken) |
There was a problem hiding this comment.
| } | |
| private async Task FinalizeEncryptionAsync(bool isAsync, CancellationToken cancellationToken) | |
| } | |
| private async Task FinalizeEncryptionAsync(bool isAsync, CancellationToken cancellationToken) |
| // Write Auth Code | ||
| WriteAuthCodeCoreAsync(false, CancellationToken.None).GetAwaiter().GetResult(); | ||
|
|
||
| if (_baseStream.CanWrite) |
There was a problem hiding this comment.
In what circumstance would CanWrite be false while encrypting and WriteAuthCodeCoreAsync didn't throw?
| // Ensure header is written even for empty files | ||
| if (!_headerWritten) | ||
| { | ||
| await WriteHeaderAsync(CancellationToken.None).ConfigureAwait(false); | ||
| } | ||
|
|
||
| // Encrypt remaining partial data | ||
| await FinalizeEncryptionAsync(true, CancellationToken.None).ConfigureAwait(false); | ||
|
|
||
| // Write Auth Code | ||
| await WriteAuthCodeCoreAsync(true, CancellationToken.None).ConfigureAwait(false); | ||
|
|
||
| if (_baseStream.CanWrite) | ||
| { | ||
| await _baseStream.FlushAsync().ConfigureAwait(false); | ||
| } |
There was a problem hiding this comment.
I recommend factoring out the block here and in synchronous dispose to a FinishEncrypting(bool isAsync). 4 steps is on the fence between "we'll keep them in sync" and "we'll fail to". But the fact they both need the comment warning about empty files makes me think it's too complicated to be duplicated.
| } | ||
| public override bool CanRead => !_encrypting && !_disposed; |
There was a problem hiding this comment.
| } | |
| public override bool CanRead => !_encrypting && !_disposed; | |
| } | |
| public override bool CanRead => !_encrypting && !_disposed; |
| public override long Length => throw new NotSupportedException(); | ||
| public override long Position |
There was a problem hiding this comment.
| public override long Length => throw new NotSupportedException(); | |
| public override long Position | |
| public override long Length => throw new NotSupportedException(); | |
| public override long Position |
| @@ -1,4 +1,4 @@ | |||
| <Project Sdk="Microsoft.NET.Sdk"> | |||
| <Project Sdk="Microsoft.NET.Sdk"> | |||
There was a problem hiding this comment.
As Copilot has said twice: Remove the BOM.
Fixes #1545
Big Milestones and status: