From 03176ac28d810b207a112cd9ecfc3b9710f02b2b Mon Sep 17 00:00:00 2001 From: Isabelle Date: Wed, 12 Aug 2026 15:53:32 -0700 Subject: [PATCH 01/18] initial implementation --- .../CHANGELOG.md | 1 + .../cryptography/CryptographyConstants.java | 14 ++ .../specialized/cryptography/DecryptorV2.java | 88 +++++++++- .../cryptography/DecryptorV2ReorderTests.java | 157 ++++++++++++++++++ .../EncryptedBlockBlobApiTests.java | 44 +++++ 5 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java diff --git a/sdk/storage/azure-storage-blob-cryptography/CHANGELOG.md b/sdk/storage/azure-storage-blob-cryptography/CHANGELOG.md index 094528620fb5..0fa4ffa44d5c 100644 --- a/sdk/storage/azure-storage-blob-cryptography/CHANGELOG.md +++ b/sdk/storage/azure-storage-blob-cryptography/CHANGELOG.md @@ -7,6 +7,7 @@ ### Breaking Changes ### 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 an exception is thrown. For data recovery purposes, this behavior can be reverted by setting the `AZURE_STORAGE_CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS` environment variable (or the `Azure.Storage.CseV2AllowMisorderedAuthRegions` system property) to `true`. ### Other Changes diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CryptographyConstants.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CryptographyConstants.java index 30763ad782c1..9a648702403d 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CryptographyConstants.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CryptographyConstants.java @@ -55,6 +55,20 @@ final class CryptographyConstants { static final ByteBuffer EMPTY_BUFFER = ByteBuffer.allocate(0); + /** + * System property name that, when set to {@code true}, disables detection of reordered client-side encryption v2 + * authenticated regions. Intended for data recovery only. + */ + static final String CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_SWITCH_NAME + = "Azure.Storage.CseV2AllowMisorderedAuthRegions"; + + /** + * Environment variable name that, when set to {@code true}, disables detection of reordered client-side encryption + * v2 authenticated regions. Intended for data recovery only. + */ + static final String CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_ENV_VAR + = "AZURE_STORAGE_CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS"; + private CryptographyConstants() { } diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java index 5129e800a5fb..10ae1e51af97 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java @@ -5,6 +5,7 @@ import com.azure.core.cryptography.AsyncKeyEncryptionKey; import com.azure.core.cryptography.AsyncKeyEncryptionKeyResolver; +import com.azure.core.util.CoreUtils; import com.azure.core.util.logging.ClientLogger; import com.azure.storage.common.implementation.BufferStagingArea; import reactor.core.Exceptions; @@ -24,11 +25,14 @@ import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; +import java.util.Arrays; import java.util.concurrent.atomic.AtomicLong; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.AES; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.AES_GCM_NO_PADDING; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.AES_KEY_SIZE_BITS; +import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_ENV_VAR; +import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_SWITCH_NAME; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.EMPTY_BUFFER; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.TAG_LENGTH; @@ -49,11 +53,33 @@ Flux decrypt(Flux encryptedFlux, EncryptedBlobRange encr BufferStagingArea stagingArea = new BufferStagingArea(authenticatedRegionDataLength + TAG_LENGTH + nonceLength, authenticatedRegionDataLength + TAG_LENGTH + nonceLength); + /* + * Each CSEv2 region is encrypted under a unique, sequential nonce equal to the region's index (see + * EncryptorV2). Because the nonce is stored alongside the ciphertext, individual regions of otherwise + * untampered ciphertext can be rearranged without invalidating any single region's authentication tag, + * silently corrupting the decrypted plaintext. Detect this by asserting that the nonce of each region matches + * its expected sequential value. The first downloaded region depends on the requested range. This behavior can + * be disabled for data recovery via a compatibility switch. + */ + final boolean detectRegionReorder = !cseV2AllowMisorderedAuthRegions(); + final long initialRegion = authenticatedRegionDataLength == 0 + ? 0 + : encryptedBlobRange.getOriginalRange().getOffset() / authenticatedRegionDataLength; + return encryptedFlux.flatMapSequential(stagingArea::write, 1, 1) .concatWith(Flux.defer(stagingArea::flush)) - .flatMapSequential(aggregator -> { + .index() + .flatMapSequential(indexedAggregator -> { // Get the IV out of the beginning of the aggregator - byte[] gmcIv = aggregator.getFirstNBytes(nonceLength); + byte[] gmcIv = indexedAggregator.getT2().getFirstNBytes(nonceLength); + + if (detectRegionReorder) { + long expectedRegion = initialRegion + indexedAggregator.getT1(); + RuntimeException reorderError = validateRegionNonce(gmcIv, nonceLength, expectedRegion); + if (reorderError != null) { + return Mono.error(reorderError); + } + } Cipher gmcCipher; try { @@ -63,7 +89,7 @@ Flux decrypt(Flux encryptedFlux, EncryptedBlobRange encr } ByteBuffer decryptedRegion = ByteBuffer.allocate(authenticatedRegionDataLength); - return aggregator.asFlux().map(buffer -> { + return indexedAggregator.getT2().asFlux().map(buffer -> { // Write into the preallocated buffer and always return this buffer. try { gmcCipher.update(buffer, decryptedRegion); @@ -81,6 +107,62 @@ Flux decrypt(Flux encryptedFlux, EncryptedBlobRange encr }); } + /** + * Validates that the nonce of an authenticated region matches the nonce that would have been produced for the + * expected sequential region index during encryption. A mismatch indicates that the region has been reordered or + * otherwise tampered with. + * + * @param actualNonce The nonce read from the downloaded region. + * @param nonceLength The length of the nonce. + * @param expectedRegion The expected 0-based region index. + * @return A {@link RuntimeException} describing the tampering if the nonce is out of order, or {@code null} if the + * nonce is valid. + */ + private RuntimeException validateRegionNonce(byte[] actualNonce, int nonceLength, long expectedRegion) { + // Cannot reconstruct the expected nonce if it is too short to hold the region index. This should never happen + // for CSEv2 (nonce length is 12), so treat it as unverifiable rather than a failure. + if (nonceLength < Long.BYTES || actualNonce.length < Long.BYTES) { + return null; + } + + // Reconstruct the nonce exactly as EncryptorV2 does: an 8-byte big-endian region index followed by zero + // padding to the nonce length. + byte[] expectedNonce = ByteBuffer.allocate(nonceLength).putLong(expectedRegion).array(); + if (Arrays.equals(expectedNonce, actualNonce)) { + return null; + } + + long actualRegion = ByteBuffer.wrap(actualNonce).getLong(); + return LOGGER.logExceptionAsError(new IllegalStateException( + "Encountered an out-of-order authenticated region while decrypting client-side encrypted (v2) content. " + + "This may indicate that the blob's authenticated regions have been rearranged or otherwise tampered " + + "with. Expected region " + expectedRegion + " but found region " + actualRegion + ". To recover data " + + "from an affected blob, set the \"" + CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_ENV_VAR + + "\" environment variable (or the \"" + CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_SWITCH_NAME + + "\" system property) to \"true\".")); + } + + /** + * Whether detection of reordered client-side encryption v2 authenticated regions should be disabled. + *

+ * This is a data-recovery escape hatch, read live from a system property or environment variable. When enabled, the + * client will not throw when it encounters authenticated regions that appear to have been rearranged, allowing + * (potentially tampered) plaintext to be recovered. + *

