PEMBasedSslContextFactory.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 org.apache.cassandra.security;

import java.io.IOException;
import java.nio.file.Files;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLException;
import javax.net.ssl.TrustManagerFactory;

import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.apache.cassandra.io.util.File;


/**
 * SslContextFactory for the <a href="">PEM standard</a> encoded PKCS#8 private keys and X509 certificates/public-keys.
 * It parses the key material based on the standard defined in the <a href="https://datatracker.ietf.org/doc/html/rfc7468">RFC 7468</a>.
 * It creates <a href="https://datatracker.ietf.org/doc/html/rfc5208">PKCS# 8</a> based private key and X509 certificate(s)
 * for the public key to build the required keystore and the truststore managers that are used for the SSL context creation.
 * Internally it builds Java {@link KeyStore} with <a href="https://datatracker.ietf.org/doc/html/rfc7292">PKCS# 12</a> <a href="https://docs.oracle.com/en/java/javase/11/docs/specs/security/standard-names.html#keystore-types">store type</a>
 * to be used for keystore and the truststore managers.
 * <p>
 * This factory also supports 'hot reloading' of the key material, the same way as defined by {@link FileBasedSslContextFactory},
 * <b>if it is file based</b>. This factory ignores the existing 'store_type' configuration used for other file based store
 * types like JKS.
 * <p>
 * You can configure this factory with either inline PEM data or with the files having the required PEM data as shown
 * below,
 *
 * <b>Configuration: PEM keys/certs defined inline (mind the spaces in the YAML!)</b>
 * <pre>
 *     client/server_encryption_options:
 *      ssl_context_factory:
 *         class_name: org.apache.cassandra.security.PEMBasedSslContextFactory
 *         parameters:
 *             private_key: |
 *              -----BEGIN ENCRYPTED PRIVATE KEY----- OR -----BEGIN PRIVATE KEY-----
 *              <your base64 encoded private key>
 *              -----END ENCRYPTED PRIVATE KEY----- OR -----END PRIVATE KEY-----
 *              -----BEGIN CERTIFICATE-----
 *              <your base64 encoded certificate chain>
 *              -----END CERTIFICATE-----
 *
 *             private_key_password: "<your password if the private key is encrypted with a password>"
 *
 *             trusted_certificates: |
 *               -----BEGIN CERTIFICATE-----
 *               <your base64 encoded certificate>
 *               -----END CERTIFICATE-----
 * </pre>
 *
 * <b>Configuration: PEM keys/certs defined in files</b>
 * <pre>
 *     client/server_encryption_options:
 *      ssl_context_factory:
 *         class_name: org.apache.cassandra.security.PEMBasedSslContextFactory
 *      keystore: <file path to the keystore file in the PEM format with the private key and the certificate chain>
 *      keystore_password: "<your password if the private key is encrypted with a password>"
 *      truststore: <file path to the truststore file in the PEM format>
 * </pre>
 */
public final class PEMBasedSslContextFactory extends FileBasedSslContextFactory
{
    public static final String DEFAULT_TARGET_STORETYPE = "PKCS12";
    private static final Logger logger = LoggerFactory.getLogger(PEMBasedSslContextFactory.class);
    private PEMBasedKeyStoreContext pemEncodedTrustCertificates;
    private PEMBasedKeyStoreContext pemEncodedKeyContext;
    private PEMBasedKeyStoreContext pemEncodedOutboundKeyContext;

    public PEMBasedSslContextFactory()
    {
    }

    private void validatePasswords()
    {
        boolean shouldThrow = !keystoreContext.passwordMatchesIfPresent(pemEncodedKeyContext.password)
                              || !outboundKeystoreContext.passwordMatchesIfPresent(pemEncodedOutboundKeyContext.password);
        boolean outboundPasswordMismatch = !outboundKeystoreContext.passwordMatchesIfPresent(pemEncodedOutboundKeyContext.password);
        String keyName = outboundPasswordMismatch ? "outbound_" : "";

        if (shouldThrow)
        {
            final String msg = String.format("'%skeystore_password' and '%skey_password' both configurations are given and the values do not match", keyName, keyName);
            throw new IllegalArgumentException(msg);
        }
    }

