diff --git a/BUILDGUIDE.md b/BUILDGUIDE.md index 6298bbc9eb..f33a0e48a5 100644 --- a/BUILDGUIDE.md +++ b/BUILDGUIDE.md @@ -155,6 +155,7 @@ dotnet build -t: [optional_parameters] |----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| | `Test` | Runs all tests in the repository for all platforms supported by the host OS. _This will take a considerable amount of time and is not recommended_. | | `TestAbstractions` | Runs all tests for Microsoft.Data.SqlClient.Extensions.Abstractions | +| `TestAkvProvider` | Runs the unit test project for Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider. | | `TestAzure` | Runs all tests for Microsoft.Data.SqlClient.Extensions.Azure | | `TestSqlClient` | Runs all tests for Microsoft.Data.SqlClient. | | `TestSqlClientFunctional` | Runs the "functional" test project for Microsoft.Data.SqlClient. These are a mix of unit and integration tests against live servers. | diff --git a/build.proj b/build.proj index 7a8ce075a7..c88a000e3b 100644 --- a/build.proj +++ b/build.proj @@ -401,7 +401,7 @@ environments. Please consider running project specific test targets or specific test sets within the project. --> - + @@ -751,6 +751,7 @@ $(RepoRoot)src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/ $(AkvProviderSrcRoot)src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider.csproj + $(AkvProviderSrcRoot)test/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider.Test.csproj $(RepoRoot)artifacts/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/$(Configuration)/ @@ -827,9 +828,37 @@ SkipUnchangedFiles="true" /> + + + + + AkvProviderTests-$(OS) + $(LogFilePrefix)-$(TestFramework) + + + "$(DotnetPath)dotnet" test "$(AkvProviderTestProjectPath)" + -p:Configuration=$(Configuration) + $(TestBlameArgument) + $(TestCodeCoverageArgument) + $(TestFiltersArgument) + $(TestFrameworkArgument) + --results-directory "$(TestResultsFolderPath)" + --logger:"trx;LogFilePrefix=$(LogFilePrefix)" + + + $([System.Text.RegularExpressions.Regex]::Replace($(DotnetCommand), "\s+", " ")) + + + + + + + - $(RepoRoot)src/Microsoft.Data.SqlClient.Extensions/Abstractions/ $(AbstractionsSrcRoot)src/Abstractions.csproj @@ -1179,7 +1208,21 @@ - + + + + + + + "$(DotnetPath)dotnet" build "$(AkvProviderTestProjectPath)" + -p:Configuration=$(Configuration) + + $([System.Text.RegularExpressions.Regex]::Replace($(DotnetCommand), "\s+", " ")) + + + + + diff --git a/eng/pipelines/pr/stages/test-stages.yml b/eng/pipelines/pr/stages/test-stages.yml index f28bf6b9e9..0ad9921ec6 100644 --- a/eng/pipelines/pr/stages/test-stages.yml +++ b/eng/pipelines/pr/stages/test-stages.yml @@ -155,6 +155,21 @@ stages: packageShortName: "Azure" testDisplayName: "azure" + # TestAkvProvider + - template: /eng/pipelines/pr/jobs/test-buildproj-job.yml@self + parameters: + buildConfiguration: ${{ parameters.buildConfiguration }} + buildSuffix: ${{ parameters.buildSuffix }} + dotnetVerbosity: ${{ parameters.dotnetVerbosity }} + platformDisplayName: ${{ platform.displayName }} + platformDotnet: ${{ platform.dotnet }} + platformImage: ${{ platform.image }} + poolName: ${{ parameters.poolName }} + testResultsArtifactBaseName: ${{ parameters.testResultsArtifactBaseName }} + + packageShortName: "AkvProvider" + testDisplayName: "akvprovider" + # TestSqlClientFunctional - template: /eng/pipelines/pr/jobs/test-buildproj-job.yml@self parameters: diff --git a/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/src/AzureSqlKeyCryptographer.cs b/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/src/AzureSqlKeyCryptographer.cs index 5f1182fb63..758ebb45a1 100644 --- a/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/src/AzureSqlKeyCryptographer.cs +++ b/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/src/AzureSqlKeyCryptographer.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Concurrent; using System.Threading; +using System.Threading.Tasks; using static Azure.Security.KeyVault.Keys.Cryptography.SignatureAlgorithm; using Microsoft.Data.SqlClient.Internal; @@ -32,9 +33,17 @@ internal sealed class AzureSqlKeyCryptographer : IDisposable /// /// SemaphoreSlim to ensure thread safety when accessing the key dictionary or making network calls to Azure Key Vault to fetch keys. + /// Used by the synchronous path only. The asynchronous path uses so that a synchronous caller + /// never blocks a thread waiting on an asynchronous, network bound fetch. /// private SemaphoreSlim _keyDictionarySemaphore = new(1, 1); + /// + /// Gates concurrent asynchronous fetches of the same key, so that only one caller retrieves a given key from + /// Azure Key Vault. Fetches of different keys are not serialized against one another. + /// + private readonly KeyedAsyncLock _keyFetchLock = new(); + /// /// Holds references to the Azure Key Vault CryptographyClient objects and maps them to their corresponding Azure Key Vault Key Identifier (URI). /// @@ -89,6 +98,47 @@ internal void AddKey(string keyIdentifierUri) } } + /// + /// Asynchronously adds the key, specified by the Key Identifier URI, to the cache. + /// Validates the key type and fetches the key from Azure Key Vault if it is not already cached. + /// + /// The key vault key identifier URI + /// Token used to request cancellation of the operation + /// + /// Mirrors the deduplication of the synchronous path: only one caller fetches a given key from Azure Key Vault. + /// The gate is per key and is only ever awaited, so no thread is blocked while a fetch is in flight and fetches of + /// different keys proceed in parallel. The gate is deliberately not shared with the synchronous path, because a + /// synchronous caller blocking on a gate held across an awaited network call would consume a thread pool thread for + /// the duration of that call. A synchronous and an asynchronous caller may therefore both fetch the same key; the + /// result is identical and the last write wins. + /// + internal async Task AddKeyAsync(string keyIdentifierUri, CancellationToken cancellationToken = default) + { + if (_keyDictionary.ContainsKey(keyIdentifierUri)) + { + return; + } + + using (await _keyFetchLock.AcquireAsync(keyIdentifierUri, cancellationToken).ConfigureAwait(false)) + { + // Another caller may have fetched the key while this one waited for the gate. + if (_keyDictionary.ContainsKey(keyIdentifierUri)) + { + return; + } + + ParseAKVPath(keyIdentifierUri, out Uri vaultUri, out string keyName, out string keyVersion); + + // Fetch the KeyClient for the Key vault URI. + KeyClient keyClient = GetOrCreateKeyClient(vaultUri); + + // Fetch the key from Azure Key Vault. + KeyVaultKey key = await FetchKeyFromKeyVaultAsync(keyClient, keyName, keyVersion, cancellationToken).ConfigureAwait(false); + + _keyDictionary.AddOrUpdate(keyIdentifierUri, key, (k, v) => key); + } + } + /// /// Returns the key specified by the Key Identifier URI /// @@ -133,6 +183,34 @@ internal bool VerifyData(byte[] message, byte[] signature, string keyIdentifierU return cryptographyClient.VerifyData(RS256, message, signature).IsValid; } + /// + /// Asynchronously generates a signature based on the RSA PKCS#v1.5 scheme using a specified Azure Key Vault Key URL. + /// + /// The data to sign + /// The key vault key identifier URI + /// Token used to request cancellation of the operation + internal async Task SignDataAsync(byte[] message, string keyIdentifierUri, CancellationToken cancellationToken = default) + { + CryptographyClient cryptographyClient = GetCryptographyClient(keyIdentifierUri); + SignResult result = await cryptographyClient.SignDataAsync(RS256, message, cancellationToken).ConfigureAwait(false); + return result.Signature; + } + + /// + /// Asynchronously verifies a signature based on the RSA PKCS#v1.5 scheme using a specified Azure Key Vault Key URL. + /// + /// The signed data + /// The signature to verify + /// The key vault key identifier URI + /// Token used to request cancellation of the operation + internal async Task VerifyDataAsync(byte[] message, byte[] signature, string keyIdentifierUri, CancellationToken cancellationToken = default) + { + CryptographyClient cryptographyClient = GetCryptographyClient(keyIdentifierUri); + SqlClientEventSource.Log.TryTraceEvent("Sending request to verify data"); + VerifyResult result = await cryptographyClient.VerifyDataAsync(RS256, message, signature, cancellationToken).ConfigureAwait(false); + return result.IsValid; + } + internal byte[] UnwrapKey(KeyWrapAlgorithm keyWrapAlgorithm, byte[] encryptedKey, string keyIdentifierUri) { CryptographyClient cryptographyClient = GetCryptographyClient(keyIdentifierUri); @@ -147,6 +225,43 @@ internal byte[] WrapKey(KeyWrapAlgorithm keyWrapAlgorithm, byte[] key, string ke return cryptographyClient.WrapKey(keyWrapAlgorithm, key).EncryptedKey; } + /// + /// Asynchronously unwraps the given encrypted key using the specified Azure Key Vault key. + /// + /// The key wrap algorithm + /// The encrypted key to unwrap + /// The key vault key identifier URI + /// Token used to request cancellation of the operation + internal async Task UnwrapKeyAsync(KeyWrapAlgorithm keyWrapAlgorithm, byte[] encryptedKey, string keyIdentifierUri, CancellationToken cancellationToken = default) + { + CryptographyClient cryptographyClient = GetCryptographyClient(keyIdentifierUri); + SqlClientEventSource.Log.TryTraceEvent("Sending request to unwrap key."); + UnwrapResult result = await cryptographyClient.UnwrapKeyAsync(keyWrapAlgorithm, encryptedKey, cancellationToken).ConfigureAwait(false); + return result.Key; + } + + /// + /// Asynchronously wraps the given key using the specified Azure Key Vault key. + /// + /// The key wrap algorithm + /// The key to wrap + /// The key vault key identifier URI + /// Token used to request cancellation of the operation + internal async Task WrapKeyAsync(KeyWrapAlgorithm keyWrapAlgorithm, byte[] key, string keyIdentifierUri, CancellationToken cancellationToken = default) + { + CryptographyClient cryptographyClient = GetCryptographyClient(keyIdentifierUri); + SqlClientEventSource.Log.TryTraceEvent("Sending request to wrap key."); + WrapResult result = await cryptographyClient.WrapKeyAsync(keyWrapAlgorithm, key, cancellationToken).ConfigureAwait(false); + return result.EncryptedKey; + } + + /// + /// Returns the CryptographyClient for the given key identifier URI, creating it if necessary. + /// + /// + /// Concurrent callers may each construct a client, but all of them observe the single instance that wins the + /// race, so a key is never used through two different clients. + /// private CryptographyClient GetCryptographyClient(string keyIdentifierUri) { if (_cryptoClientDictionary.TryGetValue(keyIdentifierUri, out CryptographyClient client)) @@ -154,9 +269,9 @@ private CryptographyClient GetCryptographyClient(string keyIdentifierUri) return client; } - CryptographyClient cryptographyClient = new(GetKey(keyIdentifierUri).Id, TokenCredential); - _cryptoClientDictionary.TryAdd(keyIdentifierUri, cryptographyClient); - return cryptographyClient; + return _cryptoClientDictionary.GetOrAdd( + keyIdentifierUri, + uri => new CryptographyClient(GetKey(uri).Id, TokenCredential)); } /// @@ -171,6 +286,35 @@ private KeyVaultKey FetchKeyFromKeyVault(KeyClient keyClient, string keyName, st Azure.Response keyResponse = keyClient?.GetKey(keyName, keyVersion); + return ValidateKeyResponse(keyResponse, keyName, keyVersion); + } + + /// + /// Asynchronously fetches the column master key from the Azure Key Vault. + /// + /// The KeyClient instance + /// The name of the Azure Key Vault key + /// The version of the Azure Key Vault key + /// Token used to request cancellation of the operation + private async Task FetchKeyFromKeyVaultAsync(KeyClient keyClient, string keyName, string keyVersion, CancellationToken cancellationToken) + { + SqlClientEventSource.Log.TryTraceEvent("Fetching key name={0}", keyName); + + Azure.Response keyResponse = keyClient is null + ? null + : await keyClient.GetKeyAsync(keyName, keyVersion, cancellationToken).ConfigureAwait(false); + + return ValidateKeyResponse(keyResponse, keyName, keyVersion); + } + + /// + /// Validates the response received from Azure Key Vault and ensures the returned key is an RSA key. + /// + /// The response returned by Azure Key Vault + /// The name of the Azure Key Vault key + /// The version of the Azure Key Vault key + private static KeyVaultKey ValidateKeyResponse(Azure.Response keyResponse, string keyName, string keyVersion) + { // Handle the case where the key response is null or contains an error // This can happen if the key does not exist or if there is an issue with the KeyClient. // In such cases, we log the error and throw an exception. diff --git a/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/src/KeyedAsyncLock.cs b/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/src/KeyedAsyncLock.cs new file mode 100644 index 0000000000..1f019f4e60 --- /dev/null +++ b/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/src/KeyedAsyncLock.cs @@ -0,0 +1,126 @@ +// 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.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider +{ + /// + /// Provides best-effort deduplication scoped to an individual key, so that concurrent callers asking + /// for the same key are normally serialized while callers asking for different keys proceed in parallel. + /// + /// + /// + /// Exclusion is deliberately best-effort rather than guaranteed. Gates are reclaimed once nobody holds + /// or waits on them, so a caller sitting between fetching a gate and waiting on it can acquire a gate + /// that has just been reclaimed while a later caller creates a fresh one for the same key. Two callers + /// can therefore run the guarded work concurrently for one key. This is acceptable only where the + /// guarded work is idempotent and duplicating it is merely wasteful, which is the case for the key + /// store fetches this type guards. Do not reuse it where exclusion must be absolute. + /// + /// + /// The lock is only ever awaited, never blocked on, so no thread is held while the work it guards is + /// in flight. It must not be combined with a synchronous wait on the same gate: doing so would block a + /// thread pool thread for the duration of an asynchronous, potentially network bound, operation. + /// + /// + /// Cancellation applies to the caller requesting it and never to the work already in flight. A caller + /// whose wait is cancelled simply gives up its place in line. + /// + /// + /// Gates are removed once no caller holds or waits on them, so the number of retained gates is bounded + /// by the number of keys being acquired concurrently rather than by the number of distinct keys seen. + /// The gates themselves are never disposed, which is safe because only + /// allocates a disposable wait handle when is used. + /// + /// + internal sealed class KeyedAsyncLock + { + private readonly ConcurrentDictionary _gates = new(); + + /// + /// Gets the number of gates currently retained. Used by tests to verify that gates do not accumulate. + /// + internal int GateCount => _gates.Count; + + /// + /// Asynchronously acquires the lock for the specified key. + /// + /// The key to lock. + /// Token used to request cancellation of the wait. + /// A value that releases the lock when disposed. + internal async Task AcquireAsync(TKey key, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + SemaphoreSlim gate = _gates.GetOrAdd(key, static _ => new SemaphoreSlim(1, 1)); + + try + { + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + // The gate was published before the wait, so an abandoned wait must not leave it behind. + RemoveIfUnused(key, gate); + throw; + } + + return new Releaser(this, key, gate); + } + + /// + /// Removes the gate for the specified key if no caller holds or waits on it. A caller that fetched + /// this instance just before removal may still acquire it, which at worst allows the guarded work + /// to run twice and never produces an incorrect result. + /// + private void RemoveIfUnused(TKey key, SemaphoreSlim gate) + { + if (gate.CurrentCount == 1) + { + // ConcurrentDictionary.TryRemove(KeyValuePair) is not available on netstandard2.0, so the + // explicitly implemented ICollection member is used to remove only a matching entry. + ICollection> gates = _gates; + gates.Remove(new KeyValuePair(key, gate)); + } + } + + /// + /// Releases a lock acquired from . Disposal is idempotent, so a + /// second disposal cannot inflate the gate's count and hand the key to two callers at once. + /// + internal sealed class Releaser : IDisposable + { + private readonly KeyedAsyncLock _owner; + private readonly TKey _key; + private SemaphoreSlim _gate; + + internal Releaser(KeyedAsyncLock owner, TKey key, SemaphoreSlim gate) + { + _owner = owner; + _key = key; + _gate = gate; + } + + /// + /// Releases the lock and discards its gate if no other caller is using it. + /// + public void Dispose() + { + SemaphoreSlim gate = Interlocked.Exchange(ref _gate, null); + if (gate is null) + { + return; + } + + gate.Release(); + _owner.RemoveIfUnused(_key, gate); + } + } + } +} diff --git a/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/src/LocalCache.cs b/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/src/LocalCache.cs index 12c896feb0..5965c427ef 100644 --- a/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/src/LocalCache.cs +++ b/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/src/LocalCache.cs @@ -3,6 +3,8 @@ // See the LICENSE file in the project root for more information. using System; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; using static System.Math; using Microsoft.Data.SqlClient.Internal; @@ -26,6 +28,12 @@ internal class LocalCache private readonly int _maxSize; + /// + /// Gates concurrent asynchronous creation of the same cache entry, so that a burst of + /// concurrent misses for one key results in a single invocation of the create delegate. + /// + private readonly KeyedAsyncLock _entryCreationLock = new(); + /// /// Sets an absolute expiration time, relative to now. /// @@ -36,6 +44,12 @@ internal class LocalCache /// internal int Count => _cache.Count; + /// + /// Gets the number of asynchronous creation gates currently retained. + /// Used in unit tests to verify that gates do not accumulate. + /// + internal int EntryCreationGateCount => _entryCreationLock.GateCount; + /// /// Constructs a new LocalCache object. /// @@ -69,7 +83,7 @@ internal TValue GetOrCreate(TKey key, Func createItem) if (!_cache.TryGetValue(key, out TValue cacheEntry)) { SqlClientEventSource.Log.TryTraceEvent("Cached entry not found, creating new entry."); - if (_cache.Count == _maxSize) + if (_cache.Count >= _maxSize) { _cache.Compact(Max(0.10, 1.0 / _maxSize)); } @@ -91,6 +105,70 @@ internal TValue GetOrCreate(TKey key, Func createItem) return cacheEntry; } + /// + /// Asynchronous counterpart of . Looks for the cache entry that maps to the + /// value. If it exists (cache hit) it will simply be returned. Otherwise, the + /// delegate function will be awaited to create the value. It will then get stored in the + /// cache and set the time-to-live before getting returned. + /// + /// The key for the cache entry. + /// The delegate function that will asynchronously create the cache entry if it does not exist. + /// Token used to request cancellation of the wait for another caller's creation of the entry. + /// The cache entry. + /// + /// Concurrent misses for the same key are gated so that only one caller invokes ; the others + /// await that caller and then observe the cached value. The gate is per key, so misses for different keys proceed in + /// parallel, and no lock is held by a blocked thread. Cancellation applies only to the caller requesting it: if the caller + /// that owns the gate is cancelled, the next waiter creates the entry using its own cancellation token. + /// + /// When caching is disabled the gate is bypassed, because there is no entry for a waiting caller to observe and gating + /// would serialize callers without saving any work. Callers therefore reach the create delegate in parallel. + /// + /// + internal async Task GetOrCreateAsync(TKey key, Func> createItem, CancellationToken cancellationToken = default) + { + if (TimeToLive <= TimeSpan.Zero) + { + SqlClientEventSource.Log.TryTraceEvent("Key caching found disabled, fetching key information."); + return await createItem().ConfigureAwait(false); + } + + if (_cache.TryGetValue(key, out TValue cacheEntry)) + { + SqlClientEventSource.Log.TryTraceEvent("Cached entry found."); + return cacheEntry; + } + + using (await _entryCreationLock.AcquireAsync(key, cancellationToken).ConfigureAwait(false)) + { + // Another caller may have created the entry while this one waited for the gate. + if (_cache.TryGetValue(key, out cacheEntry)) + { + SqlClientEventSource.Log.TryTraceEvent("Cached entry found."); + return cacheEntry; + } + + SqlClientEventSource.Log.TryTraceEvent("Cached entry not found, creating new entry."); + + cacheEntry = await createItem().ConfigureAwait(false); + + if (_cache.Count >= _maxSize) + { + _cache.Compact(Max(0.10, 1.0 / _maxSize)); + } + + MemoryCacheEntryOptions cacheEntryOptions = new() + { + AbsoluteExpirationRelativeToNow = TimeToLive + }; + + _cache.Set(key, cacheEntry, cacheEntryOptions); + SqlClientEventSource.Log.TryTraceEvent("Entry added to local cache."); + + return cacheEntry; + } + } + /// /// Determines whether the LocalCache contains the specified key. /// Used in unit tests to verify that the cache contains the expected entries. diff --git a/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider.csproj b/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider.csproj index 203aabd1f5..9d61e2de98 100644 --- a/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider.csproj +++ b/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider.csproj @@ -11,6 +11,17 @@ Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider + + + + + + + + + diff --git a/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/test/KeyedAsyncLockTest.cs b/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/test/KeyedAsyncLockTest.cs new file mode 100644 index 0000000000..e862cf0529 --- /dev/null +++ b/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/test/KeyedAsyncLockTest.cs @@ -0,0 +1,291 @@ +// 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.Collections.Concurrent; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider.Test +{ + /// + /// Unit tests for . + /// + /// + /// Every test that depends on a caller reaching a particular point uses an explicit + /// handshake rather than a delay, so the tests are + /// deterministic rather than timing dependent. + /// + public class KeyedAsyncLockTest + { + /// + /// Bounds how long a test will wait for an expected signal before failing, so a regression + /// surfaces as a failure rather than a hung test run. + /// + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + [Fact] + public async Task AcquireAsync_UncontendedAcquisition_Succeeds() + { + KeyedAsyncLock sut = new(); + + using (await sut.AcquireAsync("key", CancellationToken.None)) + { + Assert.Equal(1, sut.GateCount); + } + + Assert.Equal(0, sut.GateCount); + } + + [Fact] + public async Task AcquireAsync_SameKeyHeld_BlocksSecondCallerUntilReleased() + { + KeyedAsyncLock sut = new(); + + KeyedAsyncLock.Releaser first = await sut.AcquireAsync("key", CancellationToken.None); + + Task.Releaser> second = sut.AcquireAsync("key", CancellationToken.None); + Assert.False(second.IsCompleted, "The second caller must wait while the first holds the key."); + + first.Dispose(); + + using (await WithTimeout(second)) + { + Assert.Equal(1, sut.GateCount); + } + + Assert.Equal(0, sut.GateCount); + } + + [Fact] + public async Task AcquireAsync_DifferentKeys_DoNotBlockEachOther() + { + KeyedAsyncLock sut = new(); + + using (await sut.AcquireAsync("first", CancellationToken.None)) + using (await sut.AcquireAsync("second", CancellationToken.None)) + { + Assert.Equal(2, sut.GateCount); + } + + Assert.Equal(0, sut.GateCount); + } + + [Fact] + public async Task AcquireAsync_ConcurrentCallersOnOneKey_NeverOverlap() + { + const int callerCount = 32; + + KeyedAsyncLock sut = new(); + int active = 0; + int maxActive = 0; + + async Task Contend() + { + using (await sut.AcquireAsync("key", CancellationToken.None)) + { + int current = Interlocked.Increment(ref active); + InterlockedMax(ref maxActive, current); + + // Yield inside the guarded region so an overlap would be observed rather than + // hidden by the work completing synchronously. + await Task.Yield(); + + Interlocked.Decrement(ref active); + } + } + + await WithTimeout(Task.WhenAll(Enumerable.Range(0, callerCount).Select(_ => Task.Run(Contend)))); + + Assert.Equal(1, maxActive); + Assert.Equal(0, sut.GateCount); + } + + [Fact] + public async Task AcquireAsync_AlreadyCancelledToken_ThrowsWithoutCreatingGate() + { + KeyedAsyncLock sut = new(); + using CancellationTokenSource cts = new(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + () => sut.AcquireAsync("key", cts.Token)); + + Assert.Equal(0, sut.GateCount); + } + + [Fact] + public async Task AcquireAsync_CancelledWhileWaiting_ThrowsAndLeavesNoGate() + { + KeyedAsyncLock sut = new(); + using CancellationTokenSource cts = new(); + + KeyedAsyncLock.Releaser holder = await sut.AcquireAsync("key", CancellationToken.None); + + Task waiter = sut.AcquireAsync("key", cts.Token); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => WithTimeout(waiter)); + + // The holder still owns the gate, so it must not have been reclaimed by the abandoned wait. + Assert.Equal(1, sut.GateCount); + + holder.Dispose(); + + Assert.Equal(0, sut.GateCount); + } + + [Fact] + public async Task AcquireAsync_ManyCancelledWaiters_DoNotAccumulateGates() + { + const int waiterCount = 64; + + KeyedAsyncLock sut = new(); + using CancellationTokenSource cts = new(); + + KeyedAsyncLock.Releaser holder = await sut.AcquireAsync("key", CancellationToken.None); + + Task[] waiters = Enumerable + .Range(0, waiterCount) + .Select(_ => sut.AcquireAsync("key", cts.Token)) + .Cast() + .ToArray(); + + cts.Cancel(); + + // Every waiter must fault; none may acquire the key while it is held. + foreach (Task waiter in waiters) + { + await Assert.ThrowsAnyAsync(() => WithTimeout(waiter)); + } + + holder.Dispose(); + + Assert.Equal(0, sut.GateCount); + } + + [Fact] + public async Task AcquireAsync_CancellationOfWaiter_DoesNotDisturbWorkInFlight() + { + KeyedAsyncLock sut = new(); + using CancellationTokenSource cts = new(); + + KeyedAsyncLock.Releaser holder = await sut.AcquireAsync("key", CancellationToken.None); + + Task cancelled = sut.AcquireAsync("key", cts.Token); + Task.Releaser> survivor = sut.AcquireAsync("key", CancellationToken.None); + + cts.Cancel(); + await Assert.ThrowsAnyAsync(() => WithTimeout(cancelled)); + + Assert.False(survivor.IsCompleted, "Cancelling one waiter must not hand the key to another."); + + holder.Dispose(); + + using (await WithTimeout(survivor)) + { + Assert.Equal(1, sut.GateCount); + } + + Assert.Equal(0, sut.GateCount); + } + + [Fact] + public async Task Releaser_DisposedTwice_DoesNotHandKeyToTwoCallers() + { + KeyedAsyncLock sut = new(); + + KeyedAsyncLock.Releaser releaser = await sut.AcquireAsync("key", CancellationToken.None); + releaser.Dispose(); + releaser.Dispose(); + + // A second release would have raised the gate's count, letting two callers in at once. + using (await sut.AcquireAsync("key", CancellationToken.None)) + { + Task.Releaser> blocked = sut.AcquireAsync("key", CancellationToken.None); + Assert.False(blocked.IsCompleted, "Repeated disposal must not permit concurrent acquisition."); + } + } + + [Fact] + public async Task AcquireAsync_GuardedWork_RunsOncePerKeyUnderContention() + { + const int keyCount = 8; + const int callersPerKey = 8; + + KeyedAsyncLock sut = new(); + ConcurrentDictionary results = new(); + ConcurrentDictionary invocations = new(); + + async Task GetOrCreate(string key) + { + if (results.TryGetValue(key, out string existing)) + { + return existing; + } + + using (await sut.AcquireAsync(key, CancellationToken.None)) + { + if (results.TryGetValue(key, out existing)) + { + return existing; + } + + invocations.AddOrUpdate(key, 1, static (_, count) => count + 1); + + // Simulate an awaited round trip so callers genuinely queue behind the gate. + await Task.Yield(); + + string created = "value:" + key; + results[key] = created; + return created; + } + } + + Task[] callers = Enumerable + .Range(0, keyCount) + .SelectMany(keyIndex => Enumerable + .Range(0, callersPerKey) + .Select(_ => Task.Run(() => GetOrCreate("key" + keyIndex)))) + .ToArray(); + + string[] values = await WithTimeout(Task.WhenAll(callers)); + + Assert.Equal(keyCount, invocations.Count); + Assert.All(invocations.Values, count => Assert.Equal(1, count)); + Assert.All(values, Assert.NotNull); + Assert.Equal(0, sut.GateCount); + } + + private static async Task WithTimeout(Task task) + { + await WithTimeout((Task)task).ConfigureAwait(false); + return await task.ConfigureAwait(false); + } + + private static async Task WithTimeout(Task task) + { + Task completed = await Task.WhenAny(task, Task.Delay(Timeout)).ConfigureAwait(false); + Assert.True(ReferenceEquals(completed, task), "Timed out waiting for the operation to complete."); + + await task.ConfigureAwait(false); + } + + private static void InterlockedMax(ref int target, int value) + { + int current = Volatile.Read(ref target); + while (value > current) + { + int observed = Interlocked.CompareExchange(ref target, value, current); + if (observed == current) + { + return; + } + + current = observed; + } + } + } +} diff --git a/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/test/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider.Test.csproj b/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/test/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider.Test.csproj new file mode 100644 index 0000000000..ba59c67559 --- /dev/null +++ b/src/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider/test/Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider.Test.csproj @@ -0,0 +1,49 @@ + + + + Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider.Test + net462;net8.0;net9.0;net10.0 + + false + true + + + + + Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider.Test + + + + + + + + + + + PreserveNewest + xunit.runner.json + + + + + + + + + + + + + + + + + + + + diff --git a/src/Microsoft.Data.SqlClient.slnx b/src/Microsoft.Data.SqlClient.slnx index 62e0612395..9c03addd51 100644 --- a/src/Microsoft.Data.SqlClient.slnx +++ b/src/Microsoft.Data.SqlClient.slnx @@ -138,6 +138,7 @@ + diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/AKVUnitTests.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/AKVUnitTests.cs index c8c3820978..d292abcba3 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/AKVUnitTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/AKVUnitTests.cs @@ -9,6 +9,7 @@ using System; using System.Collections.Generic; using System.Threading; +using System.Threading.Tasks; namespace Microsoft.Data.SqlClient.ManualTesting.Tests.AlwaysEncrypted { @@ -197,6 +198,248 @@ public void CekCacheShouldBeDisabledWhenCustomProviderIsRegisteredGlobally() } } + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAKVSetupAvailable))] + public async Task EncryptAndDecryptColumnEncryptionKeyAsyncRoundTrips() + { + SqlColumnEncryptionAzureKeyVaultProvider akvProvider = new(DataTestUtility.GetTokenCredential()); + + byte[] encryptedCek = await akvProvider.EncryptColumnEncryptionKeyAsync( + _fixture.GeneratedKeyUri, EncryptionAlgorithm, s_columnEncryptionKey); + byte[] decryptedCek = await akvProvider.DecryptColumnEncryptionKeyAsync( + _fixture.GeneratedKeyUri, EncryptionAlgorithm, encryptedCek); + + Assert.Equal(s_columnEncryptionKey, decryptedCek); + } + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAKVSetupAvailable))] + public async Task AsyncAndSyncEncryptedKeysAreInterchangeable() + { + SqlColumnEncryptionAzureKeyVaultProvider akvProvider = new(DataTestUtility.GetTokenCredential()); + + byte[] syncEncryptedCek = akvProvider.EncryptColumnEncryptionKey( + _fixture.GeneratedKeyUri, EncryptionAlgorithm, s_columnEncryptionKey); + byte[] asyncDecryptedCek = await akvProvider.DecryptColumnEncryptionKeyAsync( + _fixture.GeneratedKeyUri, EncryptionAlgorithm, syncEncryptedCek); + Assert.Equal(s_columnEncryptionKey, asyncDecryptedCek); + + byte[] asyncEncryptedCek = await akvProvider.EncryptColumnEncryptionKeyAsync( + _fixture.GeneratedKeyUri, EncryptionAlgorithm, s_columnEncryptionKey); + byte[] syncDecryptedCek = akvProvider.DecryptColumnEncryptionKey( + _fixture.GeneratedKeyUri, EncryptionAlgorithm, asyncEncryptedCek); + Assert.Equal(s_columnEncryptionKey, syncDecryptedCek); + } + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAKVSetupAvailable))] + public async Task SignAndVerifyColumnMasterKeyMetadataAsyncRoundTrips() + { + SqlColumnEncryptionAzureKeyVaultProvider akvProvider = new(DataTestUtility.GetTokenCredential()); + + byte[] signature = await akvProvider.SignColumnMasterKeyMetadataAsync(_fixture.GeneratedKeyUri, true); + Assert.True(await akvProvider.VerifyColumnMasterKeyMetadataAsync(_fixture.GeneratedKeyUri, true, signature)); + + // Signatures produced by the sync path must verify on the async path and vice versa. + byte[] syncSignature = akvProvider.SignColumnMasterKeyMetadata(_fixture.GeneratedKeyUri, false); + Assert.True(await akvProvider.VerifyColumnMasterKeyMetadataAsync(_fixture.GeneratedKeyUri, false, syncSignature)); + Assert.True(akvProvider.VerifyColumnMasterKeyMetadata(_fixture.GeneratedKeyUri, true, signature)); + } + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAKVSetupAvailable))] + public async Task DecryptedCekIsCachedDuringAsyncDecryption() + { + SqlColumnEncryptionAzureKeyVaultProvider akvProvider = new(new SqlClientCustomTokenCredential()); + byte[] plaintextKey1 = { 1, 2, 3 }; + byte[] plaintextKey2 = { 0, 1, 2, 3 }; + byte[] encryptedKey1 = await akvProvider.EncryptColumnEncryptionKeyAsync(_fixture.GeneratedKeyUri, EncryptionAlgorithm, plaintextKey1); + byte[] encryptedKey2 = await akvProvider.EncryptColumnEncryptionKeyAsync(_fixture.GeneratedKeyUri, EncryptionAlgorithm, plaintextKey2); + + byte[] decryptedKey1 = await akvProvider.DecryptColumnEncryptionKeyAsync(_fixture.GeneratedKeyUri, EncryptionAlgorithm, encryptedKey1); + Assert.Equal(plaintextKey1, decryptedKey1); + Assert.Equal(1, GetCacheCount(cekCacheName, akvProvider)); + Assert.True(CekCacheContainsKey(encryptedKey1, akvProvider)); + + // A repeated decryption of the same encrypted key must be served from the cache. + decryptedKey1 = await akvProvider.DecryptColumnEncryptionKeyAsync(_fixture.GeneratedKeyUri, EncryptionAlgorithm, encryptedKey1); + Assert.Equal(plaintextKey1, decryptedKey1); + Assert.Equal(1, GetCacheCount(cekCacheName, akvProvider)); + + byte[] decryptedKey2 = await akvProvider.DecryptColumnEncryptionKeyAsync(_fixture.GeneratedKeyUri, EncryptionAlgorithm, encryptedKey2); + Assert.Equal(plaintextKey2, decryptedKey2); + Assert.Equal(2, GetCacheCount(cekCacheName, akvProvider)); + } + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAKVSetupAvailable))] + public async Task CekCacheIsSharedBetweenSyncAndAsyncDecryption() + { + SqlColumnEncryptionAzureKeyVaultProvider akvProvider = new(new SqlClientCustomTokenCredential()); + byte[] plaintextKey = { 1, 2, 3 }; + byte[] encryptedKey = akvProvider.EncryptColumnEncryptionKey(_fixture.GeneratedKeyUri, EncryptionAlgorithm, plaintextKey); + + akvProvider.DecryptColumnEncryptionKey(_fixture.GeneratedKeyUri, EncryptionAlgorithm, encryptedKey); + Assert.Equal(1, GetCacheCount(cekCacheName, akvProvider)); + + byte[] decryptedKey = await akvProvider.DecryptColumnEncryptionKeyAsync(_fixture.GeneratedKeyUri, EncryptionAlgorithm, encryptedKey); + Assert.Equal(plaintextKey, decryptedKey); + Assert.Equal(1, GetCacheCount(cekCacheName, akvProvider)); + } + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAKVSetupAvailable))] + public async Task CekCachingIsDisabledForAsyncDecryptionWhenTtlIsZero() + { + SqlColumnEncryptionAzureKeyVaultProvider akvProvider = new(new SqlClientCustomTokenCredential()); + akvProvider.ColumnEncryptionKeyCacheTtl = TimeSpan.Zero; + byte[] plaintextKey = { 1, 2, 3 }; + byte[] encryptedKey = await akvProvider.EncryptColumnEncryptionKeyAsync(_fixture.GeneratedKeyUri, EncryptionAlgorithm, plaintextKey); + + byte[] decryptedKey = await akvProvider.DecryptColumnEncryptionKeyAsync(_fixture.GeneratedKeyUri, EncryptionAlgorithm, encryptedKey); + + Assert.Equal(plaintextKey, decryptedKey); + Assert.Equal(0, GetCacheCount(cekCacheName, akvProvider)); + } + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAKVSetupAvailable))] + public async Task SignatureVerificationResultIsCachedDuringAsyncVerification() + { + SqlColumnEncryptionAzureKeyVaultProvider akvProvider = new(new SqlClientCustomTokenCredential()); + byte[] signature = await akvProvider.SignColumnMasterKeyMetadataAsync(_fixture.GeneratedKeyUri, true); + byte[] signatureWithoutEnclave = await akvProvider.SignColumnMasterKeyMetadataAsync(_fixture.GeneratedKeyUri, false); + + Assert.True(await akvProvider.VerifyColumnMasterKeyMetadataAsync(_fixture.GeneratedKeyUri, true, signature)); + Assert.Equal(1, GetCacheCount(signatureVerificationResultCacheName, akvProvider)); + + Assert.True(await akvProvider.VerifyColumnMasterKeyMetadataAsync(_fixture.GeneratedKeyUri, true, signature)); + Assert.Equal(1, GetCacheCount(signatureVerificationResultCacheName, akvProvider)); + + Assert.True(await akvProvider.VerifyColumnMasterKeyMetadataAsync(_fixture.GeneratedKeyUri, false, signatureWithoutEnclave)); + Assert.Equal(2, GetCacheCount(signatureVerificationResultCacheName, akvProvider)); + } + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAKVSetupAvailable))] + public async Task CancelledAsyncDecryptionsDoNotAccumulateCreationGates() + { + SqlColumnEncryptionAzureKeyVaultProvider akvProvider = new(new SqlClientCustomTokenCredential()); + byte[] plaintextKey = { 1, 2, 3 }; + + // A gate is only created once a caller reaches the cache, so the cancellation being + // exercised here must happen while a caller waits on a gate another caller holds. + // Each iteration uses a distinct cache key, so a gate that is abandoned rather than + // removed would accumulate without bound. + for (int i = 0; i < 20; i++) + { + byte[] encryptedKey = akvProvider.EncryptColumnEncryptionKey( + _fixture.GeneratedKeyUri, EncryptionAlgorithm, plaintextKey); + + // The first caller takes the gate and holds it for the duration of the key vault + // round trip. It is not cancelled, so it always creates the entry. + Task gateOwner = Task.Run(() => akvProvider.DecryptColumnEncryptionKeyAsync( + _fixture.GeneratedKeyUri, EncryptionAlgorithm, encryptedKey)); + + // The remaining callers queue behind it and are cancelled while waiting. + using CancellationTokenSource cts = new(); + Task[] waiters = new Task[8]; + for (int j = 0; j < waiters.Length; j++) + { + waiters[j] = Task.Run(() => akvProvider.DecryptColumnEncryptionKeyAsync( + _fixture.GeneratedKeyUri, EncryptionAlgorithm, encryptedKey, cts.Token)); + } + + cts.Cancel(); + + // A waiter either observed the cancellation or completed first; both are valid. + foreach (Task waiter in waiters) + { + try + { + Assert.Equal(plaintextKey, await waiter); + } + catch (OperationCanceledException) + { + } + } + + Assert.Equal(plaintextKey, await gateOwner); + } + + Assert.Equal(0, GetEntryCreationGateCount(cekCacheName, akvProvider)); + } + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAKVSetupAvailable))] + public async Task ConcurrentAsyncDecryptionOfSameKeyIsDeduplicated() + { + SqlColumnEncryptionAzureKeyVaultProvider akvProvider = new(new SqlClientCustomTokenCredential()); + byte[] plaintextKey = { 1, 2, 3 }; + byte[] encryptedKey = akvProvider.EncryptColumnEncryptionKey(_fixture.GeneratedKeyUri, EncryptionAlgorithm, plaintextKey); + + Task[] decryptions = new Task[32]; + for (int i = 0; i < decryptions.Length; i++) + { + decryptions[i] = Task.Run(() => akvProvider.DecryptColumnEncryptionKeyAsync( + _fixture.GeneratedKeyUri, EncryptionAlgorithm, encryptedKey)); + } + + byte[][] decryptedKeys = await Task.WhenAll(decryptions); + + foreach (byte[] decryptedKey in decryptedKeys) + { + Assert.Equal(plaintextKey, decryptedKey); + } + + // Concurrent misses for the same key must collapse into a single cache entry. + Assert.Equal(1, GetCacheCount(cekCacheName, akvProvider)); + } + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAKVSetupAvailable))] + public async Task AsyncApisHonorCancellationToken() + { + SqlColumnEncryptionAzureKeyVaultProvider akvProvider = new(new SqlClientCustomTokenCredential()); + byte[] encryptedKey = akvProvider.EncryptColumnEncryptionKey( + _fixture.GeneratedKeyUri, EncryptionAlgorithm, s_columnEncryptionKey); + byte[] signature = akvProvider.SignColumnMasterKeyMetadata(_fixture.GeneratedKeyUri, true); + + using CancellationTokenSource cts = new(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + () => akvProvider.EncryptColumnEncryptionKeyAsync( + _fixture.GeneratedKeyUri, EncryptionAlgorithm, s_columnEncryptionKey, cts.Token)); + await Assert.ThrowsAnyAsync( + () => akvProvider.DecryptColumnEncryptionKeyAsync( + _fixture.GeneratedKeyUri, EncryptionAlgorithm, encryptedKey, cts.Token)); + await Assert.ThrowsAnyAsync( + () => akvProvider.SignColumnMasterKeyMetadataAsync( + _fixture.GeneratedKeyUri, true, cts.Token)); + await Assert.ThrowsAnyAsync( + () => akvProvider.VerifyColumnMasterKeyMetadataAsync( + _fixture.GeneratedKeyUri, true, signature, cts.Token)); + + // Cancellation is observed before argument validation, matching the base class. + await Assert.ThrowsAnyAsync( + () => akvProvider.DecryptColumnEncryptionKeyAsync( + "https://my-key-vault.vault.azure.net/keys", EncryptionAlgorithm, encryptedKey, cts.Token)); + } + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAKVSetupAvailable))] + public async Task AsyncApisValidateMasterKeyPath() + { + SqlColumnEncryptionAzureKeyVaultProvider akvProvider = new(new SqlClientCustomTokenCredential()); + string invalidKeyPath = "https://my-key-vault.vault.azure.net/keys"; + + ArgumentException ex1 = await Assert.ThrowsAsync( + () => akvProvider.EncryptColumnEncryptionKeyAsync(invalidKeyPath, EncryptionAlgorithm, s_columnEncryptionKey)); + Assert.Contains($"Invalid url specified: '{invalidKeyPath}'", ex1.Message); + + ArgumentException ex2 = await Assert.ThrowsAsync( + () => akvProvider.DecryptColumnEncryptionKeyAsync(invalidKeyPath, EncryptionAlgorithm, s_columnEncryptionKey)); + Assert.Contains($"Invalid url specified: '{invalidKeyPath}'", ex2.Message); + + ArgumentException ex3 = await Assert.ThrowsAsync( + () => akvProvider.SignColumnMasterKeyMetadataAsync(invalidKeyPath, true)); + Assert.Contains($"Invalid url specified: '{invalidKeyPath}'", ex3.Message); + + ArgumentException ex4 = await Assert.ThrowsAsync( + () => akvProvider.VerifyColumnMasterKeyMetadataAsync(invalidKeyPath, true, s_columnEncryptionKey)); + Assert.Contains($"Invalid url specified: '{invalidKeyPath}'", ex4.Message); + } + private static int GetCacheCount(string cacheName, SqlColumnEncryptionAzureKeyVaultProvider akvProvider) { var cacheInstance = GetCacheInstance(cacheName, akvProvider); @@ -206,6 +449,15 @@ private static int GetCacheCount(string cacheName, SqlColumnEncryptionAzureKeyVa return countValue; } + private static int GetEntryCreationGateCount(string cacheName, SqlColumnEncryptionAzureKeyVaultProvider akvProvider) + { + var cacheInstance = GetCacheInstance(cacheName, akvProvider); + Type cacheType = cacheInstance.GetType(); + PropertyInfo gateCountProperty = cacheType.GetProperty( + "EntryCreationGateCount", BindingFlags.Instance | BindingFlags.NonPublic); + return (int)gateCountProperty.GetValue(cacheInstance); + } + private static bool CekCacheContainsKey(byte[] encryptedCek, SqlColumnEncryptionAzureKeyVaultProvider akvProvider) { var cacheInstance = GetCacheInstance("_columnEncryptionKeyCache", akvProvider); diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ExceptionTestAKVStore.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ExceptionTestAKVStore.cs index 9f006c3f55..75f5e59701 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ExceptionTestAKVStore.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ExceptionTestAKVStore.cs @@ -4,6 +4,7 @@ using System; using System.Security.Cryptography; +using System.Threading.Tasks; using Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider; using Microsoft.Data.SqlClient.ManualTesting.Tests.AlwaysEncrypted.Setup; using Xunit; @@ -58,6 +59,110 @@ public void EmptyColumnEncryptionKey() Assert.Matches($@"Internal error. Empty 'columnEncryptionKey' specified.", ex1.Message); } + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAKVSetupAvailable))] + public async Task VerifyRejectsNullOrEmptySignature() + { + SqlColumnEncryptionAzureKeyVaultProvider azureKeyProvider = new SqlColumnEncryptionAzureKeyVaultProvider( + new SqlClientCustomTokenCredential()); + + string nullMessage = $@"Value cannot be null.\s+\(?Parameter (name: )?'?signature('\))?"; + string emptyMessage = $@"Internal error. Empty 'signature' specified."; + + Exception syncNull = Assert.Throws( + () => azureKeyProvider.VerifyColumnMasterKeyMetadata(_fixture.AkvKeyUrl, true, null)); + Assert.Matches(nullMessage, syncNull.Message); + + Exception syncEmpty = Assert.Throws( + () => azureKeyProvider.VerifyColumnMasterKeyMetadata(_fixture.AkvKeyUrl, true, new byte[] { })); + Assert.Matches(emptyMessage, syncEmpty.Message); + + Exception asyncNull = await Assert.ThrowsAsync( + () => azureKeyProvider.VerifyColumnMasterKeyMetadataAsync(_fixture.AkvKeyUrl, true, null)); + Assert.Matches(nullMessage, asyncNull.Message); + + Exception asyncEmpty = await Assert.ThrowsAsync( + () => azureKeyProvider.VerifyColumnMasterKeyMetadataAsync(_fixture.AkvKeyUrl, true, new byte[] { })); + Assert.Matches(emptyMessage, asyncEmpty.Message); + } + + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.IsAKVSetupAvailable))] + public async Task SignAndVerifyAsyncRejectInvalidAKVPath(string masterKeyPath) + { + SqlColumnEncryptionAzureKeyVaultProvider azureKeyProvider = new SqlColumnEncryptionAzureKeyVaultProvider( + new SqlClientCustomTokenCredential()); + + ArgumentException signException = await Assert.ThrowsAnyAsync( + () => azureKeyProvider.SignColumnMasterKeyMetadataAsync(masterKeyPath, false)); + + ArgumentException verifyException = await Assert.ThrowsAnyAsync( + () => azureKeyProvider.VerifyColumnMasterKeyMetadataAsync(masterKeyPath, false, encryptedCek)); + + // The message is not anchored, so it matches whether or not the operation is flagged as + // a system operation and prefixes the text with "Internal error.". + string expectedMessage = masterKeyPath == null + ? "Azure Key Vault key path cannot be null." + : "Invalid Azure Key Vault key path specified"; + + Assert.Matches(expectedMessage, signException.Message); + Assert.Matches(expectedMessage, verifyException.Message); + } + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAKVSetupAvailable))] + public async Task AsyncApisValidateArguments() + { + ArgumentException ex1 = await Assert.ThrowsAsync( + () => _fixture.AkvStoreProvider.DecryptColumnEncryptionKeyAsync(_fixture.AkvKeyUrl, BadMasterKeyEncAlgo, cek)); + Assert.Matches($@"Invalid key encryption algorithm specified: 'BadMasterKeyAlgorithm'. Expected value: 'RSA_OAEP' or 'RSA-OAEP'.\s+\(?Parameter (name: )?'?encryptionAlgorithm('\))?", ex1.Message); + + ArgumentNullException ex2 = await Assert.ThrowsAsync( + () => _fixture.AkvStoreProvider.EncryptColumnEncryptionKeyAsync(_fixture.AkvKeyUrl, null, cek)); + Assert.Matches($@"Internal error. Key encryption algorithm cannot be null.\s+\(?Parameter (name: )?'?encryptionAlgorithm('\))?", ex2.Message); + + ArgumentException ex3 = await Assert.ThrowsAsync( + () => _fixture.AkvStoreProvider.EncryptColumnEncryptionKeyAsync(_fixture.AkvKeyUrl, MasterKeyEncAlgo, new byte[] { })); + Assert.Matches($@"Internal error. Empty 'columnEncryptionKey' specified.", ex3.Message); + + ArgumentNullException ex4 = await Assert.ThrowsAsync( + () => _fixture.AkvStoreProvider.DecryptColumnEncryptionKeyAsync(_fixture.AkvKeyUrl, MasterKeyEncAlgo, null)); + Assert.Matches($@"Value cannot be null.\s+\(?Parameter (name: )?'?encryptedColumnEncryptionKey('\))?", ex4.Message); + } + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAKVSetupAvailable))] + public async Task AsyncDecryptionRejectsInvalidAlgorithmVersion() + { + byte[] encryptedCekLocal = ColumnEncryptionKey.GenerateInvalidEncryptedCek(encryptedCek, ColumnEncryptionKey.ECEKCorruption.ALGORITHM_VERSION); + + ArgumentException ex = await Assert.ThrowsAsync( + () => _fixture.AkvStoreProvider.DecryptColumnEncryptionKeyAsync(_fixture.AkvKeyUrl, MasterKeyEncAlgo, encryptedCekLocal)); + Assert.Matches($@"Specified encrypted column encryption key contains an invalid encryption algorithm version '10'. Expected version is '01'.\s+\(?Parameter (name: )?'?encryptedColumnEncryptionKey('\))?", ex.Message); + } + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAKVSetupAvailable))] + public async Task AsyncDecryptionRejectsInvalidSignature() + { + byte[] encryptedCekLocal = ColumnEncryptionKey.GenerateInvalidEncryptedCek(encryptedCek, ColumnEncryptionKey.ECEKCorruption.SIGNATURE); + string errorMessage = + $@"The specified encrypted column encryption key signature does not match the signature computed with the column master key \(Asymmetric key in Azure Key Vault\) in '{_fixture.AkvKeyUrl}'. The encrypted column encryption key may be corrupt, or the specified path may be incorrect.\s+\(?Parameter (name: )?'?encryptedColumnEncryptionKey('\))?"; + + ArgumentException ex = await Assert.ThrowsAsync( + () => _fixture.AkvStoreProvider.DecryptColumnEncryptionKeyAsync(_fixture.AkvKeyUrl, MasterKeyEncAlgo, encryptedCekLocal)); + Assert.Matches(errorMessage, ex.Message); + } + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAKVSetupAvailable))] + public async Task AsyncDecryptionRejectsInvalidCipherTextLength() + { + byte[] encryptedCekLocal = ColumnEncryptionKey.GenerateInvalidEncryptedCek(encryptedCek, ColumnEncryptionKey.ECEKCorruption.CEK_LENGTH); + string errorMessage = $@"The specified encrypted column encryption key's ciphertext length: 251 does not match the ciphertext length: 256 when using column master key \(Azure Key Vault key\) in '{_fixture.AkvKeyUrl}'. The encrypted column encryption key may be corrupt, or the specified Azure Key Vault key path may be incorrect.\s+\(?Parameter (name: )?'?encryptedColumnEncryptionKey('\))?"; + + ArgumentException ex = await Assert.ThrowsAsync( + () => _fixture.AkvStoreProvider.DecryptColumnEncryptionKeyAsync(_fixture.AkvKeyUrl, MasterKeyEncAlgo, encryptedCekLocal)); + Assert.Matches(errorMessage, ex.Message); + } + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.IsAKVSetupAvailable))] public void NullColumnEncryptionKey() {