Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-azure-storage-blob for 
openSUSE:Factory checked in at 2026-09-02 17:00:18
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-azure-storage-blob (Old)
 and      /work/SRC/openSUSE:Factory/.python-azure-storage-blob.new.1265 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-azure-storage-blob"

Wed Sep  2 17:00:18 2026 rev:48 rq:1375205 version:12.30.1

Changes:
--------
--- 
/work/SRC/openSUSE:Factory/python-azure-storage-blob/python-azure-storage-blob.changes
      2026-06-10 15:48:47.457872068 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-azure-storage-blob.new.1265/python-azure-storage-blob.changes
    2026-09-02 17:00:22.358408118 +0200
@@ -1,0 +2,8 @@
+Tue Sep  1 08:51:29 UTC 2026 - John Paul Adrian Glaubitz 
<[email protected]>
+
+- New upstream release
+  + Version 12.30.1
+  + For detailed information about changes see the
+    CHANGELOG.md file provided with this package
+
+-------------------------------------------------------------------

Old:
----
  azure_storage_blob-12.30.0.tar.gz

New:
----
  azure_storage_blob-12.30.1.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-azure-storage-blob.spec ++++++
--- /var/tmp/diff_new_pack.uiucMp/_old  2026-09-02 17:00:23.208437668 +0200
+++ /var/tmp/diff_new_pack.uiucMp/_new  2026-09-02 17:00:23.212437807 +0200
@@ -18,7 +18,7 @@
 
 %{?sle15_python_module_pythons}
 Name:           python-azure-storage-blob
-Version:        12.30.0
+Version:        12.30.1
 Release:        0
 Summary:        Microsoft Azure Storage Blob Client Library for Python
 License:        MIT

++++++ azure_storage_blob-12.30.0.tar.gz -> azure_storage_blob-12.30.1.tar.gz 
++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/azure_storage_blob-12.30.0/CHANGELOG.md 
new/azure_storage_blob-12.30.1/CHANGELOG.md
--- old/azure_storage_blob-12.30.0/CHANGELOG.md 2026-06-08 12:07:11.000000000 
+0200
+++ new/azure_storage_blob-12.30.1/CHANGELOG.md 2026-08-27 19:22:34.000000000 
+0200
@@ -1,5 +1,11 @@
 # Release History
 
+## 12.30.1 (2026-08-24)
+
+### Bugs Fixed
+- Fixed a bug where client-side encryption 2.0 could not detect a 
rearrangement of otherwise-untampered authenticated regions in blob content. 
This is now detected and exceptions are thrown. For data recovery purposes, 
this behavior can be reverted by setting the 
"AZURE_STORAGE_CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS" environment variable.
+- Fixed a bug in client-side encryption where version downgrades, and other 
metadata tampering, was only detected at the start of a download.
+
 ## 12.30.0 (2026-06-08)
 
 ### Features Added
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/azure_storage_blob-12.30.0/PKG-INFO 
new/azure_storage_blob-12.30.1/PKG-INFO
--- old/azure_storage_blob-12.30.0/PKG-INFO     2026-06-08 12:08:09.434975600 
+0200
+++ new/azure_storage_blob-12.30.1/PKG-INFO     2026-08-27 19:24:12.725366600 
+0200
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: azure-storage-blob
-Version: 12.30.0
+Version: 12.30.1
 Summary: Microsoft Azure Blob Storage Client Library for Python
 Home-page: 
https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/storage/azure-storage-blob
 Author: Microsoft Corporation
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/azure_storage_blob-12.30.0/azure/storage/blob/_download.py 
new/azure_storage_blob-12.30.1/azure/storage/blob/_download.py
--- old/azure_storage_blob-12.30.0/azure/storage/blob/_download.py      
2026-06-08 12:07:11.000000000 +0200
+++ new/azure_storage_blob-12.30.1/azure/storage/blob/_download.py      
2026-08-27 19:22:34.000000000 +0200
@@ -27,7 +27,8 @@
     decrypt_blob,
     get_adjusted_download_range_and_offset,
     is_encryption_v2,
-    parse_encryption_data
+    parse_encryption_data,
+    _GCMRegionNonceValidator,
 )
 
 if TYPE_CHECKING:
@@ -60,7 +61,13 @@
     return (start_range, end_range), (start_offset, end_offset)
 
 
-def process_content(data: Any, start_offset: int, end_offset: int, encryption: 
Dict[str, Any]) -> bytes:
+def process_content(
+    data: Any,
+    start_offset: int,
+    end_offset: int,
+    encryption: Dict[str, Any],
+    expected_encryption_data: Optional["_EncryptionData"],
+) -> bytes:
     if data is None:
         raise ValueError("Response cannot be None.")
 
@@ -76,6 +83,8 @@
                 start_offset,
                 end_offset,
                 data.response.headers,
+                expected_encryption_data,
+                encryption.get("gcm_nonce_validator"),
             )
         except Exception as error:
             raise HttpResponseError(message="Decryption failed.", 
response=data.response, error=error) from error
@@ -234,7 +243,9 @@
                     process_storage_error(error)
 
                 try:
-                    chunk_data = process_content(response, offset[0], 
offset[1], self.encryption_options)
+                    chunk_data = process_content(
+                        response, offset[0], offset[1], 
self.encryption_options, self.encryption_data
+                    )
                     retry_active = False
                 except (IncompleteReadError, HttpResponseError, DecodeError, 
ServiceResponseError) as error:
                     retry_total -= 1
@@ -380,6 +391,8 @@
 
         if self._encryption_options.get("key") is not None or 
self._encryption_options.get("resolver") is not None:
             self._get_encryption_data_request()
+            if is_encryption_v2(self._encryption_data):
+                self._encryption_options["gcm_nonce_validator"] = 
_GCMRegionNonceValidator()
 
         # The service only provides transactional MD5s for chunks under 4MB.
         # If validate_content is on, get only self.MAX_CHUNK_GET_SIZE for the 
first
@@ -520,7 +533,8 @@
                         response,
                         self._initial_offset[0],
                         self._initial_offset[1],
