diff --git a/sdk/storage/azure-storage-blob-cryptography/CHANGELOG.md b/sdk/storage/azure-storage-blob-cryptography/CHANGELOG.md index 094528620fb5..9436c2d291b0 100644 --- a/sdk/storage/azure-storage-blob-cryptography/CHANGELOG.md +++ b/sdk/storage/azure-storage-blob-cryptography/CHANGELOG.md @@ -7,8 +7,10 @@ ### Breaking Changes ### Bugs Fixed - -### Other Changes +- Fixed an issue where the client-side encryption (v2) region nonce counter was truncated to 32 bits, which could + cause GCM nonce reuse for blobs exceeding 2^32 authenticated regions. The full 64-bit region index is now used so + every region receives a unique nonce. Blobs with at most 2^31 authenticated regions remain byte-for-byte compatible; + subsequent regions now use the corrected 64-bit encoding. ## 12.35.0-beta.1 (2026-07-28) diff --git a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/EncryptorV2.java b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/EncryptorV2.java index b325b04d8782..ce68608a9cd4 100644 --- a/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/EncryptorV2.java +++ b/sdk/storage/azure-storage-blob-cryptography/src/main/java/com/azure/storage/blob/specialized/cryptography/EncryptorV2.java @@ -68,9 +68,22 @@ protected EncryptionData buildEncryptionData(Map keyWrappingMeta new EncryptedRegionInfo(encryptionOptions.getAuthenticatedRegionDataLengthInBytes(), NONCE_LENGTH)); } - private Cipher getCipher(int index) throws GeneralSecurityException { + /** + * Computes the {@link CryptographyConstants#NONCE_LENGTH}-byte GCM nonce for a CSEv2 authenticated region from its + * zero-based sequential index. The index is written as an 8-byte big-endian value into the leading bytes of the + * nonce and the remaining bytes are left zero. The full 64-bit index is used (rather than a truncated 32-bit value) + * so that every region within a blob is guaranteed a unique nonce, which AES-GCM requires to remain secure. + * + * @param index The zero-based region index. + * @return The nonce bytes for the region. + */ + static byte[] computeRegionNonce(long index) { + return ByteBuffer.allocate(NONCE_LENGTH).putLong(index).array(); + } + + private Cipher getCipher(long index) throws GeneralSecurityException { Cipher cipher = Cipher.getInstance(AES_GCM_NO_PADDING); - byte[] iv = ByteBuffer.allocate(NONCE_LENGTH).putLong(index).array(); + byte[] iv = computeRegionNonce(index); cipher.init(Cipher.ENCRYPT_MODE, aesKey, new GCMParameterSpec(TAG_LENGTH * 8, iv)); return cipher; @@ -94,9 +107,10 @@ protected Flux encrypt(Flux plainTextFlux) { .flatMapSequential(tuple -> { Cipher gcmCipher; try { - // We use the index as the nonce as a counter guarantees each nonce is used - // only once with a given key. - gcmCipher = getCipher(tuple.getT1().intValue()); + // We use the full 64-bit region index as the nonce counter so that each nonce is used only + // once with a given key. Truncating the index to 32 bits would cause nonces to repeat - and + // AES-GCM security to break - once a blob exceeds 2^32 authenticated regions. + gcmCipher = getCipher(tuple.getT1()); } catch (GeneralSecurityException e) { throw LOGGER.logExceptionAsError(Exceptions.propagate(e)); } diff --git a/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/EncryptorV2NonceTests.java b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/EncryptorV2NonceTests.java new file mode 100644 index 000000000000..0d84e114b6fb --- /dev/null +++ b/sdk/storage/azure-storage-blob-cryptography/src/test/java/com/azure/storage/blob/specialized/cryptography/EncryptorV2NonceTests.java @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.specialized.cryptography; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.security.SecureRandom; +import java.util.List; + +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.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the CSEv2 region nonce counter in {@link EncryptorV2}. + *

+ * Each authenticated region is encrypted under a nonce derived from its sequential index. The index must be encoded + * using the full 64-bit value; truncating it to 32 bits causes nonces to repeat every 2^32 regions, which is AES-GCM + * nonce reuse (a security failure). These tests exercise the encoding directly and end-to-end through + * {@link EncryptorV2#encrypt(Flux)}. + */ +public class EncryptorV2NonceTests { + private static final SecureRandom RANDOM = new SecureRandom(); + + @Test + public void regionZeroNonceIsAllZeros() { + assertArrayEquals(new byte[NONCE_LENGTH], EncryptorV2.computeRegionNonce(0)); + } + + @Test + public void nonceEncodesRegionIndexAsBigEndianLongWithTrailingZeros() { + for (long index : new long[] { 1, 2, 255, 256, 1_000_000, Integer.MAX_VALUE }) { + byte[] nonce = EncryptorV2.computeRegionNonce(index); + + assertEquals(NONCE_LENGTH, nonce.length); + // First 8 bytes are the big-endian index. + byte[] expectedPrefix = ByteBuffer.allocate(Long.BYTES).putLong(index).array(); + byte[] actualPrefix = new byte[Long.BYTES]; + System.arraycopy(nonce, 0, actualPrefix, 0, Long.BYTES); + assertArrayEquals(expectedPrefix, actualPrefix, "index=" + index); + // Remaining bytes are zero. + for (int i = Long.BYTES; i < NONCE_LENGTH; i++) { + assertEquals(0, nonce[i], "trailing byte " + i + " for index=" + index); + } + } + } + + @Test + public void nonceUsesFullLongInsteadOfTruncatedInt() { + // Region 2^31 exceeds the positive int range. The old encoder truncated the index to an int, producing + // Integer.MIN_VALUE and, after sign extension, a nonce beginning with 0xFFFFFFFF. The full-long encoding must + // instead leave the high four bytes zero. + long index = 1L << 31; + byte[] nonce = EncryptorV2.computeRegionNonce(index); + + for (int i = 0; i < 4; i++) { + assertEquals(0, nonce[i], "high byte " + i + " should be zero, not sign-extended"); + } + // Byte 4 holds the top bit of the 2^31 value. + assertEquals((byte) 0x80, nonce[4]); + + // Explicitly confirm it differs from what a truncated-int counter would have produced. + byte[] truncated = ByteBuffer.allocate(NONCE_LENGTH).putLong((int) index).array(); + assertFalse(java.util.Arrays.equals(truncated, nonce), + "full-long nonce must differ from the truncated-int nonce at index 2^31"); + } + + @Test + public void regionsExactlyNonceWrapApartHaveDistinctNonces() { + // The core regression: with a truncated 32-bit counter, region N and region N + 2^32 share a nonce (GCM nonce + // reuse). The full-long counter must give them distinct nonces. + long wrap = 1L << 32; + + assertFalse(java.util.Arrays.equals(EncryptorV2.computeRegionNonce(0), EncryptorV2.computeRegionNonce(wrap))); + assertFalse( + java.util.Arrays.equals(EncryptorV2.computeRegionNonce(5), EncryptorV2.computeRegionNonce(wrap + 5))); + + // Region 2^32 encodes as {0,0,0,1, 0,0,0,0, 0,0,0,0}. + byte[] expected = new byte[NONCE_LENGTH]; + expected[3] = 1; + assertArrayEquals(expected, EncryptorV2.computeRegionNonce(wrap)); + } + + @Test + public void allNoncesUniqueAcrossWrapBoundarySample() { + // Sample indices straddling the old 2^32 wrap point must all be distinct. + long[] indices = { 0, 1, 2, (1L << 31) - 1, 1L << 31, (1L << 32) - 1, 1L << 32, (1L << 32) + 1, 1L << 33 }; + for (int i = 0; i < indices.length; i++) { + for (int j = i + 1; j < indices.length; j++) { + assertFalse( + java.util.Arrays.equals(EncryptorV2.computeRegionNonce(indices[i]), + EncryptorV2.computeRegionNonce(indices[j])), + "nonces for " + indices[i] + " and " + indices[j] + " must differ"); + } + } + } + + @Test + public void encryptEmitsSequentialRegionNonces() { + // End-to-end: encrypt a multi-region blob with a small region size and confirm each region is prefixed with the + // nonce for its sequential index. + int regionLength = 16; + int regionCount = 5; + int plaintextLength = regionLength * (regionCount - 1) + 7; // last region is partial + + SecretKey key = new SecretKeySpec(randomBytes(32), CryptographyConstants.AES); + BlobClientSideEncryptionOptions options + = new BlobClientSideEncryptionOptions().setAuthenticatedRegionDataLengthInBytes(regionLength); + EncryptorV2 encryptor = new EncryptorV2(key, options, ENCRYPTION_PROTOCOL_V2); + + byte[] plaintext = randomBytes(plaintextLength); + List emitted = encryptor.encrypt(Flux.just(ByteBuffer.wrap(plaintext))).collectList().block(); + + byte[] ciphertext = concat(emitted); + int offset = 0; + int remaining = plaintextLength; + for (long region = 0; region < regionCount; region++) { + byte[] nonce = new byte[NONCE_LENGTH]; + System.arraycopy(ciphertext, offset, nonce, 0, NONCE_LENGTH); + assertArrayEquals(EncryptorV2.computeRegionNonce(region), nonce, "region " + region + " nonce"); + + int regionData = Math.min(regionLength, remaining); + offset += NONCE_LENGTH + regionData + TAG_LENGTH; + remaining -= regionData; + } + assertEquals(ciphertext.length, offset, "consumed the entire ciphertext"); + assertTrue(remaining <= 0); + } + + private static byte[] concat(List buffers) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (ByteBuffer buffer : buffers) { + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + out.write(bytes, 0, bytes.length); + } + return out.toByteArray(); + } + + private static byte[] randomBytes(int length) { + byte[] bytes = new byte[length]; + RANDOM.nextBytes(bytes); + return bytes; + } +}