    public PEMBasedSslContextFactory(Map<String, Object> parameters)
    {
        super(parameters);
        final String pemEncodedKey = getString(ConfigKey.ENCODED_KEY.getKeyName());
        final String pemEncodedKeyPassword = StringUtils.defaultString(getString(ConfigKey.KEY_PASSWORD.getKeyName()), keystoreContext.password);
        pemEncodedKeyContext = new PEMBasedKeyStoreContext(pemEncodedKey, pemEncodedKeyPassword, StringUtils.isEmpty(pemEncodedKey), keystoreContext);

        final String pemEncodedOutboundKey = StringUtils.defaultString(getString(ConfigKey.OUTBOUND_ENCODED_KEY.getKeyName()), pemEncodedKey);
        final String outboundKeyPassword = StringUtils.defaultString(StringUtils.defaultString(getString(ConfigKey.OUTBOUND_ENCODED_KEY_PASSWORD.getKeyName()),
                                                                                               outboundKeystoreContext.password), pemEncodedKeyPassword);
        pemEncodedOutboundKeyContext = new PEMBasedKeyStoreContext(pemEncodedKey, outboundKeyPassword, StringUtils.isEmpty(pemEncodedOutboundKey), outboundKeystoreContext);

        validatePasswords();

        if (!StringUtils.isEmpty(trustStoreContext.password))
        {
            logger.warn("PEM based truststore should not be using password. Ignoring the given value in " +
                        "'truststore_password' configuration.");
        }

        final String pemEncodedCerts = getString(ConfigKey.ENCODED_CERTIFICATES.getKeyName());
        pemEncodedTrustCertificates = new PEMBasedKeyStoreContext(pemEncodedCerts, null, StringUtils.isEmpty(pemEncodedCerts), trustStoreContext);
        enforceSinglePrivateKeySource();
        enforceSingleTurstedCertificatesSource();
    }

    /**
     * Decides if this factory has a keystore defined - key material specified in files or inline to the configuration.
     *
     * @return {@code true} if there is a keystore defined; {@code false} otherwise
     */
    @Override
    public boolean hasKeystore()
    {
        return pemEncodedKeyContext.maybeFilebasedKey
               ? keystoreContext.hasKeystore()
               : !StringUtils.isEmpty(pemEncodedKeyContext.key);
    }

    /**
     * Decides if this factory has an outbound keystore defined - key material specified in files or inline to the configuration.
     *
     * @return {@code true} if there is an outbound keystore defined; {@code false} otherwise
     */
    @Override
    public boolean hasOutboundKeystore()
    {
        return pemEncodedOutboundKeyContext.maybeFilebasedKey
               ? outboundKeystoreContext.hasKeystore()
               : !StringUtils.isEmpty(pemEncodedOutboundKeyContext.key);
    }

    /**
     * Decides if this factory has a truststore defined - key material specified in files or inline to the
     * configuration.
     *
     * @return {@code true} if there is a truststore defined; {@code false} otherwise
     */
    private boolean hasTruststore()
    {
        return pemEncodedTrustCertificates.maybeFilebasedKey ? truststoreFileExists() :
               !StringUtils.isEmpty(pemEncodedTrustCertificates.key);
    }

    /**
     * Checks if the truststore file exists.
     *
     * @return {@code true} if truststore file exists; {@code false} otherwise
     */
    private boolean truststoreFileExists()
    {
        return trustStoreContext.filePath != null && new File(trustStoreContext.filePath).exists();
    }

    /**
     * This enables 'hot' reloading of the key/trust stores based on the last updated timestamps if they are file based.
     */
    @Override
    public synchronized void initHotReloading()
    {
        List<HotReloadableFile> fileList = new ArrayList<>();
        if (pemEncodedKeyContext.maybeFilebasedKey && hasKeystore())
        {
            fileList.add(new HotReloadableFile(keystoreContext.filePath));
        }
        if (pemEncodedOutboundKeyContext.maybeFilebasedKey && hasOutboundKeystore())
        {
            fileList.add(new HotReloadableFile(outboundKeystoreContext.filePath));
        }
        if (pemEncodedTrustCertificates.maybeFilebasedKey && hasTruststore())
        {
            fileList.add(new HotReloadableFile(trustStoreContext.filePath));
        }
        if (!fileList.isEmpty())
        {
            hotReloadableFiles = fileList;
        }
    }

