Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
get_adjusted_download_range_and_offset,
is_encryption_v2,
parse_encryption_data,
_GCMRegionNonceValidator,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
134 changes: 123 additions & 11 deletions sdk/storage/azure-storage-blob/azure/storage/blob/_encryption.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import math
import os
import sys
import threading
import warnings
from collections import OrderedDict
from io import BytesIO
Expand Down Expand Up @@ -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):

Expand Down Expand Up @@ -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) :]
Expand Down Expand Up @@ -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
}
Comment on lines +923 to +927
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],
Expand All @@ -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.
Expand Down Expand Up @@ -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
"""
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand All @@ -965,13 +1072,18 @@ 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)
decrypted_data = aesgcm.decrypt(nonce, ciphertext_with_tag, None)
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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading