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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions sdk/storage/azure-storage-blob-cryptography/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,22 @@ protected EncryptionData buildEncryptionData(Map<String, String> 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;
Expand All @@ -94,9 +107,10 @@ protected Flux<ByteBuffer> encrypt(Flux<ByteBuffer> 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));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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}.
* <p>
* 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<ByteBuffer> 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<ByteBuffer> 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;
}
}
Loading