diff --git a/doc/snippets/Microsoft.Data.SqlClient/SqlColumnEncryptionKeyStoreProvider.xml b/doc/snippets/Microsoft.Data.SqlClient/SqlColumnEncryptionKeyStoreProvider.xml index 401258f9e5..c3990851c5 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/SqlColumnEncryptionKeyStoreProvider.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/SqlColumnEncryptionKeyStoreProvider.xml @@ -11,51 +11,159 @@ + + Decrypts the specified encrypted value of a column encryption key. + The encrypted value is expected to be encrypted using the column + master key with the specified key path and using the specified + algorithm. + - The master key path. + The column master key path. The path format is specific to the key + store provider implementation (e.g. a thumbprint for the certificate + store, or a key identifier URL for Azure Key Vault). - The encryption algorithm. + The encryption algorithm name. For example, + RSA_OAEP. The encrypted column encryption key. + + Returns a representing the decrypted + column encryption key. + + + - Decrypts the specified encrypted value of a column encryption key. The encrypted value is expected to be encrypted using the column master key with the specified key path and using the specified algorithm. + Asynchronously decrypts the specified encrypted value of a column + encryption key. The encrypted value is expected to be encrypted using + the column master key with the specified key path and using the + specified algorithm. + + The column master key path. The path format is specific to the key + store provider implementation (e.g. a thumbprint for the certificate + store, or a key identifier URL for Azure Key Vault). + + + The encryption algorithm name. For example, + RSA_OAEP. + + + The encrypted column encryption key. + + + A token to cancel the asynchronous operation. + - Returns . The decrypted column encryption key. + A task that returns a representing + the decrypted column encryption key on completion. - + + + The default implementation first checks the + and returns a canceled task + if cancellation has been requested. Otherwise, it calls the + synchronous + + method and wraps the result in a completed task. If the synchronous + method throws, the exception is caught and a faulted task is + returned rather than throwing synchronously. + + + Derived classes that perform I/O (such as calls to Azure Key Vault) + should override this method with a truly asynchronous + implementation that honors the cancellation token. + + + + + Encrypts a column encryption key using the column master key with + the specified key path and using the specified algorithm. + - The master key path. + The column master key path. The path format is specific to the key + store provider implementation (e.g. a thumbprint for the certificate + store, or a key identifier URL for Azure Key Vault). - The encryption algorithm. + The encryption algorithm name. For example, + RSA_OAEP. - The plaintext column encryption key. + The column encryption key to encrypt. + + Returns a representing the encrypted + column encryption key. + + + - Encrypts a column encryption key using the column master key with the specified key path and using the specified algorithm. + Asynchronously encrypts a column encryption key using the column + master key with the specified key path and using the specified + algorithm. + + The column master key path. The path format is specific to the key + store provider implementation (e.g. a thumbprint for the certificate + store, or a key identifier URL for Azure Key Vault). + + + The encryption algorithm name. For example, + RSA_OAEP. + + + The column encryption key to encrypt. + + + A token to cancel the asynchronous operation. + - Returns . The encrypted column encryption key. + A task that returns a representing + the encrypted column encryption key on completion. - + + + The default implementation first checks the + and returns a canceled task + if cancellation has been requested. Otherwise, it calls the + synchronous + + method and wraps the result in a completed task. If the synchronous + method throws, the exception is caught and a faulted task is + returned rather than throwing synchronously. + + + Derived classes that perform I/O (such as calls to Azure Key Vault) + should override this method with a truly asynchronous + implementation that honors the cancellation token. + + + + + When implemented in a derived class, signs the column master key + metadata with the column master key referenced by the + parameter. + - The column master key path. + The column master key path. The path format is specific to the key + store provider implementation (e.g. a thumbprint for the certificate + store, or a key identifier URL for Azure Key Vault). - to indicate that the column master key supports enclave computations; otherwise, . + to indicate that the column master key + supports enclave computations; otherwise, + . When , the + generated signature covers the enclave-enabled property so that + the metadata can later be verified for enclave use. - - When implemented in a derived class, digitally signs the column master key metadata with the column master key referenced by the parameter. The input values used to generate the signature should be the specified values of the and parameters. - - The signature of the column master key metadata. + Returns the signature of the column master key metadata. The format + is provider-specific. @@ -69,23 +177,142 @@ In all cases. + + + When implemented in a derived class, asynchronously signs the column + master key metadata with the column master key referenced by the + parameter. + + + The column master key path. The path format is specific to the key + store provider implementation (e.g. a thumbprint for the certificate + store, or a key identifier URL for Azure Key Vault). + + + to indicate that the column master key + supports enclave computations; otherwise, + . When , the + generated signature covers the enclave-enabled property so that + the metadata can later be verified for enclave use. + + + A token to cancel the asynchronous operation. + + + A task that, when completed, returns a + representing the signature of the column master key metadata. The + signature format is provider-specific. + + + + The default implementation first checks the + and returns a canceled task + if cancellation has been requested. Otherwise, it calls the + synchronous + + method, which throws a + by default. In + this case, the returned task will be faulted with + rather than + throwing synchronously. + + + Derived classes that perform I/O should override this method with + a truly asynchronous implementation that honors the cancellation + token. + + + Key store providers that wish to use enclaves with + Always Encrypted + should override this method with a truly asynchronous + implementation when the signing operation involves I/O. + + + + + When implemented in a derived class, this method is expected to + verify the specified signature is valid for the column master key + with the specified key path and the specified enclave behavior. The + default implementation throws + . + - The column master key path. + The column master key path. The path format is specific to the key + store provider implementation (e.g. a thumbprint for the certificate + store, or a key identifier URL for Azure Key Vault). - Indicates whether the column master key supports enclave computations. + Indicates whether the column master key supports enclave + computations. This value must match what was passed to + + when the signature was generated. - The signature of the column master key metadata. + The signature to verify, as previously returned by + . + The format is provider-specific. + + When implemented in a derived class, the method is expected to + return if the specified signature is valid, + or if the specified signature is not valid. + The default implementation throws + . + + + - When implemented in a derived class, this method is expected to verify the specified signature is valid for the column master key with the specified key path and the specified enclave behavior. The default implementation throws `NotImplementedException`. + When implemented in a derived class, asynchronously verifies the + specified signature is valid for the column master key with the + specified key path and the specified enclave behavior. The default + implementation returns a faulted task with + . + + The column master key path. The path format is specific to the key + store provider implementation (e.g. a thumbprint for the certificate + store, or a key identifier URL for Azure Key Vault). + + + Indicates whether the column master key supports enclave + computations. This value must match what was passed to + + when the signature was generated. + + + The signature to verify, as previously returned by + . + The format is provider-specific. + + + A token to cancel the asynchronous operation. + - When implemented in a derived class, the method is expected to return true if the specified signature is valid, or false if the specified signature is not valid. The default implementation throws `NotImplementedException`. + A task that, when completed, returns if the + specified signature is valid, or if it is + not valid. - + + + The default implementation first checks the + and returns a canceled task + if cancellation has been requested. Otherwise, it calls the + synchronous + + method, which throws a + by default. In + this case, the returned task will be faulted with + rather than + throwing synchronously. + + + Derived classes that perform I/O should override this method with + a truly asynchronous implementation that honors the cancellation + token. + + + Gets or sets the lifespan of the decrypted column encryption key in the cache. Once the timespan has elapsed, the decrypted column encryption key is discarded and must be revalidated. diff --git a/specs/002-async-always-encrypted/spec.md b/specs/002-async-always-encrypted/spec.md new file mode 100644 index 0000000000..dea4d526f2 --- /dev/null +++ b/specs/002-async-always-encrypted/spec.md @@ -0,0 +1,292 @@ +# Feature Specification: Async-Friendly Always Encrypted Support + +**Issue**: [#3672](https://github.com/dotnet/SqlClient/issues/3672) +**Feature Branch**: `dev/cheena/async-ae` +**Created**: 2026-05-13 +**Status**: Draft +**Milestone**: 7.1.0-preview2 + +## Summary + +Eliminate sync-over-async in Always Encrypted (AE) code paths by adding async counterparts to all provider base classes and implementations, then rewiring async execution paths in SqlCommand to use them. Currently, async operations like `ExecuteReaderAsync` block ThreadPool threads when AE is enabled because all key decryption, signature verification, and enclave attestation calls are synchronous — including HTTP calls to Azure Key Vault and attestation services. + +## User Scenarios & Testing + +### User Story 1 — Async Key Decryption with Azure Key Vault + +As an application developer using Always Encrypted with Azure Key Vault, I want `ExecuteReaderAsync` to perform column encryption key (CEK) decryption asynchronously via the Azure Key Vault SDK, so that my async code paths do not block ThreadPool threads waiting for HTTP responses from Azure Key Vault. + +**Acceptance Scenarios**: + +1. **Given** a table with AE-encrypted columns and AKV-stored CMK, **When** `ExecuteReaderAsync` is called, **Then** CEK decryption calls `CryptographyClient.UnwrapKeyAsync()` instead of the sync `UnwrapKey()`. +2. **Given** a `CancellationToken` passed to `ExecuteReaderAsync`, **When** the token is cancelled during AKV key decryption, **Then** the operation is cancelled and a `TaskCanceledException` is thrown. +3. **Given** a custom provider that does NOT override async methods, **When** `ExecuteReaderAsync` is called, **Then** the base class fallback wraps the sync method in `Task.FromResult` and completes successfully. + +### User Story 2 — Async Enclave Attestation + +As an application developer using Always Encrypted with secure enclaves, I want enclave attestation to be performed asynchronously, so that HTTP calls to attestation services do not block my async query execution path. + +**Acceptance Scenarios**: + +1. **Given** an enclave-enabled query using Azure Attestation, **When** `ExecuteReaderAsync` is called, **Then** `ConfigurationManager.GetConfigurationAsync()` is used. +2. **Given** an enclave-enabled query using HGS attestation, **When** `ExecuteReaderAsync` is called, **Then** the HTTP call uses `await HttpClient.GetStreamAsync()` instead of `.Result`. + +### User Story 3 — Sync Path Preservation + +All existing sync methods are preserved as-is. The `isAsync` flag in `SqlCommand` determines which path is taken. Zero behavioral change for sync callers. + +### User Story 4 — Custom Provider Backward Compatibility + +Custom providers that only implement sync `DecryptColumnEncryptionKey` continue to work. The base class default wraps the sync call in `Task.FromResult`. If the sync method throws, the returned Task is faulted (not a synchronous throw). + +### Edge Cases + +- **Cache hit for CEK**: Async path returns immediately from cache without invoking the provider. +- **Mixed sync/async on `SemaphoreSlim`**: For sites still using `SemaphoreSlim` (non-`IMemoryCache` caches), mixing `.Wait()` and `.WaitAsync()` is safe per .NET docs. For `IMemoryCache`-backed caches, the async path uses `GetOrCreateAsync` and does not need `SemaphoreSlim`. +- **CancellationToken in default fallback**: The base class wraps sync methods and checks the token before invocation; sync calls themselves are not cancellable. +- **Concurrent cache misses**: Two threads may independently fetch the same key. Last write wins (idempotent). See FR-014 for optional deduplication. +- **Enclave session cache hit**: Returns from cache without performing attestation. +- **`ColumnEncryptionKeyCacheTtl` mutation**: Property mutation must remain inside the semaphore-held section in `SqlSymmetricKeyCache` to avoid races. + +## Design Decisions + +### 1. `Task` for Public APIs + +Public virtual methods use `Task`, not `ValueTask`. The primary beneficiary (AKV) always allocates a Task due to HTTP I/O. `ValueTask` has stricter usage rules that would be error-prone for third-party implementers. + +### 2. `CancellationToken` with Default Value + +Each async method accepts a `CancellationToken cancellationToken = default` parameter. This is a single `virtual` overload — callers can omit the token for convenience, and derived classes override a single method. The default implementation checks the token for cancellation before invoking the synchronous method. + +### 3. Certificate/CNG/CSP Providers Keep Default Fallback + +No async overrides — these perform local crypto (CPU-bound) that doesn't benefit from async. The base class `Task.FromResult` fallback is sufficient. + +### 4. Async Methods Return Tuples Instead of `out` Parameters + +C# async methods cannot have `out` parameters. Internal enclave methods return tuples (e.g., `Task<(SqlEnclaveSession, long, byte[], int)>`). `SqlSecurityUtility.DecryptSymmetricKeyAsync` returns `Task<(SqlClientSymmetricKey Key, SqlEncryptionKeyInfo KeyInfoChosen)>`. + +### 5. `ConfigureAwait(false)` on Every `await` + +Every `await` in library code MUST use `.ConfigureAwait(false)`. Omission causes deadlocks in ASP.NET Framework, WPF, and WinForms hosts. This is a hard correctness requirement enforced in CI. + +### 6. `MemoryCache.GetOrCreateAsync` for Async Cache Patterns + +**API**: [`CacheExtensions.GetOrCreateAsync(IMemoryCache, Object, Func>)`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.caching.memory.cacheextensions.getorcreateasync?view=net-10.0-pp) + +For `IMemoryCache`-backed caches, use `GetOrCreateAsync` with an async factory delegate instead of `SemaphoreSlim` + manual `TryGetValue`/`Set`. This eliminates lock-during-I/O without requiring the check-release-fetch-relock pattern. + +```csharp +var result = await _cache.GetOrCreateAsync(cacheKey, async entry => +{ + entry.SlidingExpiration = _columnEncryptionKeyCacheTtl; + return await provider.DecryptColumnEncryptionKeyAsync( + masterKeyPath, algorithm, encryptedKey, ct) + .ConfigureAwait(false); +}).ConfigureAwait(false); +``` + +**Affected**: `SqlColumnEncryptionAzureKeyVaultProvider._columnEncryptionKeyCache` + +For sites NOT using `IMemoryCache` (custom `Dictionary`/`ConcurrentDictionary` caches), use the check-release-fetch-relock pattern with `SemaphoreSlim`: + +```csharp +await semaphore.WaitAsync(ct).ConfigureAwait(false); +try +{ + if (cache.TryGetValue(key, out var cached)) + return cached; +} +finally { semaphore.Release(); } // release BEFORE I/O + +var value = await FetchAsync(key, ct).ConfigureAwait(false); + +await semaphore.WaitAsync(ct).ConfigureAwait(false); +try { cache.Set(key, value); } +finally { semaphore.Release(); } +return value; +``` + +**Affected**: `SqlSymmetricKeyCache._cacheLock`, `AzureSqlKeyCryptographer._keyDictionarySemaphore` + +### 7. `GetParameterEncryptionDataReaderAsync` Must Be True `async Task` + +Remove the `Task.Run(() => { ... })` wrapper entirely. The method must `await` each I/O step directly using clean `async/await`, not the `AsyncHelper.ContinueTaskWithState` callback pattern. + +## Requirements + +### Functional Requirements + +| ID | Requirement | +|----|-------------| +| FR-001 | Add `virtual` async counterparts for `DecryptColumnEncryptionKey`, `EncryptColumnEncryptionKey`, `SignColumnMasterKeyMetadata`, and `VerifyColumnMasterKeyMetadata` to `SqlColumnEncryptionKeyStoreProvider`. | +| FR-002 | Each async method accepts `CancellationToken cancellationToken = default` as a single virtual overload. Default checks token before sync fallback. | +| FR-003 | Default async implementations wrap sync via `Task.FromResult`; return faulted Tasks (not synchronous throws) on exception. | +| FR-004 | `SqlColumnEncryptionAzureKeyVaultProvider` overrides all 4 async methods with truly async Azure SDK calls. | +| FR-005 | AKV provider propagates `CancellationToken` to all Azure SDK calls. | +| FR-006 | Internal async counterparts added to enclave provider base class and all implementations. | +| FR-007 | Enclave providers with HTTP I/O (Azure Attestation, HGS) implement truly async attestation. | +| FR-008 | Async utility methods added: `SqlSecurityUtility.DecryptSymmetricKeyAsync` (returns tuple), `VerifyColumnMasterKeySignatureAsync`, `SqlSymmetricKeyCache.GetKeyAsync`. All propagate `CancellationToken`. | +| FR-009 | `SqlCommand` async paths use async AE methods when `isAsync=true`. `GetParameterEncryptionDataReaderAsync` becomes true `async Task`. | +| FR-010 | No changes to existing sync code paths or behavior. | +| FR-011 | All new public APIs declared in `src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs`. | +| FR-012 | Async caching: `IMemoryCache` sites use `GetOrCreateAsync`; custom-cache sites use check-release-fetch-relock with `SemaphoreSlim`. No lock held during I/O. | +| FR-013 | Every `await` uses `.ConfigureAwait(false)`. Enforced in CI. | +| FR-014 | Concurrent cache misses tolerated without deadlock. Last write wins. Optional deduplication via `ConcurrentDictionary>`. | +| FR-015 | `lock(enclaveCacheLock)` replaced with `SemaphoreSlim` if async operations must run before session storage. Sync-only paths may retain `lock`. | + +### Key Entities + +| Entity | Scope | Changes | +|--------|-------|---------| +| `SqlColumnEncryptionKeyStoreProvider` | public abstract | +4 virtual async methods with `CancellationToken = default` | +| `SqlColumnEncryptionAzureKeyVaultProvider` | public (separate package) | Overrides all 4 async methods with Azure SDK async calls | +| `AzureSqlKeyCryptographer` | internal (AKV package) | +async counterparts for `AddKey`, `UnwrapKey`, `WrapKey`, `SignData`, `VerifyData` | +| `SqlColumnEncryptionEnclaveProvider` | internal abstract | +4 abstract async methods with tuple returns | +| `EnclaveDelegate` | internal sealed | +async dispatcher methods | +| `SqlSecurityUtility` | internal static | +`DecryptSymmetricKeyAsync`, `VerifyColumnMasterKeySignatureAsync` | +| `SqlSymmetricKeyCache` | internal sealed, singleton | +`GetKeyAsync` with check-release-fetch-relock | + +## Implementation Phases + +### Phase 1: Base Class API (PR #3673 — in progress) + +*Depends on: nothing* + +Add 4 virtual async methods + 4 convenience overloads to `SqlColumnEncryptionKeyStoreProvider`, update ref assemblies and XML docs. + +**Files**: +- `src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlColumnEncryptionKeyStoreProvider.cs` +- `src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs` +- `doc/snippets/Microsoft.Data.SqlClient/SqlColumnEncryptionKeyStoreProvider.xml` + +### Phase 2: Key Store Provider Implementations + +*Depends on: Phase 1. Parallel with Phase 3.* + +#### 2A. AzureKeyVaultProvider (truly async) + +- `AzureSqlKeyCryptographer.cs` — Add async counterparts using Azure SDK. `AddKeyAsync` uses check-release-fetch-relock on `_keyDictionarySemaphore` (uses `Dictionary`, not `IMemoryCache`). +- `SqlColumnEncryptionAzureKeyVaultProvider.cs` — Override 4 async methods. Use `GetOrCreateAsync` on `_columnEncryptionKeyCache`: + +**Async key-unwrap flow**: +1. `await _columnEncryptionKeyCache.GetOrCreateAsync(cacheKey, async entry => { ... }).ConfigureAwait(false)` +2. Inside factory: `await KeyCryptographer.UnwrapKeyAsync(..., ct).ConfigureAwait(false)` — I/O runs without user-managed locks +3. Configure entry expiration via `ICacheEntry` +4. `MemoryCache` handles cache insertion + +The sync path remains unchanged (`_cacheSemaphore.Wait()` + `GetOrCreate`). `GetOrCreateSignatureVerificationResult` stays sync (CPU-bound RSA). + +#### 2B–2D. CertificateStore, CNG, CSP Providers + +No changes — default base class fallback is sufficient. + +### Phase 3: Enclave Provider Async APIs + +*Depends on: nothing. Parallel with Phase 2.* + +- `SqlColumnEncryptionEnclaveProvider.cs` — 4 internal abstract async methods +- `EnclaveProviderBase.cs` — `GetEnclaveSessionHelperAsync()` +- `EnclaveSessionCache.cs` — Replace `lock` with `SemaphoreSlim` where async operations precede session storage (FR-015) +- `NoneAttestationEnclaveProvider.cs` — Sync wrappers (no real I/O) +- `AzureAttestationBasedEnclaveProvider.cs` — Truly async (`GetConfigurationAsync()`) +- `VirtualSecureModeEnclaveProviderBase.cs` — Abstract `MakeRequestAsync`, `VerifyAttestationInfoAsync` +- `VirtualSecureModeEnclaveProvider.cs` — Truly async (`HttpClient.GetStreamAsync`) +- `EnclaveDelegate.Crypto.cs` — Async dispatcher methods + +### Phase 4: Async Utility Layer + +*Depends on: Phase 1.* + +- `SqlSecurityUtility.cs`: + - `DecryptSymmetricKeyAsync` → returns `Task<(SqlClientSymmetricKey Key, SqlEncryptionKeyInfo KeyInfoChosen)>` + - `GetKeyFromLocalProvidersAsync` — propagates `CancellationToken` + - `VerifyColumnMasterKeySignatureAsync` — propagates `CancellationToken` +- `SqlSymmetricKeyCache.cs`: + - `GetKeyAsync` — check-release-fetch-relock on static `_cacheLock`; propagates `CancellationToken` + +> **Note**: Phases 1–4 deliver async infrastructure. End-to-end benefit requires Phase 5. + +### Phase 5: Async Call-Site Integration + +*Depends on: Phase 4.* + +- `SqlCommand.Encryption.cs`: + 1. `ReadDescribeEncryptionParameterResultsKeysAsync` → `VerifyColumnMasterKeySignatureAsync()` + 2. `ReadDescribeEncryptionParameterResultsMetadataAsync` → `DecryptSymmetricKeyAsync()` (**highest impact**) + 3. `ReadDescribeEncryptionParameterResultsAsync` → orchestrates above + 4. **Refactor `GetParameterEncryptionDataReaderAsync`** into true `async Task` — remove `Task.Run` wrapper + 5. `TryFetchInputParameterEncryptionInfoAsync` → `EnclaveDelegate.GetEnclaveSessionAsync()` + +### Phase 6: Testing & Documentation + +*Spans all phases.* + +**Unit Tests** (`tests/UnitTests/`): Default fallback, cancellation, faulted Tasks +**Functional Tests** (`tests/FunctionalTests/`): `DummyKeyStoreProvider` async overrides +**Manual Tests** (`tests/ManualTests/`): AKV end-to-end, enclave attestation async, encrypted query async +**CI Gate**: Roslyn analyzer or grep check for `.ConfigureAwait(false)` on every `await` +**Documentation**: XML docs, `doc/samples/` async AE examples + +## New Public API Surface + +### SqlColumnEncryptionKeyStoreProvider + +```csharp +public abstract class SqlColumnEncryptionKeyStoreProvider +{ + // Existing (unchanged) + public abstract byte[] DecryptColumnEncryptionKey(string masterKeyPath, string encryptionAlgorithm, byte[] encryptedColumnEncryptionKey); + public abstract byte[] EncryptColumnEncryptionKey(string masterKeyPath, string encryptionAlgorithm, byte[] columnEncryptionKey); + public virtual byte[] SignColumnMasterKeyMetadata(string masterKeyPath, bool allowEnclaveComputations); + public virtual bool VerifyColumnMasterKeyMetadata(string masterKeyPath, bool allowEnclaveComputations, byte[] signature); + public virtual TimeSpan? ColumnEncryptionKeyCacheTtl { get; set; } + + // NEW: Async with optional CancellationToken (virtual, overridable) + public virtual Task DecryptColumnEncryptionKeyAsync(string masterKeyPath, string encryptionAlgorithm, byte[] encryptedColumnEncryptionKey, CancellationToken cancellationToken = default); + public virtual Task EncryptColumnEncryptionKeyAsync(string masterKeyPath, string encryptionAlgorithm, byte[] columnEncryptionKey, CancellationToken cancellationToken = default); + public virtual Task SignColumnMasterKeyMetadataAsync(string masterKeyPath, bool allowEnclaveComputations, CancellationToken cancellationToken = default); + public virtual Task VerifyColumnMasterKeyMetadataAsync(string masterKeyPath, bool allowEnclaveComputations, byte[] signature, CancellationToken cancellationToken = default); +} +``` + +### SqlColumnEncryptionAzureKeyVaultProvider + +```csharp +public class SqlColumnEncryptionAzureKeyVaultProvider : SqlColumnEncryptionKeyStoreProvider +{ + // Overrides — truly async via Azure SDK + public override Task DecryptColumnEncryptionKeyAsync(..., CancellationToken cancellationToken = default); + public override Task EncryptColumnEncryptionKeyAsync(..., CancellationToken cancellationToken = default); + public override Task SignColumnMasterKeyMetadataAsync(..., CancellationToken cancellationToken = default); + public override Task VerifyColumnMasterKeyMetadataAsync(..., CancellationToken cancellationToken = default); +} +``` + +## Success Criteria + +| ID | Criterion | +|----|-----------| +| SC-001 | `ExecuteReaderAsync` with AE + AKV does NOT block any ThreadPool thread on HTTP I/O. | +| SC-002 | All existing AE tests pass unchanged — zero sync regressions. | +| SC-003 | `Task.Run` is no longer used in `GetParameterEncryptionDataReaderAsync`. | +| SC-004 | `CancellationToken` propagates from `SqlCommand` async methods through to Azure SDK calls. | +| SC-005 | Custom providers with only sync implementations continue to work in async paths. | + +## Assumptions + +- Azure SDK async methods (`UnwrapKeyAsync`, `WrapKeyAsync`, etc.) are stable and production-ready. +- `SemaphoreSlim` supports mixed `.Wait()` / `.WaitAsync()` on the same instance. +- `SqlColumnEncryptionEnclaveProvider` is internal — API changes have no public impact. +- The existing `isAsync` flag in `SqlCommand` determines sync vs async AE paths. +- PR #3673 has not shipped — adding `CancellationToken` is non-breaking. +- Certificate/CNG/CSP providers perform local crypto; `Task.FromResult` is sufficient. +- `Microsoft.Extensions.Caching.Abstractions` (already referenced by AKV provider) provides `GetOrCreateAsync`. + +## Further Considerations + +1. **`ConfigureAwait(false)` CI enforcement**: Add a Roslyn analyzer or grep-based check to prevent regressions. +2. **Third-party provider guidance**: Document how custom providers should override async methods, use `ConfigureAwait(false)`, support cancellation, and avoid lock-during-I/O. +3. **Performance benchmarking**: After Phase 5, quantify ThreadPool savings and latency improvements under load. +4. **In-flight request deduplication**: Optional `ConcurrentDictionary>` for concurrent cold-cache AKV requests (FR-014). +5. **`ValueTask` for internal fast paths**: Internal methods like `SqlSymmetricKeyCache.GetKeyAsync` have high cache-hit rates — `ValueTask` avoids Task allocations. P2 optimization with no API impact. diff --git a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs index e52cbdc8d3..167ce9f80b 100644 --- a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs +++ b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs @@ -704,12 +704,20 @@ public abstract class SqlColumnEncryptionKeyStoreProvider protected SqlColumnEncryptionKeyStoreProvider() { } /// public abstract byte[] DecryptColumnEncryptionKey(string masterKeyPath, string encryptionAlgorithm, byte[] encryptedColumnEncryptionKey); + /// + public virtual System.Threading.Tasks.Task DecryptColumnEncryptionKeyAsync(string masterKeyPath, string encryptionAlgorithm, byte[] encryptedColumnEncryptionKey, System.Threading.CancellationToken cancellationToken = default) { throw null; } /// public abstract byte[] EncryptColumnEncryptionKey(string masterKeyPath, string encryptionAlgorithm, byte[] columnEncryptionKey); + /// + public virtual System.Threading.Tasks.Task EncryptColumnEncryptionKeyAsync(string masterKeyPath, string encryptionAlgorithm, byte[] columnEncryptionKey, System.Threading.CancellationToken cancellationToken = default) { throw null; } /// public virtual byte[] SignColumnMasterKeyMetadata(string masterKeyPath, bool allowEnclaveComputations) { throw null; } + /// + public virtual System.Threading.Tasks.Task SignColumnMasterKeyMetadataAsync(string masterKeyPath, bool allowEnclaveComputations, System.Threading.CancellationToken cancellationToken = default) { throw null; } /// public virtual bool VerifyColumnMasterKeyMetadata(string masterKeyPath, bool allowEnclaveComputations, byte[] signature) { throw null; } + /// + public virtual System.Threading.Tasks.Task VerifyColumnMasterKeyMetadataAsync(string masterKeyPath, bool allowEnclaveComputations, byte[] signature, System.Threading.CancellationToken cancellationToken = default) { throw null; } /// public virtual System.TimeSpan? ColumnEncryptionKeyCacheTtl { get { throw null; } set { } } } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlColumnEncryptionKeyStoreProvider.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlColumnEncryptionKeyStoreProvider.cs index 641e09e8ba..2daa2dbab3 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlColumnEncryptionKeyStoreProvider.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlColumnEncryptionKeyStoreProvider.cs @@ -3,6 +3,9 @@ // See the LICENSE file in the project root for more information. using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.Common; namespace Microsoft.Data.SqlClient { @@ -16,19 +19,91 @@ public abstract class SqlColumnEncryptionKeyStoreProvider /// public abstract byte[] DecryptColumnEncryptionKey(string masterKeyPath, string encryptionAlgorithm, byte[] encryptedColumnEncryptionKey); + /// + public virtual Task DecryptColumnEncryptionKeyAsync(string masterKeyPath, string encryptionAlgorithm, byte[] encryptedColumnEncryptionKey, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + try + { + return Task.FromResult(DecryptColumnEncryptionKey(masterKeyPath, encryptionAlgorithm, encryptedColumnEncryptionKey)); + } + catch (Exception e) when (ADP.IsCatchableExceptionType(e)) + { + return Task.FromException(e); + } + } + /// public abstract byte[] EncryptColumnEncryptionKey(string masterKeyPath, string encryptionAlgorithm, byte[] columnEncryptionKey); + /// + public virtual Task EncryptColumnEncryptionKeyAsync(string masterKeyPath, string encryptionAlgorithm, byte[] columnEncryptionKey, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + try + { + return Task.FromResult(EncryptColumnEncryptionKey(masterKeyPath, encryptionAlgorithm, columnEncryptionKey)); + } + catch (Exception e) when (ADP.IsCatchableExceptionType(e)) + { + return Task.FromException(e); + } + } + /// public virtual byte[] SignColumnMasterKeyMetadata(string masterKeyPath, bool allowEnclaveComputations) { throw new NotImplementedException(); } + /// + public virtual Task SignColumnMasterKeyMetadataAsync(string masterKeyPath, bool allowEnclaveComputations, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + try + { + return Task.FromResult(SignColumnMasterKeyMetadata(masterKeyPath, allowEnclaveComputations)); + } + catch (Exception e) when (ADP.IsCatchableExceptionType(e)) + { + return Task.FromException(e); + } + } + /// public virtual bool VerifyColumnMasterKeyMetadata(string masterKeyPath, bool allowEnclaveComputations, byte[] signature) { throw new NotImplementedException(); } + + /// + public virtual Task VerifyColumnMasterKeyMetadataAsync(string masterKeyPath, bool allowEnclaveComputations, byte[] signature, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + try + { + return Task.FromResult(VerifyColumnMasterKeyMetadata(masterKeyPath, allowEnclaveComputations, signature)); + } + catch (Exception e) when (ADP.IsCatchableExceptionType(e)) + { + return Task.FromException(e); + } + } } } diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AlwaysEncrypted/SqlColumnEncryptionKeyStoreProviderAsyncShould.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AlwaysEncrypted/SqlColumnEncryptionKeyStoreProviderAsyncShould.cs new file mode 100644 index 0000000000..ae78544227 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/AlwaysEncrypted/SqlColumnEncryptionKeyStoreProviderAsyncShould.cs @@ -0,0 +1,289 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests.AlwaysEncrypted +{ + /// + /// Tests for the async virtual methods on , + /// verifying default fallback behavior, cancellation, faulted tasks, and convenience overloads. + /// + public class SqlColumnEncryptionKeyStoreProviderAsyncShould + { + #region Default fallback behavior + + /// + /// Verifies that the default async decrypt implementation wraps the sync result via Task.FromResult. + /// + [Fact] + public async Task DecryptColumnEncryptionKeyAsync_DefaultFallback_ReturnsSameResultAsSyncMethod() + { + var provider = new TestKeyStoreProvider(); + byte[] expected = provider.DecryptColumnEncryptionKey("path", "algo", new byte[] { 1, 2, 3 }); + byte[] actual = await provider.DecryptColumnEncryptionKeyAsync("path", "algo", new byte[] { 1, 2, 3 }, CancellationToken.None); + Assert.Equal(expected, actual); + } + + /// + /// Verifies that the default async encrypt implementation wraps the sync result via Task.FromResult. + /// + [Fact] + public async Task EncryptColumnEncryptionKeyAsync_DefaultFallback_ReturnsSameResultAsSyncMethod() + { + var provider = new TestKeyStoreProvider(); + byte[] expected = provider.EncryptColumnEncryptionKey("path", "algo", new byte[] { 4, 5, 6 }); + byte[] actual = await provider.EncryptColumnEncryptionKeyAsync("path", "algo", new byte[] { 4, 5, 6 }, CancellationToken.None); + Assert.Equal(expected, actual); + } + + #endregion + + #region Faulted task behavior + + /// + /// Verifies that when the sync decrypt method throws, the async version returns a faulted Task + /// rather than throwing synchronously. + /// + [Fact] + public async Task DecryptColumnEncryptionKeyAsync_WhenSyncThrows_ReturnsFaultedTask() + { + var provider = new ThrowingKeyStoreProvider(); + Task task = provider.DecryptColumnEncryptionKeyAsync("path", "algo", new byte[] { 1 }, CancellationToken.None); + + Assert.True(task.IsFaulted); + await Assert.ThrowsAsync(() => task); + } + + /// + /// Verifies that when the sync encrypt method throws, the async version returns a faulted Task + /// rather than throwing synchronously. + /// + [Fact] + public async Task EncryptColumnEncryptionKeyAsync_WhenSyncThrows_ReturnsFaultedTask() + { + var provider = new ThrowingKeyStoreProvider(); + Task task = provider.EncryptColumnEncryptionKeyAsync("path", "algo", new byte[] { 1 }, CancellationToken.None); + + Assert.True(task.IsFaulted); + await Assert.ThrowsAsync(() => task); + } + + /// + /// Verifies that the base class SignColumnMasterKeyMetadata throws NotImplementedException, + /// and the async version surfaces this as a faulted Task. + /// + [Fact] + public async Task SignColumnMasterKeyMetadataAsync_DefaultImplementation_ReturnsFaultedTask() + { + var provider = new TestKeyStoreProvider(); + Task task = provider.SignColumnMasterKeyMetadataAsync("path", true, CancellationToken.None); + + Assert.True(task.IsFaulted); + await Assert.ThrowsAsync(() => task); + } + + /// + /// Verifies that the base class VerifyColumnMasterKeyMetadata throws NotImplementedException, + /// and the async version surfaces this as a faulted Task. + /// + [Fact] + public async Task VerifyColumnMasterKeyMetadataAsync_DefaultImplementation_ReturnsFaultedTask() + { + var provider = new TestKeyStoreProvider(); + Task task = provider.VerifyColumnMasterKeyMetadataAsync("path", true, new byte[] { 1 }, CancellationToken.None); + + Assert.True(task.IsFaulted); + await Assert.ThrowsAsync(() => task); + } + + #endregion + + #region Cancellation behavior + + /// + /// Verifies that passing an already-cancelled token returns a cancelled Task immediately. + /// + [Fact] + public void DecryptColumnEncryptionKeyAsync_CancelledToken_ReturnsCancelledTask() + { + var provider = new TestKeyStoreProvider(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Task task = provider.DecryptColumnEncryptionKeyAsync("path", "algo", new byte[] { 1, 2, 3 }, cts.Token); + + Assert.True(task.IsCanceled); + } + + /// + /// Verifies that passing an already-cancelled token returns a cancelled Task immediately. + /// + [Fact] + public void EncryptColumnEncryptionKeyAsync_CancelledToken_ReturnsCancelledTask() + { + var provider = new TestKeyStoreProvider(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Task task = provider.EncryptColumnEncryptionKeyAsync("path", "algo", new byte[] { 4, 5, 6 }, cts.Token); + + Assert.True(task.IsCanceled); + } + + /// + /// Verifies that passing an already-cancelled token returns a cancelled Task immediately. + /// + [Fact] + public void SignColumnMasterKeyMetadataAsync_CancelledToken_ReturnsCancelledTask() + { + var provider = new TestKeyStoreProvider(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Task task = provider.SignColumnMasterKeyMetadataAsync("path", true, cts.Token); + + Assert.True(task.IsCanceled); + } + + /// + /// Verifies that passing an already-cancelled token returns a cancelled Task immediately. + /// + [Fact] + public void VerifyColumnMasterKeyMetadataAsync_CancelledToken_ReturnsCancelledTask() + { + var provider = new TestKeyStoreProvider(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Task task = provider.VerifyColumnMasterKeyMetadataAsync("path", true, new byte[] { 1 }, cts.Token); + + Assert.True(task.IsCanceled); + } + + /// + /// Verifies that a non-cancelled (live) token does not interfere with successful decrypt. + /// + [Fact] + public async Task DecryptColumnEncryptionKeyAsync_LiveToken_CompletesSuccessfully() + { + var provider = new TestKeyStoreProvider(); + using var cts = new CancellationTokenSource(); + + byte[] result = await provider.DecryptColumnEncryptionKeyAsync("path", "algo", new byte[] { 7, 8, 9 }, cts.Token); + Assert.Equal(new byte[] { 7, 8, 9 }, result); + } + + /// + /// Verifies that a non-cancelled (live) token does not interfere with successful encrypt. + /// + [Fact] + public async Task EncryptColumnEncryptionKeyAsync_LiveToken_CompletesSuccessfully() + { + var provider = new TestKeyStoreProvider(); + using var cts = new CancellationTokenSource(); + + byte[] result = await provider.EncryptColumnEncryptionKeyAsync("path", "algo", new byte[] { 7, 8, 9 }, cts.Token); + Assert.Equal(new byte[] { 7, 8, 9 }, result); + } + + #endregion + + #region Default CancellationToken parameter + + /// + /// Verifies calling without CancellationToken (uses default) works for decrypt. + /// + [Fact] + public async Task DecryptColumnEncryptionKeyAsync_DefaultCancellationToken_Works() + { + var provider = new TestKeyStoreProvider(); + byte[] result = await provider.DecryptColumnEncryptionKeyAsync("path", "algo", new byte[] { 1, 2, 3 }); + Assert.NotNull(result); + Assert.Equal(new byte[] { 1, 2, 3 }, result); + } + + /// + /// Verifies calling without CancellationToken (uses default) works for encrypt. + /// + [Fact] + public async Task EncryptColumnEncryptionKeyAsync_DefaultCancellationToken_Works() + { + var provider = new TestKeyStoreProvider(); + byte[] result = await provider.EncryptColumnEncryptionKeyAsync("path", "algo", new byte[] { 4, 5, 6 }); + Assert.NotNull(result); + Assert.Equal(new byte[] { 4, 5, 6 }, result); + } + + /// + /// Verifies calling sign without CancellationToken returns a faulted Task + /// (base class throws NotImplementedException). + /// + [Fact] + public async Task SignColumnMasterKeyMetadataAsync_DefaultCancellationToken_ReturnsFaultedTask() + { + var provider = new TestKeyStoreProvider(); + Task task = provider.SignColumnMasterKeyMetadataAsync("path", true); + + Assert.True(task.IsFaulted); + await Assert.ThrowsAsync(() => task); + } + + /// + /// Verifies calling verify without CancellationToken returns a faulted Task + /// (base class throws NotImplementedException). + /// + [Fact] + public async Task VerifyColumnMasterKeyMetadataAsync_DefaultCancellationToken_ReturnsFaultedTask() + { + var provider = new TestKeyStoreProvider(); + Task task = provider.VerifyColumnMasterKeyMetadataAsync("path", true, new byte[] { 1 }); + + Assert.True(task.IsFaulted); + await Assert.ThrowsAsync(() => task); + } + + #endregion + + #region Test helpers + + /// + /// A test provider that implements sync methods with pass-through behavior (returns input as-is). + /// + private class TestKeyStoreProvider : SqlColumnEncryptionKeyStoreProvider + { + public override byte[] DecryptColumnEncryptionKey(string masterKeyPath, string encryptionAlgorithm, byte[] encryptedColumnEncryptionKey) + { + return encryptedColumnEncryptionKey; + } + + public override byte[] EncryptColumnEncryptionKey(string masterKeyPath, string encryptionAlgorithm, byte[] columnEncryptionKey) + { + return columnEncryptionKey; + } + } + + /// + /// A test provider whose sync methods always throw . + /// + private class ThrowingKeyStoreProvider : SqlColumnEncryptionKeyStoreProvider + { + public override byte[] DecryptColumnEncryptionKey(string masterKeyPath, string encryptionAlgorithm, byte[] encryptedColumnEncryptionKey) + { + throw new InvalidOperationException("Decrypt failed"); + } + + public override byte[] EncryptColumnEncryptionKey(string masterKeyPath, string encryptionAlgorithm, byte[] columnEncryptionKey) + { + throw new InvalidOperationException("Encrypt failed"); + } + } + + #endregion + } +}