diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 768ba1a50..07d92f78f 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -10,7 +10,7 @@ jobs: matrix: SQL-2019: Target_SQL: 'HGS-2k19-01' - Ex_Groups: 'xSQLv15' + Ex_Groups: 'xSQLv15, clientCertAuth' SQL-2012: Target_SQL: 'SQL-2K12-SP3-1' Ex_Groups: 'xSQLv12' diff --git a/pom.xml b/pom.xml index a467a6723..64ee763b8 100644 --- a/pom.xml +++ b/pom.xml @@ -51,9 +51,10 @@ xAzureSQLMI - - - - For tests not compatible with Azure SQL Managed Instance NTLM - - - - - - - For tests using NTLM Authentication mode (excluded by default) reqExternalSetup - For tests requiring external setup (excluded by default) + clientCertAuth - - For tests requiring client certificate authentication setup (excluded by default) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Default testing enabled with SQL Server 2019 (SQLv15) --> - xSQLv15, NTLM, reqExternalSetup + xSQLv15, NTLM, reqExternalSetup, clientCertAuth @@ -66,6 +67,8 @@ 5.0.0 4.7.2 2.8.6 + 1.64 + 1.64 [1.3.2, 1.5.2] @@ -119,7 +122,15 @@ org.bouncycastle bcprov-jdk15on - 1.64 + ${bouncycastle.bcprov.version} + true + + + + + org.bouncycastle + bcpkix-jdk15on + ${bouncycastle.bcpkix.version} true diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/IOBuffer.java b/src/main/java/com/microsoft/sqlserver/jdbc/IOBuffer.java index 94e9b07e5..b046a3e0c 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/IOBuffer.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/IOBuffer.java @@ -62,6 +62,7 @@ import java.util.logging.Level; import java.util.logging.Logger; +import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocket; import javax.net.ssl.TrustManager; @@ -364,6 +365,7 @@ static final String getTokenName(int tdsTokenType) { static final byte ENCRYPT_ON = 0x01; static final byte ENCRYPT_NOT_SUP = 0x02; static final byte ENCRYPT_REQ = 0x03; + static final byte ENCRYPT_CLIENT_CERT = (byte) 0x80; static final byte ENCRYPT_INVALID = (byte) 0xFF; static final String getEncryptionLevel(int level) { @@ -1597,9 +1599,16 @@ enum SSLHandhsakeState { * Server Host Name for SSL Handshake * @param port * Server Port for SSL Handshake + * @param clientCertificate + * Client certificate path + * @param clientKey + * Private key file path + * @param clientKeyPassword + * Private key file's password * @throws SQLServerException */ - void enableSSL(String host, int port) throws SQLServerException { + void enableSSL(String host, int port, String clientCertificate, String clientKey, + String clientKeyPassword) throws SQLServerException { // If enabling SSL fails, which it can for a number of reasons, the following items // are used in logging information to the TDS channel logger to help diagnose the problem. Provider tmfProvider = null; // TrustManagerFactory provider @@ -1774,13 +1783,16 @@ else if (con.getTrustManagerClass() != null) { if (logger.isLoggable(Level.FINEST)) logger.finest(toString() + " Getting TLS or better SSL context"); + KeyManager[] km = (null != clientCertificate && clientCertificate.length() > 0) ? SQLServerCertificateUtils + .getKeyManagerFromFile(clientCertificate, clientKey, clientKeyPassword) : null; + sslContext = SSLContext.getInstance(sslProtocol); sslContextProvider = sslContext.getProvider(); if (logger.isLoggable(Level.FINEST)) logger.finest(toString() + " Initializing SSL context"); - sslContext.init(null, tm, null); + sslContext.init(km, tm, null); // Got the SSL context. Now create an SSL socket over our own proxy socket // which we can toggle between TDS-encapsulated and raw communications. @@ -6202,7 +6214,8 @@ void writeRPCReaderUnicode(String sName, Reader re, long reLength, boolean bOut, void sendEnclavePackage(String sql, ArrayList enclaveCEKs) throws SQLServerException { if (null != con && con.isAEv2()) { - if (null != sql && !sql.isEmpty() && null != enclaveCEKs && 0 < enclaveCEKs.size() && con.enclaveEstablished()) { + if (null != sql && !sql.isEmpty() && null != enclaveCEKs && 0 < enclaveCEKs.size() + && con.enclaveEstablished()) { byte[] b = con.generateEnclavePackage(sql, enclaveCEKs); if (null != b && 0 != b.length) { this.writeShort((short) b.length); diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerDataSource.java b/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerDataSource.java index c9e4e1379..7690092a2 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerDataSource.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/ISQLServerDataSource.java @@ -903,5 +903,43 @@ public interface ISQLServerDataSource extends javax.sql.CommonDataSource { * Enclave attestation protocol. */ void setEnclaveAttestationProtocol(String protocol); + + /** + * Returns client certificate path for client certificate authentication. + * + * @return Client certificate path. + */ + String getClientCertificate(); + + /** + * Sets client certificate path for client certificate authentication. + * + * @param certPath + * Client certificate path. + */ + void setClientCertificate(String certPath); + + /** + * Returns Private key file path for client certificate authentication. + * + * @return Private key file path. + */ + String getClientKey(); + + /** + * Sets Private key file path for client certificate authentication. + * + * @param keyPath + * Private key file path. + */ + void setClientKey(String keyPath); + + /** + * Sets the password to be used for Private key provided by the user for client certificate authentication. + * + * @param password + * Private key password. + */ + void setClientKeyPassword(String password); } diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerBouncyCastleLoader.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerBouncyCastleLoader.java index 6c13d5af0..c94c40466 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerBouncyCastleLoader.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerBouncyCastleLoader.java @@ -1,12 +1,22 @@ +/* + * 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.security.Provider; import java.security.Security; /* * Class that is meant to statically load the BouncyCastle Provider for JDK 8. Hides the call so JDK 11/13 don't have to include the dependency. + * Also loads BouncyCastle provider for PKCS1 private key parsing. */ class SQLServerBouncyCastleLoader { static void loadBouncyCastle() { - Security.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider()); + Provider p = new org.bouncycastle.jce.provider.BouncyCastleProvider(); + if (null == Security.getProvider(p.getName())) { + Security.addProvider(p); + } } } diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerCertificateUtils.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerCertificateUtils.java new file mode 100644 index 000000000..45d68e884 --- /dev/null +++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerCertificateUtils.java @@ -0,0 +1,295 @@ +/* + * 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.io.ByteArrayInputStream; +import java.io.DataInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringReader; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.RSAPrivateCrtKeySpec; +import java.util.Arrays; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; + +import org.bouncycastle.openssl.PEMDecryptorProvider; +import org.bouncycastle.openssl.PEMEncryptedKeyPair; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.bouncycastle.openssl.jcajce.JcePEMDecryptorProviderBuilder; + + +final class SQLServerCertificateUtils { + + static KeyManager[] getKeyManagerFromFile(String certPath, String keyPath, + String keyPassword) throws IOException, GeneralSecurityException, SQLServerException { + if (keyPath != null && keyPath.length() > 0) { + return readPKCS8Certificate(certPath, keyPath, keyPassword); + } else { + return readPKCS12Certificate(certPath, keyPassword); + } + } + + // PKCS#12 format + private static final String PKCS12_ALG = "PKCS12"; + private static final String SUN_X_509 = "SunX509"; + // PKCS#8 format + private static final String PEM_PRIVATE_START = "-----BEGIN PRIVATE KEY-----"; + private static final String PEM_PRIVATE_END = "-----END PRIVATE KEY-----"; + private static final String JAVA_KEY_STORE = "JKS"; + private static final String CLIENT_CERT = "client-cert"; + private static final String CLIENT_KEY = "client-key"; + // PKCS#1 format + private static final String PEM_RSA_PRIVATE_START = "-----BEGIN RSA PRIVATE KEY-----"; + // PVK format + private static final long PVK_MAGIC = 0xB0B5F11EL; + private static final byte[] RSA2_MAGIC = {82, 83, 65, 50}; + private static final String RC4_ALG = "RC4"; + private static final String RSA_ALG = "RSA"; + + private static KeyManager[] readPKCS12Certificate(String certPath, + String keyPassword) throws NoSuchAlgorithmException, CertificateException, FileNotFoundException, IOException, UnrecoverableKeyException, KeyStoreException, SQLServerException { + KeyStore keystore = KeyStore.getInstance(PKCS12_ALG); + try { + keystore.load(new FileInputStream(certPath), keyPassword.toCharArray()); + } catch (FileNotFoundException e) { + throw new SQLServerException(SQLServerException.getErrString("R_clientCertError"), null, 0, null); + } + KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(SUN_X_509); + keyManagerFactory.init(keystore, keyPassword.toCharArray()); + return keyManagerFactory.getKeyManagers(); + } + + private static KeyManager[] readPKCS8Certificate(String certPath, String keyPath, + String keyPassword) throws IOException, GeneralSecurityException, SQLServerException { + Certificate clientCertificate = loadCertificate(certPath); + PrivateKey privateKey = loadPrivateKey(keyPath, keyPassword); + + KeyStore keyStore = KeyStore.getInstance(JAVA_KEY_STORE); + keyStore.load(null, null); + keyStore.setCertificateEntry(CLIENT_CERT, clientCertificate); + keyStore.setKeyEntry(CLIENT_KEY, privateKey, keyPassword.toCharArray(), new Certificate[] {clientCertificate}); + + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, keyPassword.toCharArray()); + return kmf.getKeyManagers(); + } + + private static PrivateKey loadPrivateKeyFromPKCS8( + String key) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { + StringBuilder sb = new StringBuilder(key); + deleteFirst(sb, PEM_PRIVATE_START); + deleteFirst(sb, PEM_PRIVATE_END); + byte[] formattedKey = Base64.getDecoder().decode(sb.toString().replaceAll("\\s", "")); + + KeyFactory factory = KeyFactory.getInstance(RSA_ALG); + return factory.generatePrivate(new PKCS8EncodedKeySpec(formattedKey)); + } + + private static void deleteFirst(StringBuilder sb, String str) { + int i = sb.indexOf(str); + if (i != -1) { + sb.delete(i, i + str.length()); + } + } + + private static PrivateKey loadPrivateKeyFromPKCS1(String key, + String keyPass) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { + SQLServerBouncyCastleLoader.loadBouncyCastle(); + PEMParser pemParser = null; + try { + pemParser = new PEMParser(new StringReader(key)); + Object object = pemParser.readObject(); + JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC"); + KeyPair kp; + if (object instanceof PEMEncryptedKeyPair && keyPass != null) { + PEMDecryptorProvider decProv = new JcePEMDecryptorProviderBuilder().build(keyPass.toCharArray()); + kp = converter.getKeyPair(((PEMEncryptedKeyPair) object).decryptKeyPair(decProv)); + } else { + kp = converter.getKeyPair((PEMKeyPair) object); + } + return kp.getPrivate(); + } finally { + if (null != pemParser) { + pemParser.close(); + } + } + } + + private static PrivateKey loadPrivateKeyFromPVK(String keyPath, + String keyPass) throws IOException, GeneralSecurityException, SQLServerException { + File f = new File(keyPath); + ByteBuffer buffer = ByteBuffer.allocate((int) f.length()); + try (FileInputStream in = new FileInputStream(f)) { + in.getChannel().read(buffer); + buffer.order(ByteOrder.LITTLE_ENDIAN).rewind(); + + long magic = buffer.getInt() & 0xFFFFFFFFL; + if (PVK_MAGIC != magic) { + SQLServerException.makeFromDriverError(null, magic, SQLServerResource.getResource("R_pvkHeaderError"), + "", false); + } + + buffer.position(buffer.position() + 8); // skip reserved and keytype + boolean encrypted = buffer.getInt() != 0; + int saltLength = buffer.getInt(); + int keyLength = buffer.getInt(); + byte[] salt = new byte[saltLength]; + buffer.get(salt); + + buffer.position(buffer.position() + 8); // skip btype(1b), version(1b), reserved(2b), and keyalg(4b) + + byte[] key = new byte[keyLength - 8]; + buffer.get(key); + + if (encrypted) { + MessageDigest digest = MessageDigest.getInstance("SHA1"); + digest.update(salt); + if (null != keyPass) { + digest.update(keyPass.getBytes()); + } + byte[] hash = digest.digest(); + key = getSecretKeyFromHash(key, hash); + } + + ByteBuffer buff = ByteBuffer.wrap(key).order(ByteOrder.LITTLE_ENDIAN); + buff.position(RSA2_MAGIC.length); // skip the header + + int byteLength = buff.getInt() / 8; + BigInteger publicExponent = BigInteger.valueOf(buff.getInt()); + BigInteger modulus = getBigInteger(buff, byteLength); + BigInteger prime1 = getBigInteger(buff, byteLength / 2); + BigInteger prime2 = getBigInteger(buff, byteLength / 2); + BigInteger primeExponent1 = getBigInteger(buff, byteLength / 2); + BigInteger primeExponent2 = getBigInteger(buff, byteLength / 2); + BigInteger crtCoefficient = getBigInteger(buff, byteLength / 2); + BigInteger privateExponent = getBigInteger(buff, byteLength); + + RSAPrivateCrtKeySpec spec = new RSAPrivateCrtKeySpec(modulus, publicExponent, privateExponent, prime1, + prime2, primeExponent1, primeExponent2, crtCoefficient); + KeyFactory factory = KeyFactory.getInstance(RSA_ALG); + return factory.generatePrivate(spec); + } + } + + private static Certificate loadCertificate(String certificatePem) throws IOException, GeneralSecurityException, SQLServerException { + CertificateFactory certificateFactory = CertificateFactory.getInstance("X509"); + InputStream certstream = fileToStream(certificatePem); + return certificateFactory.generateCertificate(certstream); + } + + private static PrivateKey loadPrivateKey(String privateKeyPemPath, + String privateKeyPassword) throws GeneralSecurityException, IOException, SQLServerException { + String privateKeyPem = getStringFromFile(privateKeyPemPath); + + if (privateKeyPem.contains(PEM_PRIVATE_START)) { // PKCS#8 format + return loadPrivateKeyFromPKCS8(privateKeyPem); + } else if (privateKeyPem.contains(PEM_RSA_PRIVATE_START)) { // PKCS#1 format + return loadPrivateKeyFromPKCS1(privateKeyPem, privateKeyPassword); + } else { + return loadPrivateKeyFromPVK(privateKeyPemPath, privateKeyPassword); + } + } + + private static boolean startsWithMagic(byte[] b) { + for (int i = 0; i < RSA2_MAGIC.length; i++) { + if (b[i] != RSA2_MAGIC[i]) + return false; + } + return true; + } + + private static byte[] getSecretKeyFromHash(byte[] originalKey, + byte[] keyHash) throws GeneralSecurityException, SQLServerException { + SecretKey key = new SecretKeySpec(keyHash, 0, 16, RC4_ALG); + byte[] decrypted = decryptSecretKey(key, originalKey); + if (startsWithMagic(decrypted)) { + return decrypted; + } + + // Couldn't find magic due to padding, trim the key + Arrays.fill(keyHash, 5, keyHash.length, (byte) 0); + key = new SecretKeySpec(keyHash, 0, 16, RC4_ALG); + decrypted = decryptSecretKey(key, originalKey); + if (startsWithMagic(decrypted)) { + return decrypted; + } + + SQLServerException.makeFromDriverError(null, originalKey, SQLServerResource.getResource("R_pvkParseError"), "", + false); + return null; + } + + private static byte[] decryptSecretKey(SecretKey key, byte[] encoded) throws GeneralSecurityException { + Cipher cipher = Cipher.getInstance(key.getAlgorithm()); + cipher.init(Cipher.DECRYPT_MODE, key); + return cipher.doFinal(encoded); + } + + private static BigInteger getBigInteger(ByteBuffer buffer, int length) { + // Add an extra bit for signum + byte[] array = new byte[length + 1]; + // Write in reverse because our buffer was set to Little Endian + for (int i = 0; i < length; i++) { + array[array.length - 1 - i] = buffer.get(); + } + return new BigInteger(array); + } + + private static InputStream fileToStream(String fname) throws IOException, SQLServerException { + FileInputStream fis = null; + DataInputStream dis = null; + try { + fis = new FileInputStream(fname); + dis = new DataInputStream(fis); + byte[] bytes = new byte[dis.available()]; + dis.readFully(bytes); + ByteArrayInputStream bais = new ByteArrayInputStream(bytes); + return bais; + } catch (FileNotFoundException e) { + throw new SQLServerException(SQLServerException.getErrString("R_clientCertError"), null, 0, null); + } finally { + if (null != dis) { + dis.close(); + } + if (null != fis) { + fis.close(); + } + } + } + + private static String getStringFromFile(String filePath) throws IOException { + return new String(Files.readAllBytes(Paths.get(filePath))); + } +} diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerConnection.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerConnection.java index 7f7687c38..13f6e913a 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerConnection.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerConnection.java @@ -142,6 +142,10 @@ public class SQLServerConnection implements ISQLServerConnection, java.io.Serial private SqlFedAuthToken fedAuthToken = null; private String originalHostNameInCertificate = null; + + private String clientCertificate = null; + private String clientKey = null; + private String clientKeyPassword = ""; final int ENGINE_EDITION_FOR_SQL_AZURE = 5; final int ENGINE_EDITION_FOR_SQL_AZURE_DW = 6; @@ -2021,6 +2025,27 @@ else if (0 == requestedPacketSize) if (null != sPropValue) { activeConnectionProperties.setProperty(sPropKey, sPropValue); } + + sPropKey = SQLServerDriverStringProperty.CLIENT_CERTIFICATE.toString(); + sPropValue = activeConnectionProperties.getProperty(sPropKey); + if (null != sPropValue) { + activeConnectionProperties.setProperty(sPropKey, sPropValue); + clientCertificate = sPropValue; + } + + sPropKey = SQLServerDriverStringProperty.CLIENT_KEY.toString(); + sPropValue = activeConnectionProperties.getProperty(sPropKey); + if (null != sPropValue) { + activeConnectionProperties.setProperty(sPropKey, sPropValue); + clientKey = sPropValue; + } + + sPropKey = SQLServerDriverStringProperty.CLIENT_KEY_PASSWORD.toString(); + sPropValue = activeConnectionProperties.getProperty(sPropKey); + if (null != sPropValue) { + activeConnectionProperties.setProperty(sPropKey, sPropValue); + clientKeyPassword = sPropValue; + } FailoverInfo fo = null; String databaseNameProperty = SQLServerDriverStringProperty.DATABASE_NAME.toString(); @@ -2555,7 +2580,7 @@ private void connectHelper(ServerPortPlaceHolder serverInfo, int timeOutSliceInM // If prelogin negotiated SSL encryption then, enable it on the TDS channel. if (TDS.ENCRYPT_NOT_SUP != negotiatedEncryptionLevel) { - tdsChannel.enableSSL(serverInfo.getServerName(), serverInfo.getPortNumber()); + tdsChannel.enableSSL(serverInfo.getServerName(), serverInfo.getPortNumber(), clientCertificate, clientKey, clientKeyPassword); } // We have successfully connected, now do the login. logon takes seconds timeout @@ -2629,7 +2654,7 @@ void Prelogin(String serverName, int portNumber) throws SQLServerException { 0, 0, 0, 0, 0, 0, // - Encryption - - requestedEncryptionLevel, + (null == clientCertificate) ? requestedEncryptionLevel : (byte) (requestedEncryptionLevel | TDS.ENCRYPT_CLIENT_CERT), // TRACEID Data Session (ClientConnectionId + ActivityId) - Initialize to 0 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, @@ -4960,7 +4985,7 @@ final boolean complete(LogonCommand logonCommand, TDSReader tdsReader) throws SQ + 4; // AE is always on; // only add lengths of password and username if not using SSPI or requesting federated authentication info - if (!integratedSecurity && !(federatedAuthenticationInfoRequested || federatedAuthenticationRequested)) { + if (!integratedSecurity && !(federatedAuthenticationInfoRequested || federatedAuthenticationRequested) && null == clientCertificate) { len = len + passwordLen + userBytes.length; } @@ -5038,7 +5063,7 @@ final boolean complete(LogonCommand logonCommand, TDSReader tdsReader) throws SQ tdsWriter.writeShort((short) (tdsLoginRequestBaseLength + dataLen)); tdsWriter.writeShort((short) (0)); - } else if (!integratedSecurity && !(federatedAuthenticationInfoRequested || federatedAuthenticationRequested)) { + } else if (!integratedSecurity && !(federatedAuthenticationInfoRequested || federatedAuthenticationRequested) && null == clientCertificate) { // User and Password tdsWriter.writeShort((short) (tdsLoginRequestBaseLength + dataLen)); tdsWriter.writeShort((short) (sUser == null ? 0 : sUser.length())); @@ -5130,7 +5155,8 @@ final boolean complete(LogonCommand logonCommand, TDSReader tdsReader) throws SQ // if we are using NTLM or SSPI or fed auth ADAL, do not send over username/password, since we will use SSPI // instead - if (!integratedSecurity && !(federatedAuthenticationInfoRequested || federatedAuthenticationRequested)) { + // Also do not send username or password if user is attempting client certificate authentication. + if (!integratedSecurity && !(federatedAuthenticationInfoRequested || federatedAuthenticationRequested) && null == clientCertificate) { tdsWriter.writeBytes(userBytes); // Username tdsWriter.writeBytes(passwordBytes); // Password (encrypted) } diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerDataSource.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerDataSource.java index 75397e02f..8f8d39b77 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerDataSource.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerDataSource.java @@ -943,6 +943,36 @@ public void setEnclaveAttestationProtocol(String protocol) { setStringProperty(connectionProps, SQLServerDriverStringProperty.ENCLAVE_ATTESTATION_PROTOCOL.toString(), protocol); } + + @Override + public String getClientCertificate() { + return getStringProperty(connectionProps, SQLServerDriverStringProperty.CLIENT_CERTIFICATE.toString(), + SQLServerDriverStringProperty.CLIENT_CERTIFICATE.getDefaultValue()); + } + + @Override + public void setClientCertificate(String certPath) { + setStringProperty(connectionProps, SQLServerDriverStringProperty.CLIENT_CERTIFICATE.toString(), + certPath); + } + + @Override + public String getClientKey() { + return getStringProperty(connectionProps, SQLServerDriverStringProperty.CLIENT_KEY.toString(), + SQLServerDriverStringProperty.CLIENT_KEY.getDefaultValue()); + } + + @Override + public void setClientKey(String keyPath) { + setStringProperty(connectionProps, SQLServerDriverStringProperty.CLIENT_KEY.toString(), + keyPath); + } + + @Override + public void setClientKeyPassword(String password) { + setStringProperty(connectionProps, SQLServerDriverStringProperty.CLIENT_KEY_PASSWORD.toString(), + password); + } /** * Sets a property string value. diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerDriver.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerDriver.java index 8ee0047b9..d765d01c2 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerDriver.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerDriver.java @@ -351,7 +351,10 @@ enum SQLServerDriverStringProperty { SSL_PROTOCOL("sslProtocol", SSLProtocol.TLS.toString()), MSI_CLIENT_ID("msiClientId", ""), KEY_VAULT_PROVIDER_CLIENT_ID("keyVaultProviderClientId", ""), - KEY_VAULT_PROVIDER_CLIENT_KEY("keyVaultProviderClientKey", ""); + KEY_VAULT_PROVIDER_CLIENT_KEY("keyVaultProviderClientKey", ""), + CLIENT_CERTIFICATE("clientCertificate", ""), + CLIENT_KEY("clientKey", ""), + CLIENT_KEY_PASSWORD("clientKeyPassword", ""); private final String name; private final String defaultValue; @@ -598,7 +601,14 @@ public final class SQLServerDriver implements java.sql.Driver { SQLServerDriverStringProperty.KEY_VAULT_PROVIDER_CLIENT_KEY.getDefaultValue(), false, null), new SQLServerDriverPropertyInfo(SQLServerDriverBooleanProperty.USE_FMT_ONLY.toString(), Boolean.toString(SQLServerDriverBooleanProperty.USE_FMT_ONLY.getDefaultValue()), false, - TRUE_FALSE),}; + TRUE_FALSE), + new SQLServerDriverPropertyInfo(SQLServerDriverStringProperty.CLIENT_CERTIFICATE.toString(), + SQLServerDriverStringProperty.CLIENT_CERTIFICATE.getDefaultValue(), false, null), + new SQLServerDriverPropertyInfo(SQLServerDriverStringProperty.CLIENT_KEY.toString(), + SQLServerDriverStringProperty.CLIENT_KEY.getDefaultValue(), false, null), + new SQLServerDriverPropertyInfo(SQLServerDriverStringProperty.CLIENT_KEY_PASSWORD.toString(), + SQLServerDriverStringProperty.CLIENT_KEY_PASSWORD.getDefaultValue(), false, null), + }; /** * Properties that can only be set by using Properties. Cannot set in connection string diff --git a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerResource.java b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerResource.java index 89e8983e1..b9c64eb72 100644 --- a/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerResource.java +++ b/src/main/java/com/microsoft/sqlserver/jdbc/SQLServerResource.java @@ -254,6 +254,9 @@ protected Object[][] getContents() { {"R_gsscredentialPropertyDescription", "Impersonated GSS Credential to access SQL Server."}, {"R_msiClientIdPropertyDescription", "Client Id of User Assigned Managed Identity to be used for generating access token for Azure AD MSI Authentication"}, + {"R_clientCertificatePropertyDescription", "Client certificate path for client certificate authentication feature."}, + {"R_clientKeyPropertyDescription", "Private key file path for client certificate authentication feature."}, + {"R_clientKeyPasswordPropertyDescription", "Password for private key if the private key is password protected."}, {"R_noParserSupport", "An error occurred while instantiating the required parser. Error: \"{0}\""}, {"R_writeOnlyXML", "Cannot read from this SQLXML instance. This instance is for writing data only."}, {"R_dataHasBeenReadXML", "Cannot read from this SQLXML instance. The data has already been read."}, @@ -621,5 +624,8 @@ protected Object[][] getContents() { "Enclave attestation failed, the DH public key signature can't be verified with the enclave public key."}, {"R_AasJWTError", "An error occured when retrieving and validating the JSON web token."}, {"R_AasEhdError", "aas-ehd claim from JWT did not match enclave public key."}, - {"R_VbsRpDataError", "rp_data claim from JWT did not match client nonce."},}; + {"R_VbsRpDataError", "rp_data claim from JWT did not match client nonce."}, + {"R_pvkParseError", "Could not read Private Key from PVK, check the password provided."}, + {"R_pvkHeaderError", "Cannot parse the PVK, PVK file does not contain the correct header."}, + {"R_clientCertError", "Reading client certificate failed. Please verify the location of the certificate."}}; }; diff --git a/src/test/java/com/microsoft/sqlserver/clientcertauth/ClientCertificateAuthenticationTest.java b/src/test/java/com/microsoft/sqlserver/clientcertauth/ClientCertificateAuthenticationTest.java new file mode 100644 index 000000000..acf746d0c --- /dev/null +++ b/src/test/java/com/microsoft/sqlserver/clientcertauth/ClientCertificateAuthenticationTest.java @@ -0,0 +1,229 @@ +/* + * 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.clientcertauth; + +import static org.junit.Assert.assertTrue; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; + +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.SQLServerDataSource; +import com.microsoft.sqlserver.jdbc.SQLServerException; +import com.microsoft.sqlserver.jdbc.TestResource; +import com.microsoft.sqlserver.jdbc.TestUtils; +import com.microsoft.sqlserver.testframework.AbstractTest; +import com.microsoft.sqlserver.testframework.Constants; + + +/** + * Tests client certificate authentication feature + * The feature is only supported against SQL Server Linux CU2 or higher. + * + */ +@RunWith(JUnitPlatform.class) +@Tag(Constants.xSQLv12) +@Tag(Constants.xSQLv14) +@Tag(Constants.xAzureSQLDW) +@Tag(Constants.xAzureSQLDB) +@Tag(Constants.clientCertAuth) +public class ClientCertificateAuthenticationTest extends AbstractTest { + + static final String PEM_SUFFIX = ".pem;"; + static final String CER_SUFFIX = ".cer;"; + static final String PVK_SUFFIX = ".pvk;"; + + static final String PKCS1_KEY_SUFFIX = "-pkcs1.key;"; + static final String ENCRYPTED_PKCS1_KEY_SUFFIX = "-encrypted-pkcs1.key;"; + static final String PKCS8_KEY_SUFFIX = "-pkcs8.key;"; + static final String ENCRYPTED_PKCS8_KEY_SUFFIX = "-encrypted-pkcs8.key;"; + static final String PFX_KEY_SUFFIX = ".pfx;"; + static final String ENCRYPTED_PFX_KEY_SUFFIX = "-encrypted.pfx;"; + + /** + * Tests client certificate authentication feature with PKCS1 private key. + * + * @throws Exception + */ + @Test + public void pkcs1Test() throws Exception { + String conStr = connectionString + ";clientCertificate=" + clientCertificate + PEM_SUFFIX + "clientKey=" + + clientKey + PKCS1_KEY_SUFFIX; + try (Connection conn = DriverManager.getConnection(conStr)) { + assertTrue(conn.isValid(1)); + } + } + + /** + * Tests client certificate authentication feature with PKCS1 private key that has been encrypted with a password. + * + * @throws Exception + */ + @Test + public void pkcs1EncryptedTest() throws Exception { + String conStr = connectionString + ";clientCertificate=" + clientCertificate + PEM_SUFFIX + "clientKey=" + + clientKey + ENCRYPTED_PKCS1_KEY_SUFFIX + "clientKeyPassword=" + clientKeyPassword + ";"; + try (Connection conn = DriverManager.getConnection(conStr)) { + assertTrue(conn.isValid(1)); + } + } + + /** + * Tests client certificate authentication feature with PKCS8 private key. + * + * @throws Exception + */ + @Test + public void pkcs8Test() throws Exception { + String conStr = connectionString + ";clientCertificate=" + clientCertificate + PEM_SUFFIX + "clientKey=" + + clientKey + PKCS8_KEY_SUFFIX; + try (Connection conn = DriverManager.getConnection(conStr)) { + assertTrue(conn.isValid(1)); + } + } + + /** + * Tests client certificate authentication feature with PKCS8 private key that has been encrypted with a password. + * + * @throws Exception + */ + @Test + public void pkcs8EncryptedTest() throws Exception { + String conStr = connectionString + ";clientCertificate=" + clientCertificate + PEM_SUFFIX + "clientKey=" + + clientKey + ENCRYPTED_PKCS8_KEY_SUFFIX + "clientKeyPassword=" + clientKeyPassword + ";"; + try (Connection conn = DriverManager.getConnection(conStr)) { + assertTrue(conn.isValid(1)); + } + } + + /** + * Tests client certificate authentication feature with PFX private key. + * + * @throws Exception + */ + @Test + public void pfxTest() throws Exception { + String conStr = connectionString + ";clientCertificate=" + clientCertificate + PFX_KEY_SUFFIX; + try (Connection conn = DriverManager.getConnection(conStr)) { + assertTrue(conn.isValid(1)); + } + } + + /** + * Tests client certificate authentication feature with PFX private key that has been encrypted with a password. + * + * @throws Exception + */ + @Test + public void pfxEncrytedTest() throws Exception { + String conStr = connectionString + ";clientCertificate=" + clientCertificate + ENCRYPTED_PFX_KEY_SUFFIX + + "clientKeyPassword=" + clientKeyPassword + ";"; + try (Connection conn = DriverManager.getConnection(conStr)) { + assertTrue(conn.isValid(1)); + } + } + + /** + * Tests client certificate authentication feature with PVK private key. + * + * @throws Exception + */ + @Test + public void pvkTest() throws Exception { + String conStr = connectionString + ";clientCertificate=" + clientCertificate + CER_SUFFIX + "clientKey=" + + clientKey + PVK_SUFFIX + "clientKeyPassword=" + clientKeyPassword + ";"; + try (Connection conn = DriverManager.getConnection(conStr)) { + assertTrue(conn.isValid(1)); + } + } + + /** + * Tests client certificate authentication feature with invalid certificate provided. + * + * @throws Exception + */ + @Test + public void invalidCert() throws Exception { + String conStr = connectionString + ";clientCertificate=invalid_path;" + "clientKeyPassword=" + clientKeyPassword + + ";"; + try (Connection conn = DriverManager.getConnection(conStr)) {} catch (SQLServerException e) { + assertTrue(e.getCause().getMessage().matches(TestUtils.formatErrorMsg("R_clientCertError"))); + } + } + + /** + * Tests client certificate authentication feature with invalid certificate password provided. + * + * @throws Exception + */ + @Test + public void invalidCertPassword() throws Exception { + String conStr = connectionString + ";clientCertificate=" + clientCertificate + PFX_KEY_SUFFIX + + "clientKeyPassword=invalid_password;"; + try (Connection conn = DriverManager.getConnection(conStr)) {} catch (SQLServerException e) { + assertTrue(e.getMessage().contains(TestResource.getResource("R_keystorePassword"))); + } + } + + /** + * Tests client certificate authentication feature using a data source. + * + * @throws Exception + */ + @Test + public void testDataSource() throws Exception { + SQLServerDataSource dsLocal = new SQLServerDataSource(); + AbstractTest.updateDataSource(connectionString, dsLocal); + dsLocal.setClientCertificate(clientCertificate + PEM_SUFFIX.substring(0, PEM_SUFFIX.length() - 1)); + dsLocal.setClientKey( + clientKey + ENCRYPTED_PKCS1_KEY_SUFFIX.substring(0, ENCRYPTED_PKCS1_KEY_SUFFIX.length() - 1)); + dsLocal.setClientKeyPassword(clientKeyPassword); + + try (Connection conn = dsLocal.getConnection()) { + assertTrue(conn.isValid(1)); + } + } + + /** + * Tests client certificate authentication feature with encryption turned on. + * + * @throws Exception + */ + @Test + public void testEncryptTrusted() throws Exception { + String conStr = connectionString + ";clientCertificate=" + clientCertificate + PEM_SUFFIX + "clientKey=" + + clientKey + PKCS8_KEY_SUFFIX + "encrypt=true;trustServerCertificate=true;"; + try (Connection conn = DriverManager.getConnection(conStr); Statement stmt = conn.createStatement()) { + ResultSet rs = stmt + .executeQuery("SELECT encrypt_option FROM sys.dm_exec_connections WHERE session_id = @@SPID"); + rs.next(); + assertTrue(rs.getBoolean(1)); + } + } + + /** + * Tests client certificate authentication feature with encryption turned on, untrusted. + * + * @throws Exception + */ + @Test + public void testEncryptUntrusted() throws Exception { + String conStr = connectionString + ";clientCertificate=" + clientCertificate + PEM_SUFFIX + "clientKey=" + + clientKey + PKCS8_KEY_SUFFIX + "encrypt=true;trustServerCertificate=false;trustStore=" + + trustStorePath; + try (Connection conn = DriverManager.getConnection(conStr); Statement stmt = conn.createStatement()) { + ResultSet rs = stmt + .executeQuery("SELECT encrypt_option FROM sys.dm_exec_connections WHERE session_id = @@SPID"); + rs.next(); + assertTrue(rs.getBoolean(1)); + } + } +} diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/ActivityIDTest.java b/src/test/java/com/microsoft/sqlserver/jdbc/ActivityIDTest.java index 9af431ded..0b48bf499 100644 --- a/src/test/java/com/microsoft/sqlserver/jdbc/ActivityIDTest.java +++ b/src/test/java/com/microsoft/sqlserver/jdbc/ActivityIDTest.java @@ -1,3 +1,7 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/TestResource.java b/src/test/java/com/microsoft/sqlserver/jdbc/TestResource.java index 80d641d22..488091591 100644 --- a/src/test/java/com/microsoft/sqlserver/jdbc/TestResource.java +++ b/src/test/java/com/microsoft/sqlserver/jdbc/TestResource.java @@ -63,6 +63,7 @@ protected Object[][] getContents() { {"R_wrongExceptionMessage", "Wrong exception message"}, {"R_parameterNotDefined", "Parameter {0} was not defined"}, {"R_unexpectedExceptionContent", "Unexpected content in exception message"}, + {"R_connectionClosed", "The connection has been closed"}, {"R_conversionFailed", "Conversion failed when converting {0} to {1} data type"}, {"R_invalidQueryTimeout", "The query timeout value {0} is not valid."}, {"R_skipAzure", "Skipping test case on Azure SQL."}, @@ -183,5 +184,6 @@ protected Object[][] getContents() { {"R_resultSetEmpty", "Result set is empty."}, {"R_AlterAEv2Error", "Alter Column Encryption failed."}, {"R_RichQueryError", "Rich query failed."}, {"R_reqExternalSetup", "External setup for test required."}, {"R_invalidEnclaveSessionFailed", "invalidate enclave session failed."}, - {"R_invalidEnclaveType", "Invalid enclave type {0}."}}; + {"R_invalidEnclaveType", "Invalid enclave type {0}."}, + {"R_keystorePassword", "keystore password was incorrect"}}; } diff --git a/src/test/java/com/microsoft/sqlserver/jdbc/connection/SSLProtocolTest.java b/src/test/java/com/microsoft/sqlserver/jdbc/connection/SSLProtocolTest.java index 83e91bc13..aa12e8fee 100644 --- a/src/test/java/com/microsoft/sqlserver/jdbc/connection/SSLProtocolTest.java +++ b/src/test/java/com/microsoft/sqlserver/jdbc/connection/SSLProtocolTest.java @@ -45,7 +45,8 @@ public void testWithSupportedProtocols(String sslProtocol) throws Exception { // Some older versions of SQLServer might not have all the TLS protocol versions enabled. // Example, if the highest TLS version enabled in the server is TLSv1.1, // the connection will fail if we enable only TLSv1.2 - assertTrue(e.getMessage().contains(TestResource.getResource("R_noProtocolVersion"))); + assertTrue(e.getMessage().contains(TestResource.getResource("R_noProtocolVersion")) + || e.getCause().getCause().getMessage().contains(TestResource.getResource("R_connectionClosed"))); } } diff --git a/src/test/java/com/microsoft/sqlserver/testframework/AbstractTest.java b/src/test/java/com/microsoft/sqlserver/testframework/AbstractTest.java index 159f382f8..9f2de885d 100644 --- a/src/test/java/com/microsoft/sqlserver/testframework/AbstractTest.java +++ b/src/test/java/com/microsoft/sqlserver/testframework/AbstractTest.java @@ -63,6 +63,12 @@ public abstract class AbstractTest { protected static String[] enclaveServer = null; protected static String[] enclaveAttestationUrl = null; protected static String[] enclaveAttestationProtocol = null; + + protected static String clientCertificate = null; + protected static String clientKey = null; + protected static String clientKeyPassword = ""; + + protected static String trustStorePath = ""; protected static String javaKeyPath = null; protected static String javaKeyAliases = null; @@ -139,6 +145,14 @@ public static void setup() throws Exception { prop = getConfiguredProperty("enclaveAttestationProtocol", null); enclaveAttestationProtocol = null != prop ? prop.split(Constants.SEMI_COLON) : null; + + clientCertificate = getConfiguredProperty("clientCertificate", null); + + clientKey = getConfiguredProperty("clientKey", null); + + clientKeyPassword = getConfiguredProperty("clientKeyPassword", ""); + + trustStorePath = getConfiguredProperty("trustStore", ""); Map map = new HashMap(); if (null == jksProvider) { @@ -295,6 +309,15 @@ protected static ISQLServerDataSource updateDataSource(String connectionString, case Constants.ENCLAVE_ATTESTATIONPROTOCOL: ds.setEnclaveAttestationProtocol(value); break; + case Constants.CLIENT_CERTIFICATE: + ds.setClientCertificate(value); + break; + case Constants.CLIENT_KEY: + ds.setClientKey(value); + break; + case Constants.CLIENT_KEY_PASSWORD: + ds.setClientKeyPassword(value); + break; default: break; } diff --git a/src/test/java/com/microsoft/sqlserver/testframework/Constants.java b/src/test/java/com/microsoft/sqlserver/testframework/Constants.java index b7d452bc4..fa809341f 100644 --- a/src/test/java/com/microsoft/sqlserver/testframework/Constants.java +++ b/src/test/java/com/microsoft/sqlserver/testframework/Constants.java @@ -24,6 +24,7 @@ private Constants() {} * xAzureSQLMI - - - - For tests not compatible with Azure SQL Managed Instance * NTLM - - - - - - - For NTLM tests * reqExternalSetup - For tests requiring external setup + * clientCertAuth - - For tests requiring client certificate authentication setup * */ public static final String xJDBC42 = "xJDBC42"; @@ -36,6 +37,7 @@ private Constants() {} public static final String xAzureSQLMI = "xAzureSQLMI"; public static final String NTLM = "NTLM"; public static final String reqExternalSetup = "reqExternalSetup"; + public static final String clientCertAuth = "clientCertAuth"; public static final ThreadLocalRandom RANDOM = ThreadLocalRandom.current(); public static final Logger LOGGER = Logger.getLogger("AbstractTest"); @@ -139,7 +141,11 @@ private Constants() {} public static final String ENCLAVE_ATTESTATIONURL = "enclaveAttestationUrl"; public static final String ENCLAVE_ATTESTATIONPROTOCOL = "enclaveAttestationProtocol"; - + + public static final String CLIENT_CERTIFICATE = "CLIENTCERTIFICATE"; + public static final String CLIENT_KEY = "CLIENTKEY"; + public static final String CLIENT_KEY_PASSWORD = "CLIENTKEYPASSWORD"; + public static final String CONFIG_PROPERTIES_FILE = "config.properties"; public enum LOB {