    /**
     * Builds required KeyManagerFactory from the PEM based keystore. It also checks for the PrivateKey's certificate's
     * expiry and logs {@code warning} for each expired PrivateKey's certitificate.
     *
     * @return KeyManagerFactory built from the PEM based keystore.
     * @throws SSLException if any issues encountered during the build process
     */
    @Override
    protected KeyManagerFactory buildKeyManagerFactory() throws SSLException
    {
        return buildKeyManagerFactory(pemEncodedKeyContext, keystoreContext);
    }

    @Override
    protected KeyManagerFactory buildOutboundKeyManagerFactory() throws SSLException
    {
        return buildKeyManagerFactory(pemEncodedOutboundKeyContext, outboundKeystoreContext);
    }

    private KeyManagerFactory buildKeyManagerFactory(PEMBasedKeyStoreContext pemBasedKeyStoreContext, FileBasedStoreContext keyStoreContext) throws SSLException
    {
        try
        {
            if (pemBasedKeyStoreContext.hasKey())
            {
                if (pemBasedKeyStoreContext.maybeFilebasedKey)
                {
                    pemBasedKeyStoreContext.key = readPEMFile(keyStoreContext.filePath); // read PEM from the file
                }

                KeyManagerFactory kmf = KeyManagerFactory.getInstance(
                algorithm == null ? KeyManagerFactory.getDefaultAlgorithm() : algorithm);
                KeyStore ks = buildKeyStore(pemBasedKeyStoreContext.key, pemBasedKeyStoreContext.password);
                if (!keyStoreContext.checkedExpiry)
                {
                    checkExpiredCerts(ks);
                    keyStoreContext.checkedExpiry = true;
                }
                kmf.init(ks, pemBasedKeyStoreContext.password != null ? pemBasedKeyStoreContext.password.toCharArray() : null);
                return kmf;
            }
            else
            {
                throw new SSLException("Must provide outbound_keystore or outbound_private_key in configuration for PEMBasedSSlContextFactory");
            }
        }
        catch (Exception e)
        {
            throw new SSLException("Failed to build key manager store for secure connections", e);
        }
    }

    /**
     * Builds TrustManagerFactory from the PEM based truststore.
     *
     * @return TrustManagerFactory from the PEM based truststore
     * @throws SSLException if any issues encountered during the build process
     */
    @Override
    protected TrustManagerFactory buildTrustManagerFactory() throws SSLException
    {
        try
        {
            if (hasTruststore())
            {
                if (pemEncodedTrustCertificates.maybeFilebasedKey)
                {
                    pemEncodedTrustCertificates.key = readPEMFile(trustStoreContext.filePath); // read PEM from the file
                }

                TrustManagerFactory tmf = TrustManagerFactory.getInstance(
                algorithm == null ? TrustManagerFactory.getDefaultAlgorithm() : algorithm);
                KeyStore ts = buildTrustStore();
                tmf.init(ts);
                return tmf;
            }
            else
            {
                throw new SSLException("Must provide truststore or trusted_certificates in configuration for " +
                                       "PEMBasedSSlContextFactory");
            }
        }
        catch (Exception e)
        {
            throw new SSLException("Failed to build trust manager store for secure connections", e);
        }
    }

    private String readPEMFile(String file) throws IOException
    {
        return new String(Files.readAllBytes(File.getPath(file)));
    }

    /**
     * Builds KeyStore object given the {@link #DEFAULT_TARGET_STORETYPE} out of the PEM formatted private key material.
     * It uses {@code cassandra-ssl-keystore} as the alias for the created key-entry.
     */
    private static KeyStore buildKeyStore(final String pemEncodedKey, final String keyPassword) throws GeneralSecurityException, IOException
    {
        char[] keyPasswordArray = keyPassword != null ? keyPassword.toCharArray() : null;
        PrivateKey privateKey = PEMReader.extractPrivateKey(pemEncodedKey, keyPassword);
        Certificate[] certChainArray = PEMReader.extractCertificates(pemEncodedKey);
        if (certChainArray == null || certChainArray.length == 0)
        {
            throw new SSLException("Could not read any certificates for the certChain for the private key");
        }

        KeyStore keyStore = KeyStore.getInstance(DEFAULT_TARGET_STORETYPE);
        keyStore.load(null, null);
        keyStore.setKeyEntry("cassandra-ssl-keystore", privateKey, keyPasswordArray, certChainArray);
        return keyStore;
    }