-                        self._encryption_options
+                        self._encryption_options,
+                        self._encryption_data,
                     )
                 retry_active = False
             except (IncompleteReadError, HttpResponseError, DecodeError, 
ServiceResponseError) as error:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/azure_storage_blob-12.30.0/azure/storage/blob/_encryption.py 
new/azure_storage_blob-12.30.1/azure/storage/blob/_encryption.py
--- old/azure_storage_blob-12.30.0/azure/storage/blob/_encryption.py    
2026-06-08 12:07:11.000000000 +0200
+++ new/azure_storage_blob-12.30.1/azure/storage/blob/_encryption.py    
2026-08-27 19:22:34.000000000 +0200
@@ -8,6 +8,7 @@
 import math
 import os
 import sys
+import threading
 import warnings
 from collections import OrderedDict
 from io import BytesIO
@@ -52,6 +53,13 @@
 _ERROR_UNSUPPORTED_METHOD_FOR_ENCRYPTION = (
     "The require_encryption flag is set, but encryption is not supported for 
this method."
 )
+_ERROR_ENCRYPTION_METADATA_MISMATCH = (
+    "The encryption metadata in the download response does not match the 
encryption metadata "
+    "retrieved at the start of the download. The blob's encryption metadata 
may have been modified "
+    "while the download was in progress."
+)
+
+_ERROR_INVALID_ENCRYPTION_METADATA = "The encryption metadata is not valid and 
may have been modified."
 
 
 class KeyEncryptionKey(Protocol):
@@ -216,6 +224,49 @@
         self.wrapped_content_key = wrapped_content_key
         self.key_wrapping_metadata = key_wrapping_metadata
 
+    def matches(self, other: "_EncryptionData") -> bool:
+        """
+        Determines whether this encryption data refers to the same encrypted 
content by comparing
+        every field that affects decryption. This is used to detect whether a 
blob's encryption
+        metadata has changed partway through a download, which could indicate 
the blob was
+        overwritten or tampered with.
+
+        :param _EncryptionData other: The encryption data to compare against.
+        :return: True if the decryption-relevant metadata matches, False 
otherwise.
+        :rtype: bool
+        """
+        if (
+            self.encryption_agent.protocol != other.encryption_agent.protocol
+            or self.encryption_agent.encryption_algorithm != 
other.encryption_agent.encryption_algorithm
+        ):
+            return False
+
+        if (
+            self.wrapped_content_key.key_id != other.wrapped_content_key.key_id
+            or self.wrapped_content_key.algorithm != 
other.wrapped_content_key.algorithm
+            or self.wrapped_content_key.encrypted_key != 
other.wrapped_content_key.encrypted_key
+        ):
+            return False
+
+        # Compare the content encryption IV (used for AES-CBC / V1).
+        if self.content_encryption_IV != other.content_encryption_IV:
+            return False
+
+        # Compare the encrypted region info (used for AES-GCM / V2).
+        self_region = self.encrypted_region_info
+        other_region = other.encrypted_region_info
+        if (self_region is None) != (other_region is None):
+            return False
+        if self_region is not None and other_region is not None:
+            if (
+                self_region.data_length != other_region.data_length
+                or self_region.nonce_length != other_region.nonce_length
+                or self_region.tag_length != other_region.tag_length
+            ):
+                return False
+
+        return True
+
 
 class GCMBlobEncryptionStream:
     """
@@ -665,7 +716,7 @@
         version_2_bytes = 
encryption_data.encryption_agent.protocol.encode().ljust(8, b"\0")
         cek_version_bytes = content_encryption_key[: len(version_2_bytes)]
         if cek_version_bytes != version_2_bytes:
-            raise ValueError("The encryption metadata is not valid and may 
have been modified.")
+            raise ValueError(_ERROR_INVALID_ENCRYPTION_METADATA)
 
         # Remove version from the start of the cek.
         content_encryption_key = content_encryption_key[len(version_2_bytes) :]
@@ -839,7 +890,96 @@
     return content_encryption_key, initialization_vector, encryption_data
 
 
-def decrypt_blob(  # pylint: disable=too-many-locals,too-many-statements
+def _parse_content_range(content_range: str) -> Tuple[int, int, int]:
+    """
+    Parses a Content-Range header of the form 'bytes x-y/size' into its
+    start, end, and total size components.
+
+    :param str content_range: The Content-Range header value.
+    :return: A tuple of (start, end, total size).
+    :rtype: Tuple[int, int, int]
+    """
+    # Format: 'bytes x-y/size' -- ignore the leading 'bytes' word.
+    byte_range, size = content_range.split(" ")[1].split("/")
+    start, end = byte_range.split("-")
+    return int(start), int(end), int(size)
+
+
+def _region_nonce_encodings(nonce_length: int) -> Dict[str, Callable[[int], 
bytes]]:
+    """
+    Returns the supported per-region nonce encodings, keyed by the SDK that 
produces them.
+
+    The per-region nonce is a counter of the region's position, but each SDK 
encodes it
+    differently, so all supported encodings must be understood for 
interoperability:
+
+    * Python: zero-based counter, big-endian across the whole nonce (value in 
trailing bytes).
+    * Java: zero-based counter, big-endian in the leading 8 bytes, trailing 
bytes zeroed.
+    * .NET: one-based counter, little-endian in the trailing 8 bytes, leading 
bytes zeroed.
+
+    These encodings share the same value space, so they must not be accepted 
independently
+    per region (for example Java's nonce for region 1 is identical to .NET's 
nonce for
+    region 16,777,215). A single encoding is instead selected and enforced 
across the whole
+    download; see ``decrypt_blob``.
+
+    :param int nonce_length: The length of the nonce in bytes.
+    :return: A mapping of SDK name to a function returning that SDK's nonce 
for a region index.
+    :rtype: Dict[str, Callable[[int], bytes]]
+    """
+    encodings: Dict[str, Callable[[int], bytes]] = {
+        "python": lambda index: index.to_bytes(nonce_length, "big"),
+    }
+
+    counter_length = 8
+    pad = nonce_length - counter_length
+    encodings["java"] = lambda index: index.to_bytes(counter_length, "big") + 
b"\x00" * pad
+    encodings["dotnet"] = lambda index: b"\x00" * pad + (index + 
1).to_bytes(counter_length, "little")
+
+    return encodings
+
+
+class _GCMRegionNonceValidator:
+    """
+    Enforces that every region across a whole download uses a single nonce 
encoding.
+
+    ``decrypt_blob`` runs once per HTTP chunk, so the candidate encodings are 
shared and
+    intersected across all chunks (including concurrent ones) rather than 
reset per call.
+    Otherwise the encoding could change at a chunk boundary and, at an 
encoding collision,
+    let a relocated region pass validation.
+    """
+
+    def __init__(self) -> None:
+        self._lock = threading.Lock()
+        self._candidates: Optional[Dict[str, Callable[[int], bytes]]] = None
+        # Set once the candidates collapse to a single encoding; read 
lock-free thereafter.
+        self._encoding: Optional[Callable[[int], bytes]] = None
+
+    def validate_region(self, region_index: int, nonce: bytes, nonce_length: 
int) -> None:
+        """
+        Narrows the shared candidate encodings to those consistent with this 
region.
+
+        :param int region_index: The zero-based index of the region within the 
blob.
+        :param bytes nonce: The nonce read from the region.
+        :param int nonce_length: The length of the nonce in bytes.
+        """
+        encoding = self._encoding
+        if encoding is not None:
+            if encoding(region_index) != nonce:
+                raise ValueError(_ERROR_INVALID_ENCRYPTION_METADATA)
+            return
+
+        with self._lock:
+            if self._candidates is None:
+                self._candidates = _region_nonce_encodings(nonce_length)
+            self._candidates = {
+                name: encode for name, encode in self._candidates.items() if 
encode(region_index) == nonce
+            }
+            if not self._candidates:
+                raise ValueError(_ERROR_INVALID_ENCRYPTION_METADATA)
+            if len(self._candidates) == 1:
+                self._encoding = next(iter(self._candidates.values()))
+
+
+def decrypt_blob(  # pylint: 
disable=too-many-locals,too-many-statements,too-many-branches
     require_encryption: bool,
     key_encryption_key: Optional[KeyEncryptionKey],
     key_resolver: Optional[Callable[[str], KeyEncryptionKey]],
@@ -847,6 +987,8 @@
     start_offset: int,
     end_offset: int,
     response_headers: Dict[str, Any],
+    expected_encryption_data: Optional[_EncryptionData] = None,
+    nonce_validator: Optional["_GCMRegionNonceValidator"] = None,
 ) -> bytes:
     """
     Decrypts the given blob contents and returns only the requested range.