+ * {@link com.azure.core.util.Configuration} is intentionally not used here because the global configuration caches + * the first value it reads for a given name, which would prevent the switch from being honored if it is set after + * the first read. + * + * @return {@code true} if reordered authenticated regions should be allowed, {@code false} otherwise. + */ + private static boolean cseV2AllowMisorderedAuthRegions() { + String value = System.getProperty(CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_SWITCH_NAME); + if (CoreUtils.isNullOrEmpty(value)) { + value = System.getenv(CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_ENV_VAR); + } + return Boolean.parseBoolean(value); + } + @Override protected Mono getKeyEncryptionKey() { return super.getKeyEncryptionKey().flatMap(keyBytes -> { diff --git a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java new file mode 100644 index 000000000000..fa52385f48b6 --- /dev/null +++ b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.specialized.cryptography; + +import com.azure.storage.blob.models.BlobRange; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.api.parallel.Isolated; +import reactor.core.publisher.Flux; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.concurrent.atomic.AtomicLong; + +import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.AES; +import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.ENCRYPTION_PROTOCOL_V2; +import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.NONCE_LENGTH; +import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.TAG_LENGTH; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for detection of reordered client-side encryption v2 authenticated regions. These exercise + * {@link EncryptorV2} and {@link DecryptorV2} directly and do not require a storage account. + *

+ * The compatibility switch is a process-global system property, so this class is isolated and run on a single thread to + * avoid interfering with (or being interfered with by) tests running in parallel. + */ +@Isolated +@Execution(ExecutionMode.SAME_THREAD) +public class DecryptorV2ReorderTests { + private static final int REGION_DATA_LENGTH = 1024; + private static final int REGION_TOTAL_LENGTH = NONCE_LENGTH + REGION_DATA_LENGTH + TAG_LENGTH; + private static final int REGION_COUNT = 4; + private static final Random RANDOM = new Random(); + + @AfterEach + public void clearSwitch() { + System.clearProperty(CryptographyConstants.CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_SWITCH_NAME); + } + + @Test + public void unmodifiedContentDecryptsSuccessfully() { + byte[] cek = randomBytes(32); + byte[] plaintext = randomBytes(REGION_DATA_LENGTH * REGION_COUNT); + byte[] ciphertext = encrypt(cek, plaintext); + + assertArrayEquals(plaintext, decrypt(cek, ciphertext, 0)); + } + + @Test + public void detectsRegionReorder() { + byte[] cek = randomBytes(32); + byte[] plaintext = randomBytes(REGION_DATA_LENGTH * REGION_COUNT); + byte[] ciphertext = encrypt(cek, plaintext); + + // Swap two otherwise-untampered authenticated regions. + swapRegions(ciphertext, 2, 3, REGION_TOTAL_LENGTH); + + IllegalStateException e = assertThrows(IllegalStateException.class, () -> decrypt(cek, ciphertext, 0)); + assertTrue(e.getMessage().contains("out-of-order"), e.getMessage()); + } + + @Test + public void detectsRegionReorderOnRangedDownload() { + byte[] cek = randomBytes(32); + byte[] plaintext = randomBytes(REGION_DATA_LENGTH * REGION_COUNT); + byte[] ciphertext = encrypt(cek, plaintext); + + // Simulate a ranged download that begins at region 1 by dropping the first region's ciphertext. + byte[] ranged = Arrays.copyOfRange(ciphertext, REGION_TOTAL_LENGTH, ciphertext.length); + + // A ranged download starting at the correct region decrypts successfully (no false positive). + byte[] expected = Arrays.copyOfRange(plaintext, REGION_DATA_LENGTH, plaintext.length); + assertArrayEquals(expected, decrypt(cek, ranged, REGION_DATA_LENGTH)); + + // Reordering within the ranged content is still detected, with the region index offset by the range. + swapRegions(ranged, 1, 2, REGION_TOTAL_LENGTH); + assertThrows(IllegalStateException.class, () -> decrypt(cek, ranged, REGION_DATA_LENGTH)); + } + + @Test + public void compatSwitchAllowsReorder() { + System.setProperty(CryptographyConstants.CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_SWITCH_NAME, "true"); + + byte[] cek = randomBytes(32); + byte[] plaintext = randomBytes(REGION_DATA_LENGTH * REGION_COUNT); + byte[] ciphertext = encrypt(cek, plaintext); + swapRegions(ciphertext, 2, 3, REGION_TOTAL_LENGTH); + + // With the switch enabled, decryption does not throw and recovers plaintext in the received order. + byte[] recovered = decrypt(cek, ciphertext, 0); + + byte[] expected = plaintext.clone(); + swapRegions(expected, 2, 3, REGION_DATA_LENGTH); + assertArrayEquals(expected, recovered); + } + + private static byte[] encrypt(byte[] cek, byte[] plaintext) { + SecretKey key = new SecretKeySpec(cek, AES); + EncryptorV2 encryptor = new EncryptorV2(key, + new BlobClientSideEncryptionOptions().setAuthenticatedRegionDataLengthInBytes(REGION_DATA_LENGTH), + ENCRYPTION_PROTOCOL_V2); + List buffers = encryptor.encrypt(Flux.just(ByteBuffer.wrap(plaintext))).collectList().block(); + return toBytes(buffers); + } + + private static byte[] decrypt(byte[] cek, byte[] ciphertext, long offset) { + EncryptionData encryptionData = new EncryptionData() + .setEncryptionAgent(new EncryptionAgent(ENCRYPTION_PROTOCOL_V2, EncryptionAlgorithm.AES_GCM_256)) + .setEncryptedRegionInfo(new EncryptedRegionInfo(REGION_DATA_LENGTH, NONCE_LENGTH)); + DecryptorV2 decryptor = new DecryptorV2(null, null, encryptionData); + EncryptedBlobRange range = new EncryptedBlobRange(new BlobRange(offset), encryptionData); + + List out + = decryptor.decrypt(Flux.just(ByteBuffer.wrap(ciphertext)), range, false, "uri", new AtomicLong(0), cek) + .collectList() + .block(); + return toBytes(out); + } + + private static byte[] toBytes(List buffers) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (ByteBuffer buffer : buffers) { + ByteBuffer duplicate = buffer.duplicate(); + byte[] arr = new byte[duplicate.remaining()]; + duplicate.get(arr); + out.write(arr, 0, arr.length); + } + return out.toByteArray(); + } + + private static void swapRegions(byte[] buffer, int leftRegion, int rightRegion, int regionLength) { + int leftOffset = leftRegion * regionLength; + int rightOffset = rightRegion * regionLength; + for (int i = 0; i < regionLength; i++) { + byte temp = buffer[leftOffset + i]; + buffer[leftOffset + i] = buffer[rightOffset + i]; + buffer[rightOffset + i] = temp; + } + } + + private static byte[] randomBytes(int length) { + byte[] bytes = new byte[length]; + RANDOM.nextBytes(bytes); + return bytes; + } +} diff --git a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlockBlobApiTests.java b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlockBlobApiTests.java index 9e0ebac5b644..0b7623811501 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlockBlobApiTests.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlockBlobApiTests.java @@ -599,6 +599,50 @@ public void encryptionV2DowngradeAttack() { assertThrows(Exception.class, () -> bec.downloadStream(new ByteArrayOutputStream())); } + @LiveOnly + @Test + public void encryptionV2DetectRegionReorder() { + // Region must be large enough for at least a few regions; use the minimum region size to keep the blob small. + int regionDataLength = 16; + int regionCount = 4; + int regionTotalLength = NONCE_LENGTH + regionDataLength + TAG_LENGTH; + byte[] plaintext = getRandomByteArray(regionDataLength * regionCount); + + String blobName = generateBlobName(); + EncryptedBlobClient encryptedClient = new EncryptedBlobClient(getEncryptedClientBuilder(fakeKey, null, + ENV.getPrimaryAccount().getCredential(), cc.getBlobContainerUrl(), EncryptionVersion.V2) + .blobName(blobName) + .clientSideEncryptionOptions( + new BlobClientSideEncryptionOptions().setAuthenticatedRegionDataLengthInBytes(regionDataLength)) + .buildEncryptedBlobAsyncClient()); + + // Upload with encryption. + encryptedClient.upload(BinaryData.fromBytes(plaintext)); + + // Download the raw ciphertext (bypassing decryption) and preserve its encryption metadata. + BlobClient plainClient = cc.getBlobClient(blobName); + byte[] ciphertext = plainClient.downloadContent().toBytes(); + Map metadata = plainClient.getProperties().getMetadata(); + + // Swap two otherwise-untampered authenticated regions and re-upload the tampered ciphertext. + swapRegions(ciphertext, 2, 3, regionTotalLength); + plainClient.upload(BinaryData.fromBytes(ciphertext), true); + plainClient.setMetadata(metadata); + + // The reordering must now be detected on download. + assertThrows(Exception.class, () -> encryptedClient.downloadStream(new ByteArrayOutputStream())); + } + + private static void swapRegions(byte[] buffer, int leftRegion, int rightRegion, int regionLength) { + int leftOffset = leftRegion * regionLength; + int rightOffset = rightRegion * regionLength; + for (int i = 0; i < regionLength; i++) { + byte temp = buffer[leftOffset + i]; + buffer[leftOffset + i] = buffer[rightOffset + i]; + buffer[rightOffset + i] = temp; + } + } + @Test public void downloadUnencryptedData() { // Create client From e5f329ca6d258e5e968de5aad72e4e991afff6ec Mon Sep 17 00:00:00 2001 From: Isabelle Date: Wed, 12 Aug 2026 16:16:04 -0700 Subject: [PATCH 02/18] fixing nonce reconstruction --- .../specialized/cryptography/DecryptorV2.java | 9 ++- .../cryptography/DecryptorV2ReorderTests.java | 61 +++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java index 10ae1e51af97..556dc3c9cf5e 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java @@ -125,9 +125,12 @@ private RuntimeException validateRegionNonce(byte[] actualNonce, int nonceLength return null; } - // Reconstruct the nonce exactly as EncryptorV2 does: an 8-byte big-endian region index followed by zero - // padding to the nonce length. - byte[] expectedNonce = ByteBuffer.allocate(nonceLength).putLong(expectedRegion).array(); + // Reconstruct the nonce exactly as EncryptorV2 does: the region index is truncated to an int (see + // EncryptorV2.getCipher(int) / Tuple.getT1().intValue()) and then written as an 8-byte big-endian + // (sign-extended) value followed by zero padding to the nonce length. The int truncation must be replicated + // here so that valid blobs with region indices >= Integer.MAX_VALUE (reachable at ~32 GiB with the minimum + // 16-byte region size) are not incorrectly flagged as reordered. + byte[] expectedNonce = ByteBuffer.allocate(nonceLength).putLong((int) expectedRegion).array(); if (Arrays.equals(expectedNonce, actualNonce)) { return null; } diff --git a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java index fa52385f48b6..dee2d9a27a5c 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java @@ -11,16 +11,20 @@ import org.junit.jupiter.api.parallel.Isolated; import reactor.core.publisher.Flux; +import javax.crypto.Cipher; import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.io.ByteArrayOutputStream; import java.nio.ByteBuffer; +import java.security.GeneralSecurityException; import java.util.Arrays; import java.util.List; import java.util.Random; import java.util.concurrent.atomic.AtomicLong; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.AES; +import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.AES_GCM_NO_PADDING; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.ENCRYPTION_PROTOCOL_V2; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.NONCE_LENGTH; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.TAG_LENGTH; @@ -105,6 +109,41 @@ public void compatSwitchAllowsReorder() { assertArrayEquals(expected, recovered); } + @Test + public void decryptsRegionsAcrossIntegerMaxValueBoundary() { + // EncryptorV2 truncates the region index to an int when producing the nonce, so region Integer.MAX_VALUE + 1 + // wraps to a negative (sign-extended) nonce. The reorder detection must replicate that truncation, otherwise + // valid blobs whose region indices cross Integer.MAX_VALUE (reachable at ~32 GiB with the minimum 16-byte + // region size) would be incorrectly rejected as reordered. + byte[] cek = randomBytes(32); + long firstRegion = Integer.MAX_VALUE; + byte[] region0Plaintext = randomBytes(REGION_DATA_LENGTH); + byte[] region1Plaintext = randomBytes(REGION_DATA_LENGTH); + + byte[] ciphertext = concat(encryptRegionAt(cek, firstRegion, region0Plaintext), + encryptRegionAt(cek, firstRegion + 1, region1Plaintext)); + + long offset = firstRegion * REGION_DATA_LENGTH; + byte[] recovered = decrypt(cek, ciphertext, offset); + + assertArrayEquals(concat(region0Plaintext, region1Plaintext), recovered); + } + + @Test + public void detectsReorderAcrossIntegerMaxValueBoundary() { + // Detection must still fire across the int boundary: place a region whose nonce belongs to a different index + // than expected and confirm it is rejected. + byte[] cek = randomBytes(32); + long firstRegion = Integer.MAX_VALUE; + + byte[] ciphertext = concat(encryptRegionAt(cek, firstRegion, randomBytes(REGION_DATA_LENGTH)), + // Expected index here is firstRegion + 1, but this region is nonced for firstRegion + 2. + encryptRegionAt(cek, firstRegion + 2, randomBytes(REGION_DATA_LENGTH))); + + long offset = firstRegion * REGION_DATA_LENGTH; + assertThrows(IllegalStateException.class, () -> decrypt(cek, ciphertext, offset)); + } + private static byte[] encrypt(byte[] cek, byte[] plaintext) { SecretKey key = new SecretKeySpec(cek, AES); EncryptorV2 encryptor = new EncryptorV2(key, @@ -114,6 +153,22 @@ private static byte[] encrypt(byte[] cek, byte[] plaintext) { return toBytes(buffers); } + /** + * Encrypts a single region using the same nonce scheme as {@link EncryptorV2}: the region index is truncated to an + * int and written big-endian into the nonce. Produces {@code nonce || ciphertext || tag}. Used to craft ciphertext + * for arbitrary (very large) region indices without materializing all preceding regions. + */ + private static byte[] encryptRegionAt(byte[] cek, long regionIndex, byte[] plaintext) { + try { + byte[] nonce = ByteBuffer.allocate(NONCE_LENGTH).putLong((int) regionIndex).array(); + Cipher cipher = Cipher.getInstance(AES_GCM_NO_PADDING); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(cek, AES), new GCMParameterSpec(TAG_LENGTH * 8, nonce)); + return concat(nonce, cipher.doFinal(plaintext)); + } catch (GeneralSecurityException e) { + throw new RuntimeException(e); + } + } + private static byte[] decrypt(byte[] cek, byte[] ciphertext, long offset) { EncryptionData encryptionData = new EncryptionData() .setEncryptionAgent(new EncryptionAgent(ENCRYPTION_PROTOCOL_V2, EncryptionAlgorithm.AES_GCM_256)) @@ -154,4 +209,10 @@ private static byte[] randomBytes(int length) { RANDOM.nextBytes(bytes); return bytes; } + + private static byte[] concat(byte[] left, byte[] right) { + byte[] result = Arrays.copyOf(left, left.length + right.length); + System.arraycopy(right, 0, result, left.length, right.length); + return result; + } } From 10e4c4acccdd788adbe16154ff71d2a41470edfa Mon Sep 17 00:00:00 2001 From: Isabelle Date: Wed, 12 Aug 2026 16:39:29 -0700 Subject: [PATCH 03/18] fixing reversion i introduced --- .../specialized/cryptography/DecryptorV2.java | 30 +++++++++++-- .../cryptography/DecryptorV2ReorderTests.java | 42 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java index 556dc3c9cf5e..969ff39976a3 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java @@ -39,6 +39,14 @@ class DecryptorV2 extends Decryptor { private static final ClientLogger LOGGER = new ClientLogger(DecryptorV2.class); + /* + * EncryptorV2 truncates the region index to an int when producing each region's nonce (see EncryptorV2.getCipher), + * so nonces are unique only for the first 2^32 regions. At or beyond this index the nonce repeats: two distinct + * regions can share a nonce and each retain a valid GCM tag. That both defeats sequential reorder detection and is + * GCM nonce reuse, so integrity cannot be verified past this boundary. + */ + private static final long NONCE_WRAP_REGION_COUNT = 1L << 32; + protected DecryptorV2(AsyncKeyEncryptionKeyResolver keyResolver, AsyncKeyEncryptionKey keyWrapper, EncryptionData encryptionData) { super(keyResolver, keyWrapper, encryptionData); @@ -110,13 +118,14 @@ Flux decrypt(Flux encryptedFlux, EncryptedBlobRange encr /** * Validates that the nonce of an authenticated region matches the nonce that would have been produced for the * expected sequential region index during encryption. A mismatch indicates that the region has been reordered or - * otherwise tampered with. + * otherwise tampered with. Also fails closed once the region index reaches the point where the encryption nonce + * begins to repeat, as integrity can no longer be guaranteed there. * * @param actualNonce The nonce read from the downloaded region. * @param nonceLength The length of the nonce. * @param expectedRegion The expected 0-based region index. - * @return A {@link RuntimeException} describing the tampering if the nonce is out of order, or {@code null} if the - * nonce is valid. + * @return A {@link RuntimeException} describing the tampering or unverifiable state if the region is invalid, or + * {@code null} if the nonce is valid. */ private RuntimeException validateRegionNonce(byte[] actualNonce, int nonceLength, long expectedRegion) { // Cannot reconstruct the expected nonce if it is too short to hold the region index. This should never happen @@ -125,6 +134,21 @@ private RuntimeException validateRegionNonce(byte[] actualNonce, int nonceLength return null; } + // Fail closed once the region index reaches the point where EncryptorV2's nonces begin to repeat. Beyond this + // boundary two regions can share a nonce and a valid GCM tag, so a reorder of them is undetectable and the + // ciphertext already suffers GCM nonce reuse. We therefore cannot vouch for integrity and refuse to proceed + // (unless the data-recovery switch is enabled, which is handled by the caller). This is only reachable for + // extremely large blobs using a small authenticated region size (~64 GiB at the 16-byte minimum). + if (expectedRegion >= NONCE_WRAP_REGION_COUNT) { + return LOGGER.logExceptionAsError(new IllegalStateException( + "Cannot verify the integrity of client-side encrypted (v2) content beyond " + NONCE_WRAP_REGION_COUNT + + " authenticated regions (region index " + expectedRegion + "), because the encryption nonce " + + "repeats past that point, resulting in GCM nonce reuse. This blob is too large for its " + + "authenticated region size to be safely verified. To recover data anyway, set the \"" + + CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_ENV_VAR + "\" environment variable (or the \"" + + CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_SWITCH_NAME + "\" system property) to \"true\".")); + } + // Reconstruct the nonce exactly as EncryptorV2 does: the region index is truncated to an int (see // EncryptorV2.getCipher(int) / Tuple.getT1().intValue()) and then written as an 8-byte big-endian // (sign-extended) value followed by zero padding to the nonce length. The int truncation must be replicated diff --git a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java index dee2d9a27a5c..e16b0034bde4 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java @@ -45,6 +45,8 @@ public class DecryptorV2ReorderTests { private static final int REGION_DATA_LENGTH = 1024; private static final int REGION_TOTAL_LENGTH = NONCE_LENGTH + REGION_DATA_LENGTH + TAG_LENGTH; private static final int REGION_COUNT = 4; + // Mirrors DecryptorV2.NONCE_WRAP_REGION_COUNT: EncryptorV2's nonce repeats every 2^32 regions. + private static final long NONCE_WRAP_REGION_COUNT = 1L << 32; private static final Random RANDOM = new Random(); @AfterEach @@ -144,6 +146,46 @@ public void detectsReorderAcrossIntegerMaxValueBoundary() { assertThrows(IllegalStateException.class, () -> decrypt(cek, ciphertext, offset)); } + @Test + public void decryptsLastRegionBeforeNonceWrap() { + // Region 2^32 - 1 is the last region with a unique nonce; it must still decrypt successfully. + byte[] cek = randomBytes(32); + long lastUniqueRegion = NONCE_WRAP_REGION_COUNT - 1; + byte[] plaintext = randomBytes(REGION_DATA_LENGTH); + + byte[] ciphertext = encryptRegionAt(cek, lastUniqueRegion, plaintext); + byte[] recovered = decrypt(cek, ciphertext, lastUniqueRegion * REGION_DATA_LENGTH); + + assertArrayEquals(plaintext, recovered); + } + + @Test + public void failsClosedAtNonceWrapBoundary() { + // Region 2^32 is where EncryptorV2's nonce repeats (reuses region 0's nonce). Integrity cannot be verified, so + // decryption must fail closed by default. + byte[] cek = randomBytes(32); + long wrapRegion = NONCE_WRAP_REGION_COUNT; + byte[] ciphertext = encryptRegionAt(cek, wrapRegion, randomBytes(REGION_DATA_LENGTH)); + + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> decrypt(cek, ciphertext, wrapRegion * REGION_DATA_LENGTH)); + assertTrue(e.getMessage().contains("nonce reuse"), e.getMessage()); + } + + @Test + public void recoverySwitchAllowsPastNonceWrapBoundary() { + // With the recovery switch, the wrap-boundary fail-closed is bypassed and plaintext is recovered. + System.setProperty(CryptographyConstants.CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_SWITCH_NAME, "true"); + + byte[] cek = randomBytes(32); + long wrapRegion = NONCE_WRAP_REGION_COUNT; + byte[] plaintext = randomBytes(REGION_DATA_LENGTH); + byte[] ciphertext = encryptRegionAt(cek, wrapRegion, plaintext); + + byte[] recovered = decrypt(cek, ciphertext, wrapRegion * REGION_DATA_LENGTH); + assertArrayEquals(plaintext, recovered); + } + private static byte[] encrypt(byte[] cek, byte[] plaintext) { SecretKey key = new SecretKeySpec(cek, AES); EncryptorV2 encryptor = new EncryptorV2(key, From f2aaffb68bd04df940f95e05b02570ebb75b41c5 Mon Sep 17 00:00:00 2001 From: Isabelle Date: Fri, 14 Aug 2026 10:06:42 -0700 Subject: [PATCH 04/18] adding support for cross sdk reorder detection --- .../specialized/cryptography/DecryptorV2.java | 181 ++++++++++++++---- .../cryptography/DecryptorV2ReorderTests.java | 140 +++++++++++++- .../EncryptedBlockBlobApiTests.java | 2 +- 3 files changed, 283 insertions(+), 40 deletions(-) diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java index 969ff39976a3..3df758a94be0 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java @@ -26,7 +26,9 @@ import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.Arrays; +import java.util.EnumSet; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.AES; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.AES_GCM_NO_PADDING; @@ -62,14 +64,23 @@ Flux decrypt(Flux encryptedFlux, EncryptedBlobRange encr authenticatedRegionDataLength + TAG_LENGTH + nonceLength); /* - * Each CSEv2 region is encrypted under a unique, sequential nonce equal to the region's index (see + * Each CSEv2 region is encrypted under a unique, sequential nonce derived from the region's index (see * EncryptorV2). Because the nonce is stored alongside the ciphertext, individual regions of otherwise * untampered ciphertext can be rearranged without invalidating any single region's authentication tag, * silently corrupting the decrypted plaintext. Detect this by asserting that the nonce of each region matches - * its expected sequential value. The first downloaded region depends on the requested range. This behavior can - * be disabled for data recovery via a compatibility switch. + * the value expected for its sequential position. + * + * CSEv2 is cross-SDK interoperable and each Azure Storage SDK encodes the region counter into the nonce + * differently (see NonceScheme). Decryption itself is unaffected (it uses the inline nonce), but to validate + * ordering we must recognize the scheme the blob was written with. We start by considering all known schemes + * and intersect the set of schemes still consistent with every region seen so far. Intersection is + * order-independent, so this remains correct even if regions are processed concurrently/out of order. If the + * set ever becomes empty, a region is out of place. This behavior can be disabled for data recovery via a + * compatibility switch. */ final boolean detectRegionReorder = !cseV2AllowMisorderedAuthRegions(); + final AtomicReference> candidateSchemes + = detectRegionReorder ? new AtomicReference<>(EnumSet.allOf(NonceScheme.class)) : null; final long initialRegion = authenticatedRegionDataLength == 0 ? 0 : encryptedBlobRange.getOriginalRange().getOffset() / authenticatedRegionDataLength; @@ -83,7 +94,8 @@ Flux decrypt(Flux encryptedFlux, EncryptedBlobRange encr if (detectRegionReorder) { long expectedRegion = initialRegion + indexedAggregator.getT1(); - RuntimeException reorderError = validateRegionNonce(gmcIv, nonceLength, expectedRegion); + RuntimeException reorderError + = validateRegionNonce(gmcIv, nonceLength, expectedRegion, candidateSchemes); if (reorderError != null) { return Mono.error(reorderError); } @@ -116,57 +128,156 @@ Flux decrypt(Flux encryptedFlux, EncryptedBlobRange encr } /** - * Validates that the nonce of an authenticated region matches the nonce that would have been produced for the - * expected sequential region index during encryption. A mismatch indicates that the region has been reordered or - * otherwise tampered with. Also fails closed once the region index reaches the point where the encryption nonce - * begins to repeat, as integrity can no longer be guaranteed there. + * Validates that the nonce of an authenticated region is consistent with the region occupying its expected + * sequential position, under one of the recognized cross-SDK nonce schemes. A region whose nonce matches none of + * the schemes for its position - or that is inconsistent with the scheme established by earlier regions - indicates + * a reorder or other tampering. Also fails closed once the region index reaches the point where the Java encoder's + * nonce begins to repeat, as integrity can no longer be guaranteed there. * * @param actualNonce The nonce read from the downloaded region. * @param nonceLength The length of the nonce. * @param expectedRegion The expected 0-based region index. + * @param candidateSchemes The set of nonce schemes still consistent with all regions seen so far. Intersected in + * place with the schemes matching this region; intersection is order-independent and therefore safe under + * concurrent region processing. * @return A {@link RuntimeException} describing the tampering or unverifiable state if the region is invalid, or * {@code null} if the nonce is valid. */ - private RuntimeException validateRegionNonce(byte[] actualNonce, int nonceLength, long expectedRegion) { + private RuntimeException validateRegionNonce(byte[] actualNonce, int nonceLength, long expectedRegion, + AtomicReference> candidateSchemes) { // Cannot reconstruct the expected nonce if it is too short to hold the region index. This should never happen // for CSEv2 (nonce length is 12), so treat it as unverifiable rather than a failure. if (nonceLength < Long.BYTES || actualNonce.length < Long.BYTES) { return null; } - // Fail closed once the region index reaches the point where EncryptorV2's nonces begin to repeat. Beyond this - // boundary two regions can share a nonce and a valid GCM tag, so a reorder of them is undetectable and the - // ciphertext already suffers GCM nonce reuse. We therefore cannot vouch for integrity and refuse to proceed - // (unless the data-recovery switch is enabled, which is handled by the caller). This is only reachable for - // extremely large blobs using a small authenticated region size (~64 GiB at the 16-byte minimum). - if (expectedRegion >= NONCE_WRAP_REGION_COUNT) { - return LOGGER.logExceptionAsError(new IllegalStateException( - "Cannot verify the integrity of client-side encrypted (v2) content beyond " + NONCE_WRAP_REGION_COUNT - + " authenticated regions (region index " + expectedRegion + "), because the encryption nonce " - + "repeats past that point, resulting in GCM nonce reuse. This blob is too large for its " - + "authenticated region size to be safely verified. To recover data anyway, set the \"" - + CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_ENV_VAR + "\" environment variable (or the \"" - + CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_SWITCH_NAME + "\" system property) to \"true\".")); + // The Java encoder truncates the region index to an int, so its nonces begin to repeat at 2^32 regions. Past + // that boundary a Java-encoded blob can no longer be verified (nonce reuse), while the Python and .NET encoders + // use wider counters that do not repeat for any real blob size. So past the boundary we only consider the + // non-wrapping schemes; if none match, we fail closed. + boolean pastJavaNonceWrap = expectedRegion >= NONCE_WRAP_REGION_COUNT; + + EnumSet matchesHere = EnumSet.noneOf(NonceScheme.class); + for (NonceScheme scheme : NonceScheme.values()) { + if (pastJavaNonceWrap && scheme == NonceScheme.JAVA) { + continue; + } + if (Arrays.equals(scheme.expectedNonce(expectedRegion, nonceLength), actualNonce)) { + matchesHere.add(scheme); + } } - // Reconstruct the nonce exactly as EncryptorV2 does: the region index is truncated to an int (see - // EncryptorV2.getCipher(int) / Tuple.getT1().intValue()) and then written as an 8-byte big-endian - // (sign-extended) value followed by zero padding to the nonce length. The int truncation must be replicated - // here so that valid blobs with region indices >= Integer.MAX_VALUE (reachable at ~32 GiB with the minimum - // 16-byte region size) are not incorrectly flagged as reordered. - byte[] expectedNonce = ByteBuffer.allocate(nonceLength).putLong((int) expectedRegion).array(); - if (Arrays.equals(expectedNonce, actualNonce)) { - return null; + if (matchesHere.isEmpty()) { + if (pastJavaNonceWrap) { + return LOGGER.logExceptionAsError(new IllegalStateException("Cannot verify the integrity of client-side" + + " encrypted (v2) content at or beyond " + NONCE_WRAP_REGION_COUNT + " authenticated regions" + + " (region index " + expectedRegion + "). For content encrypted by this (Java) SDK the encryption" + + " nonce repeats past that point, resulting in GCM nonce reuse, and the blob is too large for its" + + " authenticated region size to be safely verified. " + recoveryInstruction())); + } + return reorderException(expectedRegion, actualNonce); + } + + // Lock onto the scheme(s) consistent with every region so far. A valid blob keeps its own scheme in the set at + // every region; a reorder that happens to look valid under a different scheme for a single region is caught + // here because it is inconsistent with the scheme the rest of the blob uses. + EnumSet remaining = candidateSchemes.updateAndGet(current -> { + EnumSet next = EnumSet.copyOf(current); + next.retainAll(matchesHere); + return next; + }); + if (remaining.isEmpty()) { + return reorderException(expectedRegion, actualNonce); } - long actualRegion = ByteBuffer.wrap(actualNonce).getLong(); + return null; + } + + private RuntimeException reorderException(long expectedRegion, byte[] actualNonce) { return LOGGER.logExceptionAsError(new IllegalStateException( "Encountered an out-of-order authenticated region while decrypting client-side encrypted (v2) content. " + "This may indicate that the blob's authenticated regions have been rearranged or otherwise tampered " - + "with. Expected region " + expectedRegion + " but found region " + actualRegion + ". To recover data " - + "from an affected blob, set the \"" + CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_ENV_VAR - + "\" environment variable (or the \"" + CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_SWITCH_NAME - + "\" system property) to \"true\".")); + + "with. The nonce at region index " + expectedRegion + " (0x" + bytesToHex(actualNonce) + ") does not " + + "match any recognized client-side encryption nonce scheme for that position. " + + recoveryInstruction())); + } + + private static String recoveryInstruction() { + return "To recover data from an affected blob, set the \"" + CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_ENV_VAR + + "\" environment variable (or the \"" + CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_SWITCH_NAME + + "\" system property) to \"true\"."; + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } + + /** + * The recognized ways Azure Storage client-side encryption v2 SDKs encode a region's sequential counter into its + * GCM nonce. Decryption uses the inline nonce and is unaffected by these differences, but reorder detection must + * reconstruct the expected nonce, which requires knowing the encoder's scheme. The complete set of SDKs that + * produce CSEv2 content is .NET, Java, and Python. + */ + private enum NonceScheme { + /** + * Java: the region index truncated to an int, written as an 8-byte big-endian (sign-extended) value in the + * first 8 bytes, remaining bytes zero. 0-based. See {@link EncryptorV2}. + */ + JAVA { + @Override + byte[] expectedNonce(long regionIndex, int nonceLength) { + byte[] nonce = new byte[nonceLength]; + long value = (int) regionIndex; + for (int i = 0; i < Long.BYTES; i++) { + nonce[i] = (byte) (value >>> (Long.SIZE - Byte.SIZE - Byte.SIZE * i)); + } + return nonce; + } + }, + + /** + * Python: the region index encoded as a big-endian integer across the whole nonce (counter in the low bytes). + * 0-based. See azure-storage-blob {@code encrypt_data_v2}. + */ + PYTHON { + @Override + byte[] expectedNonce(long regionIndex, int nonceLength) { + byte[] nonce = new byte[nonceLength]; + for (int i = 0; i < Long.BYTES; i++) { + nonce[nonceLength - 1 - i] = (byte) (regionIndex >>> (Byte.SIZE * i)); + } + return nonce; + } + }, + + /** + * .NET: four zero bytes followed by an 8-byte little-endian counter. 1-based (the first region uses counter 1). + * See Azure.Storage.Common {@code GcmAuthenticatedCryptographicTransform}. + */ + DOTNET { + @Override + byte[] expectedNonce(long regionIndex, int nonceLength) { + byte[] nonce = new byte[nonceLength]; + long counter = regionIndex + 1; + for (int i = 0; i < Long.BYTES; i++) { + nonce[(nonceLength - Long.BYTES) + i] = (byte) (counter >>> (Byte.SIZE * i)); + } + return nonce; + } + }; + + /** + * Produces the nonce this scheme assigns to the given region index. + * + * @param regionIndex The 0-based region index. + * @param nonceLength The nonce length (12 for CSEv2). Must be at least {@link Long#BYTES}. + * @return The expected nonce bytes. + */ + abstract byte[] expectedNonce(long regionIndex, int nonceLength); } /** diff --git a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java index e16b0034bde4..af8bc2ce17fd 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java @@ -186,6 +186,92 @@ public void recoverySwitchAllowsPastNonceWrapBoundary() { assertArrayEquals(plaintext, recovered); } + // ---- Cross-SDK interoperability: Java must still decrypt (and detect reorders in) blobs written by other SDKs, + // which encode the region counter into the nonce differently. ---- + + @Test + public void pythonEncodedBlobDecryptsWithoutFalseReorder() { + byte[] cek = randomBytes(32); + byte[][] plaintext = { + randomBytes(REGION_DATA_LENGTH), + randomBytes(REGION_DATA_LENGTH), + randomBytes(REGION_DATA_LENGTH), + randomBytes(REGION_DATA_LENGTH) }; + byte[] ciphertext = buildBlob(cek, DecryptorV2ReorderTests::pythonNonce, plaintext); + + assertArrayEquals(flatten(plaintext), decrypt(cek, ciphertext, 0)); + } + + @Test + public void dotnetEncodedBlobDecryptsWithoutFalseReorder() { + byte[] cek = randomBytes(32); + byte[][] plaintext = { + randomBytes(REGION_DATA_LENGTH), + randomBytes(REGION_DATA_LENGTH), + randomBytes(REGION_DATA_LENGTH), + randomBytes(REGION_DATA_LENGTH) }; + byte[] ciphertext = buildBlob(cek, DecryptorV2ReorderTests::dotnetNonce, plaintext); + + assertArrayEquals(flatten(plaintext), decrypt(cek, ciphertext, 0)); + } + + @Test + public void detectsReorderInPythonEncodedBlob() { + byte[] cek = randomBytes(32); + byte[][] plaintext = { + randomBytes(REGION_DATA_LENGTH), + randomBytes(REGION_DATA_LENGTH), + randomBytes(REGION_DATA_LENGTH), + randomBytes(REGION_DATA_LENGTH) }; + byte[] ciphertext = buildBlob(cek, DecryptorV2ReorderTests::pythonNonce, plaintext); + swapRegions(ciphertext, 2, 3, REGION_TOTAL_LENGTH); + + assertThrows(IllegalStateException.class, () -> decrypt(cek, ciphertext, 0)); + } + + @Test + public void detectsReorderInDotnetEncodedBlob() { + byte[] cek = randomBytes(32); + byte[][] plaintext = { + randomBytes(REGION_DATA_LENGTH), + randomBytes(REGION_DATA_LENGTH), + randomBytes(REGION_DATA_LENGTH), + randomBytes(REGION_DATA_LENGTH) }; + byte[] ciphertext = buildBlob(cek, DecryptorV2ReorderTests::dotnetNonce, plaintext); + swapRegions(ciphertext, 1, 2, REGION_TOTAL_LENGTH); + + assertThrows(IllegalStateException.class, () -> decrypt(cek, ciphertext, 0)); + } + + @Test + public void detectsSchemeSwitchMidBlob() { + // A blob whose first region uses one SDK's nonce scheme and a later region uses another's cannot be a single + // valid blob; the scheme is locked after the first region, so the switch is detected. + byte[] cek = randomBytes(32); + byte[] region0 = encryptRegionWithNonce(cek, dotnetNonce(0), randomBytes(REGION_DATA_LENGTH)); + byte[] region1 = encryptRegionWithNonce(cek, javaNonce(1), randomBytes(REGION_DATA_LENGTH)); + + assertThrows(IllegalStateException.class, () -> decrypt(cek, concat(region0, region1), 0)); + } + + @Test + public void rejectsMixedNonceEncodingsAcrossCollidingValueSpace() { + // The supported SDK nonce encodings share a value space, so accepting them independently per region would + // weaken reorder detection. For example the Java nonce for region 1 is byte-identical to the .NET nonce for + // region 16,777,215, so a region could be moved across that boundary and still pass a naive per-region union + // check. Detection must instead lock onto a single encoding and enforce it for every region. (Mirrors the + // Python SDK's test_decrypt_rejects_mixed_nonce_encodings.) + assertArrayEquals(javaNonce(1), dotnetNonce(16_777_215)); + + byte[] cek = randomBytes(32); + // Region 0 uses the Java/Python encoding (all zeros); region 1 uses the .NET encoding. A per-region union + // check would accept both, but single-encoding enforcement rejects the mix. + byte[] region0 = encryptRegionWithNonce(cek, javaNonce(0), randomBytes(REGION_DATA_LENGTH)); + byte[] region1 = encryptRegionWithNonce(cek, dotnetNonce(1), randomBytes(REGION_DATA_LENGTH)); + + assertThrows(IllegalStateException.class, () -> decrypt(cek, concat(region0, region1), 0)); + } + private static byte[] encrypt(byte[] cek, byte[] plaintext) { SecretKey key = new SecretKeySpec(cek, AES); EncryptorV2 encryptor = new EncryptorV2(key, @@ -196,13 +282,16 @@ private static byte[] encrypt(byte[] cek, byte[] plaintext) { } /** - * Encrypts a single region using the same nonce scheme as {@link EncryptorV2}: the region index is truncated to an - * int and written big-endian into the nonce. Produces {@code nonce || ciphertext || tag}. Used to craft ciphertext - * for arbitrary (very large) region indices without materializing all preceding regions. + * Encrypts a single region using the Java SDK nonce scheme (region index truncated to an int, written big-endian). + * Produces {@code nonce || ciphertext || tag}. Used to craft ciphertext for arbitrary (very large) region indices + * without materializing all preceding regions. */ private static byte[] encryptRegionAt(byte[] cek, long regionIndex, byte[] plaintext) { + return encryptRegionWithNonce(cek, javaNonce(regionIndex), plaintext); + } + + private static byte[] encryptRegionWithNonce(byte[] cek, byte[] nonce, byte[] plaintext) { try { - byte[] nonce = ByteBuffer.allocate(NONCE_LENGTH).putLong((int) regionIndex).array(); Cipher cipher = Cipher.getInstance(AES_GCM_NO_PADDING); cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(cek, AES), new GCMParameterSpec(TAG_LENGTH * 8, nonce)); return concat(nonce, cipher.doFinal(plaintext)); @@ -211,6 +300,49 @@ private static byte[] encryptRegionAt(byte[] cek, long regionIndex, byte[] plain } } + /** Builds a multi-region CSEv2 blob whose region i uses the nonce produced by {@code nonceForRegion}. */ + private static byte[] buildBlob(byte[] cek, java.util.function.LongFunction nonceForRegion, + byte[][] regionPlaintext) { + byte[] result = new byte[0]; + for (int i = 0; i < regionPlaintext.length; i++) { + result = concat(result, encryptRegionWithNonce(cek, nonceForRegion.apply(i), regionPlaintext[i])); + } + return result; + } + + // Independent implementations of each SDK's nonce scheme, serving as cross-SDK test vectors. + + private static byte[] javaNonce(long regionIndex) { + return ByteBuffer.allocate(NONCE_LENGTH).putLong((int) regionIndex).array(); + } + + private static byte[] pythonNonce(long regionIndex) { + // 12-byte big-endian integer (counter in the low bytes), 0-based. + byte[] nonce = new byte[NONCE_LENGTH]; + for (int i = 0; i < Long.BYTES; i++) { + nonce[NONCE_LENGTH - 1 - i] = (byte) (regionIndex >>> (Byte.SIZE * i)); + } + return nonce; + } + + private static byte[] dotnetNonce(long regionIndex) { + // Four zero bytes then an 8-byte little-endian counter, 1-based. + byte[] nonce = new byte[NONCE_LENGTH]; + long counter = regionIndex + 1; + for (int i = 0; i < Long.BYTES; i++) { + nonce[(NONCE_LENGTH - Long.BYTES) + i] = (byte) (counter >>> (Byte.SIZE * i)); + } + return nonce; + } + + private static byte[] flatten(byte[][] arrays) { + byte[] result = new byte[0]; + for (byte[] a : arrays) { + result = concat(result, a); + } + return result; + } + private static byte[] decrypt(byte[] cek, byte[] ciphertext, long offset) { EncryptionData encryptionData = new EncryptionData() .setEncryptionAgent(new EncryptionAgent(ENCRYPTION_PROTOCOL_V2, EncryptionAlgorithm.AES_GCM_256)) diff --git a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlockBlobApiTests.java b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlockBlobApiTests.java index 0b7623811501..af243285ad7f 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlockBlobApiTests.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlockBlobApiTests.java @@ -610,7 +610,7 @@ public void encryptionV2DetectRegionReorder() { String blobName = generateBlobName(); EncryptedBlobClient encryptedClient = new EncryptedBlobClient(getEncryptedClientBuilder(fakeKey, null, - ENV.getPrimaryAccount().getCredential(), cc.getBlobContainerUrl(), EncryptionVersion.V2) + ENV.getPrimaryAccount().getCredential(), cc.getBlobContainerUrl(), EncryptionVersion.V2_1) .blobName(blobName) .clientSideEncryptionOptions( new BlobClientSideEncryptionOptions().setAuthenticatedRegionDataLengthInBytes(regionDataLength)) From 3c07545a835020a8848db1fafe4773cfbc7ecb2d Mon Sep 17 00:00:00 2001 From: Isabelle Date: Fri, 14 Aug 2026 11:02:44 -0700 Subject: [PATCH 05/18] adding misordered to cspell overrides --- .vscode/cspell.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.vscode/cspell.json b/.vscode/cspell.json index b713b5fb2962..2719210dc1dc 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -1096,7 +1096,8 @@ "filename": "sdk/storage/azure-storage-blob-cryptography/**", "words": [ "akek", - "azstorage" + "azstorage", + "misordered" ] }, { From fc1bf2ba0c7926f504c3848ef1533f3ae2387a2b Mon Sep 17 00:00:00 2001 From: Isabelle Date: Fri, 14 Aug 2026 12:04:40 -0700 Subject: [PATCH 06/18] adding cross sdk test --- .../cryptography/CryptographyConstants.java | 6 +- .../specialized/cryptography/DecryptorV2.java | 23 ++++-- .../cryptography/DecryptorV2ReorderTests.java | 29 ++++++- .../EncryptedBlockBlobApiTests.java | 81 +++++++++++++++++++ 4 files changed, 124 insertions(+), 15 deletions(-) diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CryptographyConstants.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CryptographyConstants.java index 9a648702403d..9470fd137a64 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CryptographyConstants.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CryptographyConstants.java @@ -59,15 +59,13 @@ final class CryptographyConstants { * System property name that, when set to {@code true}, disables detection of reordered client-side encryption v2 * authenticated regions. Intended for data recovery only. */ - static final String CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_SWITCH_NAME - = "Azure.Storage.CseV2AllowMisorderedAuthRegions"; + static final String ALLOW_MISORDERED_REGIONS_PROPERTY = "Azure.Storage.CseV2AllowMisorderedAuthRegions"; /** * Environment variable name that, when set to {@code true}, disables detection of reordered client-side encryption * v2 authenticated regions. Intended for data recovery only. */ - static final String CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_ENV_VAR - = "AZURE_STORAGE_CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS"; + static final String ALLOW_MISORDERED_REGIONS_ENV_VAR = "AZURE_STORAGE_CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS"; private CryptographyConstants() { } diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java index 3df758a94be0..ac12344682be 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java @@ -33,8 +33,8 @@ import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.AES; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.AES_GCM_NO_PADDING; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.AES_KEY_SIZE_BITS; -import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_ENV_VAR; -import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_SWITCH_NAME; +import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.ALLOW_MISORDERED_REGIONS_ENV_VAR; +import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.ALLOW_MISORDERED_REGIONS_PROPERTY; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.EMPTY_BUFFER; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.TAG_LENGTH; @@ -77,6 +77,13 @@ Flux decrypt(Flux encryptedFlux, EncryptedBlobRange encr * order-independent, so this remains correct even if regions are processed concurrently/out of order. If the * set ever becomes empty, a region is out of place. This behavior can be disabled for data recovery via a * compatibility switch. + * + * Limitation: a download that contains only a single region (for example an explicit one-region ranged read) + * cannot cross-check regions against each other, so it cannot positively establish which scheme the blob uses. + * Because the schemes share a value space, a lone region's nonce can be valid for its position under more than + * one scheme (for example the Java nonce for region 1 equals the .NET nonce for region 16,777,215). A full or + * multi-region download anchors the scheme from its earlier regions and therefore detects such substitutions; + * a single-region ranged download cannot, and matches the behavior of the other SDKs' cross-SDK detection. */ final boolean detectRegionReorder = !cseV2AllowMisorderedAuthRegions(); final AtomicReference> candidateSchemes @@ -172,8 +179,8 @@ private RuntimeException validateRegionNonce(byte[] actualNonce, int nonceLength return LOGGER.logExceptionAsError(new IllegalStateException("Cannot verify the integrity of client-side" + " encrypted (v2) content at or beyond " + NONCE_WRAP_REGION_COUNT + " authenticated regions" + " (region index " + expectedRegion + "). For content encrypted by this (Java) SDK the encryption" - + " nonce repeats past that point, resulting in GCM nonce reuse, and the blob is too large for its" - + " authenticated region size to be safely verified. " + recoveryInstruction())); + + " nonce repeats past that point, resulting in GCM nonce reuse, and the blob is too large for its " + + "authenticated region size to be safely verified. " + recoveryInstruction())); } return reorderException(expectedRegion, actualNonce); } @@ -203,8 +210,8 @@ private RuntimeException reorderException(long expectedRegion, byte[] actualNonc } private static String recoveryInstruction() { - return "To recover data from an affected blob, set the \"" + CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_ENV_VAR - + "\" environment variable (or the \"" + CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_SWITCH_NAME + return "To recover data from an affected blob, set the \"" + ALLOW_MISORDERED_REGIONS_ENV_VAR + + "\" environment variable (or the \"" + ALLOW_MISORDERED_REGIONS_PROPERTY + "\" system property) to \"true\"."; } @@ -294,9 +301,9 @@ byte[] expectedNonce(long regionIndex, int nonceLength) { * @return {@code true} if reordered authenticated regions should be allowed, {@code false} otherwise. */ private static boolean cseV2AllowMisorderedAuthRegions() { - String value = System.getProperty(CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_SWITCH_NAME); + String value = System.getProperty(ALLOW_MISORDERED_REGIONS_PROPERTY); if (CoreUtils.isNullOrEmpty(value)) { - value = System.getenv(CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_ENV_VAR); + value = System.getenv(ALLOW_MISORDERED_REGIONS_ENV_VAR); } return Boolean.parseBoolean(value); } diff --git a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java index af8bc2ce17fd..5f6c14e71ba9 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java @@ -51,7 +51,7 @@ public class DecryptorV2ReorderTests { @AfterEach public void clearSwitch() { - System.clearProperty(CryptographyConstants.CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_SWITCH_NAME); + System.clearProperty(CryptographyConstants.ALLOW_MISORDERED_REGIONS_PROPERTY); } @Test @@ -96,7 +96,7 @@ public void detectsRegionReorderOnRangedDownload() { @Test public void compatSwitchAllowsReorder() { - System.setProperty(CryptographyConstants.CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_SWITCH_NAME, "true"); + System.setProperty(CryptographyConstants.ALLOW_MISORDERED_REGIONS_PROPERTY, "true"); byte[] cek = randomBytes(32); byte[] plaintext = randomBytes(REGION_DATA_LENGTH * REGION_COUNT); @@ -175,7 +175,7 @@ public void failsClosedAtNonceWrapBoundary() { @Test public void recoverySwitchAllowsPastNonceWrapBoundary() { // With the recovery switch, the wrap-boundary fail-closed is bypassed and plaintext is recovered. - System.setProperty(CryptographyConstants.CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS_SWITCH_NAME, "true"); + System.setProperty(CryptographyConstants.ALLOW_MISORDERED_REGIONS_PROPERTY, "true"); byte[] cek = randomBytes(32); long wrapRegion = NONCE_WRAP_REGION_COUNT; @@ -272,6 +272,29 @@ public void rejectsMixedNonceEncodingsAcrossCollidingValueSpace() { assertThrows(IllegalStateException.class, () -> decrypt(cek, concat(region0, region1), 0)); } + @Test + public void singleRegionRangedDownloadCannotDetectCollisionSubstitution() { + // KNOWN LIMITATION (documented in DecryptorV2): a download containing only one region cannot cross-check + // regions to establish which SDK's nonce scheme the blob uses. Because the schemes share a value space + // (javaNonce(1) == dotnetNonce(16,777,215)), a lone region's nonce is valid for its position under more than + // one scheme. Here a Java-encoded region 1 is served for a single-region ranged read of region 16,777,215; it + // matches the .NET scheme for that position, so it is accepted rather than flagged. A full or multi-region + // download anchors the scheme from earlier regions and DOES detect such substitutions (see + // rejectsMixedNonceEncodingsAcrossCollidingValueSpace). This matches the other SDKs' cross-SDK detection. + assertArrayEquals(javaNonce(1), dotnetNonce(16_777_215)); + + byte[] cek = randomBytes(32); + byte[] region1Plaintext = randomBytes(REGION_DATA_LENGTH); + byte[] substituted = encryptRegionWithNonce(cek, javaNonce(1), region1Plaintext); + + long collidingOffset = 16_777_215L * REGION_DATA_LENGTH; + byte[] recovered = decrypt(cek, substituted, collidingOffset); + + // The substitution is not detected on a single-region ranged download; the region still decrypts under its + // inline nonce. (A multi-region download would have anchored the scheme and rejected this.) + assertArrayEquals(region1Plaintext, recovered); + } + private static byte[] encrypt(byte[] cek, byte[] plaintext) { SecretKey key = new SecretKeySpec(cek, AES); EncryptorV2 encryptor = new EncryptorV2(key, diff --git a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlockBlobApiTests.java b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlockBlobApiTests.java index af243285ad7f..a3aedda87b16 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlockBlobApiTests.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlockBlobApiTests.java @@ -380,6 +380,87 @@ public void testForCrossPlat() { encryptClient.upload(BinaryData.fromBytes(data), true); } + // Cross-SDK interoperability: Java must decrypt CSE v2 blobs written by other SDKs, whose per-region GCM nonces + // encode the region counter differently than Java's. This exercises a container of Python-produced blobs. Requires + // a container coordinated between languages; run manually. Verification/reorder-detection of the nonce schemes is + // covered without a live account by DecryptorV2ReorderTests. + @Disabled + @Test + public void crossPlatDecryptPythonV2() { + // The Python azure-storage-blob KeyWrapper("key1") test helper wraps the CEK with A256KW using this fixed KEK. + byte[] kek = fromHex("bea4114b9e4a07da664683ad2bad76412043e8bc90a4117d47c30fd4b4196d11"); + AsyncKeyEncryptionKey key = new A256KwKey("key1", kek); + + String containerName = "cross-sdk-cse-v2"; + String endpoint = ENV.getPrimaryAccount().getBlobEndpoint(); + + // Reference plaintext uploaded alongside the encrypted blob. + BlobClient plaintextClient = new BlobClientBuilder().endpoint(endpoint) + .containerName(containerName) + .blobName("python_plaintext") + .credential(ENV.getPrimaryAccount().getCredential()) + .buildClient(); + byte[] expected = plaintextClient.downloadContent().toBytes(); + + // Decrypt the Python-encrypted blob (Python nonce scheme) and confirm it round-trips without a false reorder. + EncryptedBlobClient decryptionClient = new EncryptedBlobClientBuilder(EncryptionVersion.V2).endpoint(endpoint) + .containerName(containerName) + .blobName("python_encrypted") + .key(key, "A256KW") + .credential(ENV.getPrimaryAccount().getCredential()) + .buildEncryptedBlobClient(); + ByteArrayOutputStream decrypted = new ByteArrayOutputStream(); + decryptionClient.downloadStream(decrypted); + + assertArraysEqual(expected, decrypted.toByteArray()); + } + + private static byte[] fromHex(String hex) { + byte[] out = new byte[hex.length() / 2]; + for (int i = 0; i < out.length; i++) { + out[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16); + } + return out; + } + + // AES key-wrap (A256KW / RFC 3394) key encryption key, matching the wrapping used by other SDKs' test helpers. + static final class A256KwKey implements AsyncKeyEncryptionKey { + private final String keyId; + private final javax.crypto.SecretKey kek; + + A256KwKey(String keyId, byte[] kekBytes) { + this.keyId = keyId; + this.kek = new SecretKeySpec(kekBytes, "AES"); + } + + @Override + public Mono getKeyId() { + return Mono.just(keyId); + } + + @Override + public Mono wrapKey(String algorithm, byte[] key) { + try { + Cipher cipher = Cipher.getInstance("AESWrap"); + cipher.init(Cipher.WRAP_MODE, kek); + return Mono.just(cipher.wrap(new SecretKeySpec(key, "AES"))); + } catch (GeneralSecurityException e) { + return Mono.error(e); + } + } + + @Override + public Mono unwrapKey(String algorithm, byte[] encryptedKey) { + try { + Cipher cipher = Cipher.getInstance("AESWrap"); + cipher.init(Cipher.UNWRAP_MODE, kek); + return Mono.just(cipher.unwrap(encryptedKey, "AES", Cipher.SECRET_KEY).getEncoded()); + } catch (GeneralSecurityException e) { + return Mono.error(e); + } + } + } + static class NoOpKey implements AsyncKeyEncryptionKey { @Override public Mono getKeyId() { From 375e94e13566cefd2bd375a4dd2357c7302ef171 Mon Sep 17 00:00:00 2001 From: Isabelle Date: Tue, 18 Aug 2026 11:55:16 -0700 Subject: [PATCH 07/18] logic consolidation for single nonce encoding --- .../cryptography/BlobDecryptionPolicy.java | 18 +- .../cryptography/CryptographyConstants.java | 6 + .../CseV2NonceOrderValidator.java | 244 ++++++++++++++++++ .../specialized/cryptography/Decryptor.java | 4 +- .../specialized/cryptography/DecryptorV2.java | 227 +--------------- .../EncryptedBlobAsyncClient.java | 2 + .../cryptography/EncryptedBlobClient.java | 2 + .../cryptography/DecryptionTests.java | 2 +- .../cryptography/DecryptorV2ReorderTests.java | 30 ++- 9 files changed, 314 insertions(+), 221 deletions(-) create mode 100644 sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/BlobDecryptionPolicy.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/BlobDecryptionPolicy.java index 80ce271d246d..0b753943efa1 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/BlobDecryptionPolicy.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/BlobDecryptionPolicy.java @@ -110,7 +110,7 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN boolean padding = hasPadding(responseHeaders, encryptionData, encryptedRange); Flux plainTextData = this.decryptBlob(httpResponse.getBody(), encryptedRange, padding, - encryptionData, httpResponse.getRequest().getUrl()); + encryptionData, httpResponse.getRequest().getUrl(), null); return Mono.just(new BlobDecryptionPolicy.DecryptedResponse(httpResponse, plainTextData)); } else { @@ -126,6 +126,13 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN EncryptionData encryptionData = (EncryptionData) context.getData(CryptographyConstants.ENCRYPTION_DATA_KEY).get(); + // Shared across every chunk of this download so the CSEv2 nonce scheme is enforced consistently across the + // whole operation (partitioned downloadToFile / chunked openInputStream issue one ranged request per + // chunk). Set up once per operation alongside the encryption data. + CseV2NonceOrderValidator nonceValidator + = (CseV2NonceOrderValidator) context.getData(CryptographyConstants.GCM_NONCE_VALIDATOR_KEY) + .orElse(null); + EncryptedBlobRange encryptedRange = EncryptedBlobRange.getEncryptedBlobRangeFromHeader(initialRangeHeader, encryptionData); if (context.getHttpRequest().getHeaders().getValue(RANGE_HEADER) != null) { @@ -151,7 +158,7 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN boolean padding = hasPadding(httpResponse.getHeaders(), encryptionData, encryptedRange); Flux plainTextData = this.decryptBlob(httpResponse.getBody(), encryptedRange, padding, - encryptionData, httpResponse.getRequest().getUrl()); + encryptionData, httpResponse.getRequest().getUrl(), nonceValidator); return new DecryptedResponse(httpResponse, plainTextData); } else { @@ -207,10 +214,13 @@ private boolean isDownloadResponse(HttpResponse httpResponse) { * @param encryptedBlobRange A {@link EncryptedBlobRange} indicating the range to decrypt * @param padding Boolean indicating if the padding mode should be set or not. * @param encryptionData The {@link EncryptionData} + * @param nonceValidator The per-download-operation validator that enforces a single CSEv2 nonce scheme across all + * chunks. May be {@code null} (e.g. a full-blob single-shot download), in which case decryption uses a fresh + * per-call validator. * @return A Flux ByteBuffer that has been decrypted */ Flux decryptBlob(Flux encryptedFlux, EncryptedBlobRange encryptedBlobRange, boolean padding, - EncryptionData encryptionData, URL requestUri) { + EncryptionData encryptionData, URL requestUri, CseV2NonceOrderValidator nonceValidator) { String uriToLog = requestUri.getHost() + requestUri.getPath(); // The number of bytes we have put into the Cipher so far. @@ -218,7 +228,7 @@ Flux decryptBlob(Flux encryptedFlux, EncryptedBlobRange // The number of bytes that have been sent to the downstream so far. AtomicLong totalOutputBytes = new AtomicLong(0); - Decryptor decryptor = Decryptor.getDecryptor(keyResolver, keyWrapper, encryptionData); + Decryptor decryptor = Decryptor.getDecryptor(keyResolver, keyWrapper, encryptionData, nonceValidator); Flux dataToTrim = decryptor.getKeyEncryptionKey() .flatMapMany( key -> decryptor.decrypt(encryptedFlux, encryptedBlobRange, padding, uriToLog, totalInputBytes, key)); diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CryptographyConstants.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CryptographyConstants.java index 9470fd137a64..92a4b22ad4e4 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CryptographyConstants.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CryptographyConstants.java @@ -33,6 +33,12 @@ final class CryptographyConstants { static final String ENCRYPTION_DATA_KEY = "encryptiondata"; + /** + * Context key under which a per-download-operation {@link CseV2NonceOrderValidator} is shared, so that the CSEv2 + * nonce scheme is enforced consistently across every (possibly concurrent) chunk of a single download. + */ + static final String GCM_NONCE_VALIDATOR_KEY = "gcmNonceValidator"; + static final HttpHeaderName ENCRYPTION_METADATA_HEADER = HttpHeaderName.fromString(Constants.HeaderConstants.X_MS_META + "-" + ENCRYPTION_DATA_KEY); diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java new file mode 100644 index 000000000000..b7ec5cfc8f1f --- /dev/null +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java @@ -0,0 +1,244 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.specialized.cryptography; + +import com.azure.core.util.CoreUtils; +import com.azure.core.util.logging.ClientLogger; + +import java.util.Arrays; +import java.util.EnumSet; +import java.util.concurrent.atomic.AtomicReference; + +import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.ALLOW_MISORDERED_REGIONS_ENV_VAR; +import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.ALLOW_MISORDERED_REGIONS_PROPERTY; + +/** + * Detects rearrangement of client-side encryption v2 authenticated regions by validating each region's nonce against + * the value expected for its sequential position. + *

+ * Each CSEv2 region is encrypted under a unique, sequential nonce derived from the region's index. Because the nonce is + * stored alongside the ciphertext, individual regions of otherwise untampered ciphertext can be rearranged without + * invalidating any single region's GCM tag, silently corrupting the decrypted plaintext. Validating that each region's + * nonce matches its position detects this. + *

+ * CSEv2 is cross-SDK interoperable and each Azure Storage SDK encodes the region counter into the nonce differently + * (see {@link NonceScheme}). Decryption itself is unaffected (it uses the inline nonce), but reorder validation must + * reconstruct the expected nonce, which requires knowing the encoder's scheme. This validator begins by considering all + * known schemes and intersects the set of schemes still consistent with every region seen so far, collapsing to a + * single scheme once enough regions have been read. Once collapsed, subsequent regions are checked lock-free against + * the resolved scheme. + *

+ * A single instance is intended to be shared across an entire logical download operation (which may span multiple + * concurrent HTTP range requests, e.g. a parallel {@code downloadToFile} or a chunked {@code openInputStream}). Sharing + * the instance means the scheme is enforced consistently across the whole download rather than being re-established per + * chunk; otherwise, because the schemes share a value space, a region relocated across a chunk boundary to a colliding + * position could pass validation. The intersection is performed atomically and is order-independent, so it is safe + * under concurrent region processing. + */ +final class CseV2NonceOrderValidator { + private static final ClientLogger LOGGER = new ClientLogger(CseV2NonceOrderValidator.class); + + /* + * The Java encoder truncates the region index to an int when producing each region's nonce (see + * EncryptorV2.getCipher), so its nonces are unique only for the first 2^32 regions. At or beyond this index the + * nonce repeats: two distinct regions can share a nonce and each retain a valid GCM tag. That both defeats + * sequential reorder detection and is GCM nonce reuse, so Java-encoded content cannot be verified past this + * boundary. The Python and .NET encoders use wider counters that do not repeat for any real blob size. + */ + private static final long NONCE_WRAP_REGION_COUNT = 1L << 32; + + private final boolean validationEnabled; + private final AtomicReference> candidateSchemes + = new AtomicReference<>(EnumSet.allOf(NonceScheme.class)); + + CseV2NonceOrderValidator() { + // Read the data-recovery bypass switch once per download operation. + this.validationEnabled = !cseV2AllowMisorderedAuthRegions(); + } + + /** + * Validates that the nonce of an authenticated region is consistent with the region occupying its expected + * sequential position, under a single recognized cross-SDK nonce scheme enforced across the whole download. A + * region whose nonce matches none of the schemes for its position - or that is inconsistent with the scheme + * established by earlier regions - indicates a reorder or other tampering. Also fails closed once the region index + * reaches the point where the Java encoder's nonce begins to repeat, as integrity can no longer be guaranteed + * there. + * + * @param actualNonce The nonce read from the downloaded region. + * @param nonceLength The length of the nonce. + * @param expectedRegion The expected 0-based region index for this region's position. + * @return A {@link RuntimeException} describing the tampering or unverifiable state if the region is invalid, or + * {@code null} if the nonce is valid. + */ + RuntimeException validateRegion(byte[] actualNonce, int nonceLength, long expectedRegion) { + if (!validationEnabled) { + return null; + } + + // Cannot reconstruct the expected nonce if it is too short to hold the region index. This should never happen + // for CSEv2 (nonce length is 12), so treat it as unverifiable rather than a failure. + if (nonceLength < Long.BYTES || actualNonce.length < Long.BYTES) { + return null; + } + + boolean pastJavaNonceWrap = expectedRegion >= NONCE_WRAP_REGION_COUNT; + + // Fast path: once the scheme has collapsed to a single encoding, verify directly without mutating shared state. + EnumSet current = candidateSchemes.get(); + if (current.size() == 1) { + NonceScheme locked = current.iterator().next(); + if (pastJavaNonceWrap && locked == NonceScheme.JAVA) { + return nonceWrapException(expectedRegion); + } + return Arrays.equals(locked.expectedNonce(expectedRegion, nonceLength), actualNonce) + ? null + : reorderException(expectedRegion, actualNonce); + } + + // Determine which schemes are consistent with this region at its position. + EnumSet matchesHere = EnumSet.noneOf(NonceScheme.class); + for (NonceScheme scheme : NonceScheme.values()) { + if (pastJavaNonceWrap && scheme == NonceScheme.JAVA) { + continue; + } + if (Arrays.equals(scheme.expectedNonce(expectedRegion, nonceLength), actualNonce)) { + matchesHere.add(scheme); + } + } + + if (matchesHere.isEmpty()) { + return pastJavaNonceWrap + ? nonceWrapException(expectedRegion) + : reorderException(expectedRegion, actualNonce); + } + + // Intersect the shared candidate set with the schemes matching this region. Atomic and order-independent, so it + // remains correct even if regions are processed concurrently. A valid blob keeps its own scheme in the set at + // every region; a reorder that looks valid under a different scheme for a single region is caught here because + // it is inconsistent with the scheme the rest of the download uses. + EnumSet remaining = candidateSchemes.updateAndGet(existing -> { + EnumSet next = EnumSet.copyOf(existing); + next.retainAll(matchesHere); + return next; + }); + return remaining.isEmpty() ? reorderException(expectedRegion, actualNonce) : null; + } + + private static RuntimeException reorderException(long expectedRegion, byte[] actualNonce) { + return LOGGER.logExceptionAsError(new IllegalStateException( + "Encountered an out-of-order authenticated region while decrypting client-side encrypted (v2) content. " + + "This may indicate that the blob's authenticated regions have been rearranged or otherwise tampered " + + "with. The nonce at region index " + expectedRegion + " (0x" + bytesToHex(actualNonce) + ") does not " + + "match the recognized client-side encryption nonce scheme for that position. " + + recoveryInstruction())); + } + + private static RuntimeException nonceWrapException(long expectedRegion) { + return LOGGER.logExceptionAsError(new IllegalStateException("Cannot verify the integrity of client-side" + + " encrypted (v2) content at or beyond " + NONCE_WRAP_REGION_COUNT + + " authenticated regions (region index " + expectedRegion + + "). For content encrypted by this (Java) SDK the encryption nonce repeats past that" + + " point, resulting in GCM nonce reuse, and the blob is too large for its authenticated region size to be" + + " safely verified. " + recoveryInstruction())); + } + + private static String recoveryInstruction() { + return "To recover data from an affected blob, set the \"" + ALLOW_MISORDERED_REGIONS_ENV_VAR + + "\" environment variable (or the \"" + ALLOW_MISORDERED_REGIONS_PROPERTY + + "\" system property) to \"true\"."; + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString(); + } + + /** + * Whether detection of reordered client-side encryption v2 authenticated regions should be disabled. + *

+ * This is a data-recovery escape hatch, read live from a system property or environment variable. When enabled, the + * client will not throw when it encounters authenticated regions that appear to have been rearranged, allowing + * (potentially tampered) plaintext to be recovered. + *

+ * {@link com.azure.core.util.Configuration} is intentionally not used here because the global configuration caches + * the first value it reads for a given name, which would prevent the switch from being honored if it is set after + * the first read. + * + * @return {@code true} if reordered authenticated regions should be allowed, {@code false} otherwise. + */ + private static boolean cseV2AllowMisorderedAuthRegions() { + String value = System.getProperty(ALLOW_MISORDERED_REGIONS_PROPERTY); + if (CoreUtils.isNullOrEmpty(value)) { + value = System.getenv(ALLOW_MISORDERED_REGIONS_ENV_VAR); + } + return Boolean.parseBoolean(value); + } + + /** + * The recognized ways Azure Storage client-side encryption v2 SDKs encode a region's sequential counter into its + * GCM nonce. Decryption uses the inline nonce and is unaffected by these differences, but reorder detection must + * reconstruct the expected nonce, which requires knowing the encoder's scheme. The complete set of SDKs that + * produce CSEv2 content is .NET, Java, and Python. + */ + private enum NonceScheme { + /** + * Java: the region index truncated to an int, written as an 8-byte big-endian (sign-extended) value in the + * first 8 bytes, remaining bytes zero. 0-based. See {@link EncryptorV2}. + */ + JAVA { + @Override + byte[] expectedNonce(long regionIndex, int nonceLength) { + byte[] nonce = new byte[nonceLength]; + long value = (int) regionIndex; + for (int i = 0; i < Long.BYTES; i++) { + nonce[i] = (byte) (value >>> (Long.SIZE - Byte.SIZE - Byte.SIZE * i)); + } + return nonce; + } + }, + + /** + * Python: the region index encoded as a big-endian integer across the whole nonce (counter in the low bytes). + * 0-based. See azure-storage-blob {@code encrypt_data_v2}. + */ + PYTHON { + @Override + byte[] expectedNonce(long regionIndex, int nonceLength) { + byte[] nonce = new byte[nonceLength]; + for (int i = 0; i < Long.BYTES; i++) { + nonce[nonceLength - 1 - i] = (byte) (regionIndex >>> (Byte.SIZE * i)); + } + return nonce; + } + }, + + /** + * .NET: four zero bytes followed by an 8-byte little-endian counter. 1-based (the first region uses counter 1). + * See Azure.Storage.Common {@code GcmAuthenticatedCryptographicTransform}. + */ + DOTNET { + @Override + byte[] expectedNonce(long regionIndex, int nonceLength) { + byte[] nonce = new byte[nonceLength]; + long counter = regionIndex + 1; + for (int i = 0; i < Long.BYTES; i++) { + nonce[(nonceLength - Long.BYTES) + i] = (byte) (counter >>> (Byte.SIZE * i)); + } + return nonce; + } + }; + + /** + * Produces the nonce this scheme assigns to the given region index. + * + * @param regionIndex The 0-based region index. + * @param nonceLength The nonce length (12 for CSEv2). Must be at least {@link Long#BYTES}. + * @return The expected nonce bytes. + */ + abstract byte[] expectedNonce(long regionIndex, int nonceLength); + } +} diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/Decryptor.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/Decryptor.java index aed6ee717a35..f8bbc1755d86 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/Decryptor.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/Decryptor.java @@ -89,7 +89,7 @@ abstract Flux decrypt(Flux encryptedFlux, EncryptedBlobR boolean padding, String requestUri, AtomicLong totalInputBytes, byte[] contentEncryptionKey); static Decryptor getDecryptor(AsyncKeyEncryptionKeyResolver keyResolver, AsyncKeyEncryptionKey keyWrapper, - EncryptionData encryptionData) { + EncryptionData encryptionData, CseV2NonceOrderValidator nonceValidator) { if (encryptionData == null) { return new NoOpDecryptor(keyResolver, keyWrapper, null); } @@ -99,7 +99,7 @@ static Decryptor getDecryptor(AsyncKeyEncryptionKeyResolver keyResolver, AsyncKe case ENCRYPTION_PROTOCOL_V2: case ENCRYPTION_PROTOCOL_V2_1: - return new DecryptorV2(keyResolver, keyWrapper, encryptionData); + return new DecryptorV2(keyResolver, keyWrapper, encryptionData, nonceValidator); default: throw LOGGER.logExceptionAsError(new IllegalStateException( diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java index ac12344682be..2ec932c9f8e6 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java @@ -5,7 +5,6 @@ import com.azure.core.cryptography.AsyncKeyEncryptionKey; import com.azure.core.cryptography.AsyncKeyEncryptionKeyResolver; -import com.azure.core.util.CoreUtils; import com.azure.core.util.logging.ClientLogger; import com.azure.storage.common.implementation.BufferStagingArea; import reactor.core.Exceptions; @@ -25,16 +24,11 @@ import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; -import java.util.Arrays; -import java.util.EnumSet; import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.atomic.AtomicReference; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.AES; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.AES_GCM_NO_PADDING; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.AES_KEY_SIZE_BITS; -import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.ALLOW_MISORDERED_REGIONS_ENV_VAR; -import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.ALLOW_MISORDERED_REGIONS_PROPERTY; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.EMPTY_BUFFER; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.TAG_LENGTH; @@ -42,16 +36,16 @@ class DecryptorV2 extends Decryptor { private static final ClientLogger LOGGER = new ClientLogger(DecryptorV2.class); /* - * EncryptorV2 truncates the region index to an int when producing each region's nonce (see EncryptorV2.getCipher), - * so nonces are unique only for the first 2^32 regions. At or beyond this index the nonce repeats: two distinct - * regions can share a nonce and each retain a valid GCM tag. That both defeats sequential reorder detection and is - * GCM nonce reuse, so integrity cannot be verified past this boundary. + * Shared across every chunk of a single download operation so that the CSEv2 nonce scheme is enforced consistently + * across the whole download (see CseV2NonceOrderValidator). Never null; callers that do not supply a shared + * validator get a fresh per-instance one. */ - private static final long NONCE_WRAP_REGION_COUNT = 1L << 32; + private final CseV2NonceOrderValidator nonceValidator; protected DecryptorV2(AsyncKeyEncryptionKeyResolver keyResolver, AsyncKeyEncryptionKey keyWrapper, - EncryptionData encryptionData) { + EncryptionData encryptionData, CseV2NonceOrderValidator nonceValidator) { super(keyResolver, keyWrapper, encryptionData); + this.nonceValidator = nonceValidator != null ? nonceValidator : new CseV2NonceOrderValidator(); } @Override @@ -67,27 +61,11 @@ Flux decrypt(Flux encryptedFlux, EncryptedBlobRange encr * Each CSEv2 region is encrypted under a unique, sequential nonce derived from the region's index (see * EncryptorV2). Because the nonce is stored alongside the ciphertext, individual regions of otherwise * untampered ciphertext can be rearranged without invalidating any single region's authentication tag, - * silently corrupting the decrypted plaintext. Detect this by asserting that the nonce of each region matches - * the value expected for its sequential position. - * - * CSEv2 is cross-SDK interoperable and each Azure Storage SDK encodes the region counter into the nonce - * differently (see NonceScheme). Decryption itself is unaffected (it uses the inline nonce), but to validate - * ordering we must recognize the scheme the blob was written with. We start by considering all known schemes - * and intersect the set of schemes still consistent with every region seen so far. Intersection is - * order-independent, so this remains correct even if regions are processed concurrently/out of order. If the - * set ever becomes empty, a region is out of place. This behavior can be disabled for data recovery via a - * compatibility switch. - * - * Limitation: a download that contains only a single region (for example an explicit one-region ranged read) - * cannot cross-check regions against each other, so it cannot positively establish which scheme the blob uses. - * Because the schemes share a value space, a lone region's nonce can be valid for its position under more than - * one scheme (for example the Java nonce for region 1 equals the .NET nonce for region 16,777,215). A full or - * multi-region download anchors the scheme from its earlier regions and therefore detects such substitutions; - * a single-region ranged download cannot, and matches the behavior of the other SDKs' cross-SDK detection. + * silently corrupting the decrypted plaintext. The shared nonceValidator asserts that each region's nonce + * matches the value expected for its sequential position, under a single cross-SDK nonce scheme enforced across + * the whole download operation. The download always begins on a region boundary, so the first region's index is + * derived from the requested range (0 for a full-blob download). */ - final boolean detectRegionReorder = !cseV2AllowMisorderedAuthRegions(); - final AtomicReference> candidateSchemes - = detectRegionReorder ? new AtomicReference<>(EnumSet.allOf(NonceScheme.class)) : null; final long initialRegion = authenticatedRegionDataLength == 0 ? 0 : encryptedBlobRange.getOriginalRange().getOffset() / authenticatedRegionDataLength; @@ -99,13 +77,10 @@ Flux decrypt(Flux encryptedFlux, EncryptedBlobRange encr // Get the IV out of the beginning of the aggregator byte[] gmcIv = indexedAggregator.getT2().getFirstNBytes(nonceLength); - if (detectRegionReorder) { - long expectedRegion = initialRegion + indexedAggregator.getT1(); - RuntimeException reorderError - = validateRegionNonce(gmcIv, nonceLength, expectedRegion, candidateSchemes); - if (reorderError != null) { - return Mono.error(reorderError); - } + long expectedRegion = initialRegion + indexedAggregator.getT1(); + RuntimeException reorderError = nonceValidator.validateRegion(gmcIv, nonceLength, expectedRegion); + if (reorderError != null) { + return Mono.error(reorderError); } Cipher gmcCipher; @@ -134,180 +109,6 @@ Flux decrypt(Flux encryptedFlux, EncryptedBlobRange encr }); } - /** - * Validates that the nonce of an authenticated region is consistent with the region occupying its expected - * sequential position, under one of the recognized cross-SDK nonce schemes. A region whose nonce matches none of - * the schemes for its position - or that is inconsistent with the scheme established by earlier regions - indicates - * a reorder or other tampering. Also fails closed once the region index reaches the point where the Java encoder's - * nonce begins to repeat, as integrity can no longer be guaranteed there. - * - * @param actualNonce The nonce read from the downloaded region. - * @param nonceLength The length of the nonce. - * @param expectedRegion The expected 0-based region index. - * @param candidateSchemes The set of nonce schemes still consistent with all regions seen so far. Intersected in - * place with the schemes matching this region; intersection is order-independent and therefore safe under - * concurrent region processing. - * @return A {@link RuntimeException} describing the tampering or unverifiable state if the region is invalid, or - * {@code null} if the nonce is valid. - */ - private RuntimeException validateRegionNonce(byte[] actualNonce, int nonceLength, long expectedRegion, - AtomicReference> candidateSchemes) { - // Cannot reconstruct the expected nonce if it is too short to hold the region index. This should never happen - // for CSEv2 (nonce length is 12), so treat it as unverifiable rather than a failure. - if (nonceLength < Long.BYTES || actualNonce.length < Long.BYTES) { - return null; - } - - // The Java encoder truncates the region index to an int, so its nonces begin to repeat at 2^32 regions. Past - // that boundary a Java-encoded blob can no longer be verified (nonce reuse), while the Python and .NET encoders - // use wider counters that do not repeat for any real blob size. So past the boundary we only consider the - // non-wrapping schemes; if none match, we fail closed. - boolean pastJavaNonceWrap = expectedRegion >= NONCE_WRAP_REGION_COUNT; - - EnumSet matchesHere = EnumSet.noneOf(NonceScheme.class); - for (NonceScheme scheme : NonceScheme.values()) { - if (pastJavaNonceWrap && scheme == NonceScheme.JAVA) { - continue; - } - if (Arrays.equals(scheme.expectedNonce(expectedRegion, nonceLength), actualNonce)) { - matchesHere.add(scheme); - } - } - - if (matchesHere.isEmpty()) { - if (pastJavaNonceWrap) { - return LOGGER.logExceptionAsError(new IllegalStateException("Cannot verify the integrity of client-side" - + " encrypted (v2) content at or beyond " + NONCE_WRAP_REGION_COUNT + " authenticated regions" - + " (region index " + expectedRegion + "). For content encrypted by this (Java) SDK the encryption" - + " nonce repeats past that point, resulting in GCM nonce reuse, and the blob is too large for its " - + "authenticated region size to be safely verified. " + recoveryInstruction())); - } - return reorderException(expectedRegion, actualNonce); - } - - // Lock onto the scheme(s) consistent with every region so far. A valid blob keeps its own scheme in the set at - // every region; a reorder that happens to look valid under a different scheme for a single region is caught - // here because it is inconsistent with the scheme the rest of the blob uses. - EnumSet remaining = candidateSchemes.updateAndGet(current -> { - EnumSet next = EnumSet.copyOf(current); - next.retainAll(matchesHere); - return next; - }); - if (remaining.isEmpty()) { - return reorderException(expectedRegion, actualNonce); - } - - return null; - } - - private RuntimeException reorderException(long expectedRegion, byte[] actualNonce) { - return LOGGER.logExceptionAsError(new IllegalStateException( - "Encountered an out-of-order authenticated region while decrypting client-side encrypted (v2) content. " - + "This may indicate that the blob's authenticated regions have been rearranged or otherwise tampered " - + "with. The nonce at region index " + expectedRegion + " (0x" + bytesToHex(actualNonce) + ") does not " - + "match any recognized client-side encryption nonce scheme for that position. " - + recoveryInstruction())); - } - - private static String recoveryInstruction() { - return "To recover data from an affected blob, set the \"" + ALLOW_MISORDERED_REGIONS_ENV_VAR - + "\" environment variable (or the \"" + ALLOW_MISORDERED_REGIONS_PROPERTY - + "\" system property) to \"true\"."; - } - - private static String bytesToHex(byte[] bytes) { - StringBuilder sb = new StringBuilder(bytes.length * 2); - for (byte b : bytes) { - sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16)); - } - return sb.toString(); - } - - /** - * The recognized ways Azure Storage client-side encryption v2 SDKs encode a region's sequential counter into its - * GCM nonce. Decryption uses the inline nonce and is unaffected by these differences, but reorder detection must - * reconstruct the expected nonce, which requires knowing the encoder's scheme. The complete set of SDKs that - * produce CSEv2 content is .NET, Java, and Python. - */ - private enum NonceScheme { - /** - * Java: the region index truncated to an int, written as an 8-byte big-endian (sign-extended) value in the - * first 8 bytes, remaining bytes zero. 0-based. See {@link EncryptorV2}. - */ - JAVA { - @Override - byte[] expectedNonce(long regionIndex, int nonceLength) { - byte[] nonce = new byte[nonceLength]; - long value = (int) regionIndex; - for (int i = 0; i < Long.BYTES; i++) { - nonce[i] = (byte) (value >>> (Long.SIZE - Byte.SIZE - Byte.SIZE * i)); - } - return nonce; - } - }, - - /** - * Python: the region index encoded as a big-endian integer across the whole nonce (counter in the low bytes). - * 0-based. See azure-storage-blob {@code encrypt_data_v2}. - */ - PYTHON { - @Override - byte[] expectedNonce(long regionIndex, int nonceLength) { - byte[] nonce = new byte[nonceLength]; - for (int i = 0; i < Long.BYTES; i++) { - nonce[nonceLength - 1 - i] = (byte) (regionIndex >>> (Byte.SIZE * i)); - } - return nonce; - } - }, - - /** - * .NET: four zero bytes followed by an 8-byte little-endian counter. 1-based (the first region uses counter 1). - * See Azure.Storage.Common {@code GcmAuthenticatedCryptographicTransform}. - */ - DOTNET { - @Override - byte[] expectedNonce(long regionIndex, int nonceLength) { - byte[] nonce = new byte[nonceLength]; - long counter = regionIndex + 1; - for (int i = 0; i < Long.BYTES; i++) { - nonce[(nonceLength - Long.BYTES) + i] = (byte) (counter >>> (Byte.SIZE * i)); - } - return nonce; - } - }; - - /** - * Produces the nonce this scheme assigns to the given region index. - * - * @param regionIndex The 0-based region index. - * @param nonceLength The nonce length (12 for CSEv2). Must be at least {@link Long#BYTES}. - * @return The expected nonce bytes. - */ - abstract byte[] expectedNonce(long regionIndex, int nonceLength); - } - - /** - * Whether detection of reordered client-side encryption v2 authenticated regions should be disabled. - *

- * This is a data-recovery escape hatch, read live from a system property or environment variable. When enabled, the - * client will not throw when it encounters authenticated regions that appear to have been rearranged, allowing - * (potentially tampered) plaintext to be recovered. - *

- * {@link com.azure.core.util.Configuration} is intentionally not used here because the global configuration caches - * the first value it reads for a given name, which would prevent the switch from being honored if it is set after - * the first read. - * - * @return {@code true} if reordered authenticated regions should be allowed, {@code false} otherwise. - */ - private static boolean cseV2AllowMisorderedAuthRegions() { - String value = System.getProperty(ALLOW_MISORDERED_REGIONS_PROPERTY); - if (CoreUtils.isNullOrEmpty(value)) { - value = System.getenv(ALLOW_MISORDERED_REGIONS_ENV_VAR); - } - return Boolean.parseBoolean(value); - } - @Override protected Mono getKeyEncryptionKey() { return super.getKeyEncryptionKey().flatMap(keyBytes -> { diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlobAsyncClient.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlobAsyncClient.java index b3bc72db6624..e93d985e788b 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlobAsyncClient.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlobAsyncClient.java @@ -68,6 +68,7 @@ import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.AGENT_METADATA_KEY; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.AGENT_METADATA_VALUE; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.ENCRYPTION_DATA_KEY; +import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.GCM_NONCE_VALIDATOR_KEY; /** * This class provides a client side encryption client that contains generic blob operations for Azure Storage Blobs. @@ -764,6 +765,7 @@ private Mono populateRequestConditionsAndContext(BlobRequestConditions re = EncryptionData.getAndValidateEncryptionData(encryptionDataKey, requiresEncryption); result = result.contextWrite(context -> context.put(ENCRYPTION_DATA_KEY, encryptionData)) + .contextWrite(context -> context.put(GCM_NONCE_VALIDATOR_KEY, new CseV2NonceOrderValidator())) .contextWrite(context -> context.put(Constants.ADJUSTED_BLOB_LENGTH_KEY, EncryptedBlobLength .computeAdjustedBlobLength(encryptionData, response.getValue().getBlobSize()))); } diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlobClient.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlobClient.java index e475aabf3342..38132fd72bba 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlobClient.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlobClient.java @@ -52,6 +52,7 @@ import java.util.Set; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.ENCRYPTION_DATA_KEY; +import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.GCM_NONCE_VALIDATOR_KEY; /** * This class provides a client side encryption client that contains generic blob operations for Azure Storage Blobs. @@ -498,6 +499,7 @@ private Context populateRequestConditionsAndContext(BlobRequestConditions reques encryptedBlobAsyncClient.isEncryptionRequired()); context = context.addData(ENCRYPTION_DATA_KEY, encryptionData) + .addData(GCM_NONCE_VALIDATOR_KEY, new CseV2NonceOrderValidator()) .addData(Constants.ADJUSTED_BLOB_LENGTH_KEY, EncryptedBlobLength.computeAdjustedBlobLength(encryptionData, initialProperties.getBlobSize())); } diff --git a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptionTests.java b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptionTests.java index 0f50e3e04924..7e391bd5b67e 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptionTests.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptionTests.java @@ -60,7 +60,7 @@ public void decryption(int testCase) throws InvalidKeyException, IOException { StepVerifier .create(FluxUtil.collectBytesInByteBufferStream( blobDecryptionPolicy.decryptBlob(flow, new EncryptedBlobRange(blobRange, encryptionData), true, - encryptionData, new URL("http://www.foo.com/path")))) + encryptionData, new URL("http://www.foo.com/path"), null))) .assertNext(bytes -> assertByteBuffersEqual(desiredOutput, ByteBuffer.wrap(bytes))) .verifyComplete(); } diff --git a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java index 5f6c14e71ba9..26f4c2410bde 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java @@ -295,6 +295,30 @@ public void singleRegionRangedDownloadCannotDetectCollisionSubstitution() { assertArrayEquals(region1Plaintext, recovered); } + @Test + public void sharedValidatorEnforcesSchemeAcrossChunks() { + // A download operation may span multiple decrypt() calls (parallel downloadToFile / chunked openInputStream). + // A shared validator must intersect the candidate encodings across chunks; otherwise the encoding could change + // at a chunk boundary and, at an encoding collision, let a relocated region pass. Mirrors the Python SDK's + // test_nonce_validator_enforces_single_encoding_across_chunks. + assertArrayEquals(javaNonce(1), dotnetNonce(16_777_215)); + + byte[] cek = randomBytes(32); + CseV2NonceOrderValidator shared = new CseV2NonceOrderValidator(); + + // First chunk: two Java-encoded regions resolve the shared encoding to Java. + byte[] chunk1 = concat(encryptRegionWithNonce(cek, javaNonce(0), randomBytes(REGION_DATA_LENGTH)), + encryptRegionWithNonce(cek, javaNonce(1), randomBytes(REGION_DATA_LENGTH))); + decrypt(cek, chunk1, 0, shared); + + // Later chunk: a region relocated to the colliding .NET index carries Java's region-1 nonce. On a fresh + // per-chunk validator its only consistent encoding is .NET and it would pass; the shared validator (already + // resolved to Java) rejects it. + byte[] relocated = encryptRegionWithNonce(cek, javaNonce(1), randomBytes(REGION_DATA_LENGTH)); + long collidingOffset = 16_777_215L * REGION_DATA_LENGTH; + assertThrows(IllegalStateException.class, () -> decrypt(cek, relocated, collidingOffset, shared)); + } + private static byte[] encrypt(byte[] cek, byte[] plaintext) { SecretKey key = new SecretKeySpec(cek, AES); EncryptorV2 encryptor = new EncryptorV2(key, @@ -367,10 +391,14 @@ private static byte[] flatten(byte[][] arrays) { } private static byte[] decrypt(byte[] cek, byte[] ciphertext, long offset) { + return decrypt(cek, ciphertext, offset, new CseV2NonceOrderValidator()); + } + + private static byte[] decrypt(byte[] cek, byte[] ciphertext, long offset, CseV2NonceOrderValidator validator) { EncryptionData encryptionData = new EncryptionData() .setEncryptionAgent(new EncryptionAgent(ENCRYPTION_PROTOCOL_V2, EncryptionAlgorithm.AES_GCM_256)) .setEncryptedRegionInfo(new EncryptedRegionInfo(REGION_DATA_LENGTH, NONCE_LENGTH)); - DecryptorV2 decryptor = new DecryptorV2(null, null, encryptionData); + DecryptorV2 decryptor = new DecryptorV2(null, null, encryptionData, validator); EncryptedBlobRange range = new EncryptedBlobRange(new BlobRange(offset), encryptionData); List out From 897c80adc3c343f3ea2f9c7a1f947c3652a14b02 Mon Sep 17 00:00:00 2001 From: Isabelle Date: Tue, 18 Aug 2026 14:51:56 -0700 Subject: [PATCH 08/18] retriving operation scoped validation for non range branch --- .../cryptography/BlobDecryptionPolicy.java | 12 +++++++++++- .../blob/specialized/cryptography/DecryptorV2.java | 7 +++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/BlobDecryptionPolicy.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/BlobDecryptionPolicy.java index 0b753943efa1..8697206ae315 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/BlobDecryptionPolicy.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/BlobDecryptionPolicy.java @@ -109,8 +109,18 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN boolean padding = hasPadding(responseHeaders, encryptionData, encryptedRange); + // Use the operation-scoped validator if one was installed (e.g. downloadContentWithResponse always + // sets it up, even for a full-blob request). Sharing it means a reliable-download ranged resume of + // this same operation - which re-enters the pipeline through the range branch with the same context + // - enforces one nonce scheme across the initial and resumed portions. Falls back to a fresh + // per-call validator when none is present (e.g. a full-blob downloadStream that never set up + // context), which is a single chunk anyway. + CseV2NonceOrderValidator nonceValidator + = (CseV2NonceOrderValidator) context.getData(CryptographyConstants.GCM_NONCE_VALIDATOR_KEY) + .orElse(null); + Flux plainTextData = this.decryptBlob(httpResponse.getBody(), encryptedRange, padding, - encryptionData, httpResponse.getRequest().getUrl(), null); + encryptionData, httpResponse.getRequest().getUrl(), nonceValidator); return Mono.just(new BlobDecryptionPolicy.DecryptedResponse(httpResponse, plainTextData)); } else { diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java index 2ec932c9f8e6..1c694182e4a6 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java @@ -37,8 +37,7 @@ class DecryptorV2 extends Decryptor { /* * Shared across every chunk of a single download operation so that the CSEv2 nonce scheme is enforced consistently - * across the whole download (see CseV2NonceOrderValidator). Never null; callers that do not supply a shared - * validator get a fresh per-instance one. + * across the whole download (see CseV2NonceOrderValidator). */ private final CseV2NonceOrderValidator nonceValidator; @@ -58,8 +57,8 @@ Flux decrypt(Flux encryptedFlux, EncryptedBlobRange encr authenticatedRegionDataLength + TAG_LENGTH + nonceLength); /* - * Each CSEv2 region is encrypted under a unique, sequential nonce derived from the region's index (see - * EncryptorV2). Because the nonce is stored alongside the ciphertext, individual regions of otherwise + * Each CSEv2 region is encrypted under a unique, sequential nonce derived from the region's index. + * Because the nonce is stored alongside the ciphertext, individual regions of otherwise * untampered ciphertext can be rearranged without invalidating any single region's authentication tag, * silently corrupting the decrypted plaintext. The shared nonceValidator asserts that each region's nonce * matches the value expected for its sequential position, under a single cross-SDK nonce scheme enforced across From 73ccba1f53b8a0b089dc71bdad7c3dc61304f03c Mon Sep 17 00:00:00 2001 From: Isabelle Date: Wed, 19 Aug 2026 13:02:41 -0700 Subject: [PATCH 09/18] small refactor to validate region method --- .../CseV2NonceOrderValidator.java | 39 +++++++++---------- .../specialized/cryptography/DecryptorV2.java | 7 +--- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java index b7ec5cfc8f1f..e99390fa6d1e 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java @@ -68,18 +68,17 @@ final class CseV2NonceOrderValidator { * @param actualNonce The nonce read from the downloaded region. * @param nonceLength The length of the nonce. * @param expectedRegion The expected 0-based region index for this region's position. - * @return A {@link RuntimeException} describing the tampering or unverifiable state if the region is invalid, or - * {@code null} if the nonce is valid. + * @throws RuntimeException If the region is invalid or its integrity cannot be verified. */ - RuntimeException validateRegion(byte[] actualNonce, int nonceLength, long expectedRegion) { + void validateRegion(byte[] actualNonce, int nonceLength, long expectedRegion) { if (!validationEnabled) { - return null; + return; } // Cannot reconstruct the expected nonce if it is too short to hold the region index. This should never happen // for CSEv2 (nonce length is 12), so treat it as unverifiable rather than a failure. if (nonceLength < Long.BYTES || actualNonce.length < Long.BYTES) { - return null; + return; } boolean pastJavaNonceWrap = expectedRegion >= NONCE_WRAP_REGION_COUNT; @@ -89,11 +88,12 @@ RuntimeException validateRegion(byte[] actualNonce, int nonceLength, long expect if (current.size() == 1) { NonceScheme locked = current.iterator().next(); if (pastJavaNonceWrap && locked == NonceScheme.JAVA) { - return nonceWrapException(expectedRegion); + throw nonceWrapException(); } - return Arrays.equals(locked.expectedNonce(expectedRegion, nonceLength), actualNonce) - ? null - : reorderException(expectedRegion, actualNonce); + if (!Arrays.equals(locked.expectedNonce(expectedRegion, nonceLength), actualNonce)) { + throw reorderException(); + } + return; } // Determine which schemes are consistent with this region at its position. @@ -108,9 +108,9 @@ RuntimeException validateRegion(byte[] actualNonce, int nonceLength, long expect } if (matchesHere.isEmpty()) { - return pastJavaNonceWrap - ? nonceWrapException(expectedRegion) - : reorderException(expectedRegion, actualNonce); + throw pastJavaNonceWrap + ? nonceWrapException() + : reorderException(); } // Intersect the shared candidate set with the schemes matching this region. Atomic and order-independent, so it @@ -122,23 +122,22 @@ RuntimeException validateRegion(byte[] actualNonce, int nonceLength, long expect next.retainAll(matchesHere); return next; }); - return remaining.isEmpty() ? reorderException(expectedRegion, actualNonce) : null; + if (remaining.isEmpty()) { + throw reorderException(); + } } - private static RuntimeException reorderException(long expectedRegion, byte[] actualNonce) { + private static RuntimeException reorderException() { return LOGGER.logExceptionAsError(new IllegalStateException( "Encountered an out-of-order authenticated region while decrypting client-side encrypted (v2) content. " + "This may indicate that the blob's authenticated regions have been rearranged or otherwise tampered " - + "with. The nonce at region index " + expectedRegion + " (0x" + bytesToHex(actualNonce) + ") does not " - + "match the recognized client-side encryption nonce scheme for that position. " - + recoveryInstruction())); + + "with." + recoveryInstruction())); } - private static RuntimeException nonceWrapException(long expectedRegion) { + private static RuntimeException nonceWrapException() { return LOGGER.logExceptionAsError(new IllegalStateException("Cannot verify the integrity of client-side" + " encrypted (v2) content at or beyond " + NONCE_WRAP_REGION_COUNT - + " authenticated regions (region index " + expectedRegion - + "). For content encrypted by this (Java) SDK the encryption nonce repeats past that" + + " authenticated regions. For content encrypted by this (Java) SDK the encryption nonce repeats past that" + " point, resulting in GCM nonce reuse, and the blob is too large for its authenticated region size to be" + " safely verified. " + recoveryInstruction())); } diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java index 1c694182e4a6..bf55eb65a719 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java @@ -77,10 +77,7 @@ Flux decrypt(Flux encryptedFlux, EncryptedBlobRange encr byte[] gmcIv = indexedAggregator.getT2().getFirstNBytes(nonceLength); long expectedRegion = initialRegion + indexedAggregator.getT1(); - RuntimeException reorderError = nonceValidator.validateRegion(gmcIv, nonceLength, expectedRegion); - if (reorderError != null) { - return Mono.error(reorderError); - } + nonceValidator.validateRegion(gmcIv, nonceLength, expectedRegion); Cipher gmcCipher; try { @@ -91,7 +88,7 @@ Flux decrypt(Flux encryptedFlux, EncryptedBlobRange encr ByteBuffer decryptedRegion = ByteBuffer.allocate(authenticatedRegionDataLength); return indexedAggregator.getT2().asFlux().map(buffer -> { - // Write into the preallocated buffer and always return this buffer. + // Write into the pre-allocated buffer and always return this buffer. try { gmcCipher.update(buffer, decryptedRegion); } catch (ShortBufferException e) { From fbcd69e6ebde1b102bd577aa601282cd8ebfc9c9 Mon Sep 17 00:00:00 2001 From: Isabelle Date: Wed, 19 Aug 2026 14:37:05 -0700 Subject: [PATCH 10/18] adjusting docs --- .../specialized/cryptography/BlobDecryptionPolicy.java | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/BlobDecryptionPolicy.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/BlobDecryptionPolicy.java index 8697206ae315..cb1e6901e81b 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/BlobDecryptionPolicy.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/BlobDecryptionPolicy.java @@ -109,12 +109,10 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN boolean padding = hasPadding(responseHeaders, encryptionData, encryptedRange); - // Use the operation-scoped validator if one was installed (e.g. downloadContentWithResponse always - // sets it up, even for a full-blob request). Sharing it means a reliable-download ranged resume of - // this same operation - which re-enters the pipeline through the range branch with the same context - // - enforces one nonce scheme across the initial and resumed portions. Falls back to a fresh - // per-call validator when none is present (e.g. a full-blob downloadStream that never set up - // context), which is a single chunk anyway. + // Use the operation-scoped validator if one was installed (e.g. downloadContentWithResponse and + // ranged downloads always set it up). Sharing it means a reliable-download ranged resume of this + // same operation - which re-enters the pipeline through the range branch with the same context - + // enforces one nonce scheme across the initial and resumed portions. CseV2NonceOrderValidator nonceValidator = (CseV2NonceOrderValidator) context.getData(CryptographyConstants.GCM_NONCE_VALIDATOR_KEY) .orElse(null); From 6ffe95ec19335c8cef326f59ce3d380e2ccbc4a9 Mon Sep 17 00:00:00 2001 From: Isabelle Date: Wed, 19 Aug 2026 14:56:30 -0700 Subject: [PATCH 11/18] removing dead code and throwing with logger --- .../CseV2NonceOrderValidator.java | 26 ++++++------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java index e99390fa6d1e..8983825066bc 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java @@ -88,10 +88,10 @@ void validateRegion(byte[] actualNonce, int nonceLength, long expectedRegion) { if (current.size() == 1) { NonceScheme locked = current.iterator().next(); if (pastJavaNonceWrap && locked == NonceScheme.JAVA) { - throw nonceWrapException(); + throw LOGGER.logExceptionAsError(nonceWrapException()); } if (!Arrays.equals(locked.expectedNonce(expectedRegion, nonceLength), actualNonce)) { - throw reorderException(); + throw LOGGER.logExceptionAsError(reorderException()); } return; } @@ -108,9 +108,7 @@ void validateRegion(byte[] actualNonce, int nonceLength, long expectedRegion) { } if (matchesHere.isEmpty()) { - throw pastJavaNonceWrap - ? nonceWrapException() - : reorderException(); + throw LOGGER.logExceptionAsError(pastJavaNonceWrap ? nonceWrapException() : reorderException()); } // Intersect the shared candidate set with the schemes matching this region. Atomic and order-independent, so it @@ -123,23 +121,23 @@ void validateRegion(byte[] actualNonce, int nonceLength, long expectedRegion) { return next; }); if (remaining.isEmpty()) { - throw reorderException(); + throw LOGGER.logExceptionAsError(reorderException()); } } private static RuntimeException reorderException() { - return LOGGER.logExceptionAsError(new IllegalStateException( + return new IllegalStateException( "Encountered an out-of-order authenticated region while decrypting client-side encrypted (v2) content. " + "This may indicate that the blob's authenticated regions have been rearranged or otherwise tampered " - + "with." + recoveryInstruction())); + + "with." + recoveryInstruction()); } private static RuntimeException nonceWrapException() { - return LOGGER.logExceptionAsError(new IllegalStateException("Cannot verify the integrity of client-side" + return new IllegalStateException("Cannot verify the integrity of client-side" + " encrypted (v2) content at or beyond " + NONCE_WRAP_REGION_COUNT + " authenticated regions. For content encrypted by this (Java) SDK the encryption nonce repeats past that" + " point, resulting in GCM nonce reuse, and the blob is too large for its authenticated region size to be" - + " safely verified. " + recoveryInstruction())); + + " safely verified. " + recoveryInstruction()); } private static String recoveryInstruction() { @@ -148,14 +146,6 @@ private static String recoveryInstruction() { + "\" system property) to \"true\"."; } - private static String bytesToHex(byte[] bytes) { - StringBuilder sb = new StringBuilder(bytes.length * 2); - for (byte b : bytes) { - sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16)); - } - return sb.toString(); - } - /** * Whether detection of reordered client-side encryption v2 authenticated regions should be disabled. *

From 999864b9feab6de4fbc287bd5569e56c376e2cd4 Mon Sep 17 00:00:00 2001 From: Isabelle Date: Thu, 20 Aug 2026 11:31:35 -0700 Subject: [PATCH 12/18] adding .net cross compat test (disabled after local verification) --- .../EncryptedBlockBlobApiTests.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlockBlobApiTests.java b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlockBlobApiTests.java index a3aedda87b16..aa673f8467d7 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlockBlobApiTests.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/EncryptedBlockBlobApiTests.java @@ -415,6 +415,42 @@ public void crossPlatDecryptPythonV2() { assertArraysEqual(expected, decrypted.toByteArray()); } + // Cross-SDK interoperability: Java must decrypt CSE v2 blobs written by other SDKs, whose per-region GCM nonces + // encode the region counter differently than Java's. This exercises a container of .NET-produced blobs. Requires + // a container coordinated between languages; run manually. Verification/reorder-detection of the nonce schemes is + // covered without a live account by DecryptorV2ReorderTests. + @Disabled + @Test + public void crossPlatDecryptDotnetV2() { + // A256KW (RFC 3394) key wrap with the fixed KEK coordinated across the .NET/Python/Java cross-SDK tests. .NET's + // test helper stores the key under the id "local:key1" (Python uses "key1"), so the id must match here. + byte[] kek = fromHex("bea4114b9e4a07da664683ad2bad76412043e8bc90a4117d47c30fd4b4196d11"); + AsyncKeyEncryptionKey key = new A256KwKey("local:key1", kek); + + String containerName = "cross-sdk-cse-v2"; + String endpoint = ENV.getPrimaryAccount().getBlobEndpoint(); + + // Reference plaintext uploaded alongside the encrypted blob. + BlobClient plaintextClient = new BlobClientBuilder().endpoint(endpoint) + .containerName(containerName) + .blobName("dotnet_plaintext") + .credential(ENV.getPrimaryAccount().getCredential()) + .buildClient(); + byte[] expected = plaintextClient.downloadContent().toBytes(); + + // Decrypt the .NET-encrypted blob (.NET nonce scheme) and confirm it round-trips without a false reorder. + EncryptedBlobClient decryptionClient = new EncryptedBlobClientBuilder(EncryptionVersion.V2).endpoint(endpoint) + .containerName(containerName) + .blobName("dotnet_encrypted") + .key(key, "A256KW") + .credential(ENV.getPrimaryAccount().getCredential()) + .buildEncryptedBlobClient(); + ByteArrayOutputStream decrypted = new ByteArrayOutputStream(); + decryptionClient.downloadStream(decrypted); + + assertArraysEqual(expected, decrypted.toByteArray()); + } + private static byte[] fromHex(String hex) { byte[] out = new byte[hex.length() / 2]; for (int i = 0; i < out.length; i++) { From 82b311bfa5195f5eca6b8071a9567c88ddb840a7 Mon Sep 17 00:00:00 2001 From: Isabelle Date: Thu, 20 Aug 2026 13:27:42 -0700 Subject: [PATCH 13/18] removing 2^32 large region count fail closed check --- .../CseV2NonceOrderValidator.java | 31 ++-------------- .../cryptography/DecryptorV2ReorderTests.java | 35 +++++++------------ 2 files changed, 14 insertions(+), 52 deletions(-) diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java index 8983825066bc..5c71de6979c6 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java @@ -39,15 +39,6 @@ final class CseV2NonceOrderValidator { private static final ClientLogger LOGGER = new ClientLogger(CseV2NonceOrderValidator.class); - /* - * The Java encoder truncates the region index to an int when producing each region's nonce (see - * EncryptorV2.getCipher), so its nonces are unique only for the first 2^32 regions. At or beyond this index the - * nonce repeats: two distinct regions can share a nonce and each retain a valid GCM tag. That both defeats - * sequential reorder detection and is GCM nonce reuse, so Java-encoded content cannot be verified past this - * boundary. The Python and .NET encoders use wider counters that do not repeat for any real blob size. - */ - private static final long NONCE_WRAP_REGION_COUNT = 1L << 32; - private final boolean validationEnabled; private final AtomicReference> candidateSchemes = new AtomicReference<>(EnumSet.allOf(NonceScheme.class)); @@ -61,9 +52,7 @@ final class CseV2NonceOrderValidator { * Validates that the nonce of an authenticated region is consistent with the region occupying its expected * sequential position, under a single recognized cross-SDK nonce scheme enforced across the whole download. A * region whose nonce matches none of the schemes for its position - or that is inconsistent with the scheme - * established by earlier regions - indicates a reorder or other tampering. Also fails closed once the region index - * reaches the point where the Java encoder's nonce begins to repeat, as integrity can no longer be guaranteed - * there. + * established by earlier regions - indicates a reorder or other tampering. * * @param actualNonce The nonce read from the downloaded region. * @param nonceLength The length of the nonce. @@ -81,15 +70,10 @@ void validateRegion(byte[] actualNonce, int nonceLength, long expectedRegion) { return; } - boolean pastJavaNonceWrap = expectedRegion >= NONCE_WRAP_REGION_COUNT; - // Fast path: once the scheme has collapsed to a single encoding, verify directly without mutating shared state. EnumSet current = candidateSchemes.get(); if (current.size() == 1) { NonceScheme locked = current.iterator().next(); - if (pastJavaNonceWrap && locked == NonceScheme.JAVA) { - throw LOGGER.logExceptionAsError(nonceWrapException()); - } if (!Arrays.equals(locked.expectedNonce(expectedRegion, nonceLength), actualNonce)) { throw LOGGER.logExceptionAsError(reorderException()); } @@ -99,16 +83,13 @@ void validateRegion(byte[] actualNonce, int nonceLength, long expectedRegion) { // Determine which schemes are consistent with this region at its position. EnumSet matchesHere = EnumSet.noneOf(NonceScheme.class); for (NonceScheme scheme : NonceScheme.values()) { - if (pastJavaNonceWrap && scheme == NonceScheme.JAVA) { - continue; - } if (Arrays.equals(scheme.expectedNonce(expectedRegion, nonceLength), actualNonce)) { matchesHere.add(scheme); } } if (matchesHere.isEmpty()) { - throw LOGGER.logExceptionAsError(pastJavaNonceWrap ? nonceWrapException() : reorderException()); + throw LOGGER.logExceptionAsError(reorderException()); } // Intersect the shared candidate set with the schemes matching this region. Atomic and order-independent, so it @@ -132,14 +113,6 @@ private static RuntimeException reorderException() { + "with." + recoveryInstruction()); } - private static RuntimeException nonceWrapException() { - return new IllegalStateException("Cannot verify the integrity of client-side" - + " encrypted (v2) content at or beyond " + NONCE_WRAP_REGION_COUNT - + " authenticated regions. For content encrypted by this (Java) SDK the encryption nonce repeats past that" - + " point, resulting in GCM nonce reuse, and the blob is too large for its authenticated region size to be" - + " safely verified. " + recoveryInstruction()); - } - private static String recoveryInstruction() { return "To recover data from an affected blob, set the \"" + ALLOW_MISORDERED_REGIONS_ENV_VAR + "\" environment variable (or the \"" + ALLOW_MISORDERED_REGIONS_PROPERTY diff --git a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java index 26f4c2410bde..50e0e170c201 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java @@ -45,8 +45,9 @@ public class DecryptorV2ReorderTests { private static final int REGION_DATA_LENGTH = 1024; private static final int REGION_TOTAL_LENGTH = NONCE_LENGTH + REGION_DATA_LENGTH + TAG_LENGTH; private static final int REGION_COUNT = 4; - // Mirrors DecryptorV2.NONCE_WRAP_REGION_COUNT: EncryptorV2's nonce repeats every 2^32 regions. - private static final long NONCE_WRAP_REGION_COUNT = 1L << 32; + // Region index at which EncryptorV2's int-truncated nonce repeats (2^32). Reorder detection no longer special-cases + // this boundary; the constant is kept only to exercise decryption at and around it. + private static final long NONCE_REPEAT_REGION = 1L << 32; private static final Random RANDOM = new Random(); @AfterEach @@ -147,10 +148,10 @@ public void detectsReorderAcrossIntegerMaxValueBoundary() { } @Test - public void decryptsLastRegionBeforeNonceWrap() { - // Region 2^32 - 1 is the last region with a unique nonce; it must still decrypt successfully. + public void decryptsLastRegionBeforeNonceRepeat() { + // Region 2^32 - 1 is the last region before the Java encoder's int-truncated nonce repeats; it decrypts. byte[] cek = randomBytes(32); - long lastUniqueRegion = NONCE_WRAP_REGION_COUNT - 1; + long lastUniqueRegion = NONCE_REPEAT_REGION - 1; byte[] plaintext = randomBytes(REGION_DATA_LENGTH); byte[] ciphertext = encryptRegionAt(cek, lastUniqueRegion, plaintext); @@ -160,25 +161,13 @@ public void decryptsLastRegionBeforeNonceWrap() { } @Test - public void failsClosedAtNonceWrapBoundary() { - // Region 2^32 is where EncryptorV2's nonce repeats (reuses region 0's nonce). Integrity cannot be verified, so - // decryption must fail closed by default. + public void decryptsValidBlobAtNonceRepeatRegion() { + // At region 2^32 the Java encoder's nonce repeats (reuses region 0's nonce), but the reorder check reconstructs + // the Java nonce with the same int truncation, so a valid blob still matches and decrypts. Reorder detection + // does not special-case very large region counts (the nonce-reuse itself is an encryptor concern handled + // separately); it must not reject valid content here. byte[] cek = randomBytes(32); - long wrapRegion = NONCE_WRAP_REGION_COUNT; - byte[] ciphertext = encryptRegionAt(cek, wrapRegion, randomBytes(REGION_DATA_LENGTH)); - - IllegalStateException e = assertThrows(IllegalStateException.class, - () -> decrypt(cek, ciphertext, wrapRegion * REGION_DATA_LENGTH)); - assertTrue(e.getMessage().contains("nonce reuse"), e.getMessage()); - } - - @Test - public void recoverySwitchAllowsPastNonceWrapBoundary() { - // With the recovery switch, the wrap-boundary fail-closed is bypassed and plaintext is recovered. - System.setProperty(CryptographyConstants.ALLOW_MISORDERED_REGIONS_PROPERTY, "true"); - - byte[] cek = randomBytes(32); - long wrapRegion = NONCE_WRAP_REGION_COUNT; + long wrapRegion = NONCE_REPEAT_REGION; byte[] plaintext = randomBytes(REGION_DATA_LENGTH); byte[] ciphertext = encryptRegionAt(cek, wrapRegion, plaintext); From 35ca4eefc1a06128fd4e3e4c4c7e8da6ce9d4034 Mon Sep 17 00:00:00 2001 From: Isabelle Date: Thu, 20 Aug 2026 15:33:45 -0700 Subject: [PATCH 14/18] fail closed: --- .../CseV2NonceOrderValidator.java | 44 ++++++++++++++++--- .../cryptography/DecryptorV2ReorderTests.java | 38 ++++++++++------ 2 files changed, 63 insertions(+), 19 deletions(-) diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java index 5c71de6979c6..9539463d7bb3 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java @@ -39,6 +39,17 @@ final class CseV2NonceOrderValidator { private static final ClientLogger LOGGER = new ClientLogger(CseV2NonceOrderValidator.class); + /* + * The Java encoder writes the region index into the nonce as an 8-byte value. Older versions of this SDK truncated + * that index to an int, so their nonces were unique only for the first 2^32 regions; at or beyond this index the + * nonce repeated, meaning two distinct regions could share a nonce and each retain a valid GCM tag. That is GCM + * nonce reuse and it also defeats sequential reorder detection, so Java-encoded content whose region index reaches + * this boundary cannot be integrity-verified. Such content is failed closed to alert the caller that the blob is + * affected and must be re-encrypted (recoverable via the data-recovery switch). The Python and .NET encoders use + * wider counters that do not repeat for any real blob size, so this boundary applies only to the Java scheme. + */ + private static final long NONCE_WRAP_REGION_COUNT = 1L << 32; + private final boolean validationEnabled; private final AtomicReference> candidateSchemes = new AtomicReference<>(EnumSet.allOf(NonceScheme.class)); @@ -52,7 +63,9 @@ final class CseV2NonceOrderValidator { * Validates that the nonce of an authenticated region is consistent with the region occupying its expected * sequential position, under a single recognized cross-SDK nonce scheme enforced across the whole download. A * region whose nonce matches none of the schemes for its position - or that is inconsistent with the scheme - * established by earlier regions - indicates a reorder or other tampering. + * established by earlier regions - indicates a reorder or other tampering. Also fails closed once a Java-scheme + * region index reaches {@link #NONCE_WRAP_REGION_COUNT}, where older Java-encoded content reused the nonce and can + * no longer be integrity-verified. * * @param actualNonce The nonce read from the downloaded region. * @param nonceLength The length of the nonce. @@ -70,26 +83,37 @@ void validateRegion(byte[] actualNonce, int nonceLength, long expectedRegion) { return; } + // At or beyond this boundary the Java encoder's nonce repeats, so Java-encoded content there cannot be + // integrity-verified and is failed closed to signal that data recovery (re-encryption) is required. + boolean pastJavaNonceWrap = expectedRegion >= NONCE_WRAP_REGION_COUNT; + // Fast path: once the scheme has collapsed to a single encoding, verify directly without mutating shared state. EnumSet current = candidateSchemes.get(); if (current.size() == 1) { NonceScheme locked = current.iterator().next(); + if (pastJavaNonceWrap && locked == NonceScheme.JAVA) { + throw LOGGER.logExceptionAsError(nonceWrapException()); + } if (!Arrays.equals(locked.expectedNonce(expectedRegion, nonceLength), actualNonce)) { throw LOGGER.logExceptionAsError(reorderException()); } return; } - // Determine which schemes are consistent with this region at its position. + // Determine which schemes are consistent with this region at its position. The Java scheme is excluded past its + // nonce-wrap boundary because it can no longer be verified there. EnumSet matchesHere = EnumSet.noneOf(NonceScheme.class); for (NonceScheme scheme : NonceScheme.values()) { + if (pastJavaNonceWrap && scheme == NonceScheme.JAVA) { + continue; + } if (Arrays.equals(scheme.expectedNonce(expectedRegion, nonceLength), actualNonce)) { matchesHere.add(scheme); } } if (matchesHere.isEmpty()) { - throw LOGGER.logExceptionAsError(reorderException()); + throw LOGGER.logExceptionAsError(pastJavaNonceWrap ? nonceWrapException() : reorderException()); } // Intersect the shared candidate set with the schemes matching this region. Atomic and order-independent, so it @@ -113,6 +137,13 @@ private static RuntimeException reorderException() { + "with." + recoveryInstruction()); } + private static RuntimeException nonceWrapException() { + return new IllegalStateException("Cannot verify the integrity of client-side encrypted (v2) content at or" + + " beyond " + NONCE_WRAP_REGION_COUNT + " authenticated regions. Content this large that was encrypted by" + + " an older version of this (Java) SDK reused the encryption nonce (GCM nonce reuse), so its integrity" + + " cannot be verified and the affected blob must be re-encrypted." + recoveryInstruction()); + } + private static String recoveryInstruction() { return "To recover data from an affected blob, set the \"" + ALLOW_MISORDERED_REGIONS_ENV_VAR + "\" environment variable (or the \"" + ALLOW_MISORDERED_REGIONS_PROPERTY @@ -148,14 +179,15 @@ private static boolean cseV2AllowMisorderedAuthRegions() { */ private enum NonceScheme { /** - * Java: the region index truncated to an int, written as an 8-byte big-endian (sign-extended) value in the - * first 8 bytes, remaining bytes zero. 0-based. See {@link EncryptorV2}. + * Java: the region index written as an 8-byte big-endian value in the first 8 bytes, remaining bytes zero. + * 0-based. See {@link EncryptorV2}. (Older versions truncated the index to an int; that content is failed + * closed past {@link #NONCE_WRAP_REGION_COUNT} rather than reconstructed here.) */ JAVA { @Override byte[] expectedNonce(long regionIndex, int nonceLength) { byte[] nonce = new byte[nonceLength]; - long value = (int) regionIndex; + long value = regionIndex; for (int i = 0; i < Long.BYTES; i++) { nonce[i] = (byte) (value >>> (Long.SIZE - Byte.SIZE - Byte.SIZE * i)); } diff --git a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java index 50e0e170c201..00b4061d2366 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java @@ -45,8 +45,8 @@ public class DecryptorV2ReorderTests { private static final int REGION_DATA_LENGTH = 1024; private static final int REGION_TOTAL_LENGTH = NONCE_LENGTH + REGION_DATA_LENGTH + TAG_LENGTH; private static final int REGION_COUNT = 4; - // Region index at which EncryptorV2's int-truncated nonce repeats (2^32). Reorder detection no longer special-cases - // this boundary; the constant is kept only to exercise decryption at and around it. + // Region index (2^32) at which older Java-encoded content's nonce repeats. Validation fails closed at and beyond + // this boundary for the Java scheme, since integrity can no longer be verified there. private static final long NONCE_REPEAT_REGION = 1L << 32; private static final Random RANDOM = new Random(); @@ -114,10 +114,9 @@ public void compatSwitchAllowsReorder() { @Test public void decryptsRegionsAcrossIntegerMaxValueBoundary() { - // EncryptorV2 truncates the region index to an int when producing the nonce, so region Integer.MAX_VALUE + 1 - // wraps to a negative (sign-extended) nonce. The reorder detection must replicate that truncation, otherwise - // valid blobs whose region indices cross Integer.MAX_VALUE (reachable at ~32 GiB with the minimum 16-byte - // region size) would be incorrectly rejected as reordered. + // The region index is encoded as a full 64-bit big-endian value, so regions whose indices cross the + // Integer.MAX_VALUE (2^31) boundary - reachable at ~32 GiB with the minimum 16-byte region size - must still + // validate and decrypt. (These indices are below the 2^32 nonce-wrap fail-closed boundary.) byte[] cek = randomBytes(32); long firstRegion = Integer.MAX_VALUE; byte[] region0Plaintext = randomBytes(REGION_DATA_LENGTH); @@ -149,7 +148,7 @@ public void detectsReorderAcrossIntegerMaxValueBoundary() { @Test public void decryptsLastRegionBeforeNonceRepeat() { - // Region 2^32 - 1 is the last region before the Java encoder's int-truncated nonce repeats; it decrypts. + // Region 2^32 - 1 is the last region below the fail-closed boundary; it validates and decrypts. byte[] cek = randomBytes(32); long lastUniqueRegion = NONCE_REPEAT_REGION - 1; byte[] plaintext = randomBytes(REGION_DATA_LENGTH); @@ -161,11 +160,23 @@ public void decryptsLastRegionBeforeNonceRepeat() { } @Test - public void decryptsValidBlobAtNonceRepeatRegion() { - // At region 2^32 the Java encoder's nonce repeats (reuses region 0's nonce), but the reorder check reconstructs - // the Java nonce with the same int truncation, so a valid blob still matches and decrypts. Reorder detection - // does not special-case very large region counts (the nonce-reuse itself is an encryptor concern handled - // separately); it must not reject valid content here. + public void failsClosedAtNonceRepeatBoundary() { + // At region 2^32 the (older) Java encoder's nonce repeats (reuses region 0's nonce), so integrity cannot be + // verified. Decryption must fail closed to alert the caller that the affected blob needs data recovery. + byte[] cek = randomBytes(32); + long wrapRegion = NONCE_REPEAT_REGION; + byte[] ciphertext = encryptRegionAt(cek, wrapRegion, randomBytes(REGION_DATA_LENGTH)); + + IllegalStateException e = assertThrows(IllegalStateException.class, + () -> decrypt(cek, ciphertext, wrapRegion * REGION_DATA_LENGTH)); + assertTrue(e.getMessage().contains("nonce reuse"), e.getMessage()); + } + + @Test + public void recoverySwitchAllowsPastNonceRepeatBoundary() { + // With the data-recovery switch, the fail-closed boundary is bypassed and plaintext is recovered. + System.setProperty(CryptographyConstants.ALLOW_MISORDERED_REGIONS_PROPERTY, "true"); + byte[] cek = randomBytes(32); long wrapRegion = NONCE_REPEAT_REGION; byte[] plaintext = randomBytes(REGION_DATA_LENGTH); @@ -349,7 +360,8 @@ private static byte[] buildBlob(byte[] cek, java.util.function.LongFunction Date: Mon, 24 Aug 2026 14:10:39 -0700 Subject: [PATCH 15/18] addressing comments --- .../CseV2NonceOrderValidator.java | 57 ++++--------------- .../specialized/cryptography/DecryptorV2.java | 4 +- .../cryptography/DecryptorV2ReorderTests.java | 45 ++++++--------- 3 files changed, 30 insertions(+), 76 deletions(-) diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java index 9539463d7bb3..42ee708f4417 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java @@ -39,17 +39,6 @@ final class CseV2NonceOrderValidator { private static final ClientLogger LOGGER = new ClientLogger(CseV2NonceOrderValidator.class); - /* - * The Java encoder writes the region index into the nonce as an 8-byte value. Older versions of this SDK truncated - * that index to an int, so their nonces were unique only for the first 2^32 regions; at or beyond this index the - * nonce repeated, meaning two distinct regions could share a nonce and each retain a valid GCM tag. That is GCM - * nonce reuse and it also defeats sequential reorder detection, so Java-encoded content whose region index reaches - * this boundary cannot be integrity-verified. Such content is failed closed to alert the caller that the blob is - * affected and must be re-encrypted (recoverable via the data-recovery switch). The Python and .NET encoders use - * wider counters that do not repeat for any real blob size, so this boundary applies only to the Java scheme. - */ - private static final long NONCE_WRAP_REGION_COUNT = 1L << 32; - private final boolean validationEnabled; private final AtomicReference> candidateSchemes = new AtomicReference<>(EnumSet.allOf(NonceScheme.class)); @@ -60,19 +49,17 @@ final class CseV2NonceOrderValidator { } /** - * Validates that the nonce of an authenticated region is consistent with the region occupying its expected - * sequential position, under a single recognized cross-SDK nonce scheme enforced across the whole download. A - * region whose nonce matches none of the schemes for its position - or that is inconsistent with the scheme - * established by earlier regions - indicates a reorder or other tampering. Also fails closed once a Java-scheme - * region index reaches {@link #NONCE_WRAP_REGION_COUNT}, where older Java-encoded content reused the nonce and can - * no longer be integrity-verified. + * Validates that the nonce of an authenticated region is consistent with the region occupying its sequential + * position, under a single recognized cross-SDK nonce scheme enforced across the whole download. A region whose + * nonce matches none of the schemes for its position - or that is inconsistent with the scheme established by + * earlier regions - indicates a reorder or other tampering. * * @param actualNonce The nonce read from the downloaded region. * @param nonceLength The length of the nonce. - * @param expectedRegion The expected 0-based region index for this region's position. + * @param region The 0-based index of this region's position in the blob. * @throws RuntimeException If the region is invalid or its integrity cannot be verified. */ - void validateRegion(byte[] actualNonce, int nonceLength, long expectedRegion) { + void validateRegion(byte[] actualNonce, int nonceLength, long region) { if (!validationEnabled) { return; } @@ -83,37 +70,26 @@ void validateRegion(byte[] actualNonce, int nonceLength, long expectedRegion) { return; } - // At or beyond this boundary the Java encoder's nonce repeats, so Java-encoded content there cannot be - // integrity-verified and is failed closed to signal that data recovery (re-encryption) is required. - boolean pastJavaNonceWrap = expectedRegion >= NONCE_WRAP_REGION_COUNT; - // Fast path: once the scheme has collapsed to a single encoding, verify directly without mutating shared state. EnumSet current = candidateSchemes.get(); if (current.size() == 1) { NonceScheme locked = current.iterator().next(); - if (pastJavaNonceWrap && locked == NonceScheme.JAVA) { - throw LOGGER.logExceptionAsError(nonceWrapException()); - } - if (!Arrays.equals(locked.expectedNonce(expectedRegion, nonceLength), actualNonce)) { + if (!Arrays.equals(locked.expectedNonce(region, nonceLength), actualNonce)) { throw LOGGER.logExceptionAsError(reorderException()); } return; } - // Determine which schemes are consistent with this region at its position. The Java scheme is excluded past its - // nonce-wrap boundary because it can no longer be verified there. + // Determine which schemes are consistent with this region at its position. EnumSet matchesHere = EnumSet.noneOf(NonceScheme.class); for (NonceScheme scheme : NonceScheme.values()) { - if (pastJavaNonceWrap && scheme == NonceScheme.JAVA) { - continue; - } - if (Arrays.equals(scheme.expectedNonce(expectedRegion, nonceLength), actualNonce)) { + if (Arrays.equals(scheme.expectedNonce(region, nonceLength), actualNonce)) { matchesHere.add(scheme); } } if (matchesHere.isEmpty()) { - throw LOGGER.logExceptionAsError(pastJavaNonceWrap ? nonceWrapException() : reorderException()); + throw LOGGER.logExceptionAsError(reorderException()); } // Intersect the shared candidate set with the schemes matching this region. Atomic and order-independent, so it @@ -137,13 +113,6 @@ private static RuntimeException reorderException() { + "with." + recoveryInstruction()); } - private static RuntimeException nonceWrapException() { - return new IllegalStateException("Cannot verify the integrity of client-side encrypted (v2) content at or" - + " beyond " + NONCE_WRAP_REGION_COUNT + " authenticated regions. Content this large that was encrypted by" - + " an older version of this (Java) SDK reused the encryption nonce (GCM nonce reuse), so its integrity" - + " cannot be verified and the affected blob must be re-encrypted." + recoveryInstruction()); - } - private static String recoveryInstruction() { return "To recover data from an affected blob, set the \"" + ALLOW_MISORDERED_REGIONS_ENV_VAR + "\" environment variable (or the \"" + ALLOW_MISORDERED_REGIONS_PROPERTY @@ -180,16 +149,14 @@ private static boolean cseV2AllowMisorderedAuthRegions() { private enum NonceScheme { /** * Java: the region index written as an 8-byte big-endian value in the first 8 bytes, remaining bytes zero. - * 0-based. See {@link EncryptorV2}. (Older versions truncated the index to an int; that content is failed - * closed past {@link #NONCE_WRAP_REGION_COUNT} rather than reconstructed here.) + * 0-based. See {@link EncryptorV2}. */ JAVA { @Override byte[] expectedNonce(long regionIndex, int nonceLength) { byte[] nonce = new byte[nonceLength]; - long value = regionIndex; for (int i = 0; i < Long.BYTES; i++) { - nonce[i] = (byte) (value >>> (Long.SIZE - Byte.SIZE - Byte.SIZE * i)); + nonce[i] = (byte) (regionIndex >>> (Long.SIZE - Byte.SIZE - Byte.SIZE * i)); } return nonce; } diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java index bf55eb65a719..ab49faa701a5 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2.java @@ -76,8 +76,8 @@ Flux decrypt(Flux encryptedFlux, EncryptedBlobRange encr // Get the IV out of the beginning of the aggregator byte[] gmcIv = indexedAggregator.getT2().getFirstNBytes(nonceLength); - long expectedRegion = initialRegion + indexedAggregator.getT1(); - nonceValidator.validateRegion(gmcIv, nonceLength, expectedRegion); + long region = initialRegion + indexedAggregator.getT1(); + nonceValidator.validateRegion(gmcIv, nonceLength, region); Cipher gmcCipher; try { diff --git a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java index 00b4061d2366..e9bb92e4f975 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java @@ -45,9 +45,9 @@ public class DecryptorV2ReorderTests { private static final int REGION_DATA_LENGTH = 1024; private static final int REGION_TOTAL_LENGTH = NONCE_LENGTH + REGION_DATA_LENGTH + TAG_LENGTH; private static final int REGION_COUNT = 4; - // Region index (2^32) at which older Java-encoded content's nonce repeats. Validation fails closed at and beyond - // this boundary for the Java scheme, since integrity can no longer be verified there. - private static final long NONCE_REPEAT_REGION = 1L << 32; + // A region index (2^32) beyond the 32-bit range, used to confirm the full 64-bit region counter validates + // correctly past the point where an int-based counter would have wrapped. + private static final long LARGE_REGION_INDEX = 1L << 32; private static final Random RANDOM = new Random(); @AfterEach @@ -116,7 +116,7 @@ public void compatSwitchAllowsReorder() { public void decryptsRegionsAcrossIntegerMaxValueBoundary() { // The region index is encoded as a full 64-bit big-endian value, so regions whose indices cross the // Integer.MAX_VALUE (2^31) boundary - reachable at ~32 GiB with the minimum 16-byte region size - must still - // validate and decrypt. (These indices are below the 2^32 nonce-wrap fail-closed boundary.) + // validate and decrypt. byte[] cek = randomBytes(32); long firstRegion = Integer.MAX_VALUE; byte[] region0Plaintext = randomBytes(REGION_DATA_LENGTH); @@ -147,42 +147,29 @@ public void detectsReorderAcrossIntegerMaxValueBoundary() { } @Test - public void decryptsLastRegionBeforeNonceRepeat() { - // Region 2^32 - 1 is the last region below the fail-closed boundary; it validates and decrypts. + public void decryptsRegionJustBelowIntWrapBoundary() { + // Region 2^32 - 1 is the last region representable in 32 bits; with the full 64-bit counter it validates and + // decrypts normally. byte[] cek = randomBytes(32); - long lastUniqueRegion = NONCE_REPEAT_REGION - 1; + long region = LARGE_REGION_INDEX - 1; byte[] plaintext = randomBytes(REGION_DATA_LENGTH); - byte[] ciphertext = encryptRegionAt(cek, lastUniqueRegion, plaintext); - byte[] recovered = decrypt(cek, ciphertext, lastUniqueRegion * REGION_DATA_LENGTH); + byte[] ciphertext = encryptRegionAt(cek, region, plaintext); + byte[] recovered = decrypt(cek, ciphertext, region * REGION_DATA_LENGTH); assertArrayEquals(plaintext, recovered); } @Test - public void failsClosedAtNonceRepeatBoundary() { - // At region 2^32 the (older) Java encoder's nonce repeats (reuses region 0's nonce), so integrity cannot be - // verified. Decryption must fail closed to alert the caller that the affected blob needs data recovery. + public void decryptsRegionAtIntWrapBoundary() { + // Region 2^32 is where an int-based counter would have wrapped and reused region 0's nonce. With the full + // 64-bit counter its nonce is distinct, so the region validates and decrypts normally. byte[] cek = randomBytes(32); - long wrapRegion = NONCE_REPEAT_REGION; - byte[] ciphertext = encryptRegionAt(cek, wrapRegion, randomBytes(REGION_DATA_LENGTH)); - - IllegalStateException e = assertThrows(IllegalStateException.class, - () -> decrypt(cek, ciphertext, wrapRegion * REGION_DATA_LENGTH)); - assertTrue(e.getMessage().contains("nonce reuse"), e.getMessage()); - } - - @Test - public void recoverySwitchAllowsPastNonceRepeatBoundary() { - // With the data-recovery switch, the fail-closed boundary is bypassed and plaintext is recovered. - System.setProperty(CryptographyConstants.ALLOW_MISORDERED_REGIONS_PROPERTY, "true"); - - byte[] cek = randomBytes(32); - long wrapRegion = NONCE_REPEAT_REGION; + long region = LARGE_REGION_INDEX; byte[] plaintext = randomBytes(REGION_DATA_LENGTH); - byte[] ciphertext = encryptRegionAt(cek, wrapRegion, plaintext); + byte[] ciphertext = encryptRegionAt(cek, region, plaintext); - byte[] recovered = decrypt(cek, ciphertext, wrapRegion * REGION_DATA_LENGTH); + byte[] recovered = decrypt(cek, ciphertext, region * REGION_DATA_LENGTH); assertArrayEquals(plaintext, recovered); } From 6778d8ca460910932b3384f48145cdd8c77e76ea Mon Sep 17 00:00:00 2001 From: Isabelle Date: Mon, 24 Aug 2026 14:38:32 -0700 Subject: [PATCH 16/18] addressing comments --- .../CseV2NonceOrderValidator.java | 13 +++++---- .../cryptography/DecryptorV2ReorderTests.java | 27 ++++++++++++++++--- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java index 42ee708f4417..112a08379e72 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java @@ -12,6 +12,7 @@ import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.ALLOW_MISORDERED_REGIONS_ENV_VAR; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.ALLOW_MISORDERED_REGIONS_PROPERTY; +import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.NONCE_LENGTH; /** * Detects rearrangement of client-side encryption v2 authenticated regions by validating each region's nonce against @@ -64,10 +65,12 @@ void validateRegion(byte[] actualNonce, int nonceLength, long region) { return; } - // Cannot reconstruct the expected nonce if it is too short to hold the region index. This should never happen - // for CSEv2 (nonce length is 12), so treat it as unverifiable rather than a failure. - if (nonceLength < Long.BYTES || actualNonce.length < Long.BYTES) { - return; + // CSEv2 uses a fixed 12-byte nonce. If the metadata advertises any other length, positional validation is + // impossible, so fail closed rather than silently skipping the integrity check (leaving reorders undetected). + if (nonceLength != NONCE_LENGTH || actualNonce.length != nonceLength) { + throw LOGGER.logExceptionAsError(new IllegalStateException( + "Cannot verify the authenticated-region order of client-side encrypted (v2) content because its nonce " + + "length is invalid (expected " + NONCE_LENGTH + "). " + recoveryInstruction())); } // Fast path: once the scheme has collapsed to a single encoding, verify directly without mutating shared state. @@ -110,7 +113,7 @@ private static RuntimeException reorderException() { return new IllegalStateException( "Encountered an out-of-order authenticated region while decrypting client-side encrypted (v2) content. " + "This may indicate that the blob's authenticated regions have been rearranged or otherwise tampered " - + "with." + recoveryInstruction()); + + "with. " + recoveryInstruction()); } private static String recoveryInstruction() { diff --git a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java index e9bb92e4f975..d0f48057b0fb 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java @@ -29,6 +29,7 @@ import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.NONCE_LENGTH; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.TAG_LENGTH; import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -95,6 +96,26 @@ public void detectsRegionReorderOnRangedDownload() { assertThrows(IllegalStateException.class, () -> decrypt(cek, ranged, REGION_DATA_LENGTH)); } + @Test + public void failsClosedWhenNonceLengthIsInvalid() { + // CSEv2 nonces are always 12 bytes. Metadata advertising any other length makes positional validation + // impossible, so the validator must fail closed rather than silently skip the integrity check. + CseV2NonceOrderValidator validator = new CseV2NonceOrderValidator(); + byte[] shortNonce = new byte[Long.BYTES]; + + assertThrows(IllegalStateException.class, () -> validator.validateRegion(shortNonce, shortNonce.length, 0)); + } + + @Test + public void recoverySwitchBypassesInvalidNonceLength() { + // The data-recovery switch disables all validation, so it short-circuits before the nonce-length check. + System.setProperty(CryptographyConstants.ALLOW_MISORDERED_REGIONS_PROPERTY, "true"); + CseV2NonceOrderValidator validator = new CseV2NonceOrderValidator(); + byte[] shortNonce = new byte[Long.BYTES]; + + assertDoesNotThrow(() -> validator.validateRegion(shortNonce, shortNonce.length, 0)); + } + @Test public void compatSwitchAllowsReorder() { System.setProperty(CryptographyConstants.ALLOW_MISORDERED_REGIONS_PROPERTY, "true"); @@ -316,9 +337,9 @@ private static byte[] encrypt(byte[] cek, byte[] plaintext) { } /** - * Encrypts a single region using the Java SDK nonce scheme (region index truncated to an int, written big-endian). - * Produces {@code nonce || ciphertext || tag}. Used to craft ciphertext for arbitrary (very large) region indices - * without materializing all preceding regions. + * Encrypts a single region using the Java SDK nonce scheme (region index written big-endian as a full 64-bit + * value). Produces {@code nonce || ciphertext || tag}. Used to craft ciphertext for arbitrary (very large) region + * indices without materializing all preceding regions. */ private static byte[] encryptRegionAt(byte[] cek, long regionIndex, byte[] plaintext) { return encryptRegionWithNonce(cek, javaNonce(regionIndex), plaintext); From 89622af30d382e5c7da3d6258568af30e702b1ee Mon Sep 17 00:00:00 2001 From: Isabelle Date: Mon, 24 Aug 2026 15:15:04 -0700 Subject: [PATCH 17/18] addressing comments --- .../cryptography/DecryptorV2ReorderTests.java | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java index d0f48057b0fb..01db29f74983 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/DecryptorV2ReorderTests.java @@ -309,21 +309,31 @@ public void sharedValidatorEnforcesSchemeAcrossChunks() { // A shared validator must intersect the candidate encodings across chunks; otherwise the encoding could change // at a chunk boundary and, at an encoding collision, let a relocated region pass. Mirrors the Python SDK's // test_nonce_validator_enforces_single_encoding_across_chunks. + // + // This is validated at the unit level (one validator instance driven across two decrypt() calls standing in for + // two chunks) because an end-to-end client test of this specific bypass is impractical: the smallest cross- + // scheme nonce collision is javaNonce(1) == dotnetNonce(16,777,215), so it only manifests at region index + // 16,777,215 - i.e. a blob with ~16.7M regions. assertArrayEquals(javaNonce(1), dotnetNonce(16_777_215)); byte[] cek = randomBytes(32); - CseV2NonceOrderValidator shared = new CseV2NonceOrderValidator(); - // First chunk: two Java-encoded regions resolve the shared encoding to Java. + // A region relocated to the colliding .NET index while carrying Java's region-1 nonce. + byte[] relocated = encryptRegionWithNonce(cek, javaNonce(1), randomBytes(REGION_DATA_LENGTH)); + long collidingOffset = 16_777_215L * REGION_DATA_LENGTH; + + // Regression guard: a fresh per-chunk validator sees only this region, whose sole consistent encoding is .NET, + // so it accepts the relocation. This is exactly the bypass that sharing one validator across chunks prevents - + // a regression that created one validator per chunk would let this through. + assertDoesNotThrow(() -> decrypt(cek, relocated, collidingOffset, new CseV2NonceOrderValidator())); + + // Shared validator: an earlier chunk of two Java-encoded regions resolves the shared encoding to Java... + CseV2NonceOrderValidator shared = new CseV2NonceOrderValidator(); byte[] chunk1 = concat(encryptRegionWithNonce(cek, javaNonce(0), randomBytes(REGION_DATA_LENGTH)), encryptRegionWithNonce(cek, javaNonce(1), randomBytes(REGION_DATA_LENGTH))); decrypt(cek, chunk1, 0, shared); - // Later chunk: a region relocated to the colliding .NET index carries Java's region-1 nonce. On a fresh - // per-chunk validator its only consistent encoding is .NET and it would pass; the shared validator (already - // resolved to Java) rejects it. - byte[] relocated = encryptRegionWithNonce(cek, javaNonce(1), randomBytes(REGION_DATA_LENGTH)); - long collidingOffset = 16_777_215L * REGION_DATA_LENGTH; + // ...so the same relocated region in a later chunk is now rejected. assertThrows(IllegalStateException.class, () -> decrypt(cek, relocated, collidingOffset, shared)); } From 7aedd93f8e182f52ab1e660824f92416079f8cb4 Mon Sep 17 00:00:00 2001 From: Isabelle Date: Mon, 24 Aug 2026 19:30:28 -0700 Subject: [PATCH 18/18] adding csev2nonceordervalidation.fromcontext --- .../cryptography/BlobDecryptionPolicy.java | 8 ++------ .../cryptography/CseV2NonceOrderValidator.java | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/BlobDecryptionPolicy.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/BlobDecryptionPolicy.java index cb1e6901e81b..c814998a642a 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/BlobDecryptionPolicy.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/BlobDecryptionPolicy.java @@ -113,9 +113,7 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN // ranged downloads always set it up). Sharing it means a reliable-download ranged resume of this // same operation - which re-enters the pipeline through the range branch with the same context - // enforces one nonce scheme across the initial and resumed portions. - CseV2NonceOrderValidator nonceValidator - = (CseV2NonceOrderValidator) context.getData(CryptographyConstants.GCM_NONCE_VALIDATOR_KEY) - .orElse(null); + CseV2NonceOrderValidator nonceValidator = CseV2NonceOrderValidator.fromContext(context); Flux plainTextData = this.decryptBlob(httpResponse.getBody(), encryptedRange, padding, encryptionData, httpResponse.getRequest().getUrl(), nonceValidator); @@ -137,9 +135,7 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN // Shared across every chunk of this download so the CSEv2 nonce scheme is enforced consistently across the // whole operation (partitioned downloadToFile / chunked openInputStream issue one ranged request per // chunk). Set up once per operation alongside the encryption data. - CseV2NonceOrderValidator nonceValidator - = (CseV2NonceOrderValidator) context.getData(CryptographyConstants.GCM_NONCE_VALIDATOR_KEY) - .orElse(null); + CseV2NonceOrderValidator nonceValidator = CseV2NonceOrderValidator.fromContext(context); EncryptedBlobRange encryptedRange = EncryptedBlobRange.getEncryptedBlobRangeFromHeader(initialRangeHeader, encryptionData); diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java index 112a08379e72..75bd155f1037 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/CseV2NonceOrderValidator.java @@ -3,6 +3,7 @@ package com.azure.storage.blob.specialized.cryptography; +import com.azure.core.http.HttpPipelineCallContext; import com.azure.core.util.CoreUtils; import com.azure.core.util.logging.ClientLogger; @@ -12,6 +13,7 @@ import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.ALLOW_MISORDERED_REGIONS_ENV_VAR; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.ALLOW_MISORDERED_REGIONS_PROPERTY; +import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.GCM_NONCE_VALIDATOR_KEY; import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.NONCE_LENGTH; /** @@ -49,6 +51,18 @@ final class CseV2NonceOrderValidator { this.validationEnabled = !cseV2AllowMisorderedAuthRegions(); } + /** + * Retrieves the operation-scoped validator installed in the pipeline context, if any. A single instance is shared + * across every chunk of a download operation so the nonce scheme is enforced consistently (see the class-level + * documentation). + * + * @param context The pipeline call context for the current request. + * @return The shared validator, or {@code null} if none was installed (e.g. a full-blob single-shot download). + */ + static CseV2NonceOrderValidator fromContext(HttpPipelineCallContext context) { + return (CseV2NonceOrderValidator) context.getData(GCM_NONCE_VALIDATOR_KEY).orElse(null); + } + /** * Validates that the nonce of an authenticated region is consistent with the region occupying its sequential * position, under a single recognized cross-SDK nonce scheme enforced across the whole download. A region whose