    /**
     * Builds KeyStore object given the {@link #DEFAULT_TARGET_STORETYPE} out of the PEM formatted certificates/public-key
     * material.
     * <p>
     * It uses {@code cassandra-ssl-trusted-cert-<numeric-id>} as the alias for the created certificate-entry.
     */
    private KeyStore buildTrustStore() throws GeneralSecurityException, IOException
    {
        Certificate[] certChainArray = PEMReader.extractCertificates(pemEncodedTrustCertificates.key);
        if (certChainArray == null || certChainArray.length == 0)
        {
            throw new SSLException("Could not read any certificates from the given PEM");
        }

        KeyStore keyStore = KeyStore.getInstance(DEFAULT_TARGET_STORETYPE);
        keyStore.load(null, null);
        for (int i = 0; i < certChainArray.length; i++)
        {
            keyStore.setCertificateEntry("cassandra-ssl-trusted-cert-" + (i + 1), certChainArray[i]);
        }
        return keyStore;
    }

    /**
     * Enforces that the configuration specified a sole source of loading private keys - either {@code keystore} (the
     * actual file must exist) or {@code private_key}, not both.
     */
    private void enforceSinglePrivateKeySource()
    {
        if (keystoreContext.hasKeystore() && !StringUtils.isEmpty(pemEncodedKeyContext.key))
        {
            throw new IllegalArgumentException("Configuration must specify value for either keystore or private_key, " +
                                               "not both for PEMBasedSSlContextFactory");
        }
        if (outboundKeystoreContext.hasKeystore() && !StringUtils.isEmpty(pemEncodedOutboundKeyContext.key))
        {
            throw new IllegalArgumentException("Configuration must specify value for either outbound_keystore or outbound_private_key, " +
                                               "not both for PEMBasedSSlContextFactory");
        }
    }

    /**
     * Enforces that the configuration specified a sole source of loading trusted certificates - either {@code
     * truststore} (actual file must exist) or {@code trusted_certificates}, not both.
     */
    private void enforceSingleTurstedCertificatesSource()
    {
        if (truststoreFileExists() && !StringUtils.isEmpty(pemEncodedTrustCertificates.key))
        {
            throw new IllegalArgumentException("Configuration must specify value for either truststore or " +
                                               "trusted_certificates, not both for PEMBasedSSlContextFactory");
        }
    }

    public static class PEMBasedKeyStoreContext
    {
        public String key;
        public final String password;
        public final boolean maybeFilebasedKey;
        public final FileBasedStoreContext filebasedKeystoreContext;

        public PEMBasedKeyStoreContext(final String encodedKey, final String getEncodedKeyPassword,
                                       final boolean maybeFilebasedKey, final FileBasedStoreContext filebasedKeystoreContext)
        {
            this.key = encodedKey;
            this.password = getEncodedKeyPassword;
            this.maybeFilebasedKey = maybeFilebasedKey;
            this.filebasedKeystoreContext = filebasedKeystoreContext;
        }

        public boolean hasKey()
        {
            return maybeFilebasedKey
                   ? filebasedKeystoreContext.hasKeystore()
                   : !StringUtils.isEmpty(key);
        }
    }

    public enum ConfigKey
    {
        ENCODED_KEY("private_key"),
        KEY_PASSWORD("private_key_password"),
        OUTBOUND_ENCODED_KEY("outbound_private_key"),
        OUTBOUND_ENCODED_KEY_PASSWORD("outbound_private_key_password"),
        ENCODED_CERTIFICATES("trusted_certificates");

        final String keyName;

        ConfigKey(String keyName)
        {
            this.keyName = keyName;
        }

        String getKeyName()
        {
            return keyName;
        }
    }
}