diff --git a/sdk/storage/azure-storage-blob/azure/storage/blob/_download.py b/sdk/storage/azure-storage-blob/azure/storage/blob/_download.py index d704d13bccaa..40ac3d521745 100644 --- a/sdk/storage/azure-storage-blob/azure/storage/blob/_download.py +++ b/sdk/storage/azure-storage-blob/azure/storage/blob/_download.py @@ -41,6 +41,7 @@ get_adjusted_download_range_and_offset, is_encryption_v2, parse_encryption_data, + _GCMRegionNonceValidator, ) if TYPE_CHECKING: @@ -85,6 +86,7 @@ def process_content(data: Any, start_offset: int, end_offset: int, encryption: D start_offset, end_offset, data.response.headers, + encryption.get("gcm_nonce_validator"), ) except Exception as error: raise HttpResponseError(message="Decryption failed.", response=data.response, error=error) from error @@ -388,6 +390,8 @@ def __init__( 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 using MD5, get only self.MAX_CHUNK_GET_SIZE for the first diff --git a/sdk/storage/azure-storage-blob/azure/storage/blob/_encryption.py b/sdk/storage/azure-storage-blob/azure/storage/blob/_encryption.py index 2153d1da1da6..b0854340d6ac 100644 --- a/sdk/storage/azure-storage-blob/azure/storage/blob/_encryption.py +++ b/sdk/storage/azure-storage-blob/azure/storage/blob/_encryption.py @@ -8,6 +8,7 @@ import math import os import sys +import threading import warnings from collections import OrderedDict from io import BytesIO @@ -53,6 +54,8 @@ "The require_encryption flag is set, but encryption is not supported for this method." ) +_ERROR_INVALID_ENCRYPTION_METADATA = "The encryption metadata is not valid and may have been modified." + class KeyEncryptionKey(Protocol): @@ -665,7 +668,7 @@ def _validate_and_unwrap_cek( 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,6 +842,95 @@ def generate_blob_encryption_data( return content_encryption_key, initialization_vector, encryption_data +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 require_encryption: bool, key_encryption_key: Optional[KeyEncryptionKey], @@ -847,6 +939,7 @@ def decrypt_blob( # pylint: disable=too-many-locals,too-many-statements start_offset: int, end_offset: int, response_headers: Dict[str, Any], + nonce_validator: Optional["_GCMRegionNonceValidator"] = None, ) -> bytes: """ Decrypts the given blob contents and returns only the requested range. @@ -874,6 +967,10 @@ def decrypt_blob( # pylint: disable=too-many-locals,too-many-statements :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[_GCMRegionNonceValidator] nonce_validator: + Shared state used to enforce a single V2 nonce encoding across every chunk of a + download. Required for V2 decryption unless nonce validation is bypassed via the + AZURE_STORAGE_CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS environment variable. :return: The decrypted blob content. :rtype: bytes """ @@ -904,16 +1001,7 @@ def decrypt_blob( # pylint: disable=too-many-locals,too-many-statements 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 +1045,25 @@ def decrypt_blob( # pylint: disable=too-many-locals,too-many-statements 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 +1072,10 @@ def decrypt_blob( # pylint: disable=too-many-locals,too-many-statements # 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 +1083,7 @@ def decrypt_blob( # pylint: disable=too-many-locals,too-many-statements 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 --git a/sdk/storage/azure-storage-blob/azure/storage/blob/aio/_download_async.py b/sdk/storage/azure-storage-blob/azure/storage/blob/aio/_download_async.py index 609f5efb3e1e..3445a8f0d2dc 100644 --- a/sdk/storage/azure-storage-blob/azure/storage/blob/aio/_download_async.py +++ b/sdk/storage/azure-storage-blob/azure/storage/blob/aio/_download_async.py @@ -38,7 +38,13 @@ from .._shared.validation import is_md5_validation, CV_TYPE_PARSED from .._deserialize import deserialize_blob_properties, get_page_ranges_result from .._download import process_range_and_offset, _ChunkDownloader -from .._encryption import adjust_blob_size_for_encryption, decrypt_blob, is_encryption_v2, parse_encryption_data +from .._encryption import ( + adjust_blob_size_for_encryption, + decrypt_blob, + is_encryption_v2, + parse_encryption_data, + _GCMRegionNonceValidator, +) if TYPE_CHECKING: from codecs import IncrementalDecoder @@ -68,6 +74,7 @@ async def process_content(data: Any, start_offset: int, end_offset: int, encrypt start_offset, end_offset, data.response.headers, + encryption.get("gcm_nonce_validator"), ) except Exception as error: raise HttpResponseError(message="Decryption failed.", response=data.response, error=error) from error @@ -322,6 +329,8 @@ async def _get_encryption_data_request(self) -> None: 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 using MD5, get only self.MAX_CHUNK_GET_SIZE for the first diff --git a/sdk/storage/azure-storage-blob/tests/test_blob_encryption_v2.py b/sdk/storage/azure-storage-blob/tests/test_blob_encryption_v2.py index 05fa020d065c..eba0ffa545ef 100644 --- a/sdk/storage/azure-storage-blob/tests/test_blob_encryption_v2.py +++ b/sdk/storage/azure-storage-blob/tests/test_blob_encryption_v2.py @@ -23,12 +23,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, + _GCMRegionNonceValidator, + _region_nonce_encodings, _validate_and_unwrap_cek, + decrypt_blob, ) TEST_CONTAINER_PREFIX = "encryptionv2_container" @@ -422,6 +425,45 @@ def test_encryption_modify_cek(self, **kwargs): 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) @@ -1257,3 +1299,169 @@ def assert_user_agent(request): 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 --git a/sdk/storage/azure-storage-blob/tests/test_blob_encryption_v2_async.py b/sdk/storage/azure-storage-blob/tests/test_blob_encryption_v2_async.py index e3979cc3b27c..f26558d6613e 100644 --- a/sdk/storage/azure-storage-blob/tests/test_blob_encryption_v2_async.py +++ b/sdk/storage/azure-storage-blob/tests/test_blob_encryption_v2_async.py @@ -24,7 +24,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, @@ -427,6 +427,45 @@ async def test_encryption_modify_cek(self, **kwargs): 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):