Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
3 changes: 2 additions & 1 deletion .vscode/cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -1103,7 +1103,8 @@
"filename": "sdk/storage/azure-storage-blob-cryptography/**",
"words": [
"akek",
"azstorage"
"azstorage",
"misordered"
]
},
{
Expand Down
1 change: 1 addition & 0 deletions sdk/storage/azure-storage-blob-cryptography/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
- 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,14 @@ public Mono<HttpResponse> process(HttpPipelineCallContext context, HttpPipelineN

boolean padding = hasPadding(responseHeaders, encryptionData, encryptedRange);

// 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.fromContext(context);

Flux<ByteBuffer> plainTextData = this.decryptBlob(httpResponse.getBody(), encryptedRange, padding,
encryptionData, httpResponse.getRequest().getUrl());
encryptionData, httpResponse.getRequest().getUrl(), nonceValidator);

return Mono.just(new BlobDecryptionPolicy.DecryptedResponse(httpResponse, plainTextData));
} else {
Expand All @@ -126,6 +132,11 @@ public Mono<HttpResponse> 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.fromContext(context);

EncryptedBlobRange encryptedRange
= EncryptedBlobRange.getEncryptedBlobRangeFromHeader(initialRangeHeader, encryptionData);
if (context.getHttpRequest().getHeaders().getValue(RANGE_HEADER) != null) {
Expand All @@ -151,7 +162,7 @@ public Mono<HttpResponse> process(HttpPipelineCallContext context, HttpPipelineN
boolean padding = hasPadding(httpResponse.getHeaders(), encryptionData, encryptedRange);

Flux<ByteBuffer> plainTextData = this.decryptBlob(httpResponse.getBody(), encryptedRange, padding,
encryptionData, httpResponse.getRequest().getUrl());
encryptionData, httpResponse.getRequest().getUrl(), nonceValidator);

return new DecryptedResponse(httpResponse, plainTextData);
} else {
Expand Down Expand Up @@ -207,18 +218,21 @@ 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<ByteBuffer> decryptBlob(Flux<ByteBuffer> 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.
AtomicLong totalInputBytes = new AtomicLong(0);
// 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<ByteBuffer> dataToTrim = decryptor.getKeyEncryptionKey()
.flatMapMany(
key -> decryptor.decrypt(encryptedFlux, encryptedBlobRange, padding, uriToLog, totalInputBytes, key));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -55,6 +61,18 @@ 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 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 ALLOW_MISORDERED_REGIONS_ENV_VAR = "AZURE_STORAGE_CSE_V2_ALLOW_MISORDERED_AUTH_REGIONS";

private CryptographyConstants() {
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

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;

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;
import static com.azure.storage.blob.specialized.cryptography.CryptographyConstants.GCM_NONCE_VALIDATOR_KEY;
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
* the value expected for its sequential position.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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);

private final boolean validationEnabled;
private final AtomicReference<EnumSet<NonceScheme>> candidateSchemes
= new AtomicReference<>(EnumSet.allOf(NonceScheme.class));

CseV2NonceOrderValidator() {
// Read the data-recovery bypass switch once per download operation.
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
* 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 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 region) {
if (!validationEnabled) {
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.
EnumSet<NonceScheme> current = candidateSchemes.get();
if (current.size() == 1) {
NonceScheme locked = current.iterator().next();
if (!Arrays.equals(locked.expectedNonce(region, nonceLength), actualNonce)) {
throw LOGGER.logExceptionAsError(reorderException());
}
return;
}

// Determine which schemes are consistent with this region at its position.
EnumSet<NonceScheme> matchesHere = EnumSet.noneOf(NonceScheme.class);
for (NonceScheme scheme : NonceScheme.values()) {
if (Arrays.equals(scheme.expectedNonce(region, nonceLength), actualNonce)) {
matchesHere.add(scheme);
}
}

if (matchesHere.isEmpty()) {
throw LOGGER.logExceptionAsError(reorderException());
}

// 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<NonceScheme> remaining = candidateSchemes.updateAndGet(existing -> {
EnumSet<NonceScheme> next = EnumSet.copyOf(existing);
next.retainAll(matchesHere);
return next;
});
if (remaining.isEmpty()) {
throw LOGGER.logExceptionAsError(reorderException());
}
}

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());
}

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\".";
}

/**
* Whether detection of reordered client-side encryption v2 authenticated regions should be disabled.
* <p>
* 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.
* <p>
* {@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 written as an 8-byte big-endian value in the first 8 bytes, remaining bytes zero.
* 0-based. See {@link EncryptorV2}.
*/
JAVA {
Comment thread
ibrandes marked this conversation as resolved.
@Override
byte[] expectedNonce(long regionIndex, int nonceLength) {
byte[] nonce = new byte[nonceLength];
for (int i = 0; i < Long.BYTES; i++) {
nonce[i] = (byte) (regionIndex >>> (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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ abstract Flux<ByteBuffer> decrypt(Flux<ByteBuffer> 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);
}
Expand All @@ -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(
Expand Down
Loading
Loading