@@ -874,12 +1016,22 @@
     :param Dict[str, Any] response_headers:
         A dictionary of response headers from the download request. Expected 
to include the
         'x-ms-meta-encryptiondata' header if the blob was encrypted.
+    :param Optional[_EncryptionData] expected_encryption_data:
+        The encryption data retrieved at the start of the download. If 
provided, the encryption
+        metadata on this response is validated against it to detect the blob's 
encryption metadata
+        being modified (tampered with) partway through a download.
+    :param Optional[_GCMRegionNonceValidator] nonce_validator:
+        Shared state used to enforce a single V2 nonce encoding across every 
chunk of a Blob download.
+        Required for V2 decryption unless the caller has set the
+        AZURE_STORAGE_CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS environment 
variable.
     :return: The decrypted blob content.
     :rtype: bytes
     """
     try:
         encryption_data = 
_dict_to_encryption_data(loads(response_headers["x-ms-meta-encryptiondata"]))
     except Exception as exc:  # pylint: disable=broad-except
+        if expected_encryption_data is not None:
+            raise ValueError(_ERROR_ENCRYPTION_METADATA_MISMATCH) from exc
         if require_encryption:
             raise ValueError(
                 "Encryption required, but received data does not contain 
appropriate metadata."
@@ -888,6 +1040,10 @@
 
         return content
 
+    # Validate that the encryption metadata has not changed since the start of 
the download.
+    if expected_encryption_data is not None and not 
expected_encryption_data.matches(encryption_data):
+        raise ValueError(_ERROR_ENCRYPTION_METADATA_MISMATCH)
+
     algorithm = encryption_data.encryption_agent.encryption_algorithm
     if algorithm not in (_EncryptionAlgorithm.AES_CBC_256, 
_EncryptionAlgorithm.AES_GCM_256):
         raise ValueError("Specified encryption algorithm is not supported.")
@@ -904,16 +1060,7 @@
         iv: Optional[bytes] = None
         unpad = False
         if "content-range" in response_headers:
-            content_range = response_headers["content-range"]
-            # Format: 'bytes x-y/size'
-
-            # Ignore the word 'bytes'
-            content_range = content_range.split(" ")
-
-            content_range = content_range[1].split("-")
-            content_range = content_range[1].split("/")
-            end_range = int(content_range[0])
-            blob_size = int(content_range[1])
+            _, end_range, blob_size = 
_parse_content_range(response_headers["content-range"])
 
             if start_offset >= 16:
                 iv = content[:16]
@@ -957,6 +1104,25 @@
         tag_length = encryption_data.encrypted_region_info.tag_length
         region_length = nonce_length + data_length + tag_length
 
+        # The per-region nonce is a counter of the region's index within the 
blob. The
+        # downloaded content always begins on a region boundary, so derive the 
first
+        # region's index from the download range (0 when the whole blob was 
downloaded).
+        # This lets us validate each nonce and detect reordered regions.
+        start_range = 0
+        if "content-range" in response_headers:
+            start_range, _, _ = 
_parse_content_range(response_headers["content-range"])
+        nonce_counter = start_range // region_length
+
+        # Bypass nonce validation via an environment variable for 
data-recovery scenarios
+        # where regions were reordered. Not recommended: it can allow tampered 
data through.
+        validate_nonce = os.environ.get(
+            "AZURE_STORAGE_CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS", ""
+        ).strip().lower() not in ("true", "1")
+
+        # A validator is required to enforce a single nonce encoding across 
the whole download.
+        if validate_nonce and nonce_validator is None:
+            raise ValueError("A nonce validator is required to decrypt 
Encryption V2 content.")
+
         decrypted_content = bytearray()
         while offset < total_size:
             # Process one encryption region at a time
@@ -965,6 +1131,10 @@
 
             # First bytes are the nonce
             nonce = encrypted_region[:nonce_length]
+            # Validate the nonce matches the expected counter for this region 
under a single
+            # consistent encoding. A mismatch indicates the regions were 
reordered or tampered with.
+            if nonce_validator is not None and validate_nonce:
+                nonce_validator.validate_region(nonce_counter, nonce, 
nonce_length)
             ciphertext_with_tag = encrypted_region[nonce_length:]
 
             aesgcm = AESGCM(content_encryption_key)
@@ -972,6 +1142,7 @@
             decrypted_content.extend(decrypted_data)
 
             offset += process_size
+            nonce_counter += 1
 
         # Read the caller requested data from the decrypted content
         return decrypted_content[start_offset:end_offset]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/azure_storage_blob-12.30.0/azure/storage/blob/_version.py 
new/azure_storage_blob-12.30.1/azure/storage/blob/_version.py
--- old/azure_storage_blob-12.30.0/azure/storage/blob/_version.py       
2026-06-08 12:07:11.000000000 +0200
+++ new/azure_storage_blob-12.30.1/azure/storage/blob/_version.py       
2026-08-27 19:22:34.000000000 +0200
@@ -4,4 +4,4 @@
 # license information.
 # --------------------------------------------------------------------------
 
-VERSION = "12.30.0"
+VERSION = "12.30.1"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/azure_storage_blob-12.30.0/azure/storage/blob/aio/_download_async.py 
new/azure_storage_blob-12.30.1/azure/storage/blob/aio/_download_async.py
--- old/azure_storage_blob-12.30.0/azure/storage/blob/aio/_download_async.py    
2026-06-08 12:07:11.000000000 +0200
+++ new/azure_storage_blob-12.30.1/azure/storage/blob/aio/_download_async.py    
2026-08-27 19:22:34.000000000 +0200
@@ -30,7 +30,8 @@
     adjust_blob_size_for_encryption,
     decrypt_blob,
     is_encryption_v2,
-    parse_encryption_data
+    parse_encryption_data,
+    _GCMRegionNonceValidator,
 )
 
 if TYPE_CHECKING:
@@ -44,7 +45,13 @@
 T = TypeVar('T', bytes, str)
 
 
-async def process_content(data: Any, start_offset: int, end_offset: int, 
encryption: Dict[str, Any]) -> bytes:
+async def process_content(
+    data: Any,
+    start_offset: int,
+    end_offset: int,
+    encryption: Dict[str, Any],
+    expected_encryption_data: Optional["_EncryptionData"],
+) -> bytes:
     if data is None:
         raise ValueError("Response cannot be None.")
     if hasattr(data.response, "is_stream_consumed") and 
data.response.is_stream_consumed:
@@ -60,7 +67,9 @@
                 content,
                 start_offset,
                 end_offset,
-                data.response.headers
+                data.response.headers,
+                expected_encryption_data,
+                encryption.get("gcm_nonce_validator"),
             )
         except Exception as error:
             raise HttpResponseError(
@@ -143,7 +152,9 @@
                     process_storage_error(error)
 
                 try:
-                    chunk_data = await process_content(response, offset[0], 
offset[1], self.encryption_options)
+                    chunk_data = await process_content(
+                        response, offset[0], offset[1], 
self.encryption_options, self.encryption_data
+                    )
                     retry_active = False
                 except (IncompleteReadError, HttpResponseError, DecodeError, 
ServiceResponseError) as error:
                     retry_total -= 1
@@ -316,6 +327,8 @@
     async def _setup(self) -> None:
         if self._encryption_options.get("key") is not None or 
self._encryption_options.get("resolver") is not None:
             await self._get_encryption_data_request()
+            if is_encryption_v2(self._encryption_data):
+                self._encryption_options["gcm_nonce_validator"] = 
_GCMRegionNonceValidator()
 
         # The service only provides transactional MD5s for chunks under 4MB.
         # If validate_content is on, get only self.MAX_CHUNK_GET_SIZE for the 
first
@@ -430,7 +443,8 @@
                         response,
                         self._initial_offset[0],
                         self._initial_offset[1],
-                        self._encryption_options
+                        self._encryption_options,
+                        self._encryption_data,
                     )
                 retry_active = False
             except (IncompleteReadError, HttpResponseError, DecodeError, 
ServiceResponseError) as error:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/azure_storage_blob-12.30.0/azure_storage_blob.egg-info/PKG-INFO 
new/azure_storage_blob-12.30.1/azure_storage_blob.egg-info/PKG-INFO
--- old/azure_storage_blob-12.30.0/azure_storage_blob.egg-info/PKG-INFO 
2026-06-08 12:08:09.000000000 +0200
+++ new/azure_storage_blob-12.30.1/azure_storage_blob.egg-info/PKG-INFO 
2026-08-27 19:24:12.000000000 +0200
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: azure-storage-blob
-Version: 12.30.0
+Version: 12.30.1
 Summary: Microsoft Azure Blob Storage Client Library for Python
 Home-page: 
https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/storage/azure-storage-blob
 Author: Microsoft Corporation
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/azure_storage_blob-12.30.0/tests/test_blob_encryption_v2.py 
new/azure_storage_blob-12.30.1/tests/test_blob_encryption_v2.py
--- old/azure_storage_blob-12.30.0/tests/test_blob_encryption_v2.py     
2026-06-08 12:07:11.000000000 +0200
+++ new/azure_storage_blob-12.30.1/tests/test_blob_encryption_v2.py     
2026-08-27 19:22:34.000000000 +0200
@@ -22,9 +22,15 @@
 
 from azure.core import MatchConditions
 from azure.core.exceptions import HttpResponseError, ResourceExistsError
-from azure.storage.blob import BlobServiceClient, BlobType, ContentSettings
+from azure.storage.blob import BlobBlock, BlobServiceClient, BlobType, 
ContentSettings
 from azure.storage.blob._encryption import (
-    _dict_to_encryption_data, _GCM_NONCE_LENGTH, _GCM_TAG_LENGTH, 
_validate_and_unwrap_cek,
+    _dict_to_encryption_data,
+    _GCM_NONCE_LENGTH,
+    _GCM_TAG_LENGTH,
+    _GCMRegionNonceValidator,
+    _region_nonce_encodings,
+    _validate_and_unwrap_cek,
+    decrypt_blob,
 )
 
 
@@ -385,6 +391,55 @@
 
         assert 'Decryption failed.' in str(e.value)
 
+    @pytest.mark.live_test_only
+    @BlobPreparer()
+    def test_encryption_v2_v1_downgrade_mid_download(self, **kwargs):
+        storage_account_name = kwargs.pop("storage_account_name")
+        storage_account_key = kwargs.pop("storage_account_key")
+
+        self._setup(storage_account_name, storage_account_key)
+        kek = KeyWrapper("key1")
+        bsc = BlobServiceClient(
+            self.account_url(storage_account_name, "blob"),
+            credential=storage_account_key.secret,
+            max_single_get_size=4 * MiB,
+            max_chunk_get_size=4 * MiB,
+            require_encryption=True,
+            encryption_version="2.0",
+            key_encryption_key=kek,
+        )
+
+        blob = bsc.get_blob_client(self.container_name, 
self._get_blob_reference())
+        content = b"abcd" * 2 * MiB  # 8 MiB spans multiple encryption regions 
-> multiple download requests
+
+        blob.upload_blob(content, overwrite=True)
+
+        # Build tampered metadata that downgrades the blob from V2 to V1
+        metadata = blob.get_blob_properties().metadata
+        tampered = loads(metadata["encryptiondata"])
+        tampered["EncryptionAgent"]["Protocol"] = "1.0"
+        tampered["EncryptionAgent"]["EncryptionAlgorithm"] = "AES_CBC_256"
+        tampered["ContentEncryptionIV"] = 
base64.b64encode(os.urandom(16)).decode("utf-8")
+        tampered_header = dumps(tampered)
+
+        # Simulate the service returning downgraded (V1) encryption metadata 
partway through the
+        # download by tampering with the response headers of every request 
after the initial one.
+        from azure.storage.blob import _download as download_module
+
+        real_process_content = download_module.process_content
+        call_count = {"value": 0}
+
+        def tampering_process_content(data, start_offset, end_offset, 
encryption, expected_encryption_data):
+            call_count["value"] += 1
+            if call_count["value"] > 1:
+                data.response.headers["x-ms-meta-encryptiondata"] = 
tampered_header
+            return real_process_content(data, start_offset, end_offset, 
encryption, expected_encryption_data)
+
+        # Act / Assert
+        with mock.patch.object(download_module, "process_content", 
tampering_process_content):
+            with pytest.raises(HttpResponseError) as e:
+                blob.download_blob().readall()
+
     @BlobPreparer()
     @recorded_by_proxy
     @mock.patch('os.urandom', mock_urandom)
@@ -418,6 +473,45 @@
 
         assert 'Decryption failed.' in str(e.value)
 
+    @pytest.mark.live_test_only
+    @BlobPreparer()
+    def test_encryption_reordered_regions(self, **kwargs):
+        storage_account_name = kwargs.pop("storage_account_name")
+        storage_account_key = kwargs.pop("storage_account_key")
+
+        self._setup(storage_account_name, storage_account_key)
+        kek = KeyWrapper("key1")
+        # Each encrypted region is the plaintext region plus a nonce and tag. 
Size each
+        # block to a full encrypted region so every committed block is exactly 
one region.
+        region_length = 4 * MiB + _GCM_NONCE_LENGTH + _GCM_TAG_LENGTH
+        bsc = BlobServiceClient(
+            self.account_url(storage_account_name, "blob"),
+            credential=storage_account_key.secret,
+            max_single_put_size=1024,
+            max_block_size=region_length,
+            require_encryption=True,
+            encryption_version="2.0",
+            key_encryption_key=kek,
+        )
+
+        blob = bsc.get_blob_client(self.container_name, 
self._get_blob_reference())
+        content = b"abcd" * 3 * MiB  # 12 MiB -- three full 4 MiB encryption 
regions
+        blob.upload_blob(content, overwrite=True)
+
+        # Reorder the committed blocks so the encryption regions are out of 
order.
+        plain_blob = self.bsc.get_blob_client(self.container_name, 
self._get_blob_reference())
+        metadata = plain_blob.get_blob_properties().metadata
+        committed, _ = plain_blob.get_block_list(block_list_type="committed")
+        reordered = committed[:-2] + committed[-1:] + committed[-2:-1]
+        reordered = [BlobBlock(block_id=block.id) for block in reordered]
+        plain_blob.commit_block_list(reordered, metadata=metadata)
+
+        # Act / Assert -- a region's nonce no longer matches its position
+        with pytest.raises(HttpResponseError) as e:
+            blob.download_blob().readall()
+
+        assert "Decryption failed." in str(e.value)
+
     @BlobPreparer()
     @recorded_by_proxy
     @mock.patch('os.urandom', mock_urandom)
@@ -1236,3 +1330,171 @@
 
         blob.upload_blob(content, overwrite=True, 
raw_request_hook=assert_user_agent)
         blob.download_blob(raw_request_hook=assert_user_agent).readall()
+
+
+class TestGCMRegionNonceValidation:
+    REGION_DATA_LENGTH = 32
+
+    @staticmethod
+    def _encryption_headers(kek, cek, protocol, library, 
data_length=REGION_DATA_LENGTH):
+        # Wrap the CEK the way the V2 protocol requires (version prefix padded 
to 8 bytes).
+        wrapped_cek = kek.wrap_key(protocol.encode().ljust(8, b"\x00") + cek)
+        encryption_data = {
+            "WrappedContentKey": {
+                "KeyId": kek.get_kid(),
+                "EncryptedKey": base64.b64encode(wrapped_cek).decode(),
+                "Algorithm": kek.get_key_wrap_algorithm(),
+            },
+            "EncryptionAgent": {"Protocol": protocol, "EncryptionAlgorithm": 
"AES_GCM_256"},
+            "EncryptedRegionInfo": {"DataLength": data_length, "NonceLength": 
_GCM_NONCE_LENGTH},
+            "KeyWrappingMetadata": {"EncryptionLibrary": library},
+        }
+        return {"x-ms-meta-encryptiondata": dumps(encryption_data)}
+
+    @staticmethod
+    def _encrypt_regions(cek, nonce_for_region, plaintext_regions):
+        aesgcm = AESGCM(cek)
+        return [
+            nonce_for_region(i) + aesgcm.encrypt(nonce_for_region(i), region, 
None)
+            for i, region in enumerate(plaintext_regions)
+        ]
+
+    @staticmethod
+    def _decrypt(kek, headers, content, end_offset, nonce_validator):
+        return decrypt_blob(
+            require_encryption=True,
+            key_encryption_key=kek,
+            key_resolver=None,
+            content=content,
+            start_offset=0,
+            end_offset=end_offset,
+            response_headers=headers,
+            nonce_validator=nonce_validator,
+        )
+
+    def test_decrypt_dotnet_v2_1_nonce_encoding(self):
+        # Regression test for cross-SDK interoperability. The .NET Storage SDK 
encodes each
+        # region's GCM nonce as a one-based counter written little-endian into 
the final 8
+        # nonce bytes. Python must still decrypt these .NET-produced V2.1 
blobs while
+        # continuing to detect reordered regions.
+        kek = KeyWrapper("key1")
+        cek = os.urandom(32)
+        num_regions = 3
+        plaintext_regions = [bytes([i]) * self.REGION_DATA_LENGTH for i in 
range(num_regions)]
+
+        def dotnet_nonce(region_index):
+            # 4 zero bytes + one-based counter, little-endian, 8 bytes -- see 
.NET
+            # GcmAuthenticatedCryptographicTransform.GetNewNonce().
+            return b"\x00\x00\x00\x00" + (region_index + 1).to_bytes(8, 
"little")
+
+        encrypted_regions = self._encrypt_regions(cek, dotnet_nonce, 
plaintext_regions)
+        headers = self._encryption_headers(kek, cek, "2.1", "Dotnet")
+        plaintext = b"".join(plaintext_regions)
+
+        # Act / Assert -- the .NET nonce encoding is accepted and the content 
round-trips.
+        decrypted = self._decrypt(kek, headers, b"".join(encrypted_regions), 
len(plaintext), _GCMRegionNonceValidator())
+        assert decrypted == plaintext
+
+        # Reordering the .NET-produced regions must still be detected.
+        reordered = encrypted_regions[0] + encrypted_regions[2] + 
encrypted_regions[1]
+        with pytest.raises(ValueError):
+            self._decrypt(kek, headers, reordered, len(plaintext), 
_GCMRegionNonceValidator())
+
+    def test_decrypt_java_v2_nonce_encoding(self):
+        # Regression test for cross-SDK interoperability. The Java Storage SDK 
encodes each
+        # region's GCM nonce as a zero-based counter written big-endian into 
the leading 8
+        # nonce bytes (ByteBuffer.allocate(12).putLong(index)), which differs 
from Python's
+        # full-width big-endian counter. Python must still decrypt 
Java-produced V2 blobs
+        # while continuing to detect reordered regions.
+        kek = KeyWrapper("key1")
+        cek = os.urandom(32)
+        num_regions = 3
+        plaintext_regions = [bytes([i]) * self.REGION_DATA_LENGTH for i in 
range(num_regions)]
+
+        def java_nonce(region_index):
+            # Zero-based counter, big-endian, in the leading 8 bytes; trailing 
bytes zeroed.
+            return region_index.to_bytes(8, "big") + b"\x00" * 
(_GCM_NONCE_LENGTH - 8)
+
+        encrypted_regions = self._encrypt_regions(cek, java_nonce, 
plaintext_regions)
+        headers = self._encryption_headers(kek, cek, "2.0", "Java")
+        plaintext = b"".join(plaintext_regions)
+
+        # Act / Assert -- the Java nonce encoding is accepted and the content 
round-trips.
+        decrypted = self._decrypt(kek, headers, b"".join(encrypted_regions), 
len(plaintext), _GCMRegionNonceValidator())
+        assert decrypted == plaintext
+
+        # Reordering the Java-produced regions must still be detected.
+        reordered = encrypted_regions[0] + encrypted_regions[2] + 
encrypted_regions[1]
+        with pytest.raises(ValueError):
+            self._decrypt(kek, headers, reordered, len(plaintext), 
_GCMRegionNonceValidator())
+
+    def test_decrypt_rejects_mixed_nonce_encodings(self):
+        # Regression test: the supported SDK nonce encodings share a value 
space, so accepting
+        # them independently per region would weaken reorder detection. For 
example Java's
+        # nonce for region 1 is identical to .NET's nonce for region 
16,777,215, so a Java
+        # region could be moved to that position and still pass a per-region 
union check.
+        # decrypt_blob must instead select a single encoding and enforce it 
consistently.
+        encodings = _region_nonce_encodings(_GCM_NONCE_LENGTH)
+        # Document the overlap that motivates single-encoding enforcement.
+        assert encodings["java"](1) == encodings["dotnet"](16_777_215)
+
+        kek = KeyWrapper("key1")
+        cek = os.urandom(32)
+        aesgcm = AESGCM(cek)
+
+        # Region 0 uses the Java/Python encoding (all zeros); region 1 uses 
the .NET encoding.
+        # A per-region union check would accept both; single-encoding 
enforcement rejects the mix.
+        region0_nonce = encodings["java"](0)
+        region1_nonce = encodings["dotnet"](1)
+        region0 = region0_nonce + aesgcm.encrypt(region0_nonce, b"\x00" * 
self.REGION_DATA_LENGTH, None)
+        region1 = region1_nonce + aesgcm.encrypt(region1_nonce, b"\x11" * 
self.REGION_DATA_LENGTH, None)
+        headers = self._encryption_headers(kek, cek, "2.0", "Mixed")
+
+        # Act / Assert -- the mixed encoding is rejected rather than silently 
accepted.
+        with pytest.raises(ValueError):
+            self._decrypt(kek, headers, region0 + region1, 2 * 
self.REGION_DATA_LENGTH, _GCMRegionNonceValidator())
+
+    def test_nonce_validator_enforces_single_encoding_across_chunks(self):
+        # decrypt_blob runs once per download chunk, so a shared validator 
must intersect the
+        # candidate encodings across chunks; otherwise the encoding could 
change at a chunk
+        # boundary and, at a collision, let a relocated region pass.
+        encodings = _region_nonce_encodings(_GCM_NONCE_LENGTH)
+        # The collision that makes per-chunk validation unsound: Java region 1 
== .NET region 16,777,215.
+        assert encodings["java"](1) == encodings["dotnet"](16_777_215)
+
+        validator = _GCMRegionNonceValidator()
+        # First chunk: two Java regions resolve the encoding to Java.
+        validator.validate_region(0, encodings["java"](0), _GCM_NONCE_LENGTH)
+        validator.validate_region(1, encodings["java"](1), _GCM_NONCE_LENGTH)
+
+        # Later chunk: a region relocated to the colliding .NET index carries 
Java's region-1
+        # nonce. Its only consistent encoding is .NET, which conflicts with 
the resolved Java
+        # encoding, so the shared validator rejects it.
+        with pytest.raises(ValueError):
+            validator.validate_region(16_777_215, encodings["java"](1), 
_GCM_NONCE_LENGTH)
+
+    def test_env_var_bypasses_nonce_validation(self):
+        # The AZURE_STORAGE_CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS escape hatch 
disables nonce
+        # validation for data-recovery scenarios. With it set, a mix of nonce 
encodings that
+        # would normally be rejected must decrypt, and no validator is 
required.
+        encodings = _region_nonce_encodings(_GCM_NONCE_LENGTH)
+        kek = KeyWrapper("key1")
+        cek = os.urandom(32)
+        aesgcm = AESGCM(cek)
+
+        # Two regions using incompatible encodings (Java for region 0, .NET 
for region 1).
+        region0_plaintext = b"\x00" * self.REGION_DATA_LENGTH
+        region1_plaintext = b"\x11" * self.REGION_DATA_LENGTH
+        region0_nonce = encodings["java"](0)
+        region1_nonce = encodings["dotnet"](1)
+        region0 = region0_nonce + aesgcm.encrypt(region0_nonce, 
region0_plaintext, None)
+        region1 = region1_nonce + aesgcm.encrypt(region1_nonce, 
region1_plaintext, None)
+        headers = self._encryption_headers(kek, cek, "2.0", "Mixed")
+        plaintext = region0_plaintext + region1_plaintext
+
+        # Act / Assert -- with the bypass set, decryption succeeds without a 
validator.
+        with mock.patch.dict(os.environ, 
{"AZURE_STORAGE_CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS": "true"}):
+            decrypted = self._decrypt(
+                kek, headers, region0 + region1, 2 * self.REGION_DATA_LENGTH, 
nonce_validator=None
+            )
+        assert decrypted == plaintext
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/azure_storage_blob-12.30.0/tests/test_blob_encryption_v2_async.py 
new/azure_storage_blob-12.30.1/tests/test_blob_encryption_v2_async.py
--- old/azure_storage_blob-12.30.0/tests/test_blob_encryption_v2_async.py       
2026-06-08 12:07:11.000000000 +0200
+++ new/azure_storage_blob-12.30.1/tests/test_blob_encryption_v2_async.py       
2026-08-27 19:22:34.000000000 +0200
@@ -23,7 +23,7 @@
 
 from azure.core import MatchConditions
 from azure.core.exceptions import HttpResponseError, ResourceExistsError
-from azure.storage.blob import BlobType, ContentSettings
+from azure.storage.blob import BlobBlock, BlobType, ContentSettings
 from azure.storage.blob._encryption import (
     _dict_to_encryption_data,
     _GCM_NONCE_LENGTH,
@@ -392,6 +392,55 @@
 
         assert 'Decryption failed.' in str(e.value)
 
+    @pytest.mark.live_test_only
+    @BlobPreparer()
+    async def test_encryption_v2_v1_downgrade_mid_download(self, **kwargs):
+        storage_account_name = kwargs.pop("storage_account_name")
+        storage_account_key = kwargs.pop("storage_account_key")
+
+        await self._setup(storage_account_name, storage_account_key)
+        kek = KeyWrapper("key1")
+        bsc = BlobServiceClient(
+            self.account_url(storage_account_name, "blob"),
+            credential=storage_account_key.secret,
+            max_single_get_size=4 * MiB,
+            max_chunk_get_size=4 * MiB,
+            require_encryption=True,
+            encryption_version="2.0",
+            key_encryption_key=kek,
+        )
+
+        blob = bsc.get_blob_client(self.container_name, 
self._get_blob_reference())
+        content = b"abcd" * 2 * MiB  # 8 MiB spans multiple encryption regions 
-> multiple download requests
+
+        await blob.upload_blob(content, overwrite=True)
+
+        # Build tampered metadata that downgrades the blob from V2 to V1
+        metadata = (await blob.get_blob_properties()).metadata
+        tampered = loads(metadata["encryptiondata"])
+        tampered["EncryptionAgent"]["Protocol"] = "1.0"
+        tampered["EncryptionAgent"]["EncryptionAlgorithm"] = "AES_CBC_256"
+        tampered["ContentEncryptionIV"] = 
base64.b64encode(os.urandom(16)).decode("utf-8")
+        tampered_header = dumps(tampered)
+
+        # Simulate the service returning downgraded (V1) encryption metadata 
partway through the
+        # download by tampering with the response headers of every request 
after the initial one.
+        from azure.storage.blob.aio import _download_async as download_module
+
+        real_process_content = download_module.process_content
+        call_count = {"value": 0}
+
+        async def tampering_process_content(data, start_offset, end_offset, 
encryption, expected_encryption_data):
+            call_count["value"] += 1
+            if call_count["value"] > 1:
+                data.response.headers["x-ms-meta-encryptiondata"] = 
tampered_header
+            return await real_process_content(data, start_offset, end_offset, 
encryption, expected_encryption_data)
+
+        # Act / Assert
+        with mock.patch.object(download_module, "process_content", 
tampering_process_content):
+            with pytest.raises(HttpResponseError) as e:
+                await (await blob.download_blob()).readall()
+
     @BlobPreparer()
     @recorded_by_proxy_async
     async def test_encryption_modify_cek(self, **kwargs):
@@ -425,6 +474,45 @@
 
         assert 'Decryption failed.' in str(e.value)
 
+    @pytest.mark.live_test_only
+    @BlobPreparer()
+    async def test_encryption_reordered_regions(self, **kwargs):
+        storage_account_name = kwargs.pop("storage_account_name")
+        storage_account_key = kwargs.pop("storage_account_key")
+
+        await self._setup(storage_account_name, storage_account_key)
+        kek = KeyWrapper("key1")
+        # Each encrypted region is the plaintext region plus a nonce and tag. 
Size each
+        # block to a full encrypted region so every committed block is exactly 
one region.
+        region_length = 4 * MiB + _GCM_NONCE_LENGTH + _GCM_TAG_LENGTH
+        bsc = BlobServiceClient(
+            self.account_url(storage_account_name, "blob"),
+            credential=storage_account_key.secret,
+            max_single_put_size=1024,
+            max_block_size=region_length,
+            require_encryption=True,
+            encryption_version="2.0",
+            key_encryption_key=kek,
+        )
+
+        blob = bsc.get_blob_client(self.container_name, 
self._get_blob_reference())
+        content = b"abcd" * 3 * MiB  # 12 MiB -- three full 4 MiB encryption 
regions
+        await blob.upload_blob(content, overwrite=True)
+
+        # Reorder the committed blocks so the encryption regions are out of 
order.
+        plain_blob = self.bsc.get_blob_client(self.container_name, 
self._get_blob_reference())
+        metadata = (await plain_blob.get_blob_properties()).metadata
+        committed, _ = await 
plain_blob.get_block_list(block_list_type="committed")
+        reordered = committed[:-2] + committed[-1:] + committed[-2:-1]
+        reordered = [BlobBlock(block_id=block.id) for block in reordered]
+        await plain_blob.commit_block_list(reordered, metadata=metadata)
+
+        # Act / Assert -- a region's nonce no longer matches its position
+        with pytest.raises(HttpResponseError) as e:
+            await (await blob.download_blob()).readall()
+
+        assert "Decryption failed." in str(e.value)
+
     @BlobPreparer()
     @recorded_by_proxy_async
     async def test_case_insensitive_metadata_key(self, **kwargs):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/azure_storage_blob-12.30.0/tests/test_retry.py 
new/azure_storage_blob-12.30.1/tests/test_retry.py
--- old/azure_storage_blob-12.30.0/tests/test_retry.py  2026-06-08 
12:07:11.000000000 +0200
+++ new/azure_storage_blob-12.30.1/tests/test_retry.py  2026-08-27 
19:22:34.000000000 +0200
@@ -666,14 +666,14 @@
         # Mock the internal response to raise ServiceResponseError on first 
chunk processing
         from azure.storage.blob._download import process_content as 
real_process_content
 
-        def mock_process_content_with_error(response, start_offset, 
end_offset, encryption):
+        def mock_process_content_with_error(response, start_offset, 
end_offset, encryption, expected_encryption_data):
             retry_counter.simple_count(retry)
             conn_error = AzureError("Connection reset by peer")
             if retry_counter.count == 1:
                 raise ServiceResponseError(conn_error, error=conn_error)
             if retry_counter.count == 2:
                 raise ServiceResponseTimeoutError(conn_error, error=conn_error)
-            return real_process_content(response, start_offset, end_offset, 
encryption)
+            return real_process_content(response, start_offset, end_offset, 
encryption, expected_encryption_data)
 
         # Act
         try:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/azure_storage_blob-12.30.0/tests/test_retry_async.py 
new/azure_storage_blob-12.30.1/tests/test_retry_async.py
--- old/azure_storage_blob-12.30.0/tests/test_retry_async.py    2026-06-08 
12:07:11.000000000 +0200
+++ new/azure_storage_blob-12.30.1/tests/test_retry_async.py    2026-08-27 
19:22:34.000000000 +0200
@@ -644,14 +644,16 @@
         # Mock the internal response to raise ServiceResponseError on first 
chunk processing
         from azure.storage.blob.aio._download_async import process_content as 
real_process_content
 
-        async def mock_process_content_with_error(response, start_offset, 
end_offset, encryption):
+        async def mock_process_content_with_error(
+            response, start_offset, end_offset, encryption, 
expected_encryption_data
+        ):
             retry_counter.simple_count(retry)
             conn_error = AzureError("Connection reset by peer")
             if retry_counter.count == 1:
                 raise ServiceResponseError(conn_error, error=conn_error)
             if retry_counter.count == 2:
                 raise ServiceResponseTimeoutError(conn_error, error=conn_error)
-            return await real_process_content(response, start_offset, 
end_offset, encryption)
+            return await real_process_content(response, start_offset, 
end_offset, encryption, expected_encryption_data)
 
         # Act
         try:

Reply via email to