Repository: microsoft/mssql-jdbc
Pull request #1845 (2022-06-13 17:27:53)

Cache Parameter Metadata
Cache sp_describe_parameter_encryption calls to improved performance by reducing trips to the server. For now, supports AE without secure enclaves, with enclave support (v3) to be added in a later update.


Patch:
diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/AE.java b/src/main/java/com/microsoft/sqlserver/jdbc/AE.java
index 10222d32..678d9f7b 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 @@ package com.microsoft.sqlserver.jdbc;
 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 @@ class CryptoMetadata {
 }
 
 
+/**
+ * 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<String, Map<Integer, CekTableEntry>> cekMap = new ConcurrentHashMap<>(16);
+    private ConcurrentHashMap<String, ConcurrentHashMap<String, CryptoMetadata>> paramMap = new ConcurrentHashMap<>(16);
+
+    ConcurrentHashMap<String, ConcurrentHashMap<String, CryptoMetadata>> getParamMap() {
+        return paramMap;
+    }
+
+    void replaceParamMap(ConcurrentHashMap<String, ConcurrentHashMap<String, CryptoMetadata>> newMap) {
+        paramMap = newMap;
+    }
+
+    Map<Integer, CekTableEntry> getEnclaveEntry(String enclaveLookupKey) {
+        return cekMap.get(enclaveLookupKey);
+    }
+
+    ConcurrentHashMap<String, CryptoMetadata> getCacheEntry(String cacheLookupKey) {
+        return paramMap.get(cacheLookupKey);
+    }
+
+    void addParamEntry(String key, ConcurrentHashMap<String, CryptoMetadata> 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 fa3f747d..8ce9f566 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 @@ interface ISQLServerEnclaveProvider {
      */
     default void processSDPEv1(String userSql, String preparedTypeDefinitions, Parameter[] params,
             ArrayList<String> parameterNames, SQLServerConnection connection, SQLServerStatement sqlServerStatement,
-            PreparedStatement stmt, ResultSet rs, ArrayList<byte[]> enclaveRequestedCEKs) throws SQLException {
+            PreparedStatement stmt, ResultSet rs, ArrayList<byte[]> enclaveRequestedCEKs,
+            EnclaveSession session) throws SQLException {
         Map<Integer, CekTableEntry> cekList = new HashMap<>();
         CekTableEntry cekEntry = null;
         boolean isRequestedByEnclave = false;
@@ -279,6 +280,12 @@ interface ISQLServerEnclaveProvider {
                 }
             }
         }
+
+        // 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 @@ class EnclaveSession {
         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 00000000..52e0bf7d
--- /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<String> parameterNames, CryptoCache cache,
+            SQLServerConnection connection, SQLServerStatement stmt) throws SQLServerException {
+
+        AbstractMap.SimpleEntry<String, String> encryptionValues = getCacheLookupKeys(stmt, connection);
+        ConcurrentHashMap<String, CryptoMetadata> 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<String> parameterNames, CryptoCache cache,
+            SQLServerConnection connection, SQLServerStatement stmt,
+            Map<Integer, CekTableEntry> cekList) throws SQLServerException {
+
+        AbstractMap.SimpleEntry<String, String> encryptionValues = getCacheLookupKeys(stmt, connection);
+        if (encryptionValues.getKey() == null) {
+            return false;
+        }
+
+        ConcurrentHashMap<String, CryptoMetadata> 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<String, ConcurrentHashMap<String, CryptoMetadata>> newMap = new ConcurrentHashMap<>();
+            ConcurrentHashMap<String, ConcurrentHashMap<String, CryptoMetadata>> oldMap = cache.getParamMap();
+            int count = 0;
+
+            for (Map.Entry<String, ConcurrentHashMap<String, CryptoMetadata>> 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<String, String> 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<String, String> 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 55a021eb..c9432a54 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 @@ public class SQLServerAASEnclaveProvider implements ISQLServerEnclaveProvider {
             ArrayList<String> parameterNames) throws SQLServerException {
         ArrayList<byte[]> 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 386aab95..2ba4ab6f 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 @@ public class SQLServerNoneEnclaveProvider implements ISQLServerEnclaveProvider {
             ArrayList<String> parameterNames) throws SQLServerException {
         ArrayList<byte[]> 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 @@ public class SQLServerNoneEnclaveProvider implements ISQLServerEnclaveProvider {
 
 /**
  * 
- * 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 @@ class NoneAttestationParameters extends BaseAttestationRequest {
 
 /**
  * 
- * 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 cbb65eea..b038e348 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 @@ public final class SQLServerResource extends ListResourceBundle {
         {"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 02961eac..13d6c1e7 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 @@ public class SQLServerVSMEnclaveProvider implements ISQLServerEnclaveProvider {
             ArrayList<String> parameterNames) throws SQLServerException {
         ArrayList<byte[]> 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);
+                            }
                         }
                     }
                 }


Test patch:
diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/SQLServerConnectionTest.java b/src/test/java/com/microsoft/sqlserver/jdbc/SQLServerConnectionTest.java
index ad4f6d92..68999a4f 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.SQLException;
 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 class SQLServerConnectionTest extends AbstractTest {
                     || 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<Integer, CekTableEntry> cekList = new HashMap<>();
+            Parameter[] params = {new Parameter(false)};
+            ArrayList<String> 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 00000000..c0dcccdb
--- /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<String, SQLServerColumnEncryptionKeyStoreProvider> map1 = new HashMap<>();
+        map1.put(provider.getName(), provider);
+        SQLServerConnection.registerColumnEncryptionKeyStoreProviders(map1);
+        return provider;
+    }
+}