Introduce Async API counterparts for AE base class#3673
Conversation
There was a problem hiding this comment.
Pull Request Overview
Adds asynchronous counterparts to key store provider APIs in SqlColumnEncryptionKeyStoreProvider to enable async implementations for Always Encrypted key operations.
Key changes:
- Introduced async virtual methods (Decrypt/Encrypt column encryption key, Sign/Verify CMK metadata) that currently throw NotImplementedException.
- Added corresponding XML documentation entries for the new async methods.
- Minor wording adjustments in existing XML return documentation.
Reviewed Changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlColumnEncryptionKeyStoreProvider.cs | Adds async virtual method stubs for encryption key and metadata operations. |
| doc/snippets/Microsoft.Data.SqlClient/SqlColumnEncryptionKeyStoreProvider.xml | Documents newly added async methods and adjusts some existing return descriptions. |
paulmedynski
left a comment
There was a problem hiding this comment.
Asking for clarification on return types and synchronous implementation.
benrr101
left a comment
There was a problem hiding this comment.
For new APIs we should have some discussion on the signatures. Definitely won't be able to get this in before preview2.
|
This pull request has been marked as stale due to inactivity for more than 30 days. If you would like to keep this pull request open, please provide an update or respond to any comments. Otherwise, it will be closed automatically in 7 days. |
|
Wait, wait 🙈 |
|
This pull request has been marked as stale due to inactivity for more than 30 days. If you would like to keep this pull request open, please provide an update or respond to any comments. Otherwise, it will be closed automatically in 7 days. |
|
Stay |
b4a17f9 to
5af579f
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (2)
doc/snippets/Microsoft.Data.SqlClient/SqlColumnEncryptionKeyStoreProvider.xml:86
- The new EncryptColumnEncryptionKeyAsync docs similarly omit remarks about the default fallback (sync call wrapped into a Task) and that CancellationToken isn’t observed by the default implementation. Adding this guidance would align the async docs with the existing SignColumnMasterKeyMetadataAsync remarks.
<EncryptColumnEncryptionKeyAsync>
<param name="masterKeyPath">
The master key path.
</param>
<param name="encryptionAlgorithm">
The encryption algorithm.
</param>
<param name="columnEncryptionKey">
The plaintext column encryption key.
</param>
<param name="cancellationToken">
A token to cancel the asynchronous operation.
</param>
<summary>
Encrypts a column encryption key asynchronously using the column master key with the specified key path and using the specified algorithm.
</summary>
<returns>
Returns a task that returns <see cref="T:System.Byte[]" /> array representing the encrypted column encryption key on completion.
</returns>
</EncryptColumnEncryptionKeyAsync>
doc/snippets/Microsoft.Data.SqlClient/SqlColumnEncryptionKeyStoreProvider.xml:173
- VerifyColumnMasterKeyMetadataAsync includes a CancellationToken parameter, but the docs don’t explain whether/when it is observed (especially in the default implementation, which delegates to the sync method). Please document the CancellationToken semantics and default exception/faulted-task behavior for consistency with the other async members.
<VerifyColumnMasterKeyMetadataAsync>
<param name="masterKeyPath">
The column master key path.
</param>
<param name="allowEnclaveComputations">
Indicates whether the column master key supports enclave computations.
</param>
<param name="signature">
The signature of the column master key metadata.
</param>
<param name="cancellationToken">
A token to cancel the asynchronous operation.
</param>
<summary>
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 asynchronously. The default implementation returns a faulted task with <see cref="T:System.NotImplementedException" />.
</summary>
<returns>
A task that, when completed, returns <see langword="true" /> if the specified signature is valid, or <see langword="false" /> if it is not valid.
</returns>
</VerifyColumnMasterKeyMetadataAsync>
| **Created**: 2026-05-13 | ||
| **Status**: Draft | ||
| **Milestone**: 7.1.0-preview2 | ||
|
|
| </DecryptColumnEncryptionKey> | ||
| <DecryptColumnEncryptionKeyAsync> | ||
| <param name="masterKeyPath"> | ||
| The master key path. |
There was a problem hiding this comment.
What kind of path is this expected to be? Local filesystem, UNC, URL ... ?
For example:
The local filesystem path to the master key file. This will be used by <.NET API>; see [here] for further information.
There was a problem hiding this comment.
From SQL Server's perspective, masterKeyPath is an opaque provider-specific string. It's designed to uniquely identify the key material for a specific cryptographic key within the provider. For AKV, it'll be a URI; for certificates, it'll be [Store Location]/[Store Name]/[Thumbprint]. The rest of SqlClient doesn't know about its format.
| The master key path. | ||
| </param> | ||
| <param name="encryptionAlgorithm"> | ||
| The encryption algorithm. |
There was a problem hiding this comment.
What kind of values are expected/accepted here? Is it an opaque string to us? Examples and where they are documented would help.
There was a problem hiding this comment.
It's a technically-opaque string returned by SQL Server - for the moment, it's always RSA_OAEP. This is documented here, in the algorithm_name argument.
| protected SqlColumnEncryptionKeyStoreProvider() { } | ||
| /// <include file='../../../doc/snippets/Microsoft.Data.SqlClient/SqlColumnEncryptionKeyStoreProvider.xml' path='docs/members[@name="SqlColumnEncryptionKeyStoreProvider"]/DecryptColumnEncryptionKey/*'/> | ||
| public abstract byte[] DecryptColumnEncryptionKey(string masterKeyPath, string encryptionAlgorithm, byte[] encryptedColumnEncryptionKey); | ||
| /// <include file='../../../doc/snippets/Microsoft.Data.SqlClient/SqlColumnEncryptionKeyStoreProvider.xml' path='docs/members[@name="SqlColumnEncryptionKeyStoreProvider"]/DecryptColumnEncryptionKeyAsync/*'/> |
There was a problem hiding this comment.
Let's use nullable annotation for new APIs, and then for any nullable arguments, we must document what null means.
There was a problem hiding this comment.
The default for CancellationToken is CancellationToken.None.
But I'd argue that all of these should follow the standard library pattern where async methods get multiple overloads. In our case, that would look like:
public virtual System.Threading.Tasks.Task<byte[]> DecryptColumnEncryptionKeyAsync(string masterKeyPath, string encryptionAlgorithm, byte[] encryptedColumnEncryptionKey, System.Threading.CancellationToken cancellationToken)and
public System.Threading.Tasks.Task<byte[]> DecryptColumnEncryptionKeyAsync(string masterKeyPath, string encryptionAlgorithm, byte[] encryptedColumnEncryptionKey)Where the second calls the first, passing CancellationToken.None.
| <param name="cancellationToken"> | ||
| A token to cancel the asynchronous operation. | ||
| </param> | ||
| <summary> |
There was a problem hiding this comment.
Seems weird to put the summary after the arguments.
| A token to cancel the asynchronous operation. | ||
| </param> | ||
| <summary> | ||
| Decrypts the specified encrypted value of a column encryption key asynchronously. The encrypted value is expected to be encrypted using the column master key with the specified key path and using the specified algorithm. |
There was a problem hiding this comment.
IMO we should wrap long lines to our standard 100 chars.
| The column master key path. | ||
| </param> | ||
| <param name="allowEnclaveComputations"> | ||
| <see langword="true" /> to indicate that the column master key supports enclave computations; otherwise, <see langword="false" />. |
There was a problem hiding this comment.
But what actually happens when I specify true vs false?
There was a problem hiding this comment.
This is handled within SQL Server. It's only used during enclave-enabled Always Encrypted, and the flag indicates that the key can be passed into the secure enclave. From the view of this part of SqlClient, it's an opaque flag which is designed to be signed.
The idea here is that SQL Server's record of the column master key is little more than metadata. This method locates the key material identified by the key path, then signs the metadata that SQL Server holds. SQL Server keeps a record of that signature, and passes it to the client for validation. It forms a trust anchor for cases where SQL Server itself is compromised, but the enclave can't be tampered with: if someone modifies the CMK's key path (to try and force the client to encrypt data with an attacker-specified key) then this validation fails. The attacker can't just recalculate the correct signature because SQL Server doesn't have access to the key material - just its metadata.
| </VerifyColumnMasterKeyMetadata> | ||
| <VerifyColumnMasterKeyMetadataAsync> | ||
| <param name="masterKeyPath"> | ||
| The column master key path. |
There was a problem hiding this comment.
Same path and enclave comments.
| Indicates whether the column master key supports enclave computations. | ||
| </param> | ||
| <param name="signature"> | ||
| The signature of the column master key metadata. |
There was a problem hiding this comment.
Is this expected to be a byte array returned by Sign() ? Would this accept any format/encoding signature type?
| When implemented in a derived class, asynchronously digitally signs the column master key metadata with the column master key referenced by the <paramref name="masterKeyPath" /> parameter. The input values used to generate the signature should be the specified values of the <paramref name="masterKeyPath" /> and <paramref name="allowEnclaveComputations" /> parameters. | ||
| </summary> | ||
| <returns> | ||
| Returns a task that returns the signature of the column master key metadata on completion. |
There was a problem hiding this comment.
What is the format/encoding of the signature? Is it HMAC, SHA256, etc?
There was a problem hiding this comment.
Strictly speaking, it's opaque. For the builtin providers' purposes, it's an RSA signature of the SHA256 hash of [Provider Name] + [Master Key Path] + [Enclave Computations Enabled]. The only real API requirement that is that an untampered signature verifies successfully, and a tampered signature does not.
| } | ||
|
|
||
| [Fact] | ||
| public async Task DecryptColumnEncryptionKeyAsync_AcceptsCancellationToken() |
There was a problem hiding this comment.
Should we also test that async encrypt and decrypt ignore the cancellation token?
var task = EncryptAsync(..., token);
token.Cancel();
var result = await task;
Assert.NotNull(result);
There was a problem hiding this comment.
It's worth noting that the default implementation of DbConnection.GetSchemaAsync will return a cancelled Task if it's passed a cancelled CancellationToken. This seems like a sensible approach to take.
There was a problem hiding this comment.
We should respect cancellation tokens, especially ahead of a potentially time-consuming operation.
|
|
||
| Two threads may independently fetch the same key on a concurrent miss; the second write is idempotent (same decrypted value). This is acceptable and already implicitly assumed by the existing comment in `SqlSymmetricKeyCache`: "first one wins." | ||
|
|
||
| ### Decision 8: `IMemoryCache` Has No `GetOrCreateAsync` — Use Explicit `TryGetValue`/`Set` |
There was a problem hiding this comment.
While there's no method on IMemoryCache, there's an extension method in CacheExtensions (link). Would this be suitable?
|
|
||
| **Rationale**: Three existing `SemaphoreSlim` sites hold their lock for the full duration of I/O: | ||
|
|
||
| - `SqlSymmetricKeyCache._cacheLock` — static singleton, held during `DecryptColumnEncryptionKey` |
There was a problem hiding this comment.
This is centered on SqlSymmetricKeyCache but might be asked more broadly: do we need to retain the SemaphoreSlim instances at all?
MemoryCache is thread-safe, the documentation simply hasn't been updated yet (dotnet/AspNetCore.Docs#33103).
Post implementation, this pattern would cover:
- checks against trusted key paths, followed by the column encryption keystore provider lookup (not thread-safe - but this wouldn't change.)
- assignment of ColumnEncryptionKeyCacheTtl (if we say that the calls to DecryptColumnEncryptionKey/Async will no longer be serialized, we're already introducing a race condition if the provider sets that property. I'm not completely clear how serializing the assignment to the property would solve this.)
- wrapping of the plaintext key in a SqlClientSymmetricKey instance (thread-safe.)
- consecutive reads of static ColumnEncryptionKeyCacheTtl property (we could read this once and cache it.)
- writing the cache (thread-safe.)
At present, the only true effect of the SemaphoreSlim is to ensure that calls to the keystore provider are serialized, and that they only occur with ColumnEncryptionKeyCacheTtl set to zero. We're explicitly removing the former constraint, and I'm not convinced we'll be able to ensure the latter in all circumstances (unless we pass it as a parameter.)
|
|
||
| **Static Analysis (CI gate)**: | ||
|
|
||
| - Run a Roslyn analyzer or `grep`-based CI check to verify every `await` in `Microsoft.Data.SqlClient` and `Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider` assemblies uses `.ConfigureAwait(false)`. A single missed instance can cause production deadlocks in ASP.NET Framework / WPF / WinForms hosts. This MUST be enforced in CI, not left to manual review. |
There was a problem hiding this comment.
We can enforce this using code quality rule CA2007.
This also impacts further consideration 1.
| } | ||
|
|
||
| [Fact] | ||
| public async Task DecryptColumnEncryptionKeyAsync_AcceptsCancellationToken() |
There was a problem hiding this comment.
It's worth noting that the default implementation of DbConnection.GetSchemaAsync will return a cancelled Task if it's passed a cancelled CancellationToken. This seems like a sensible approach to take.
| </summary> | ||
| <returns> | ||
| Returns <see cref="T:System.Byte" />. The encrypted column encryption key. | ||
| Returns <see cref="T:System.Byte[]" /> array representing the encrypted column encryption key. |
There was a problem hiding this comment.
| Returns <see cref="T:System.Byte[]" /> array representing the encrypted column encryption key. | |
| Returns a <see cref="T:System.Byte[]" /> representing the encrypted column encryption key. |
| </summary> | ||
| <returns> | ||
| Returns <see cref="T:System.Byte" />. The decrypted column encryption key. | ||
| Returns <see cref="T:System.Byte[]" /> array representing the decrypted column encryption key. |
There was a problem hiding this comment.
| Returns <see cref="T:System.Byte[]" /> array representing the decrypted column encryption key. | |
| Returns a <see cref="T:System.Byte[]" /> representing the decrypted column encryption key. |
| Encrypts a column encryption key asynchronously using the column master key with the specified key path and using the specified algorithm. | ||
| </summary> | ||
| <returns> | ||
| Returns a task that returns <see cref="T:System.Byte[]" /> array representing the encrypted column encryption key on completion. |
There was a problem hiding this comment.
| Returns a task that returns <see cref="T:System.Byte[]" /> array representing the encrypted column encryption key on completion. | |
| Returns a task that returns a <see cref="T:System.Byte[]" /> representing the encrypted column encryption key on completion. |
| Decrypts the specified encrypted value of a column encryption key asynchronously. The encrypted value is expected to be encrypted using the column master key with the specified key path and using the specified algorithm. | ||
| </summary> | ||
| <returns> | ||
| Returns a task that returns <see cref="T:System.Byte[]" /> array representing the decrypted column encryption key on completion. |
There was a problem hiding this comment.
| Returns a task that returns <see cref="T:System.Byte[]" /> array representing the decrypted column encryption key on completion. | |
| Returns a task that returns a <see cref="T:System.Byte[]" /> representing the decrypted column encryption key on completion. |
|
|
||
| **Rationale**: Three existing `SemaphoreSlim` sites hold their lock for the full duration of I/O: | ||
|
|
||
| - `SqlSymmetricKeyCache._cacheLock` — static singleton, held during `DecryptColumnEncryptionKey` |
| protected SqlColumnEncryptionKeyStoreProvider() { } | ||
| /// <include file='../../../doc/snippets/Microsoft.Data.SqlClient/SqlColumnEncryptionKeyStoreProvider.xml' path='docs/members[@name="SqlColumnEncryptionKeyStoreProvider"]/DecryptColumnEncryptionKey/*'/> | ||
| public abstract byte[] DecryptColumnEncryptionKey(string masterKeyPath, string encryptionAlgorithm, byte[] encryptedColumnEncryptionKey); | ||
| /// <include file='../../../doc/snippets/Microsoft.Data.SqlClient/SqlColumnEncryptionKeyStoreProvider.xml' path='docs/members[@name="SqlColumnEncryptionKeyStoreProvider"]/DecryptColumnEncryptionKeyAsync/*'/> |
There was a problem hiding this comment.
The default for CancellationToken is CancellationToken.None.
But I'd argue that all of these should follow the standard library pattern where async methods get multiple overloads. In our case, that would look like:
public virtual System.Threading.Tasks.Task<byte[]> DecryptColumnEncryptionKeyAsync(string masterKeyPath, string encryptionAlgorithm, byte[] encryptedColumnEncryptionKey, System.Threading.CancellationToken cancellationToken)and
public System.Threading.Tasks.Task<byte[]> DecryptColumnEncryptionKeyAsync(string masterKeyPath, string encryptionAlgorithm, byte[] encryptedColumnEncryptionKey)Where the second calls the first, passing CancellationToken.None.
| } | ||
|
|
||
| [Fact] | ||
| public async Task DecryptColumnEncryptionKeyAsync_AcceptsCancellationToken() |
There was a problem hiding this comment.
We should respect cancellation tokens, especially ahead of a potentially time-consuming operation.
| // 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; |
There was a problem hiding this comment.
These can move to UnitTests.
| /// <include file='../../../../../../doc/snippets/Microsoft.Data.SqlClient/SqlColumnEncryptionKeyStoreProvider.xml' path='docs/members[@name="SqlColumnEncryptionKeyStoreProvider"]/DecryptColumnEncryptionKeyAsync/*'/> | ||
| public virtual Task<byte[]> DecryptColumnEncryptionKeyAsync(string masterKeyPath, string encryptionAlgorithm, byte[] encryptedColumnEncryptionKey, CancellationToken cancellationToken = default) | ||
| { | ||
| try |
There was a problem hiding this comment.
The cancellation token should be checked and a cancelled task returned if the token is cancelled.
For #3672:
Introduces async API counterparts in base class 'SqlColumnEncryptionKeyStoreProvider'