Skip to content

[BUG] Azure.Storage client-side encryption v1: async ranged download reads the IV with a single ReadAsync and can corrupt plaintext #63859

Open

Description

Library name and version

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)
{
    await ciphertext.ReadAsync(IV, 0, IV.Length, cancellationToken).ConfigureAwait(false);
}
else
{
    int totalRead = 0;
    while (totalRead < IV.Length)
    {
        //  Stream.Read may return fewer bytes than requested, resulting in unreliable code.
        var bytesRead = 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

  1. Upload a blob with client-side encryption v1.0.
  2. 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.
  3. 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.

Environment

Code review of main @ 2b37a08.

Activity

  1. github-actions commented on Oct 14, 2026

    @github-actions

    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)

  2. added
    StorageStorage Service (Queues, Blobs, Files)
    ClientThis issue points to a problem in the data-plane of the library.
    customer-reportedIssues that are reported by GitHub users external to the Azure organization.
    questionThe issue doesn't require a change to the product in order to be resolved. Most issues start as that
    needs-team-attentionWorkflow: This issue needs attention from Azure service team or SDK team
    on Oct 14, 2026
  3. github-actions commented on Oct 14, 2026

    @github-actions

    🎯 Agentic Issue Triage

    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.

    📋 Issue Details

    Package: Azure.Storage.Blobs (and shared Azure.Storage.Common client-side encryption code), main @ 2b37a08
    Affected API: ClientSideDecryptor.DecryptReadInternalV1_0 (async path)
    Scenarios:

    • 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:

    1. Review ClientSideDecryptor.DecryptReadInternalV1_0 and confirm the async branch's ciphertext.ReadAsync(IV, 0, IV.Length, cancellationToken) call ignores its return value.
    2. 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.
    3. 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
    👥 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.
    • AzureSdkOwners: none listed
    • ServiceOwners: @amnguye, @jalauzon-msft, @jaschrep-msft, @nickliu-msft, @seanmcc-msft
    • 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 · ◷

  4. github-actions commented on Oct 14, 2026

    @github-actions

    🔍 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 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)
    {
        await ciphertext.ReadAsync(IV, 0, IV.Length, cancellationToken).ConfigureAwait(false);
    }
    else
    {
        int totalRead = 0;
        while (totalRead < IV.Length)
        {
            //  Stream.Read may return fewer bytes than requested, resulting in unreliable code.
            var bytesRead = 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.

    Generated by Agentic Issue Investigation · copilot · auto · 31.6 AIC · ⌖ 10.9 AIC · ⊞ 13.8K · ◷

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ClientThis issue points to a problem in the data-plane of the library.StorageStorage Service (Queues, Blobs, Files)customer-reportedIssues that are reported by GitHub users external to the Azure organization.needs-team-attentionWorkflow: This issue needs attention from Azure service team or SDK teamquestionThe issue doesn't require a change to the product in order to be resolved. Most issues start as that

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions