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());
Comment on lines +111 to +113

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there are two things we could explain here:

  1. it would cause nonces to repeat and
  2. Half of the region indices would narrow to negative — every other 2^31-sized band, alternating from 2^31 up to 2^63 — yielding nonces with an FFFFFFFF prefix rather than 00000000 when widened from 32-bit to 64-bit in computeRegionNonce()

} 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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think we should name this EncryptorV2Tests because I imagine we'll test more than the nonce at some point 🤷‍♂️

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just something to check:

  1. I think we might be missing some tests to ensure that the bug isn't reintroduced. The tests all call computeRegionNonce(long) directly, but the defect was at the call site getCipher(tuple.getT1().intValue()). Readd .intValue() and the suite still passes.
  2. Also, nonceEncodesRegionIndexAsBigEndianLongWithTrailingZeros builds its expected value with ByteBuffer.putLong, which I think is the same as the implementation, so it mirrors the code . Could we add assertions against hardcoded expected bytes at these boundaries?
  • 0 // all-zero nonce
  • 1 // smallest non-zero; catches wrong offset or endianness immediately
  • 2 // confirms the counter increments by one, not by region size
  • 255 // byte boundary; catches a signed-byte or nibble-order slip
  • (1L << 31) - 1 // last correct index under the old code
  • 1L << 31 // sign-extension boundary; old code emitted a leading FFFFFFFF here
  • (1L << 32) - 1 // last index of the negative band, decoded as -1 by the old code
  • 1L << 32 // wraparound origin; old code collided this with region 0
  • (1L << 32) + 5 // wraparound, non-zero; old code collided this with region 5
  • 1L << 40 // sets a byte the old code could never reach; guards the high half

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