diff --git a/src/main/java/com/mongodb/jdbc/utils/X509Authentication.java b/src/main/java/com/mongodb/jdbc/utils/X509Authentication.java index 479cc6f1..f1c6c74f 100644 --- a/src/main/java/com/mongodb/jdbc/utils/X509Authentication.java +++ b/src/main/java/com/mongodb/jdbc/utils/X509Authentication.java @@ -19,11 +19,13 @@ import com.mongodb.MongoException; import com.mongodb.jdbc.logging.MongoLogger; import java.io.File; +import java.io.FileInputStream; import java.io.FileReader; import java.io.IOException; import java.io.StringReader; import java.security.*; import java.security.cert.Certificate; +import java.security.UnrecoverableKeyException; import java.util.logging.Level; import javax.net.ssl.*; import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; @@ -132,6 +134,54 @@ public void configureX509Authentication( } } + /** + * Configures X.509 authentication for MongoDB using a JKS keystore containing the private key and + * certificate. + * + * @param settingsBuilder The MongoDB client settings builder to apply the SSL configuration. + * Must not be null. + * @param keystorePath The path to the JKS keystore file containing the private key and certificate. + * Must not be null. + * @param keystorePassword The password for the keystore. Can be null if keystore is not password protected. + * @param certificateAlias The alias of the certificate to use from the keystore. Must not be null. + * @throws Exception If there is an error during configuration, keystore loading, or certificate extraction. + * @throws NullPointerException if settingsBuilder, keystorePath, or certificateAlias are null. + */ + public void configureX509AuthenticationFromKeystore( + com.mongodb.MongoClientSettings.Builder settingsBuilder, + String keystorePath, + char[] keystorePassword, + String certificateAlias) + throws Exception { + + if (settingsBuilder == null) { + throw new NullPointerException("settingsBuilder cannot be null"); + } + if (keystorePath == null || keystorePath.trim().isEmpty()) { + throw new NullPointerException("keystorePath cannot be null or empty"); + } + if (certificateAlias == null || certificateAlias.trim().isEmpty()) { + throw new NullPointerException("certificateAlias cannot be null or empty"); + } + + logger.log(Level.FINE, "Using JKS keystore for X509 authentication: " + keystorePath); + logger.log(Level.FINE, "Certificate alias: " + certificateAlias); + + try { + SSLContext sslContext = + createSSLContextFromKeystore(keystorePath, keystorePassword, certificateAlias); + + settingsBuilder.applyToSslSettings( + sslSettings -> { + sslSettings.enabled(true); + sslSettings.context(sslContext); + }); + } catch (Exception e) { + logger.log(Level.SEVERE, "SSL setup failed: " + e.getMessage()); + throw e; + } + } + /** * Formats a PEM string to handle escaped newlines and ensures correct header placement. Adds * required newlines for compatibility with Bouncy Castle PEMParser. @@ -381,6 +431,51 @@ private SSLContext createSSLContextFromKeyAndCert(PrivateKey privateKey, Certifi return sslContext; } + private SSLContext createSSLContextFromKeystore( + String keystorePath, char[] keystorePassword, String certificateAlias) + throws Exception { + KeyStore keystore = KeyStore.getInstance("JKS"); + try (FileInputStream keystoreStream = new FileInputStream(keystorePath)) { + keystore.load(keystoreStream, keystorePassword); + logger.log(Level.FINE, "Successfully loaded JKS keystore from: " + keystorePath); + } catch (IOException e) { + throw new MongoException("Failed to read keystore file: " + e.getMessage(), e); + } catch (Exception e) { + throw new MongoException("Failed to load keystore: " + e.getMessage(), e); + } + + Certificate cert = keystore.getCertificate(certificateAlias); + if (cert == null) { + throw new MongoException( + "Certificate with alias '" + certificateAlias + "' not found in keystore"); + } + logger.log(Level.FINE, "Found certificate with alias: " + certificateAlias); + + PrivateKey privateKey; + try { + Key key = keystore.getKey(certificateAlias, keystorePassword); + if (key == null) { + throw new MongoException( + "Private key with alias '" + certificateAlias + "' not found in keystore"); + } + if (!(key instanceof PrivateKey)) { + throw new MongoException("Key with alias '" + certificateAlias + "' is not a private key"); + } + privateKey = (PrivateKey) key; + logger.log( + Level.FINE, "Successfully extracted private key with alias: " + certificateAlias); + } catch (UnrecoverableKeyException e) { + throw new MongoException( + "Failed to extract private key with alias '" + + certificateAlias + + "': " + + e.getMessage(), + e); + } + + return createSSLContextFromKeyAndCert(privateKey, cert); + } + private PemAuthenticationInput parsePemAuthenticationInput(String input) { try { BsonDocument doc = BsonDocument.parse(input); diff --git a/src/test/java/com/mongodb/jdbc/utils/PemToKeystoreConverter.java b/src/test/java/com/mongodb/jdbc/utils/PemToKeystoreConverter.java new file mode 100644 index 00000000..80af8645 --- /dev/null +++ b/src/test/java/com/mongodb/jdbc/utils/PemToKeystoreConverter.java @@ -0,0 +1,201 @@ +/* + * Copyright 2025-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.mongodb.jdbc.utils; + +import com.mongodb.jdbc.logging.MongoLogger; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.IOException; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.logging.SimpleFormatter; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; + +/** + * Utility class to convert PEM files to JKS keystore format for testing purposes. + * This class reads unencrypted PEM files from the test resources directory and + * creates a JKS keystore with certificates using filenames as aliases. + */ +public class PemToKeystoreConverter { + private static final Logger LOGGER = Logger.getLogger("PemToKeystoreConverter"); + private static final String TEST_PEM_DIR = "X509AuthenticationTest"; + private static final String KEYSTORE_PASSWORD = "testpass"; + private static final String KEYSTORE_FILENAME = "test-certificates.jks"; + + static { + ConsoleHandler consoleHandler = new ConsoleHandler(); + consoleHandler.setFormatter(new SimpleFormatter()); + consoleHandler.setLevel(Level.ALL); + LOGGER.addHandler(consoleHandler); + LOGGER.setLevel(Level.ALL); + LOGGER.setUseParentHandlers(false); + } + + private static final MongoLogger MONGO_LOGGER = new MongoLogger(LOGGER, 1); + + /** + * Main method to convert PEM files to JKS keystore. + * Finds all *unencrypted.pem files in the test resources directory, + * extracts certificates and private keys, and creates a JKS keystore. + */ + public static void main(String[] args) throws Exception { + PemToKeystoreConverter converter = new PemToKeystoreConverter(); + converter.convertPemFilesToKeystore(); + } + + /** + * Converts all unencrypted PEM files to a JKS keystore. + */ + public void convertPemFilesToKeystore() throws Exception { + LOGGER.info("Starting PEM to JKS keystore conversion..."); + + java.security.Security.addProvider(new BouncyCastleProvider()); + + KeyStore keystore = KeyStore.getInstance("JKS"); + keystore.load(null, KEYSTORE_PASSWORD.toCharArray()); + + ClassLoader classLoader = getClass().getClassLoader(); + File testDir = new File(classLoader.getResource(TEST_PEM_DIR).getFile()); + + File[] pemFiles = testDir.listFiles((dir, name) -> name.endsWith("unencrypted.pem")); + + if (pemFiles == null || pemFiles.length == 0) { + LOGGER.warning("No unencrypted PEM files found in " + testDir.getPath()); + return; + } + + LOGGER.info("Found " + pemFiles.length + " unencrypted PEM files"); + + for (File pemFile : pemFiles) { + processPemFile(pemFile, keystore); + } + + String keystorePath = testDir.getParent() + File.separator + KEYSTORE_FILENAME; + try (FileOutputStream fos = new FileOutputStream(keystorePath)) { + keystore.store(fos, KEYSTORE_PASSWORD.toCharArray()); + LOGGER.info("Keystore saved to: " + keystorePath); + } + + verifyKeystore(keystorePath); + } + + /** + * Processes a single PEM file and adds its certificate and private key to the keystore. + */ + private void processPemFile(File pemFile, KeyStore keystore) throws Exception { + String filename = pemFile.getName(); + String alias = filename.substring(0, filename.lastIndexOf(".pem")); + + LOGGER.info("Processing file: " + filename + " with alias: " + alias); + + Certificate certificate = null; + PrivateKey privateKey = null; + + try (FileReader fileReader = new FileReader(pemFile); + PEMParser pemParser = new PEMParser(fileReader)) { + + Object pemObject; + while ((pemObject = pemParser.readObject()) != null) { + if (pemObject instanceof X509CertificateHolder) { + X509CertificateHolder certHolder = (X509CertificateHolder) pemObject; + JcaX509CertificateConverter converter = new JcaX509CertificateConverter(); + converter.setProvider("BC"); + certificate = converter.getCertificate(certHolder); + LOGGER.fine("Found X.509 certificate in " + filename); + } else if (pemObject instanceof PEMKeyPair) { + PEMKeyPair keyPair = (PEMKeyPair) pemObject; + JcaPEMKeyConverter keyConverter = new JcaPEMKeyConverter(); + keyConverter.setProvider("BC"); + privateKey = keyConverter.getPrivateKey(keyPair.getPrivateKeyInfo()); + LOGGER.fine("Found PKCS#1 private key in " + filename); + } else if (pemObject instanceof PrivateKeyInfo) { + PrivateKeyInfo keyInfo = (PrivateKeyInfo) pemObject; + JcaPEMKeyConverter keyConverter = new JcaPEMKeyConverter(); + keyConverter.setProvider("BC"); + privateKey = keyConverter.getPrivateKey(keyInfo); + LOGGER.fine("Found PKCS#8 private key in " + filename); + } + } + } + + if (certificate == null) { + throw new Exception("No certificate found in " + filename); + } + if (privateKey == null) { + throw new Exception("No private key found in " + filename); + } + + Certificate[] certChain = {certificate}; + keystore.setKeyEntry(alias, privateKey, KEYSTORE_PASSWORD.toCharArray(), certChain); + + LOGGER.info("Added certificate and private key for alias: " + alias); + } + + /** + * Verifies the created keystore by loading it and checking its contents. + */ + private void verifyKeystore(String keystorePath) throws Exception { + LOGGER.info("Verifying keystore contents..."); + + KeyStore verifyKeystore = KeyStore.getInstance("JKS"); + try (FileInputStream fis = new FileInputStream(keystorePath)) { + verifyKeystore.load(fis, KEYSTORE_PASSWORD.toCharArray()); + } + + java.util.Enumeration aliases = verifyKeystore.aliases(); + int count = 0; + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + count++; + + Certificate cert = verifyKeystore.getCertificate(alias); + PrivateKey key = (PrivateKey) verifyKeystore.getKey(alias, KEYSTORE_PASSWORD.toCharArray()); + + LOGGER.info("Verified alias: " + alias + + " - Certificate: " + (cert != null ? "OK" : "MISSING") + + " - Private Key: " + (key != null ? "OK" : "MISSING")); + } + + LOGGER.info("Keystore verification complete. Total aliases: " + count); + } + + /** + * Gets the keystore password used for testing. + */ + public static String getKeystorePassword() { + return KEYSTORE_PASSWORD; + } + + /** + * Gets the keystore filename. + */ + public static String getKeystoreFilename() { + return KEYSTORE_FILENAME; + } +}