You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[BUG] Azure.Storage client-side encryption v1: async ranged download reads the IV with a single ReadAsync and can corrupt plaintext #63859
Azure.Storage.Blobs (Azure.Storage.Common shared ClientsideEncryption), main @ 2b37a08
Describe the bug
Found by source review.
ClientSideDecryptor.DecryptReadInternalV1_0, when ivInStream is true (ranged/partial downloads of CSE v1 blobs, where the preceding ciphertext block serves as the IV):
if(async){awaitciphertext.ReadAsync(IV,0,IV.Length,cancellationToken).ConfigureAwait(false);}
else
{inttotalRead=0;while(totalRead<IV.Length){// Stream.Read may return fewer bytes than requested, resulting in unreliable code.varbytesRead=ciphertext.Read(IV,totalRead,IV.Length-totalRead);
...
The sync path loops, and its comment calls out exactly this risk. The async path makes one ReadAsync call and ignores the return value. Network and retriable streams can return fewer than 16 bytes.
Expected behavior
Both paths read exactly 16 bytes, and throw if the stream ends early.
Actual behavior
On a short async read the IV holds only part of the data (the rest are zeros), and the rest of the IV bytes stay in the stream. AES-CBC decryption then runs on a misaligned stream, so the plaintext is corrupted with no error. Neither path detects a stream that ends before 16 bytes.
Reproduction Steps
Upload a blob with client-side encryption v1.0.
Download a range that does not start at offset 0 via DownloadStreamingAsync / OpenReadAsync, using a transport or stream wrapper whose ReadAsync returns at most 8 bytes per call.
The decrypted content is wrong; the same range with the sync API is correct.
Suggested fix: loop ReadAsync until 16 bytes are read in the async path, and throw if fewer than 16 bytes are available in either path.
Summary: In Azure.Storage.Blobs' client-side encryption v1 decryption path, the async branch of ClientSideDecryptor.DecryptReadInternalV1_0 performs a single ReadAsync call to read the 16-byte IV for ranged downloads, without looping to guarantee all 16 bytes are read, unlike the sync path which correctly loops.
Ranged/partial downloads of CSE v1.0 encrypted blobs via DownloadStreamingAsync / OpenReadAsync
Underlying stream/transport returns fewer than 16 bytes per ReadAsync call (common with network streams or retriable stream wrappers)
Root ask: Fix the async path to loop until exactly 16 bytes are read for the IV (matching the sync path's existing loop), and throw if the stream ends before 16 bytes are available in either path.
🔎 Debugging / Reproduction Notes
The report includes a precise code-level diagnosis: the sync path in the same method already loops over Stream.Read with an explicit comment acknowledging that reads can return fewer bytes than requested, but the async path calls ReadAsync once and discards the returned count. This is a straightforward sync/async parity bug rather than an environment- or service-side issue — it was found via source code review rather than a live repro, but the reasoning (AES-CBC relies on a correctly-aligned 16-byte IV) is sound and the fix is well-scoped.
Suggested investigation steps:
Review ClientSideDecryptor.DecryptReadInternalV1_0 and confirm the async branch's ciphertext.ReadAsync(IV, 0, IV.Length, cancellationToken) call ignores its return value.
Add a loop in the async path mirroring the sync path's while (totalRead < IV.Length) pattern, including a check that throws if the stream ends early.
Add/verify test coverage using a mock stream that returns partial reads (e.g., 8 bytes at a time) for ranged CSE v1.0 downloads to confirm the fix and guard against regression.
🏷️ Label Confidence
Category:Client — the bug is in SDK-side decryption logic (Azure.Storage.Blobs/Azure.Storage.Common), not REST API or service behavior.
Confidence: High — package and affected API are explicitly named, there is no ambiguity across multiple services, and the pattern matches quality reference issues.
👥 Owner Routing
Matched CODEOWNERS entry:# ServiceLabel: %Storage (line 506) — the only Storage-specific ServiceLabel entry matching the predicted Storage label; scanned bottom-to-top, the only other Storage-related entry was %Data Lake Storage Gen2 (line 584), which did not match since the issue is not specific to Data Lake Gen2.
Routing action: Requested "Service Attention" equivalent via ServiceOwners mention; added needs-team-attention since reporter is external and no AzureSdkOwners were listed for direct assignment.
Scan notes: No other entries matched both predicted labels; this was the first and only match for %Storage.
Generated by Agentic Triage for #63859 · copilot · auto · 33.4 AIC · ⌖ 7.73 AIC · ⊞ 16.6K · ◷
In ClientSideDecryptor.DecryptReadInternalV1_0, the async path for reading the IV from a ranged-download ciphertext stream issues a single ReadAsync call and ignores the number of bytes actually returned, while the sync path correctly loops until all 16 IV bytes are read. A short async read silently corrupts the IV and produces wrong plaintext with no error.
🩹 Mitigation
No SDK-side workaround avoids this entirely while the IV is read from the stream. As a practical mitigation, avoid ranged/partial downloads (DownloadStreaming/OpenRead with an offset) of client-side-encryption v1.0 blobs via the async APIs when the underlying transport or stream wrapper may return short reads; using the synchronous download APIs for these ranged reads follows the correct, already-looping code path and is not affected by this bug.
🧭 Root Cause
sdk/storage/Azure.Storage.Common/src/Shared/ClientsideEncryption/ClientSideDecryptor.cs, in DecryptReadInternalV1_0, when ivInStream is true:
if(async){awaitciphertext.ReadAsync(IV,0,IV.Length,cancellationToken).ConfigureAwait(false);}
else
{inttotalRead=0;while(totalRead<IV.Length){// Stream.Read may return fewer bytes than requested, resulting in unreliable code.varbytesRead=ciphertext.Read(IV,totalRead,IV.Length-totalRead);totalRead+=bytesRead;if(bytesRead==0)
break;}}
The async branch makes exactly one ReadAsync call and discards its return value. Stream.ReadAsync is permitted to return fewer bytes than requested, so on a short read the trailing portion of the 16-byte IV buffer remains zero-filled, misaligning the AES-CBC decryption for the rest of the stream with no thrown exception. This is present in current main code, confirmed by direct inspection of the file above.
🛠️ Suggested Fix
Mirror the sync path's read loop in the async branch: repeatedly call ReadAsync accumulating into IV until IV.Length bytes have been read or the stream returns 0 bytes, and in both the sync and async branches throw (e.g. an IOException or similar) when fewer than 16 bytes were obtained before the stream ended, since a short IV read means the ciphertext is malformed/truncated and must not be decrypted silently. A regression test should exercise a ranged/partial CSE v1.0 download using a test stream wrapper whose ReadAsync/Read return fewer than 16 bytes per call, asserting correct decrypted content for the async path and an exception when the stream ends early.
✅ Decision Basis
Version currency. Issue is reported against main; the bug is directly confirmed present in current source, so the version-currency check does not apply.
Duplicate. No specific matching issue found.
Ownership. SDK side, confirmed by the source excerpt above showing the async/sync code-path asymmetry.
Scope. Bounded, single-method change (add a read loop + short-read exception) with a straightforward unit test using a short-read stream wrapper.
A maintainer can assign Copilot to proceed. Automated assignment is best effort on this repository and may not complete.
Activity
github-actions commentedon Oct 14, 2026
Thank you for your feedback. Tagging and routing to the team member(s) best able to assist.
//cc: Amanda Nguyen (@amnguye) Jacob Lauzon (@jalauzon-msft) Jocelyn (@jaschrep-msft) Nick Liu (@nickliu-msft) Sean McCullough (@seanmcc-msft)
github-actions commentedon Oct 14, 2026
🎯 Agentic Issue Triage
Summary: In
Azure.Storage.Blobs' client-side encryption v1 decryption path, the async branch ofClientSideDecryptor.DecryptReadInternalV1_0performs a singleReadAsynccall to read the 16-byte IV for ranged downloads, without looping to guarantee all 16 bytes are read, unlike the sync path which correctly loops.📋 Issue Details
Package:
Azure.Storage.Blobs(and sharedAzure.Storage.Commonclient-side encryption code), main @ 2b37a08Affected API:
ClientSideDecryptor.DecryptReadInternalV1_0(async path)Scenarios:
DownloadStreamingAsync/OpenReadAsyncReadAsynccall (common with network streams or retriable stream wrappers)Root ask: Fix the async path to loop until exactly 16 bytes are read for the IV (matching the sync path's existing loop), and throw if the stream ends before 16 bytes are available in either path.
🔎 Debugging / Reproduction Notes
The report includes a precise code-level diagnosis: the sync path in the same method already loops over
Stream.Readwith an explicit comment acknowledging that reads can return fewer bytes than requested, but the async path callsReadAsynconce and discards the returned count. This is a straightforward sync/async parity bug rather than an environment- or service-side issue — it was found via source code review rather than a live repro, but the reasoning (AES-CBC relies on a correctly-aligned 16-byte IV) is sound and the fix is well-scoped.Suggested investigation steps:
ClientSideDecryptor.DecryptReadInternalV1_0and confirm the async branch'sciphertext.ReadAsync(IV, 0, IV.Length, cancellationToken)call ignores its return value.while (totalRead < IV.Length)pattern, including a check that throws if the stream ends early.🏷️ Label Confidence
Client— the bug is in SDK-side decryption logic (Azure.Storage.Blobs/Azure.Storage.Common), not REST API or service behavior.Storage— issue explicitly namesAzure.Storage.Blobsand client-side encryption shared withAzure.Storage.Common; consistent with reference issues [QUERY] Migration path for blob encryption from Microsoft.Azure.Storage.Blob to Azure.Storage.Blobs #38264 and [BUG] Cannot get lease on container - HTTP 400 returned on header value #31866 which both used theClient+Storagelabel pair.👥 Owner Routing
# ServiceLabel: %Storage(line 506) — the only Storage-specificServiceLabelentry matching the predictedStoragelabel; scanned bottom-to-top, the only other Storage-related entry was%Data Lake Storage Gen2(line 584), which did not match since the issue is not specific to Data Lake Gen2.@amnguye,@jalauzon-msft,@jaschrep-msft,@nickliu-msft,@seanmcc-msftneeds-team-attentionsince reporter is external and no AzureSdkOwners were listed for direct assignment.%Storage.github-actions commentedon Oct 14, 2026
🔍 Agentic Issue Investigation
Outcome
Recommended for Copilot automated fix
Summary
In
ClientSideDecryptor.DecryptReadInternalV1_0, the async path for reading the IV from a ranged-download ciphertext stream issues a singleReadAsynccall and ignores the number of bytes actually returned, while the sync path correctly loops until all 16 IV bytes are read. A short async read silently corrupts the IV and produces wrong plaintext with no error.🩹 Mitigation
No SDK-side workaround avoids this entirely while the IV is read from the stream. As a practical mitigation, avoid ranged/partial downloads (
DownloadStreaming/OpenReadwith an offset) of client-side-encryption v1.0 blobs via the async APIs when the underlying transport or stream wrapper may return short reads; using the synchronous download APIs for these ranged reads follows the correct, already-looping code path and is not affected by this bug.🧭 Root Cause
sdk/storage/Azure.Storage.Common/src/Shared/ClientsideEncryption/ClientSideDecryptor.cs, inDecryptReadInternalV1_0, whenivInStreamis true:The async branch makes exactly one
ReadAsynccall and discards its return value.Stream.ReadAsyncis permitted to return fewer bytes than requested, so on a short read the trailing portion of the 16-byte IV buffer remains zero-filled, misaligning the AES-CBC decryption for the rest of the stream with no thrown exception. This is present in currentmaincode, confirmed by direct inspection of the file above.🛠️ Suggested Fix
Mirror the sync path's read loop in the async branch: repeatedly call
ReadAsyncaccumulating intoIVuntilIV.Lengthbytes have been read or the stream returns 0 bytes, and in both the sync and async branches throw (e.g. anIOExceptionor similar) when fewer than 16 bytes were obtained before the stream ended, since a short IV read means the ciphertext is malformed/truncated and must not be decrypted silently. A regression test should exercise a ranged/partial CSE v1.0 download using a test stream wrapper whoseReadAsync/Readreturn fewer than 16 bytes per call, asserting correct decrypted content for the async path and an exception when the stream ends early.✅ Decision Basis
main; the bug is directly confirmed present in current source, so the version-currency check does not apply.A maintainer can assign Copilot to proceed. Automated assignment is best effort on this repository and may not complete.