blob: e06da1f0cbe9ce1c97cb7d2cf5fdf2aad33a6459 [file] [log] [blame]
/*
* 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.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.netty.buffer.ByteBufAllocator;
import io.netty.handler.ssl.CipherSuiteFilter;
import io.netty.handler.ssl.OpenSsl;
import io.netty.handler.ssl.SslContext;
import io.netty.util.ReferenceCountUtil;
import org.apache.cassandra.concurrent.ScheduledExecutors;
import org.apache.cassandra.config.Config;
import org.apache.cassandra.config.DatabaseDescriptor;
import org.apache.cassandra.config.EncryptionOptions;
import org.apache.cassandra.security.ISslContextFactory.SocketType;
/**
* A Factory for providing and setting up client {@link SSLSocket}s. Also provides
* methods for creating both JSSE {@link SSLContext} instances as well as netty {@link SslContext} instances.
* <p>
* Netty {@link SslContext} instances are expensive to create (as well as to destroy) and consume a lof of resources
* (especially direct memory), but instances can be reused across connections (assuming the SSL params are the same).
* Hence we cache created instances in {@link #cachedSslContexts}.
*/
public final class SSLFactory
{
private static final Logger logger = LoggerFactory.getLogger(SSLFactory.class);
// Isolate calls to OpenSsl.isAvailable to allow in-jvm dtests to disable tcnative openssl
// support. It creates a circular reference that prevents the instance class loader from being
// garbage collected.
static private final boolean openSslIsAvailable;
static
{
if (Boolean.getBoolean(Config.PROPERTY_PREFIX + "disable_tcactive_openssl"))
{
openSslIsAvailable = false;
}
else
{
openSslIsAvailable = OpenSsl.isAvailable();
}
}
public static boolean openSslIsAvailable()
{
return openSslIsAvailable;
}
/**
* Cached references of SSL Contexts
*/
private static final ConcurrentHashMap<CacheKey, SslContext> cachedSslContexts = new ConcurrentHashMap<>();
/**
* Default initial delay for hot reloading
*/
public static final int DEFAULT_HOT_RELOAD_INITIAL_DELAY_SEC = 600;
/**
* Default periodic check delay for hot reloading
*/
public static final int DEFAULT_HOT_RELOAD_PERIOD_SEC = 600;
/**
* State variable to maintain initialization invariant
*/
private static boolean isHotReloadingInitialized = false;
/** Provides the list of protocols that would have been supported if "TLS" was selected as the
* protocol before the change for CASSANDRA-13325 that expects explicit protocol versions.
* @return list of enabled protocol names
*/
public static List<String> tlsInstanceProtocolSubstitution()
{
try
{
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, null, null);
SSLParameters params = ctx.getDefaultSSLParameters();
String[] protocols = params.getProtocols();
return Arrays.asList(protocols);
}
catch (Exception e)
{
throw new RuntimeException("Error finding supported TLS Protocols", e);
}
}
/**
* Create a JSSE {@link SSLContext}.
*/
public static SSLContext createSSLContext(EncryptionOptions options, boolean verifyPeerCertificate) throws IOException
{
return options.sslContextFactoryInstance.createJSSESslContext(verifyPeerCertificate);
}
/**
* get a netty {@link SslContext} instance
*/
public static SslContext getOrCreateSslContext(EncryptionOptions options, boolean verifyPeerCertificate,
SocketType socketType) throws IOException
{
CacheKey key = new CacheKey(options, socketType);
SslContext sslContext;
sslContext = cachedSslContexts.get(key);
if (sslContext != null)
return sslContext;
sslContext = createNettySslContext(options, verifyPeerCertificate, socketType);
SslContext previous = cachedSslContexts.putIfAbsent(key, sslContext);
if (previous == null)
return sslContext;
ReferenceCountUtil.release(sslContext);
return previous;
}
/**
* Create a Netty {@link SslContext}
*/
static SslContext createNettySslContext(EncryptionOptions options, boolean verifyPeerCertificate,
SocketType socketType) throws IOException
{
return createNettySslContext(options, verifyPeerCertificate, socketType,
LoggingCipherSuiteFilter.QUIET_FILTER);
}
/**
* Create a Netty {@link SslContext} with a supplied cipherFilter
*/
static SslContext createNettySslContext(EncryptionOptions options, boolean verifyPeerCertificate,
SocketType socketType, CipherSuiteFilter cipherFilter) throws IOException
{
return options.sslContextFactoryInstance.createNettySslContext(verifyPeerCertificate, socketType,
cipherFilter);
}
/**
* Performs a lightweight check whether the certificate files have been refreshed.
*
* @throws IllegalStateException if {@link #initHotReloading(EncryptionOptions.ServerEncryptionOptions, EncryptionOptions, boolean)}
* is not called first
*/
public static void checkCertFilesForHotReloading(EncryptionOptions.ServerEncryptionOptions serverOpts,
EncryptionOptions clientOpts)
{
if (!isHotReloadingInitialized)
throw new IllegalStateException("Hot reloading functionality has not been initialized.");
logger.debug("Checking whether certificates have been updated for server {} and client {}",
serverOpts.sslContextFactoryInstance.getClass().getName(), clientOpts.sslContextFactoryInstance.getClass().getName());
if (serverOpts != null)
{
checkCertFilesForHotReloading(serverOpts, "server_encryption_options", true);
}
if (clientOpts != null)
{
checkCertFilesForHotReloading(clientOpts, "client_encryption_options", clientOpts.require_client_auth);
}
}
private static void checkCertFilesForHotReloading(EncryptionOptions options, String contextDescription,
boolean verifyPeerCertificate)
{
try
{
if (options.sslContextFactoryInstance.shouldReload())
{
logger.info("SSL certificates have been updated for {}. Resetting the ssl contexts for new " +
"connections.", options.getClass().getName());
validateSslContext(contextDescription, options, verifyPeerCertificate, false);
clearSslContextCache(options);
}
}
catch(Exception e)
{
logger.error("Failed to hot reload the SSL Certificates! Please check the certificate files.", e);
}
}
/**
* This clears the cache of Netty's SslContext objects for Client and Server sockets. This is made publically
* available so that any {@link ISslContextFactory}'s implementation can call this to handle any special scenario
* to invalidate the SslContext cache.
* This should be used with caution since the purpose of this cache is save costly creation of Netty's SslContext
* objects and this essentially results in re-creating it.
*/
public static void clearSslContextCache()
{
cachedSslContexts.clear();
}
private static void clearSslContextCache(EncryptionOptions options)
{
cachedSslContexts.forEachKey(1, cacheKey -> {
if (cacheKey.encryptionOptions.equals(options))
{
cachedSslContexts.remove(cacheKey);
}
});
}
/**
* Determines whether to hot reload certificates and schedules a periodic task for it.
*
* @param serverOpts Server encryption options (Internode)
* @param clientOpts Client encryption options (Native Protocol)
*/
public static synchronized void initHotReloading(EncryptionOptions.ServerEncryptionOptions serverOpts,
EncryptionOptions clientOpts,
boolean force) throws IOException
{
if (isHotReloadingInitialized && !force)
return;
logger.debug("Initializing hot reloading SSLContext");
if ( serverOpts != null && serverOpts.tlsEncryptionPolicy() != EncryptionOptions.TlsEncryptionPolicy.UNENCRYPTED) {
serverOpts.sslContextFactoryInstance.initHotReloading();
}
if ( clientOpts != null && clientOpts.tlsEncryptionPolicy() != EncryptionOptions.TlsEncryptionPolicy.UNENCRYPTED) {
clientOpts.sslContextFactoryInstance.initHotReloading();
}
if (!isHotReloadingInitialized)
{
ScheduledExecutors.scheduledTasks
.scheduleWithFixedDelay(() -> checkCertFilesForHotReloading(
DatabaseDescriptor.getInternodeMessagingEncyptionOptions(),
DatabaseDescriptor.getNativeProtocolEncryptionOptions()),
DEFAULT_HOT_RELOAD_INITIAL_DELAY_SEC,
DEFAULT_HOT_RELOAD_PERIOD_SEC, TimeUnit.SECONDS);
}
isHotReloadingInitialized = true;
}
// Non-logging
/*
* This class will filter all requested ciphers out that are not supported by the current {@link SSLEngine},
* logging messages for all dropped ciphers, and throws an exception if no ciphers are supported
*/
public static final class LoggingCipherSuiteFilter implements CipherSuiteFilter
{
// Version without logging the ciphers, make sure same filtering logic is used
// all the time, regardless of user output.
public static final CipherSuiteFilter QUIET_FILTER = new LoggingCipherSuiteFilter();
final String settingDescription;
private LoggingCipherSuiteFilter()
{
this.settingDescription = null;
}
public LoggingCipherSuiteFilter(String settingDescription)
{
this.settingDescription = settingDescription;
}
@Override
public String[] filterCipherSuites(Iterable<String> ciphers, List<String> defaultCiphers,
Set<String> supportedCiphers)
{
Objects.requireNonNull(defaultCiphers, "defaultCiphers");
Objects.requireNonNull(supportedCiphers, "supportedCiphers");
final List<String> newCiphers;
if (ciphers == null)
{
newCiphers = new ArrayList<>(defaultCiphers.size());
ciphers = defaultCiphers;
}
else
{
newCiphers = new ArrayList<>(supportedCiphers.size());
}
for (String c : ciphers)
{
if (c == null)
{
break;
}
if (supportedCiphers.contains(c))
{
newCiphers.add(c);
}
else
{
if (settingDescription != null)
{
logger.warn("Dropping unsupported cipher_suite {} from {} configuration",
c, settingDescription.toLowerCase());
}
}
}
if (newCiphers.isEmpty())
{
throw new IllegalStateException("No ciphers left after filtering supported cipher suite");
}
return newCiphers.toArray(new String[0]);
}
}
private static boolean filterOutSSLv2Hello(String string)
{
return !string.equals("SSLv2Hello");
}
public static void validateSslContext(String contextDescription, EncryptionOptions options, boolean verifyPeerCertificate, boolean logProtocolAndCiphers) throws IOException
{
if (options != null && options.tlsEncryptionPolicy() != EncryptionOptions.TlsEncryptionPolicy.UNENCRYPTED)
{
try
{
CipherSuiteFilter loggingCipherSuiteFilter = logProtocolAndCiphers ? new LoggingCipherSuiteFilter(contextDescription)
: LoggingCipherSuiteFilter.QUIET_FILTER;
SslContext serverSslContext = createNettySslContext(options, verifyPeerCertificate, SocketType.SERVER, loggingCipherSuiteFilter);
try
{
SSLEngine engine = serverSslContext.newEngine(ByteBufAllocator.DEFAULT);
try
{
if (logProtocolAndCiphers)
{
String[] supportedProtocols = engine.getSupportedProtocols();
String[] supportedCiphers = engine.getSupportedCipherSuites();
// Netty always adds the SSLv2Hello pseudo-protocol. (Netty commit 7a39afd031accea9ee38653afbd58eb1c466deda)
// To avoid triggering any log scanners that are concerned about SSL2 references, filter
// it from the output.
String[] enabledProtocols = engine.getEnabledProtocols();
String filteredEnabledProtocols =
supportedProtocols == null ? "system default"
: Arrays.stream(engine.getEnabledProtocols())
.filter(SSLFactory::filterOutSSLv2Hello)
.collect(Collectors.joining(", "));
String[] enabledCiphers = engine.getEnabledCipherSuites();
logger.debug("{} supported TLS protocols: {}", contextDescription,
supportedProtocols == null ? "system default" : String.join(", ", supportedProtocols));
logger.debug("{} unfiltered enabled TLS protocols: {}", contextDescription,
enabledProtocols == null ? "system default" : String.join(", ", enabledProtocols));
logger.info("{} enabled TLS protocols: {}", contextDescription, filteredEnabledProtocols);
logger.debug("{} supported cipher suites: {}", contextDescription,
supportedCiphers == null ? "system default" : String.join(", ", supportedCiphers));
logger.info("{} enabled cipher suites: {}", contextDescription,
enabledCiphers == null ? "system default" : String.join(", ", enabledCiphers));
}
}
finally
{
engine.closeInbound();
engine.closeOutbound();
ReferenceCountUtil.release(engine);
}
}
finally
{
ReferenceCountUtil.release(serverSslContext);
}
// Make sure it is possible to build the client context too
SslContext clientSslContext = createNettySslContext(options, verifyPeerCertificate, SocketType.CLIENT);
ReferenceCountUtil.release(clientSslContext);
}
catch (Exception e)
{
throw new IOException("Failed to create SSL context using " + contextDescription, e);
}
}
}
/**
* Sanity checks all certificates to ensure we can actually load them
*/
public static void validateSslCerts(EncryptionOptions.ServerEncryptionOptions serverOpts, EncryptionOptions clientOpts) throws IOException
{
validateSslContext("server_encryption_options", serverOpts, true, false);
validateSslContext("client_encryption_options", clientOpts, clientOpts.require_client_auth, false);
}
static class CacheKey
{
private final EncryptionOptions encryptionOptions;
private final SocketType socketType;
public CacheKey(EncryptionOptions encryptionOptions, SocketType socketType)
{
this.encryptionOptions = encryptionOptions;
this.socketType = socketType;
}
public boolean equals(Object o)
{
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
CacheKey cacheKey = (CacheKey) o;
return (socketType == cacheKey.socketType &&
Objects.equals(encryptionOptions, cacheKey.encryptionOptions));
}
public int hashCode()
{
int result = 0;
result += 31 * socketType.hashCode();
result += 31 * encryptionOptions.hashCode();
return result;
}
}
}