diff --git a/src/Microsoft.Data.SqlClient/tests/Common/Common.csproj b/src/Microsoft.Data.SqlClient/tests/Common/Common.csproj index d60c9440a9..4133622d20 100644 --- a/src/Microsoft.Data.SqlClient/tests/Common/Common.csproj +++ b/src/Microsoft.Data.SqlClient/tests/Common/Common.csproj @@ -51,4 +51,10 @@ + + + + + + diff --git a/src/Microsoft.Data.SqlClient/tests/Common/Fixtures/AzureKeyVaultKeyFixtureBase.cs b/src/Microsoft.Data.SqlClient/tests/Common/Fixtures/AzureKeyVaultKeyFixtureBase.cs new file mode 100644 index 0000000000..f70560be9e --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/Common/Fixtures/AzureKeyVaultKeyFixtureBase.cs @@ -0,0 +1,70 @@ +// 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; +using System.Collections.Generic; +using Azure.Core; +using Azure.Security.KeyVault.Keys; + +namespace Microsoft.Data.SqlClient.Tests.Common.Fixtures; + +/// +/// Provides a base class for managing Azure Key Vault keys in test fixtures. +/// +/// +/// This class simplifies the creation and cleanup of RSA keys in an Azure Key Vault during testing +/// scenarios. It ensures that any keys created during the fixture's lifetime are properly deleted when the fixture is +/// disposed. +/// +public abstract class AzureKeyVaultKeyFixtureBase : IDisposable +{ + private readonly KeyClient _keyClient; + private readonly Random _randomGenerator; + + private readonly List _createdKeys = new List(); + + protected AzureKeyVaultKeyFixtureBase(Uri keyVaultUri, TokenCredential keyVaultToken) + { + _keyClient = new KeyClient(keyVaultUri, keyVaultToken); + _randomGenerator = new Random(); + } + + protected Uri CreateKey(string name, int keySize) + { + CreateRsaKeyOptions createOptions = new CreateRsaKeyOptions(GenerateUniqueName(name)) { KeySize = keySize }; + KeyVaultKey created = _keyClient.CreateRsaKey(createOptions); + + _createdKeys.Add(created); + return created.Id; + } + + private string GenerateUniqueName(string name) + { + byte[] rndBytes = new byte[16]; + + _randomGenerator.NextBytes(rndBytes); + return name + "-" + BitConverter.ToString(rndBytes); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + foreach (KeyVaultKey key in _createdKeys) + { + try + { + _keyClient.StartDeleteKey(key.Name).WaitForCompletion(); + } + catch (Exception) + { + continue; + } + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/Common/Fixtures/CertificateFixtureBase.cs b/src/Microsoft.Data.SqlClient/tests/Common/Fixtures/CertificateFixtureBase.cs new file mode 100644 index 0000000000..9007796013 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/Common/Fixtures/CertificateFixtureBase.cs @@ -0,0 +1,320 @@ +// 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.Generic; +using System.Diagnostics; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; + +namespace Microsoft.Data.SqlClient.Tests.Common.Fixtures; + +/// +/// Provides a base class for managing the creation, storage, and disposal of X.509 certificates used in cryptographic +/// operations. This class is designed to facilitate scenarios where certificates need to be programmatically generated, +/// added to certificate stores, and cleaned up after use. +/// +/// +/// This class includes functionality for creating self-signed certificates with specific configurations, +/// adding certificates to system certificate stores, and ensuring proper cleanup of certificates to avoid resource +/// leaks. It is intended to be used as a base class for test fixtures or other scenarios requiring temporary +/// certificate management. +/// The class implements to ensure that any certificates added to +/// certificate stores are removed and properly disposed of when the object is no longer needed. +/// +public abstract class CertificateFixtureBase : IDisposable +{ + /// + /// Certificates must be created using this provider. Certificates created by PowerShell + /// using another provider aren't accessible from RSACryptoServiceProvider, which means + /// that we could not roundtrip between SqlColumnEncryptionCertificateStoreProvider and + /// SqlColumnEncryptionCspProvider. + /// + private const string CspProviderName = "Microsoft Enhanced RSA and AES Cryptographic Provider"; + + private sealed class CertificateStoreContext + { + public List Certificates { get; } + + public StoreLocation Location { get; } + + public StoreName Name { get; } + + public CertificateStoreContext(StoreLocation location, StoreName name) + { + Certificates = new List(); + Location = location; + Name = name; + } + } + + private readonly List _certificateStoreModifications = new List(); + + protected X509Certificate2 CreateCertificate(string subjectName, IEnumerable dnsNames, IEnumerable ipAddresses, bool forceCsp = false) + { + // This will always generate a certificate with: + // * Start date: 24hrs ago + // * End date: 24hrs in the future + // * Subject: {subjectName} + // * Subject alternative names: {dnsNames}, {ipAddresses} + // * Public key: 2048-bit RSA + // * Hash algorithm: SHA256 + // * Key usage: digital signature, key encipherment + // * Enhanced key usage: server authentication, client authentication + DateTimeOffset notBefore = DateTimeOffset.UtcNow.AddDays(-1); + DateTimeOffset notAfter = DateTimeOffset.UtcNow.AddDays(1); + byte[] passwordBytes = new byte[32]; + string password = null; + Random rnd = new Random(); + + rnd.NextBytes(passwordBytes); + password = Convert.ToBase64String(passwordBytes); +#if NET + X500DistinguishedNameBuilder subjectBuilder = new X500DistinguishedNameBuilder(); + SubjectAlternativeNameBuilder sanBuilder = new SubjectAlternativeNameBuilder(); + RSA rsaKey = CreateRSA(forceCsp); + bool hasSans = false; + + subjectBuilder.AddCommonName(subjectName); + foreach (string dnsName in dnsNames) + { + sanBuilder.AddDnsName(dnsName); + hasSans = true; + } + foreach (string ipAddress in ipAddresses) + { + sanBuilder.AddIpAddress(System.Net.IPAddress.Parse(ipAddress)); + hasSans = true; + } + + CertificateRequest request = new CertificateRequest(subjectBuilder.Build(), rsaKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection() { new Oid("1.3.6.1.5.5.7.3.1"), new Oid("1.3.6.1.5.5.7.3.2") }, true)); + + if (hasSans) + { + request.CertificateExtensions.Add(sanBuilder.Build()); + } + + // Generate an ephemeral certificate, then export it and return it as a new certificate with the correct key storage flags set. + // This is to ensure that it's imported into the certificate stores with its private key. + using (X509Certificate2 ephemeral = request.CreateSelfSigned(notBefore, notAfter)) + { +#if NET9_0_OR_GREATER + return X509CertificateLoader.LoadPkcs12( + ephemeral.Export(X509ContentType.Pkcs12, password), + password, + X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable, + new Pkcs12LoaderLimits(Pkcs12LoaderLimits.Defaults) + { + PreserveStorageProvider = true, + PreserveKeyName = true + }); +#else + return new X509Certificate2( + ephemeral.Export(X509ContentType.Pkcs12, password), + password, + X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); +#endif + } +#else + // The CertificateRequest API is available in .NET Core, but was only added to .NET Framework 4.7.2; it thus can't be used in the test projects. + // Instead, fall back to running a PowerShell script which calls New-SelfSignedCertificate. This cmdlet also adds the certificate to a specific, + // certificate store, so remove it from there. + // Normally, the PowerShell script will return zero and print the base64-encoded certificate to stdout. If there's an exception, it'll return 1 and + // print the message instead. + const string PowerShellCommandTemplate = @"$notBefore = [DateTime]::ParseExact(""{0}"", ""O"", $null) +$notAfter = [DateTime]::ParseExact(""{1}"", ""O"", $null) +$subject = ""CN={2}"" +$sAN = @({3}) + +try +{{ + $x509 = PKI\New-SelfSignedCertificate -Subject $subject -TextExtension $sAN -KeyLength 2048 -KeyAlgorithm RSA ` + -CertStoreLocation ""Cert:\CurrentUser\My"" -NotBefore $notBefore -NotAfter $notAfter ` + -KeyExportPolicy Exportable -HashAlgorithm SHA256 -Provider ""{5}"" -KeySpec KeyExchange + + if ($x509 -eq $null) + {{ throw ""Certificate was null!"" }} + + $exportedArray = $x509.Export(""Pkcs12"", ""{4}"") + Write-Output $([Convert]::ToBase64String($exportedArray)) + + Remove-Item ""Cert:\CurrentUser\My\$($x509.Thumbprint)"" + + exit 0 +}} +catch [Exception] +{{ + Write-Output $_.Exception.Message + exit 1 +}}"; + const int PowerShellCommandTimeout = 15_000; + + string sanString = string.Empty; + bool hasSans = false; + + foreach (string dnsName in dnsNames) + { + sanString += string.Format("DNS={0}&", dnsName); + hasSans = true; + } + foreach (string ipAddress in ipAddresses) + { + sanString += string.Format("IPAddress={0}&", ipAddress); + hasSans = true; + } + + sanString = hasSans ? "\"2.5.29.17={text}" + sanString.Substring(0, sanString.Length - 1) + "\"" : string.Empty; + + string formattedCommand = string.Format(PowerShellCommandTemplate, notBefore.ToString("O"), notAfter.ToString("O"), subjectName, sanString, password, CspProviderName); + + ProcessStartInfo startInfo = new() + { + FileName = "powershell.exe", + RedirectStandardOutput = true, + RedirectStandardError = false, + UseShellExecute = false, + CreateNoWindow = true, + // Pass the Base64-encoded command to remove the need to escape quote marks + Arguments = "-EncodedCommand " + Convert.ToBase64String(Encoding.Unicode.GetBytes(formattedCommand)), + // Run as Administrator, since we're manipulating the system + // certificate store. + Verb = "RunAs", + LoadUserProfile = true + }; + + // This command sometimes fails with: + // + // Access is denied. 0x80070005 (WIN32: 5 ERROR_ACCESS_DENIED) + // + // We will retry it a few times with a short delay to avoid spurious + // failures in CI pipeline runs. + // + // See ADO issue for more details: + // + // Issue 34304: #3223 Fix Functional test failures in CI + // + // https://sqlclientdrivers.visualstudio.com/ADO.Net/_workitems/edit/34304 + // + // Delay 5 seconds between retries, and retry 3 times. + const int delay = 5; + const int retries = 3; + + string commandOutput = string.Empty; + + for (int attempt = 1; attempt <= retries; ++attempt) + { + using Process psProcess = new() { StartInfo = startInfo }; + + psProcess.Start(); + commandOutput = psProcess.StandardOutput.ReadToEnd(); + + if (!psProcess.WaitForExit(PowerShellCommandTimeout)) + { + psProcess.Kill(); + throw new Exception("Process did not complete in time, exiting."); + } + + // Process completed successfully if it had an exit code of zero, the command output will be the base64-encoded certificate + var code = psProcess.ExitCode; + if (code == 0) + { + return new X509Certificate2(Convert.FromBase64String(commandOutput), password, X509KeyStorageFlags.Exportable); + } + + Console.WriteLine( + $"PowerShell command failed with exit code {code} on " + + $"attempt {attempt} of {retries}; " + + $"retrying in {delay} seconds..."); + + Thread.Sleep(TimeSpan.FromSeconds(delay)); + } + + throw new Exception( + "PowerShell command raised exception: " + + $"{commandOutput}; command was: {formattedCommand}"); +#endif + } + +#if NET + private static RSA CreateRSA(bool forceCsp) + { + const int KeySize = 2048; + const int CspProviderType = 24; + + return forceCsp && OperatingSystem.IsWindows() + ? new RSACryptoServiceProvider(KeySize, new CspParameters(CspProviderType, CspProviderName, Guid.NewGuid().ToString())) + : RSA.Create(KeySize); + } +#endif + + protected void AddToStore(X509Certificate2 cert, StoreLocation storeLocation, StoreName storeName) + { + CertificateStoreContext storeContext = _certificateStoreModifications.Find(csc => csc.Location == storeLocation && csc.Name == storeName); + + if (storeContext == null) + { + storeContext = new(storeLocation, storeName); + _certificateStoreModifications.Add(storeContext); + } + + using X509Store store = new X509Store(storeContext.Name, storeContext.Location); + + store.Open(OpenFlags.ReadWrite); + if (store.Certificates.Contains(cert)) + { + store.Remove(cert); + } + store.Add(cert); + + storeContext.Certificates.Add(cert); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + foreach (CertificateStoreContext storeContext in _certificateStoreModifications) + { + using X509Store store = new X509Store(storeContext.Name, storeContext.Location); + + try + { + store.Open(OpenFlags.ReadWrite); + } + catch (Exception) + { + continue; + } + + foreach (X509Certificate2 cert in storeContext.Certificates) + { + try + { + if (store.Certificates.Contains(cert)) + { + store.Remove(cert); + } + } + catch (Exception) + { + continue; + } + + cert.Dispose(); + } + + storeContext.Certificates.Clear(); + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/Common/Fixtures/ColumnEncryptionCertificateFixture.cs b/src/Microsoft.Data.SqlClient/tests/Common/Fixtures/ColumnEncryptionCertificateFixture.cs new file mode 100644 index 0000000000..a4aa84842f --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/Common/Fixtures/ColumnEncryptionCertificateFixture.cs @@ -0,0 +1,70 @@ +// 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.Security.Cryptography.X509Certificates; +using System.Security.Principal; + +namespace Microsoft.Data.SqlClient.Tests.Common.Fixtures; + +/// +/// Provides a fixture for managing certificates used in column encryption scenarios. +/// +/// +/// This class creates and manages certificates for testing or operational purposes, including primary +/// and secondary column encryption certificates, as well as a certificate without a private key. Certificates are +/// added to the appropriate certificate stores based on the current user's permissions. +/// +public sealed class ColumnEncryptionCertificateFixture : CertificateFixtureBase +{ + public X509Certificate2 PrimaryColumnEncryptionCertificate { get; } + + public X509Certificate2 SecondaryColumnEncryptionCertificate { get; } + + public X509Certificate2 CertificateWithoutPrivateKey { get; } + + private readonly X509Certificate2 _currentUserCertificate; + private readonly X509Certificate2 _localMachineCertificate; + + public ColumnEncryptionCertificateFixture() + { + PrimaryColumnEncryptionCertificate = CreateCertificate(nameof(PrimaryColumnEncryptionCertificate), Array.Empty(), Array.Empty()); + SecondaryColumnEncryptionCertificate = CreateCertificate(nameof(SecondaryColumnEncryptionCertificate), Array.Empty(), Array.Empty()); + _currentUserCertificate = CreateCertificate(nameof(_currentUserCertificate), Array.Empty(), Array.Empty()); + using (X509Certificate2 createdCertificate = CreateCertificate(nameof(CertificateWithoutPrivateKey), Array.Empty(), Array.Empty())) + { + // This will strip the private key away from the created certificate +#if NET9_0_OR_GREATER + CertificateWithoutPrivateKey = X509CertificateLoader.LoadCertificate(createdCertificate.Export(X509ContentType.Cert)); +#else + CertificateWithoutPrivateKey = new X509Certificate2(createdCertificate.Export(X509ContentType.Cert)); +#endif + AddToStore(CertificateWithoutPrivateKey, StoreLocation.CurrentUser, StoreName.My); + } + + AddToStore(PrimaryColumnEncryptionCertificate, StoreLocation.CurrentUser, StoreName.My); + AddToStore(SecondaryColumnEncryptionCertificate, StoreLocation.CurrentUser, StoreName.My); + AddToStore(_currentUserCertificate, StoreLocation.CurrentUser, StoreName.My); + + if (IsAdmin) + { + _localMachineCertificate = CreateCertificate(nameof(_localMachineCertificate), Array.Empty(), Array.Empty()); + + AddToStore(_localMachineCertificate, StoreLocation.LocalMachine, StoreName.My); + } + } + + public X509Certificate2 GetCertificate(StoreLocation storeLocation) + { + return storeLocation == StoreLocation.CurrentUser + ? _currentUserCertificate + : storeLocation == StoreLocation.LocalMachine && IsAdmin + ? _localMachineCertificate + : throw new InvalidOperationException("Attempted to retrieve the certificate added to the local machine store; this requires administrator rights."); + } + + public static bool IsAdmin + => Environment.OSVersion.Platform == PlatformID.Win32NT + && new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator); +} diff --git a/src/Microsoft.Data.SqlClient/tests/Common/Fixtures/ColumnMasterKeyCertificateFixture.cs b/src/Microsoft.Data.SqlClient/tests/Common/Fixtures/ColumnMasterKeyCertificateFixture.cs new file mode 100644 index 0000000000..a91ca7a0e0 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/Common/Fixtures/ColumnMasterKeyCertificateFixture.cs @@ -0,0 +1,35 @@ +// 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.Security.Cryptography.X509Certificates; + +namespace Microsoft.Data.SqlClient.Tests.Common.Fixtures; + +/// +/// Provides a test fixture for managing a column master key certificate. +/// +/// +/// This class is intended to simplify the setup and teardown of a column master key certificate for +/// testing purposes. It creates and optionally adds the certificate to the specified certificate store. +/// +public class ColumnMasterKeyCertificateFixture : CertificateFixtureBase +{ + public ColumnMasterKeyCertificateFixture() + : this(true) + { + } + + public X509Certificate2 ColumnMasterKeyCertificate { get; } + + protected ColumnMasterKeyCertificateFixture(bool createCertificate) + { + if (createCertificate) + { + ColumnMasterKeyCertificate = CreateCertificate(nameof(ColumnMasterKeyCertificate), Array.Empty(), Array.Empty()); + + AddToStore(ColumnMasterKeyCertificate, StoreLocation.CurrentUser, StoreName.My); + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/Common/Fixtures/CspCertificateFixture.cs b/src/Microsoft.Data.SqlClient/tests/Common/Fixtures/CspCertificateFixture.cs new file mode 100644 index 0000000000..74c4ca0325 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/Common/Fixtures/CspCertificateFixture.cs @@ -0,0 +1,54 @@ +// 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.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Microsoft.Data.SqlClient.Tests.Common.Fixtures; + +/// +/// Provides a fixture for working with certificates backed by a Cryptographic Service Provider (CSP). +/// +/// +/// This class creates and manages a certificate stored in the current user's certificate store, along +/// with its associated CSP key path. It is intended to facilitate testing or scenarios requiring temporary +/// certificates. +/// +public class CspCertificateFixture : CertificateFixtureBase +{ + public CspCertificateFixture() + { + CspCertificate = CreateCertificate(nameof(CspCertificate), Array.Empty(), Array.Empty(), true); + + AddToStore(CspCertificate, StoreLocation.CurrentUser, StoreName.My); + + CspCertificatePath = $"{StoreLocation.CurrentUser}/{StoreName.My}/{CspCertificate.Thumbprint}"; + CspKeyPath = GetCspPathFromCertificate(); + } + + public X509Certificate2 CspCertificate { get; } + + public string CspCertificatePath { get; } + + public string CspKeyPath { get; } + + private string GetCspPathFromCertificate() + { + RSA privateKey = CspCertificate.GetRSAPrivateKey(); + + if (privateKey is RSACryptoServiceProvider csp) + { + return string.Concat(csp.CspKeyContainerInfo.ProviderName, @"/", csp.CspKeyContainerInfo.KeyContainerName); + } + else if (privateKey is RSACng cng) + { + return string.Concat(cng.Key.Provider.Provider, @"/", cng.Key.KeyName); + } + else + { + return null; + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/Directory.Build.props b/src/Microsoft.Data.SqlClient/tests/Directory.Build.props index 4f982d4ed9..b470f899e1 100644 --- a/src/Microsoft.Data.SqlClient/tests/Directory.Build.props +++ b/src/Microsoft.Data.SqlClient/tests/Directory.Build.props @@ -22,7 +22,7 @@ net462 - net9.0 + net8.0;net9.0 diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AlwaysEncryptedTests/ExceptionsAlgorithmErrors.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AlwaysEncryptedTests/ExceptionsAlgorithmErrors.cs index ba90ddf4f0..caa98cc686 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AlwaysEncryptedTests/ExceptionsAlgorithmErrors.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AlwaysEncryptedTests/ExceptionsAlgorithmErrors.cs @@ -7,7 +7,7 @@ using System.Reflection; using System.Security.Cryptography.X509Certificates; using System.Text; -using Microsoft.Data.SqlClient.TestUtilities.Fixtures; +using Microsoft.Data.SqlClient.Tests.Common.Fixtures; using Xunit; using static Microsoft.Data.SqlClient.Tests.AlwaysEncryptedTests.Utility; diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AlwaysEncryptedTests/SqlColumnEncryptionCertificateStoreProviderShould.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AlwaysEncryptedTests/SqlColumnEncryptionCertificateStoreProviderShould.cs index 216afef784..e1fe6a0a09 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AlwaysEncryptedTests/SqlColumnEncryptionCertificateStoreProviderShould.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/AlwaysEncryptedTests/SqlColumnEncryptionCertificateStoreProviderShould.cs @@ -2,7 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using Microsoft.Data.SqlClient.TestUtilities.Fixtures; +using Microsoft.Data.SqlClient.Tests.Common.Fixtures; using System; using System.Collections.Generic; using System.Linq; diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ConversionTests.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ConversionTests.cs index 13795fec44..216db7e0c3 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ConversionTests.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/ConversionTests.cs @@ -14,7 +14,7 @@ using System.Security.Cryptography.X509Certificates; using Xunit; using Microsoft.Data.SqlClient.ManualTesting.Tests.AlwaysEncrypted.Setup; -using Microsoft.Data.SqlClient.TestUtilities.Fixtures; +using Microsoft.Data.SqlClient.Tests.Common.Fixtures; namespace Microsoft.Data.SqlClient.ManualTesting.Tests.AlwaysEncrypted { diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/CspProviderExt.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/CspProviderExt.cs index aade12b956..bdd48967b5 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/CspProviderExt.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/CspProviderExt.cs @@ -8,7 +8,7 @@ using System.Security.Cryptography.X509Certificates; using Xunit; using System.Security.Cryptography; -using Microsoft.Data.SqlClient.TestUtilities.Fixtures; +using Microsoft.Data.SqlClient.Tests.Common.Fixtures; using Microsoft.Win32; #if NET diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/TestFixtures/AzureKeyVaultKeyFixture.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/TestFixtures/AzureKeyVaultKeyFixture.cs index 4fea191d01..b59cab7e3b 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/TestFixtures/AzureKeyVaultKeyFixture.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/TestFixtures/AzureKeyVaultKeyFixture.cs @@ -2,7 +2,7 @@ // 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 Microsoft.Data.SqlClient.TestUtilities.Fixtures; +using Microsoft.Data.SqlClient.Tests.Common.Fixtures; namespace Microsoft.Data.SqlClient.ManualTesting.Tests.AlwaysEncrypted { diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/TestFixtures/SQLSetupStrategy.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/TestFixtures/SQLSetupStrategy.cs index 23ea1a9d79..d08d2a86be 100644 --- a/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/TestFixtures/SQLSetupStrategy.cs +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/AlwaysEncrypted/TestFixtures/SQLSetupStrategy.cs @@ -9,7 +9,7 @@ using System.Security.Cryptography.X509Certificates; using Microsoft.Data.SqlClient.ManualTesting.Tests.AlwaysEncrypted.Setup; using Microsoft.Data.SqlClient.ManualTesting.Tests.AlwaysEncrypted.TestFixtures.Setup; -using Microsoft.Data.SqlClient.TestUtilities.Fixtures; +using Microsoft.Data.SqlClient.Tests.Common.Fixtures; namespace Microsoft.Data.SqlClient.ManualTesting.Tests.AlwaysEncrypted { diff --git a/src/Microsoft.Data.SqlClient/tests/tools/Microsoft.Data.SqlClient.TestUtilities/Fixtures/AzureKeyVaultKeyFixtureBase.cs b/src/Microsoft.Data.SqlClient/tests/tools/Microsoft.Data.SqlClient.TestUtilities/Fixtures/AzureKeyVaultKeyFixtureBase.cs deleted file mode 100644 index 695e3ef2ea..0000000000 --- a/src/Microsoft.Data.SqlClient/tests/tools/Microsoft.Data.SqlClient.TestUtilities/Fixtures/AzureKeyVaultKeyFixtureBase.cs +++ /dev/null @@ -1,63 +0,0 @@ -// 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; -using System.Collections.Generic; -using Azure.Core; -using Azure.Security.KeyVault.Keys; - -namespace Microsoft.Data.SqlClient.TestUtilities.Fixtures -{ - public abstract class AzureKeyVaultKeyFixtureBase : IDisposable - { - private readonly KeyClient _keyClient; - private readonly Random _randomGenerator; - - private readonly List _createdKeys = new List(); - - protected AzureKeyVaultKeyFixtureBase(Uri keyVaultUri, TokenCredential keyVaultToken) - { - _keyClient = new KeyClient(keyVaultUri, keyVaultToken); - _randomGenerator = new Random(); - } - - protected Uri CreateKey(string name, int keySize) - { - CreateRsaKeyOptions createOptions = new CreateRsaKeyOptions(GenerateUniqueName(name)) { KeySize = keySize }; - KeyVaultKey created = _keyClient.CreateRsaKey(createOptions); - - _createdKeys.Add(created); - return created.Id; - } - - private string GenerateUniqueName(string name) - { - byte[] rndBytes = new byte[16]; - - _randomGenerator.NextBytes(rndBytes); - return name + "-" + BitConverter.ToString(rndBytes); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - foreach (KeyVaultKey key in _createdKeys) - { - try - { - _keyClient.StartDeleteKey(key.Name).WaitForCompletion(); - } - catch(Exception) - { - continue; - } - } - } - } -} diff --git a/src/Microsoft.Data.SqlClient/tests/tools/Microsoft.Data.SqlClient.TestUtilities/Fixtures/CertificateFixtureBase.cs b/src/Microsoft.Data.SqlClient/tests/tools/Microsoft.Data.SqlClient.TestUtilities/Fixtures/CertificateFixtureBase.cs deleted file mode 100644 index bcc0d93c07..0000000000 --- a/src/Microsoft.Data.SqlClient/tests/tools/Microsoft.Data.SqlClient.TestUtilities/Fixtures/CertificateFixtureBase.cs +++ /dev/null @@ -1,308 +0,0 @@ -// 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.Generic; -using System.Diagnostics; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Text; -using System.Threading; - -namespace Microsoft.Data.SqlClient.TestUtilities.Fixtures -{ - public abstract class CertificateFixtureBase : IDisposable - { - /// - /// Certificates must be created using this provider. Certificates created by PowerShell - /// using another provider aren't accessible from RSACryptoServiceProvider, which means - /// that we could not roundtrip between SqlColumnEncryptionCertificateStoreProvider and - /// SqlColumnEncryptionCspProvider. - /// - private const string CspProviderName = "Microsoft Enhanced RSA and AES Cryptographic Provider"; - - private sealed class CertificateStoreContext - { - public List Certificates { get; } - - public StoreLocation Location { get; } - - public StoreName Name { get; } - - public CertificateStoreContext(StoreLocation location, StoreName name) - { - Certificates = new List(); - Location = location; - Name = name; - } - } - - private readonly List _certificateStoreModifications = new List(); - - protected X509Certificate2 CreateCertificate(string subjectName, IEnumerable dnsNames, IEnumerable ipAddresses, bool forceCsp = false) - { - // This will always generate a certificate with: - // * Start date: 24hrs ago - // * End date: 24hrs in the future - // * Subject: {subjectName} - // * Subject alternative names: {dnsNames}, {ipAddresses} - // * Public key: 2048-bit RSA - // * Hash algorithm: SHA256 - // * Key usage: digital signature, key encipherment - // * Enhanced key usage: server authentication, client authentication - DateTimeOffset notBefore = DateTimeOffset.UtcNow.AddDays(-1); - DateTimeOffset notAfter = DateTimeOffset.UtcNow.AddDays(1); - byte[] passwordBytes = new byte[32]; - string password = null; - Random rnd = new Random(); - - rnd.NextBytes(passwordBytes); - password = Convert.ToBase64String(passwordBytes); -#if NET - X500DistinguishedNameBuilder subjectBuilder = new X500DistinguishedNameBuilder(); - SubjectAlternativeNameBuilder sanBuilder = new SubjectAlternativeNameBuilder(); - RSA rsaKey = CreateRSA(forceCsp); - bool hasSans = false; - - subjectBuilder.AddCommonName(subjectName); - foreach (string dnsName in dnsNames) - { - sanBuilder.AddDnsName(dnsName); - hasSans = true; - } - foreach (string ipAddress in ipAddresses) - { - sanBuilder.AddIpAddress(System.Net.IPAddress.Parse(ipAddress)); - hasSans = true; - } - - CertificateRequest request = new CertificateRequest(subjectBuilder.Build(), rsaKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - - request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); - request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false)); - request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection() { new Oid("1.3.6.1.5.5.7.3.1"), new Oid("1.3.6.1.5.5.7.3.2") }, true)); - - if (hasSans) - { - request.CertificateExtensions.Add(sanBuilder.Build()); - } - - // Generate an ephemeral certificate, then export it and return it as a new certificate with the correct key storage flags set. - // This is to ensure that it's imported into the certificate stores with its private key. - using (X509Certificate2 ephemeral = request.CreateSelfSigned(notBefore, notAfter)) - { - #if NET9_0_OR_GREATER - return X509CertificateLoader.LoadPkcs12( - ephemeral.Export(X509ContentType.Pkcs12, password), - password, - X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable, - new Pkcs12LoaderLimits(Pkcs12LoaderLimits.Defaults) - { - PreserveStorageProvider = true, - PreserveKeyName = true - }); - #else - return new X509Certificate2( - ephemeral.Export(X509ContentType.Pkcs12, password), - password, - X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); - #endif - } -#else - // The CertificateRequest API is available in .NET Core, but was only added to .NET Framework 4.7.2; it thus can't be used in the test projects. - // Instead, fall back to running a PowerShell script which calls New-SelfSignedCertificate. This cmdlet also adds the certificate to a specific, - // certificate store, so remove it from there. - // Normally, the PowerShell script will return zero and print the base64-encoded certificate to stdout. If there's an exception, it'll return 1 and - // print the message instead. - const string PowerShellCommandTemplate = @"$notBefore = [DateTime]::ParseExact(""{0}"", ""O"", $null) -$notAfter = [DateTime]::ParseExact(""{1}"", ""O"", $null) -$subject = ""CN={2}"" -$sAN = @({3}) - -try -{{ - $x509 = PKI\New-SelfSignedCertificate -Subject $subject -TextExtension $sAN -KeyLength 2048 -KeyAlgorithm RSA ` - -CertStoreLocation ""Cert:\CurrentUser\My"" -NotBefore $notBefore -NotAfter $notAfter ` - -KeyExportPolicy Exportable -HashAlgorithm SHA256 -Provider ""{5}"" -KeySpec KeyExchange - - if ($x509 -eq $null) - {{ throw ""Certificate was null!"" }} - - $exportedArray = $x509.Export(""Pkcs12"", ""{4}"") - Write-Output $([Convert]::ToBase64String($exportedArray)) - - Remove-Item ""Cert:\CurrentUser\My\$($x509.Thumbprint)"" - - exit 0 -}} -catch [Exception] -{{ - Write-Output $_.Exception.Message - exit 1 -}}"; - const int PowerShellCommandTimeout = 15_000; - - string sanString = string.Empty; - bool hasSans = false; - - foreach (string dnsName in dnsNames) - { - sanString += string.Format("DNS={0}&", dnsName); - hasSans = true; - } - foreach (string ipAddress in ipAddresses) - { - sanString += string.Format("IPAddress={0}&", ipAddress); - hasSans = true; - } - - sanString = hasSans ? "\"2.5.29.17={text}" + sanString.Substring(0, sanString.Length - 1) + "\"" : string.Empty; - - string formattedCommand = string.Format(PowerShellCommandTemplate, notBefore.ToString("O"), notAfter.ToString("O"), subjectName, sanString, password, CspProviderName); - - ProcessStartInfo startInfo = new() - { - FileName = "powershell.exe", - RedirectStandardOutput = true, - RedirectStandardError = false, - UseShellExecute = false, - CreateNoWindow = true, - // Pass the Base64-encoded command to remove the need to escape quote marks - Arguments = "-EncodedCommand " + Convert.ToBase64String(Encoding.Unicode.GetBytes(formattedCommand)), - // Run as Administrator, since we're manipulating the system - // certificate store. - Verb = "RunAs", - LoadUserProfile = true - }; - - // This command sometimes fails with: - // - // Access is denied. 0x80070005 (WIN32: 5 ERROR_ACCESS_DENIED) - // - // We will retry it a few times with a short delay to avoid spurious - // failures in CI pipeline runs. - // - // See ADO issue for more details: - // - // Issue 34304: #3223 Fix Functional test failures in CI - // - // https://sqlclientdrivers.visualstudio.com/ADO.Net/_workitems/edit/34304 - // - // Delay 5 seconds between retries, and retry 3 times. - const int delay = 5; - const int retries = 3; - - string commandOutput = string.Empty; - - for (int attempt = 1; attempt <= retries; ++attempt) - { - using Process psProcess = new() { StartInfo = startInfo }; - - psProcess.Start(); - commandOutput = psProcess.StandardOutput.ReadToEnd(); - - if (!psProcess.WaitForExit(PowerShellCommandTimeout)) - { - psProcess.Kill(); - throw new Exception("Process did not complete in time, exiting."); - } - - // Process completed successfully if it had an exit code of zero, the command output will be the base64-encoded certificate - var code = psProcess.ExitCode; - if (code == 0) - { - return new X509Certificate2(Convert.FromBase64String(commandOutput), password, X509KeyStorageFlags.Exportable); - } - - Console.WriteLine( - $"PowerShell command failed with exit code {code} on " + - $"attempt {attempt} of {retries}; " + - $"retrying in {delay} seconds..."); - - Thread.Sleep(TimeSpan.FromSeconds(delay)); - } - - throw new Exception( - "PowerShell command raised exception: " + - $"{commandOutput}; command was: {formattedCommand}"); -#endif - } - -#if NET - private static RSA CreateRSA(bool forceCsp) - { - const int KeySize = 2048; - const int CspProviderType = 24; - - return forceCsp && OperatingSystem.IsWindows() - ? new RSACryptoServiceProvider(KeySize, new CspParameters(CspProviderType, CspProviderName, Guid.NewGuid().ToString())) - : RSA.Create(KeySize); - } -#endif - - protected void AddToStore(X509Certificate2 cert, StoreLocation storeLocation, StoreName storeName) - { - CertificateStoreContext storeContext = _certificateStoreModifications.Find(csc => csc.Location == storeLocation && csc.Name == storeName); - - if (storeContext == null) - { - storeContext = new(storeLocation, storeName); - _certificateStoreModifications.Add(storeContext); - } - - using X509Store store = new X509Store(storeContext.Name, storeContext.Location); - - store.Open(OpenFlags.ReadWrite); - if (store.Certificates.Contains(cert)) - { - store.Remove(cert); - } - store.Add(cert); - - storeContext.Certificates.Add(cert); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - foreach (CertificateStoreContext storeContext in _certificateStoreModifications) - { - using X509Store store = new X509Store(storeContext.Name, storeContext.Location); - - try - { - store.Open(OpenFlags.ReadWrite); - } - catch(Exception) - { - continue; - } - - foreach (X509Certificate2 cert in storeContext.Certificates) - { - try - { - if (store.Certificates.Contains(cert)) - { - store.Remove(cert); - } - } - catch (Exception) - { - continue; - } - - cert.Dispose(); - } - - storeContext.Certificates.Clear(); - } - } - } -} diff --git a/src/Microsoft.Data.SqlClient/tests/tools/Microsoft.Data.SqlClient.TestUtilities/Fixtures/ColumnEncryptionCertificateFixture.cs b/src/Microsoft.Data.SqlClient/tests/tools/Microsoft.Data.SqlClient.TestUtilities/Fixtures/ColumnEncryptionCertificateFixture.cs deleted file mode 100644 index 7b486ccee0..0000000000 --- a/src/Microsoft.Data.SqlClient/tests/tools/Microsoft.Data.SqlClient.TestUtilities/Fixtures/ColumnEncryptionCertificateFixture.cs +++ /dev/null @@ -1,63 +0,0 @@ -// 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.Security.Cryptography.X509Certificates; -using System.Security.Principal; - -namespace Microsoft.Data.SqlClient.TestUtilities.Fixtures -{ - public sealed class ColumnEncryptionCertificateFixture : CertificateFixtureBase - { - public X509Certificate2 PrimaryColumnEncryptionCertificate { get; } - - public X509Certificate2 SecondaryColumnEncryptionCertificate { get; } - - public X509Certificate2 CertificateWithoutPrivateKey { get; } - - private readonly X509Certificate2 _currentUserCertificate; - private readonly X509Certificate2 _localMachineCertificate; - - public ColumnEncryptionCertificateFixture() - { - PrimaryColumnEncryptionCertificate = CreateCertificate(nameof(PrimaryColumnEncryptionCertificate), Array.Empty(), Array.Empty()); - SecondaryColumnEncryptionCertificate = CreateCertificate(nameof(SecondaryColumnEncryptionCertificate), Array.Empty(), Array.Empty()); - _currentUserCertificate = CreateCertificate(nameof(_currentUserCertificate), Array.Empty(), Array.Empty()); - using (X509Certificate2 createdCertificate = CreateCertificate(nameof(CertificateWithoutPrivateKey), Array.Empty(), Array.Empty())) - { - // This will strip the private key away from the created certificate -#if NET9_0_OR_GREATER - CertificateWithoutPrivateKey = X509CertificateLoader.LoadCertificate(createdCertificate.Export(X509ContentType.Cert)); -#else - CertificateWithoutPrivateKey = new X509Certificate2(createdCertificate.Export(X509ContentType.Cert)); -#endif - AddToStore(CertificateWithoutPrivateKey, StoreLocation.CurrentUser, StoreName.My); - } - - AddToStore(PrimaryColumnEncryptionCertificate, StoreLocation.CurrentUser, StoreName.My); - AddToStore(SecondaryColumnEncryptionCertificate, StoreLocation.CurrentUser, StoreName.My); - AddToStore(_currentUserCertificate, StoreLocation.CurrentUser, StoreName.My); - - if (IsAdmin) - { - _localMachineCertificate = CreateCertificate(nameof(_localMachineCertificate), Array.Empty(), Array.Empty()); - - AddToStore(_localMachineCertificate, StoreLocation.LocalMachine, StoreName.My); - } - } - - public X509Certificate2 GetCertificate(StoreLocation storeLocation) - { - return storeLocation == StoreLocation.CurrentUser - ? _currentUserCertificate - : storeLocation == StoreLocation.LocalMachine && IsAdmin - ? _localMachineCertificate - : throw new InvalidOperationException("Attempted to retrieve the certificate added to the local machine store; this requires administrator rights."); - } - - public static bool IsAdmin - => Environment.OSVersion.Platform == PlatformID.Win32NT - && new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator); - } -} diff --git a/src/Microsoft.Data.SqlClient/tests/tools/Microsoft.Data.SqlClient.TestUtilities/Fixtures/ColumnMasterKeyCertificateFixture.cs b/src/Microsoft.Data.SqlClient/tests/tools/Microsoft.Data.SqlClient.TestUtilities/Fixtures/ColumnMasterKeyCertificateFixture.cs deleted file mode 100644 index b6706be1c4..0000000000 --- a/src/Microsoft.Data.SqlClient/tests/tools/Microsoft.Data.SqlClient.TestUtilities/Fixtures/ColumnMasterKeyCertificateFixture.cs +++ /dev/null @@ -1,29 +0,0 @@ -// 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.Security.Cryptography.X509Certificates; - -namespace Microsoft.Data.SqlClient.TestUtilities.Fixtures -{ - public class ColumnMasterKeyCertificateFixture : CertificateFixtureBase - { - public ColumnMasterKeyCertificateFixture() - : this(true) - { - } - - public X509Certificate2 ColumnMasterKeyCertificate { get; } - - protected ColumnMasterKeyCertificateFixture(bool createCertificate) - { - if (createCertificate) - { - ColumnMasterKeyCertificate = CreateCertificate(nameof(ColumnMasterKeyCertificate), Array.Empty(), Array.Empty()); - - AddToStore(ColumnMasterKeyCertificate, StoreLocation.CurrentUser, StoreName.My); - } - } - } -} diff --git a/src/Microsoft.Data.SqlClient/tests/tools/Microsoft.Data.SqlClient.TestUtilities/Fixtures/CspCertificateFixture.cs b/src/Microsoft.Data.SqlClient/tests/tools/Microsoft.Data.SqlClient.TestUtilities/Fixtures/CspCertificateFixture.cs deleted file mode 100644 index 7fabaf1b9c..0000000000 --- a/src/Microsoft.Data.SqlClient/tests/tools/Microsoft.Data.SqlClient.TestUtilities/Fixtures/CspCertificateFixture.cs +++ /dev/null @@ -1,47 +0,0 @@ -// 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.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; - -namespace Microsoft.Data.SqlClient.TestUtilities.Fixtures -{ - public class CspCertificateFixture : CertificateFixtureBase - { - public CspCertificateFixture() - { - CspCertificate = CreateCertificate(nameof(CspCertificate), Array.Empty(), Array.Empty(), true); - - AddToStore(CspCertificate, StoreLocation.CurrentUser, StoreName.My); - - CspCertificatePath = string.Format("{0}/{1}/{2}", StoreLocation.CurrentUser, StoreName.My, CspCertificate.Thumbprint); - CspKeyPath = GetCspPathFromCertificate(); - } - - public X509Certificate2 CspCertificate { get; } - - public string CspCertificatePath { get; } - - public string CspKeyPath { get; } - - private string GetCspPathFromCertificate() - { - RSA privateKey = CspCertificate.GetRSAPrivateKey(); - - if (privateKey is RSACryptoServiceProvider csp) - { - return string.Concat(csp.CspKeyContainerInfo.ProviderName, @"/", csp.CspKeyContainerInfo.KeyContainerName); - } - else if (privateKey is RSACng cng) - { - return string.Concat(cng.Key.Provider.Provider, @"/", cng.Key.KeyName); - } - else - { - return null; - } - } - } -}