diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/AE.java b/src/main/java/com/microsoft/sqlserver/jdbc/AE.java index 10222d321..678d9f7bb 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/AE.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/AE.java @@ -8,6 +8,8 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** @@ -230,6 +232,43 @@ boolean isAlgorithmInitialized() { } +/** + * Represents a cache of all queries for a given enclave session. + */ +class CryptoCache { + /** + * The cryptocache stores both result sets returned from sp_describe_parameter_encryption calls. CEK data in cekMap, + * and parameter data in paramMap. + */ + private final ConcurrentHashMap> cekMap = new ConcurrentHashMap<>(16); + private ConcurrentHashMap> paramMap = new ConcurrentHashMap<>(16); + + ConcurrentHashMap> getParamMap() { + return paramMap; + } + + void replaceParamMap(ConcurrentHashMap> newMap) { + paramMap = newMap; + } + + Map getEnclaveEntry(String enclaveLookupKey) { + return cekMap.get(enclaveLookupKey); + } + + ConcurrentHashMap getCacheEntry(String cacheLookupKey) { + return paramMap.get(cacheLookupKey); + } + + void addParamEntry(String key, ConcurrentHashMap value) { + paramMap.put(key, value); + } + + void removeParamEntry(String cacheLookupKey) { + paramMap.remove(cacheLookupKey); + } +} + + // Fields in the first resultset of "sp_describe_parameter_encryption" // We expect the server to return the fields in the resultset in the same order as mentioned below. // If the server changes the below order, then transparent parameter encryption will break. diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerEnclaveProvider.java b/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerEnclaveProvider.java index fa3f747d3..8ce9f566c 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerEnclaveProvider.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerEnclaveProvider.java @@ -181,7 +181,8 @@ default ResultSet executeSDPEv1(PreparedStatement stmt, String userSql, */ default void processSDPEv1(String userSql, String preparedTypeDefinitions, Parameter[] params, ArrayList parameterNames, SQLServerConnection connection, SQLServerStatement sqlServerStatement, - PreparedStatement stmt, ResultSet rs, ArrayList enclaveRequestedCEKs) throws SQLException { + PreparedStatement stmt, ResultSet rs, ArrayList enclaveRequestedCEKs, + EnclaveSession session) throws SQLException { Map cekList = new HashMap<>(); CekTableEntry cekEntry = null; boolean isRequestedByEnclave = false; @@ -279,6 +280,12 @@ default void processSDPEv1(String userSql, String preparedTypeDefinitions, Param } } } + + // If using Always Encrypted v1 (without secure enclaves), add to cache + if (!connection.enclaveEstablished() && session != null) { + ParameterMetaDataCache.addQueryMetadata(params, parameterNames, session.getCryptoCache(), connection, + sqlServerStatement, cekList); + } } /** @@ -482,11 +489,13 @@ class EnclaveSession { private byte[] sessionID; private AtomicLong counter; private byte[] sessionSecret; + private CryptoCache cryptoCache; EnclaveSession(byte[] cs, byte[] b) { sessionID = cs; sessionSecret = b; counter = new AtomicLong(0); + cryptoCache = new CryptoCache(); } byte[] getSessionID() { @@ -497,6 +506,10 @@ byte[] getSessionSecret() { return sessionSecret; } + CryptoCache getCryptoCache() { + return cryptoCache; + } + synchronized long getCounter() { return counter.getAndIncrement(); } diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/ParameterMetaDataCache.java b/src/main/java/com/microsoft/sqlserver/jdbc/ParameterMetaDataCache.java new file mode 100644 index 000000000..52e0bf7dd --- /dev/null +++ b/src/main/java/com/microsoft/sqlserver/jdbc/ParameterMetaDataCache.java @@ -0,0 +1,243 @@ +/* + * Microsoft JDBC Driver for SQL Server Copyright(c) Microsoft Corporation All rights reserved. This program is made + * available under the terms of the MIT License. See the LICENSE file in the project root for more information. + */ +package com.microsoft.sqlserver.jdbc; + +import java.text.MessageFormat; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + + +/** + * Implements a cache for query metadata returned from sp_describe_parameter_encryption calls. Adding, removing, and + * reading from the cache is handled here, with the location of the cache being in the EnclaveSession. + * + */ +class ParameterMetaDataCache { + + static final int CACHE_SIZE = 2000; // Size of the cache in number of entries + static final int CACHE_TRIM_THRESHOLD = 300; // Threshold above which to trim the cache + + static private java.util.logging.Logger metadataCacheLogger = java.util.logging.Logger + .getLogger("com.microsoft.sqlserver.jdbc.ParameterMetaDataCache"); + + /** + * Retrieves the metadata from the cache, should it exist. + * + * @param params + * Array of parameters used + * @param parameterNames + * Names of parameters used + * @param session + * The current enclave session containing the cache + * @param connection + * The SQLServer connection + * @param stmt + * The SQLServer statement, whose returned metadata we're checking + * @return true, if the metadata for the query can be retrieved + * + */ + static boolean getQueryMetadata(Parameter[] params, ArrayList parameterNames, CryptoCache cache, + SQLServerConnection connection, SQLServerStatement stmt) throws SQLServerException { + + AbstractMap.SimpleEntry encryptionValues = getCacheLookupKeys(stmt, connection); + ConcurrentHashMap metadataMap = cache.getCacheEntry(encryptionValues.getKey()); + + if (metadataMap == null) { + if (metadataCacheLogger.isLoggable(java.util.logging.Level.FINEST)) { + metadataCacheLogger.finest("Cache Miss. Unable to retrieve cache entry from cache."); + } + return false; + } + + for (int i = 0; i < params.length; i++) { + boolean found = metadataMap.containsKey(parameterNames.get(i)); + CryptoMetadata foundData = metadataMap.get(parameterNames.get(i)); + + /* + * If ever the map doesn't contain a parameter, the cache entry cannot be used. If there is data found, it + * should never have the initialized algorithm as that would contain the key. Clear all metadata that has + * already been assigned in either case. + */ + if (!found || (foundData != null && foundData.isAlgorithmInitialized())) { + for (Parameter param : params) { + param.cryptoMeta = null; + } + if (metadataCacheLogger.isLoggable(java.util.logging.Level.FINEST)) { + metadataCacheLogger + .finest("Cache Miss. Cache entry either has missing parameter or initialized algorithm."); + } + return false; + } + params[i].cryptoMeta = foundData; + } + + // Assign the key using a metadata copy. We shouldn't load from the cached version for security reasons. + for (int i = 0; i < params.length; ++i) { + try { + CryptoMetadata cryptoCopy = null; + CryptoMetadata metaData = params[i].getCryptoMetadata(); + if (metaData != null) { + cryptoCopy = new CryptoMetadata(metaData.getCekTableEntry(), metaData.getOrdinal(), + metaData.getEncryptionAlgorithmId(), metaData.getEncryptionAlgorithmName(), + metaData.getEncryptionType().getValue(), metaData.getNormalizationRuleVersion()); + } + + params[i].cryptoMeta = cryptoCopy; + + if (cryptoCopy != null) { + try { + SQLServerSecurityUtility.decryptSymmetricKey(cryptoCopy, connection, stmt); + } catch (SQLServerException e) { + + removeCacheEntry(stmt, cache, connection); + + for (Parameter paramToCleanup : params) { + paramToCleanup.cryptoMeta = null; + } + + if (metadataCacheLogger.isLoggable(java.util.logging.Level.FINEST)) { + metadataCacheLogger.finest("Cache Miss. Unable to decrypt CEK."); + } + return false; + } + } + } catch (Exception e) { + MessageFormat form = new MessageFormat(SQLServerException.getErrString("R_CryptoCacheInaccessible")); + Object[] msgArgs = {e.getMessage()}; + throw new SQLServerException(form.format(msgArgs), null); + } + } + + if (metadataCacheLogger.isLoggable(java.util.logging.Level.FINEST)) { + metadataCacheLogger.finest("Cache Hit. Successfully retrieved metadata from cache."); + } + return true; + } + + /** + * + * Adds the parameter metadata to the cache, also handles cache trimming. + * + * @param params + * List of parameters used + * @param parameterNames + * Names of parameters used + * @param session + * Enclave session containing the cryptocache + * @param connection + * SQLServerConnection + * @param stmt + * SQLServer statement used to retrieve keys to find correct cache + * @param cekList + * The list of CEKs (from the first RS) that is also added to the cache as well as parameter metadata + * @return true, if the query metadata has been added correctly + */ + static boolean addQueryMetadata(Parameter[] params, ArrayList parameterNames, CryptoCache cache, + SQLServerConnection connection, SQLServerStatement stmt, + Map cekList) throws SQLServerException { + + AbstractMap.SimpleEntry encryptionValues = getCacheLookupKeys(stmt, connection); + if (encryptionValues.getKey() == null) { + return false; + } + + ConcurrentHashMap metadataMap = new ConcurrentHashMap<>(params.length); + + for (int i = 0; i < params.length; i++) { + try { + CryptoMetadata cryptoCopy = null; + CryptoMetadata metaData = params[i].getCryptoMetadata(); + if (metaData != null) { + + cryptoCopy = new CryptoMetadata(metaData.getCekTableEntry(), metaData.getOrdinal(), + metaData.getEncryptionAlgorithmId(), metaData.getEncryptionAlgorithmName(), + metaData.getEncryptionType().getValue(), metaData.getNormalizationRuleVersion()); + } + if (cryptoCopy != null && !cryptoCopy.isAlgorithmInitialized()) { + String paramName = parameterNames.get(i); + metadataMap.put(paramName, cryptoCopy); + } else { + return false; + } + } catch (SQLServerException e) { + MessageFormat form = new MessageFormat(SQLServerException.getErrString("R_CryptoCacheInaccessible")); + Object[] msgArgs = {e.getMessage()}; + throw new SQLServerException(form.format(msgArgs), null); + } + } + + // If the size of the cache exceeds the threshold, set that we are in trimming and trim the cache accordingly. + int cacheSizeCurrent = cache.getParamMap().size(); + if (cacheSizeCurrent > CACHE_SIZE + CACHE_TRIM_THRESHOLD) { + int entriesToRemove = cacheSizeCurrent - CACHE_SIZE; + ConcurrentHashMap> newMap = new ConcurrentHashMap<>(); + ConcurrentHashMap> oldMap = cache.getParamMap(); + int count = 0; + + for (Map.Entry> entry : oldMap.entrySet()) { + if (count >= entriesToRemove) { + newMap.put(entry.getKey(), entry.getValue()); + } + count++; + } + cache.replaceParamMap(newMap); + if (metadataCacheLogger.isLoggable(java.util.logging.Level.FINEST)) { + metadataCacheLogger.finest("Cache successfully trimmed."); + } + } + + cache.addParamEntry(encryptionValues.getKey(), metadataMap); + return true; + } + + /** + * + * Remove the cache entry. + * + * @param stmt + * SQLServer statement used to retrieve keys + * @param session + * The enclave session where the cryptocache is stored + * @param connection + * The SQLServerConnection, also used to retrieve keys + */ + static void removeCacheEntry(SQLServerStatement stmt, CryptoCache cache, SQLServerConnection connection) { + AbstractMap.SimpleEntry encryptionValues = getCacheLookupKeys(stmt, connection); + if (encryptionValues.getKey() == null) { + return; + } + + cache.removeParamEntry(encryptionValues.getKey()); + } + + /** + * + * Returns the cache and enclave lookup keys for a given connection and statement + * + * @param statement + * The SQLServer statement used to construct part of the keys + * @param connection + * The connection from which database name is retrieved + * @return A key value pair containing cache lookup key and enclave lookup key + */ + private static AbstractMap.SimpleEntry getCacheLookupKeys(SQLServerStatement statement, + SQLServerConnection connection) { + + StringBuilder cacheLookupKeyBuilder = new StringBuilder(); + cacheLookupKeyBuilder.append(":::"); + String databaseName = connection.activeConnectionProperties + .getProperty(SQLServerDriverStringProperty.DATABASE_NAME.toString()); + cacheLookupKeyBuilder.append(databaseName); + cacheLookupKeyBuilder.append(":::"); + cacheLookupKeyBuilder.append(statement.toString()); + + String cacheLookupKey = cacheLookupKeyBuilder.toString(); + String enclaveLookupKey = cacheLookupKeyBuilder.append(":::enclaveKeys").toString(); + + return new AbstractMap.SimpleEntry<>(cacheLookupKey, enclaveLookupKey); + } +} diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerAASEnclaveProvider.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerAASEnclaveProvider.java index 55a021eb4..c9432a543 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerAASEnclaveProvider.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerAASEnclaveProvider.java @@ -126,25 +126,31 @@ private ArrayList describeParameterEncryption(SQLServerConnection connec ArrayList parameterNames) throws SQLServerException { ArrayList enclaveRequestedCEKs = new ArrayList<>(); try (PreparedStatement stmt = connection.prepareStatement(connection.enclaveEstablished() ? SDPE1 : SDPE2)) { - try (ResultSet rs = connection.enclaveEstablished() ? executeSDPEv1(stmt, userSql, - preparedTypeDefinitions) : executeSDPEv2(stmt, userSql, preparedTypeDefinitions, aasParams)) { - if (null == rs) { - // No results. Meaning no parameter. - // Should never happen. - return enclaveRequestedCEKs; - } - processSDPEv1(userSql, preparedTypeDefinitions, params, parameterNames, connection, statement, stmt, rs, - enclaveRequestedCEKs); - // Process the third resultset. - if (connection.isAEv2() && stmt.getMoreResults()) { - try (ResultSet hgsRs = (SQLServerResultSet) stmt.getResultSet()) { - if (hgsRs.next()) { - hgsResponse = new AASAttestationResponse(hgsRs.getBytes(1)); - // This validates and establishes the enclave session if valid - validateAttestationResponse(); - } else { - SQLServerException.makeFromDriverError(null, this, - SQLServerException.getErrString("R_UnableRetrieveParameterMetadata"), "0", false); + // Check the cache for metadata only if we're using AEv1 (without secure enclaves) + if (connection.getServerColumnEncryptionVersion() != ColumnEncryptionVersion.AE_V1 + || !ParameterMetaDataCache.getQueryMetadata(params, parameterNames, enclaveSession.getCryptoCache(), + connection, statement)) { + try (ResultSet rs = connection.enclaveEstablished() ? executeSDPEv1(stmt, userSql, + preparedTypeDefinitions) : executeSDPEv2(stmt, userSql, preparedTypeDefinitions, aasParams)) { + if (null == rs) { + // No results. Meaning no parameter. + // Should never happen. + return enclaveRequestedCEKs; + } + processSDPEv1(userSql, preparedTypeDefinitions, params, parameterNames, connection, statement, stmt, + rs, enclaveRequestedCEKs, enclaveSession); + // Process the third resultset. + if (connection.isAEv2() && stmt.getMoreResults()) { + try (ResultSet hgsRs = (SQLServerResultSet) stmt.getResultSet()) { + if (hgsRs.next()) { + hgsResponse = new AASAttestationResponse(hgsRs.getBytes(1)); + // This validates and establishes the enclave session if valid + validateAttestationResponse(); + } else { + SQLServerException.makeFromDriverError(null, this, + SQLServerException.getErrString("R_UnableRetrieveParameterMetadata"), "0", + false); + } } } } diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerNoneEnclaveProvider.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerNoneEnclaveProvider.java index 386aab95d..2ba4ab6f7 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerNoneEnclaveProvider.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerNoneEnclaveProvider.java @@ -115,25 +115,31 @@ private ArrayList describeParameterEncryption(SQLServerConnection connec ArrayList parameterNames) throws SQLServerException { ArrayList enclaveRequestedCEKs = new ArrayList<>(); try (PreparedStatement stmt = connection.prepareStatement(connection.enclaveEstablished() ? SDPE1 : SDPE2)) { - try (ResultSet rs = connection.enclaveEstablished() ? executeSDPEv1(stmt, userSql, - preparedTypeDefinitions) : executeSDPEv2(stmt, userSql, preparedTypeDefinitions, noneParams)) { - if (null == rs) { - // No results. Meaning no parameter. - // Should never happen. - return enclaveRequestedCEKs; - } - processSDPEv1(userSql, preparedTypeDefinitions, params, parameterNames, connection, statement, stmt, rs, - enclaveRequestedCEKs); - // Process the third result set. - if (connection.isAEv2() && stmt.getMoreResults()) { - try (ResultSet hgsRs = stmt.getResultSet()) { - if (hgsRs.next()) { - noneResponse = new NoneAttestationResponse(hgsRs.getBytes(1)); - // This validates and establishes the enclave session if valid - validateAttestationResponse(); - } else { - SQLServerException.makeFromDriverError(null, this, - SQLServerException.getErrString("R_UnableRetrieveParameterMetadata"), "0", false); + // Check the cache for metadata only if we're using AEv1 (without secure enclaves) + if (connection.getServerColumnEncryptionVersion() != ColumnEncryptionVersion.AE_V1 + || !ParameterMetaDataCache.getQueryMetadata(params, parameterNames, enclaveSession.getCryptoCache(), + connection, statement)) { + try (ResultSet rs = connection.enclaveEstablished() ? executeSDPEv1(stmt, userSql, + preparedTypeDefinitions) : executeSDPEv2(stmt, userSql, preparedTypeDefinitions, noneParams)) { + if (null == rs) { + // No results. Meaning no parameter. + // Should never happen. + return enclaveRequestedCEKs; + } + processSDPEv1(userSql, preparedTypeDefinitions, params, parameterNames, connection, statement, stmt, + rs, enclaveRequestedCEKs, enclaveSession); + // Process the third result set. + if (connection.isAEv2() && stmt.getMoreResults()) { + try (ResultSet hgsRs = stmt.getResultSet()) { + if (hgsRs.next()) { + noneResponse = new NoneAttestationResponse(hgsRs.getBytes(1)); + // This validates and establishes the enclave session if valid + validateAttestationResponse(); + } else { + SQLServerException.makeFromDriverError(null, this, + SQLServerException.getErrString("R_UnableRetrieveParameterMetadata"), "0", + false); + } } } } @@ -153,8 +159,7 @@ private ArrayList describeParameterEncryption(SQLServerConnection connec /** * - * Represents the serialization of the request the client sends to the - * SQL Server while setting up a session. + * Represents the serialization of the request the client sends to the SQL Server while setting up a session. * */ class NoneAttestationParameters extends BaseAttestationRequest { @@ -200,8 +205,8 @@ byte[] getNonce() { /** * - * Represents the deserialization of the byte payload the client receives from the - * SQL Server while setting up a session. + * Represents the deserialization of the byte payload the client receives from the SQL Server while setting up a + * session. * */ class NoneAttestationResponse extends BaseAttestationResponse { diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerResource.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerResource.java index cbb65eea5..b038e348b 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerResource.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerResource.java @@ -302,6 +302,7 @@ protected Object[][] getContents() { {"R_ByteToShortConversion", "Error occurred while decrypting column encryption key."}, {"R_InvalidCertificateSignature", "The specified encrypted column encryption key signature does not match the signature computed with the column master key (certificate) in \"{0}\". The encrypted column encryption key may be corrupt, or the specified path may be incorrect."}, {"R_CEKDecryptionFailed", "Exception while decryption of encrypted column encryption key: {0} "}, + {"R_CryptoCacheInaccessible", "Error while attempting to perform session crypto cache operations: {0} "}, {"R_NullKeyEncryptionAlgorithm", "Key encryption algorithm cannot be null."}, {"R_NullKeyEncryptionAlgorithmInternal", "Internal error. Key encryption algorithm cannot be null."}, {"R_InvalidKeyEncryptionAlgorithm", "Invalid key encryption algorithm specified: {0}. Expected value: {1}."}, diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerVSMEnclaveProvider.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerVSMEnclaveProvider.java index 02961eac5..13d6c1e72 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerVSMEnclaveProvider.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerVSMEnclaveProvider.java @@ -150,25 +150,31 @@ private ArrayList describeParameterEncryption(SQLServerConnection connec ArrayList parameterNames) throws SQLServerException { ArrayList enclaveRequestedCEKs = new ArrayList<>(); try (PreparedStatement stmt = connection.prepareStatement(connection.enclaveEstablished() ? SDPE1 : SDPE2)) { - try (ResultSet rs = connection.enclaveEstablished() ? executeSDPEv1(stmt, userSql, - preparedTypeDefinitions) : executeSDPEv2(stmt, userSql, preparedTypeDefinitions, vsmParams)) { - if (null == rs) { - // No results. Meaning no parameter. - // Should never happen. - return enclaveRequestedCEKs; - } - processSDPEv1(userSql, preparedTypeDefinitions, params, parameterNames, connection, statement, stmt, rs, - enclaveRequestedCEKs); - // Process the third resultset. - if (connection.isAEv2() && stmt.getMoreResults()) { - try (ResultSet hgsRs = (SQLServerResultSet) stmt.getResultSet()) { - if (hgsRs.next()) { - hgsResponse = new VSMAttestationResponse(hgsRs.getBytes(1)); - // This validates and establishes the enclave session if valid - validateAttestationResponse(); - } else { - SQLServerException.makeFromDriverError(null, this, - SQLServerException.getErrString("R_UnableRetrieveParameterMetadata"), "0", false); + // Check the cache for metadata only if we're using AEv1 (without secure enclaves) + if (connection.getServerColumnEncryptionVersion() != ColumnEncryptionVersion.AE_V1 + || !ParameterMetaDataCache.getQueryMetadata(params, parameterNames, + enclaveSession.getCryptoCache(), connection, statement)) { + try (ResultSet rs = connection.enclaveEstablished() ? executeSDPEv1(stmt, userSql, + preparedTypeDefinitions) : executeSDPEv2(stmt, userSql, preparedTypeDefinitions, vsmParams)) { + if (null == rs) { + // No results. Meaning no parameter. + // Should never happen. + return enclaveRequestedCEKs; + } + processSDPEv1(userSql, preparedTypeDefinitions, params, parameterNames, connection, statement, stmt, + rs, enclaveRequestedCEKs, enclaveSession); + // Process the third resultset. + if (connection.isAEv2() && stmt.getMoreResults()) { + try (ResultSet hgsRs = (SQLServerResultSet) stmt.getResultSet()) { + if (hgsRs.next()) { + hgsResponse = new VSMAttestationResponse(hgsRs.getBytes(1)); + // This validates and establishes the enclave session if valid + validateAttestationResponse(); + } else { + SQLServerException.makeFromDriverError(null, this, + SQLServerException.getErrString("R_UnableRetrieveParameterMetadata"), "0", + false); + } } } } diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/SQLServerConnectionTest.java b/src/test/java/com/microsoft/sqlserver/jdbc/SQLServerConnectionTest.java index ad4f6d92a..68999a4f6 100644 --- a/src/test/java/com/microsoft/sqlserver/jdbc/SQLServerConnectionTest.java +++ b/src/test/java/com/microsoft/sqlserver/jdbc/SQLServerConnectionTest.java @@ -17,6 +17,9 @@ import java.sql.SQLFeatureNotSupportedException; import java.sql.Statement; import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; import java.util.UUID; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; @@ -972,4 +975,36 @@ public void testBadServerCert() throws SQLException { || e.getMessage().matches(TestUtils.formatErrorMsg("R_sslFailed")), e.getMessage()); } } + + /** + * Test to make sure parameter metadata denials are handled correctly. + * + * @throws SQLException + * + * @throws SQLServerException + */ + @Test + public void testParameterMetadataAccessDenial() throws SQLException { + try (SQLServerStatement stmt = (SQLServerStatement) connection.createStatement()) { + CryptoCache cache = new CryptoCache(); + Map cekList = new HashMap<>(); + Parameter[] params = {new Parameter(false)}; + ArrayList parameterNames = new ArrayList<>(1); + parameterNames.add("testParameter"); + // Both will always return false + try { + ParameterMetaDataCache.addQueryMetadata(params, parameterNames, cache, connection, stmt, cekList); + } catch (SQLException e) { + assertEquals(TestResource.getResource("R_CryptoCacheInaccessible"), e.getMessage(), + TestResource.getResource("R_wrongExceptionMessage")); + } + + try { + ParameterMetaDataCache.getQueryMetadata(params, parameterNames, cache, connection, stmt); + } catch (SQLException e) { + assertEquals(TestResource.getResource("R_CryptoCacheInaccessible"), e.getMessage(), + TestResource.getResource("R_wrongExceptionMessage")); + } + } + } } diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/parametermetadata/ParameterMetaDataCacheTest.java b/src/test/java/com/microsoft/sqlserver/jdbc/parametermetadata/ParameterMetaDataCacheTest.java new file mode 100644 index 000000000..c0dcccdb7 --- /dev/null +++ b/src/test/java/com/microsoft/sqlserver/jdbc/parametermetadata/ParameterMetaDataCacheTest.java @@ -0,0 +1,234 @@ +/* + * Microsoft JDBC Driver for SQL Server Copyright(c) Microsoft Corporation All rights reserved. This program is made + * available under the terms of the MIT License. See the LICENSE file in the project root for more information. + */ +package com.microsoft.sqlserver.jdbc.parametermetadata; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.SQLTimeoutException; +import java.sql.Statement; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.platform.runner.JUnitPlatform; +import org.junit.runner.RunWith; + +import com.microsoft.sqlserver.jdbc.RandomUtil; +import com.microsoft.sqlserver.jdbc.SQLServerColumnEncryptionAzureKeyVaultProvider; +import com.microsoft.sqlserver.jdbc.SQLServerColumnEncryptionKeyStoreProvider; +import com.microsoft.sqlserver.jdbc.SQLServerConnection; +import com.microsoft.sqlserver.jdbc.SQLServerException; +import com.microsoft.sqlserver.jdbc.SQLServerPreparedStatement; +import com.microsoft.sqlserver.jdbc.TestUtils; +import com.microsoft.sqlserver.testframework.AbstractSQLGenerator; +import com.microsoft.sqlserver.testframework.AbstractTest; +import com.microsoft.sqlserver.testframework.Constants; + + +/** + * Tests for caching parameter metadata in sp_describe_parameter_encryption calls + */ +@RunWith(JUnitPlatform.class) +@Tag(Constants.xSQLv11) +@Tag(Constants.xSQLv12) +@Tag(Constants.xSQLv14) +public class ParameterMetaDataCacheTest extends AbstractTest { + private final String firstTable = TestUtils.escapeSingleQuotes( + AbstractSQLGenerator.escapeIdentifier(RandomUtil.getIdentifier("CacheParamMetaTable1"))); + private final String secondTable = TestUtils.escapeSingleQuotes( + AbstractSQLGenerator.escapeIdentifier(RandomUtil.getIdentifier("CacheParamMetaTable2"))); + private final String cmkName = Constants.CMK_NAME + "cacheParameterMetadata1"; + private final String cekName = Constants.CEK_NAME + "cacheParameterMetadata1"; + private final String cekNameAlt = Constants.CEK_NAME + "cacheParameterMetadata2"; + private final String sampleData = "CacheParameterMetadata_testData"; + private final String sampleData2 = "CacheParameterMetadata_testData2"; + private final String columnName = "CacheParameterMetadata_column"; + + @BeforeAll + public static void setupTests() throws Exception { + connectionString = TestUtils.addOrOverrideProperty(connectionString, "columnEncryptionSetting", "Enabled"); + setConnection(); + } + + private void tableSetup() throws SQLException { + try (Connection conn = DriverManager.getConnection(connectionString); + Statement stmt = connection.createStatement()) { + TestUtils.dropTableIfExists(firstTable, stmt); + TestUtils.dropTableIfExists(secondTable, stmt); + + dropCEK(cekName); + dropCMK(cmkName); + + createCMK(cmkName, keyIDs[0]); + createCEK(cekName, cmkName, setupKeyStoreProvider(), keyIDs[0]); + createTable(cekName, firstTable, columnName); + createTable(cekName, secondTable, columnName); + } + } + + /** + * + * Tests caching of parameter metadata by running a query to be cached, another to replace parameter information, + * then the first again to measure the difference in time between the two runs. + * + * @throws SQLServerException + */ + @Test + @Tag(Constants.xSQLv11) + @Tag(Constants.xSQLv12) + @Tag(Constants.xSQLv14) + @Tag(Constants.reqExternalSetup) + public void testParameterMetaDataCache() throws Exception { + tableSetup(); + updateTable(firstTable, sampleData); + updateTable(secondTable, sampleData2); + + long firstRun = timedTestSelect(firstTable, sampleData); + selectTable(secondTable, columnName, sampleData2); + long secondRun = timedTestSelect(firstTable, sampleData); + + // As long as there is a noticeable performance improvement, caching is working as intended. For now + // the threshold measured is 5%. + double threshold = 0.05; + assertTrue(1 - (secondRun / firstRun) > threshold); + } + + /** + * + * Tests that the enclave is retried when using secure enclaves (assuming the server supports this). This is done by + * executing a query generating metadata in the cache, changing the CEK to make the metadata stale, and running the + * query again. The query should fail, but retry and pass. Currently disabled as secure enclaves are not supported. + * + * @throws SQLServerException + */ + @Tag(Constants.xSQLv11) + @Tag(Constants.xSQLv12) + @Tag(Constants.xSQLv14) + @Tag(Constants.reqExternalSetup) + @Tag("unused") + public void testRetryWithSecureCache() throws Exception { + tableSetup(); + try { + updateTable(firstTable, sampleData); + selectTable(firstTable, columnName, sampleData); + + createCEK(cekNameAlt, cmkName, setupKeyStoreProvider(), keyIDs[0]); + alterTable(firstTable, columnName, cekNameAlt); + + selectTable(firstTable, columnName, sampleData); + alterTable(firstTable, columnName, cekName); + } finally { + dropCEK(cekNameAlt); + } + } + + private static void createTable(String cekName, String tableName, String columnName) throws SQLException { + try (Connection conn = DriverManager.getConnection(connectionString); + Statement stmt = connection.createStatement()) { + stmt.executeUpdate("create table " + tableName + " (" + columnName + + " varchar(max) COLLATE Latin1_General_BIN2 ENCRYPTED WITH " + + "(ENCRYPTION_TYPE = DETERMINISTIC, ALGORITHM = 'AEAD_AES_256_CBC_HMAC_SHA_256', " + + "COLUMN_ENCRYPTION_KEY = " + cekName + ") NULL,);"); + } + } + + private static void updateTable(String tableName, + String sampleData) throws SQLTimeoutException, SQLServerException { + String sql = "insert into " + tableName + " values( ? )"; + try (SQLServerPreparedStatement pstmt = (SQLServerPreparedStatement) connection.prepareStatement(sql)) { + pstmt.setObject(1, sampleData, java.sql.Types.VARCHAR); + pstmt.execute(); + pstmt.close(); + } + } + + private static void selectTable(String tableName, String clmnName, + String sampleData) throws SQLServerException, SQLTimeoutException { + String sql = "select * from " + tableName + " where " + clmnName + " = ( ? )"; + try (SQLServerPreparedStatement pstmt = (SQLServerPreparedStatement) connection.prepareStatement(sql)) { + pstmt.setObject(1, sampleData, java.sql.Types.VARCHAR); + pstmt.execute(); + pstmt.close(); + } + } + + private static void alterTable(String tableName, String clmnName, + String newCek) throws SQLTimeoutException, SQLServerException { + String sql = "alter table " + tableName + " alter column " + clmnName + " encrypt with " + newCek; + try (SQLServerPreparedStatement pstmt = (SQLServerPreparedStatement) connection.prepareStatement(sql)) { + pstmt.execute(); + pstmt.close(); + } + } + + private long timedTestSelect(String tableName, String firstData) throws SQLServerException, SQLTimeoutException { + long timer = System.currentTimeMillis(); + selectTable(tableName, columnName, firstData); + return System.currentTimeMillis() - timer; + } + + private void createCMK(String cmk, String keyPath) throws SQLException { + try (Connection conn = DriverManager.getConnection(connectionString); + Statement stmt = connection.createStatement()) { + String sql = " if not exists (SELECT name from sys.column_master_keys where name='" + cmkName + "')" + + " begin" + " CREATE COLUMN MASTER KEY " + cmk + " WITH (KEY_STORE_PROVIDER_NAME = '" + + Constants.AZURE_KEY_VAULT_NAME + "', KEY_PATH = '" + keyPath + "')" + " end"; + stmt.execute(sql); + } + } + + private void createCEK(String cek, String cmk, SQLServerColumnEncryptionKeyStoreProvider storeProvider, + String encryptionKey) throws SQLException { + try (Connection conn = DriverManager.getConnection(connectionString); + Statement stmt = connection.createStatement()) { + String letters = Constants.CEK_STRING; + byte[] valuesDefault = letters.getBytes(); + byte[] key = storeProvider.encryptColumnEncryptionKey(encryptionKey, Constants.CEK_ALGORITHM, + valuesDefault); + String cekSql = " if not exists (SELECT name from sys.column_encryption_keys where name='" + cek + "')" + + " begin" + " CREATE COLUMN ENCRYPTION KEY " + cek + " WITH VALUES " + "(COLUMN_MASTER_KEY = " + + cmk + ", ALGORITHM = '" + Constants.CEK_ALGORITHM + "', ENCRYPTED_VALUE = 0x" + + TestUtils.bytesToHexString(key, key.length) + ")" + ";" + " end"; + stmt.execute(cekSql); + } + } + + private void dropCMK(String cmk) throws SQLException { + try (Connection conn = DriverManager.getConnection(connectionString); + Statement stmt = connection.createStatement()) { + String cekSql = " if exists (SELECT name from sys.column_master_keys where name='" + cmk + "')" + " begin" + + " drop column master key " + cmk + " end"; + stmt.execute(cekSql); + } + } + + private void dropCEK(String cek) throws SQLException { + try (Connection conn = DriverManager.getConnection(connectionString); + Statement stmt = connection.createStatement()) { + String cekSql = " if exists (SELECT name from sys.column_encryption_keys where name='" + cek + "')" + + " begin" + " drop column encryption key " + cek + " end"; + stmt.execute(cekSql); + } + } + + private SQLServerColumnEncryptionKeyStoreProvider setupKeyStoreProvider() throws SQLServerException { + SQLServerConnection.unregisterColumnEncryptionKeyStoreProviders(); + return registerProvider( + new SQLServerColumnEncryptionAzureKeyVaultProvider(applicationClientID, applicationKey)); + } + + private SQLServerColumnEncryptionKeyStoreProvider registerProvider( + SQLServerColumnEncryptionKeyStoreProvider provider) throws SQLServerException { + Map map1 = new HashMap<>(); + map1.put(provider.getName(), provider); + SQLServerConnection.registerColumnEncryptionKeyStoreProviders(map1); + return provider; + } +}