diff --git a/conf/cassandra.yaml b/conf/cassandra.yaml index a9393d4d18..42f311b0eb 100644 --- a/conf/cassandra.yaml +++ b/conf/cassandra.yaml @@ -1596,6 +1596,13 @@ server_encryption_options: # TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, TLS_RSA_WITH_AES_128_GCM_SHA256, TLS_RSA_WITH_AES_128_CBC_SHA, # TLS_RSA_WITH_AES_256_CBC_SHA # ] + # Optional setting to define the maximum allowed validity period of the client certificate used for the internode + # inbound connections. For example, if the specified max_certificate_validity_period is 30 days and the client + # uses a certificate that is issued for more than 30 days, the connection will be rejected. + # max_certificate_validity_period: 365d + # Optional setting that defines a warning threshold. When the threshold is exceeded for the internode certificate + # validity period, warnings with information about the certificate expiration will be logged. + # certificate_validity_warn_threshold: 10d # Configure client-to-server encryption. # @@ -1642,6 +1649,15 @@ client_encryption_options: # TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, TLS_RSA_WITH_AES_128_GCM_SHA256, TLS_RSA_WITH_AES_128_CBC_SHA, # TLS_RSA_WITH_AES_256_CBC_SHA # ] + # Optional setting to define the maximum validity period of the client certificate allowed to establish + # connections to the server. For example, if max_certificate_validity_period is configured for 10 days, + # and a client attempts to authenticate with a certificate with a longer validity period (say 30 days), + # then the connection will be rejected. + # max_certificate_validity_period: 365d + # Optional setting that defines a warning threshold. When the threshold is exceeded for the client certificate + # validity, warnings with information about the certificate expiration will be logged. Additionally, client + # warnings will be reported during the session establishment. + # certificate_validity_warn_threshold: 10d # internode_compression controls whether traffic between nodes is # compressed. diff --git a/src/java/org/apache/cassandra/auth/MutualTlsAuthenticator.java b/src/java/org/apache/cassandra/auth/MutualTlsAuthenticator.java index a7df4241e9..9044f6f8bb 100644 --- a/src/java/org/apache/cassandra/auth/MutualTlsAuthenticator.java +++ b/src/java/org/apache/cassandra/auth/MutualTlsAuthenticator.java @@ -35,33 +35,42 @@ import org.slf4j.helpers.MessageFormatter; import org.apache.cassandra.config.Config; import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.config.DurationSpec; import org.apache.cassandra.config.ParameterizedClass; import org.apache.cassandra.exceptions.AuthenticationException; import org.apache.cassandra.exceptions.ConfigurationException; +import org.apache.cassandra.metrics.MutualTlsMetrics; import org.apache.cassandra.schema.SchemaConstants; import org.apache.cassandra.utils.NoSpamLogger; import static org.apache.cassandra.auth.IAuthenticator.AuthenticationMode.MTLS; import static org.apache.cassandra.config.EncryptionOptions.ClientAuth.REQUIRED; -/* +/** * Performs mTLS authentication for client connections by extracting identities from client certificate * and verifying them against the authorized identities in IdentityCache. IdentityCache is a loading cache that * refreshes values on timely basis. * - * During a client connection, after SSL handshake, identity of certificate is extracted using the certificate validator + *

During a client connection, after SSL handshake, identity of certificate is extracted using the certificate validator * and is verified whether the value exists in the cache or not. If it exists access is granted, otherwise, the connection * is rejected. * - * Authenticator & Certificate validator can be configured using cassandra.yaml, one can write their own mTLS certificate + *

Authenticator & Certificate validator can be configured using cassandra.yaml, one can write their own mTLS certificate * validator and configure it in cassandra.yaml.Below is an example on how to configure validator. * note that this example uses SPIFFE based validator, It could be any other validator with any defined identifier format. * - * Example: + *

Optionally, the authenticator can be configured to restrict the validity period of the client certificates. + * This allows for better server-side controls for authentication. In some cases, clients can provide certificates + * that expire multiple months/years after the certificate was issued. For those use cases, it is desirable to + * reject the certificate if the validity period is too big (i.e. certificates issued for 10 years). + * + *

Example: + *

  * authenticator:
  *   class_name : org.apache.cassandra.auth.MutualTlsAuthenticator
  *   parameters :
  *     validator_class_name: org.apache.cassandra.auth.SpiffeCertificateValidator
+ * 
*/ public class MutualTlsAuthenticator implements IAuthenticator { @@ -72,21 +81,28 @@ public class MutualTlsAuthenticator implements IAuthenticator private final IdentityCache identityCache = new IdentityCache(); private final MutualTlsCertificateValidator certificateValidator; private static final Set AUTHENTICATION_MODES = Collections.singleton(MTLS); + private final MutualTlsCertificateValidityPeriodValidator certificateValidityPeriodValidator; + private final DurationSpec.IntMinutesBound certificateValidityWarnThreshold; // key for the 'identity' value in AuthenticatedUser metadata map. static final String METADATA_IDENTITY_KEY = "identity"; public MutualTlsAuthenticator(Map parameters) { - final String certificateValidatorClassName = parameters.get(VALIDATOR_CLASS_NAME); + final String certificateValidatorClassName = parameters != null ? parameters.get(VALIDATOR_CLASS_NAME) : null; if (StringUtils.isEmpty(certificateValidatorClassName)) { - String message ="authenticator.parameters.validator_class_name is not set"; + String message = "authenticator.parameters.validator_class_name is not set"; logger.error(message); throw new ConfigurationException(message); } certificateValidator = ParameterizedClass.newInstance(new ParameterizedClass(certificateValidatorClassName), Arrays.asList("", AuthConfig.class.getPackage().getName())); + + Config config = DatabaseDescriptor.getRawConfig(); + certificateValidityPeriodValidator = new MutualTlsCertificateValidityPeriodValidator(config.client_encryption_options.max_certificate_validity_period); + certificateValidityWarnThreshold = config.client_encryption_options.certificate_validity_warn_threshold; + AuthCacheService.instance.register(identityCache); } @@ -194,7 +210,7 @@ public class MutualTlsAuthenticator implements IAuthenticator throw new AuthenticationException(message); } - final String identity = certificateValidator.identity(clientCertificateChain); + String identity = certificateValidator.identity(clientCertificateChain); if (StringUtils.isEmpty(identity)) { String msg = "Unable to extract client identity from certificate for authentication"; @@ -208,6 +224,21 @@ public class MutualTlsAuthenticator implements IAuthenticator nospamLogger.error(msg, identity); throw new AuthenticationException(MessageFormatter.format(msg, identity).getMessage()); } + + // Validates that the certificate validity period does not exceed the maximum certificate configured validity period + int minutesToCertificateExpiration = certificateValidityPeriodValidator.validate(clientCertificateChain); + int daysToCertificateExpiration = MutualTlsUtil.minutesToDays(minutesToCertificateExpiration); + + if (certificateValidityWarnThreshold != null + && minutesToCertificateExpiration < certificateValidityWarnThreshold.toMinutes()) + { + nospamLogger.warn("Certificate with identity '{}' will expire in {}", + identity, MutualTlsUtil.toHumanReadableCertificateExpiration(minutesToCertificateExpiration)); + } + + // Report metrics on client certificate expiration + MutualTlsMetrics.instance.clientCertificateExpirationDays.update(daysToCertificateExpiration); + return new AuthenticatedUser(role, MTLS, Collections.singletonMap(METADATA_IDENTITY_KEY, identity)); } diff --git a/src/java/org/apache/cassandra/auth/MutualTlsCertificateValidator.java b/src/java/org/apache/cassandra/auth/MutualTlsCertificateValidator.java index e9735f48bf..f89e8b17fb 100644 --- a/src/java/org/apache/cassandra/auth/MutualTlsCertificateValidator.java +++ b/src/java/org/apache/cassandra/auth/MutualTlsCertificateValidator.java @@ -24,7 +24,7 @@ import org.apache.cassandra.exceptions.AuthenticationException; /** * Interface for certificate validation and authorization for mTLS authenticators. - * + *

* This interface can be implemented to provide logic for extracting custom identities from client certificates * to uniquely identify the certificates. It can also be used to provide custom authorization logic to authenticate * clients using client certificates during mTLS connections. @@ -35,29 +35,32 @@ public interface MutualTlsCertificateValidator * Perform any checks that are to be performed on the certificate before making authorization check to grant the * access to the client during mTLS connection. * - * For example - * - Verifying CA information - * - Checking CN information - * - Validating Issuer information - * - Checking organization information etc + *

For example: + *

* * @param clientCertificateChain client certificate chain - * @return returns if the certificate is valid or not + * @return {@code true} if the certificate is valid, {@code false} otherwise */ boolean isValidCertificate(Certificate[] clientCertificateChain); /** * This method should provide logic to extract identity out of a certificate to perform mTLS authentication. * - * An example of identity could be the following - * - an identifier in SAN of the certificate like SPIFFE - * - CN of the certificate - * - any other fields in the certificate can be combined and be used as identifier of the certificate + *

An example of identity could be the following: + *

* * @param clientCertificateChain client certificate chain * @return identifier extracted from certificate * @throws AuthenticationException when identity cannot be extracted */ String identity(Certificate[] clientCertificateChain) throws AuthenticationException; - } diff --git a/src/java/org/apache/cassandra/auth/MutualTlsCertificateValidityPeriodValidator.java b/src/java/org/apache/cassandra/auth/MutualTlsCertificateValidityPeriodValidator.java new file mode 100644 index 0000000000..b0c81f29fe --- /dev/null +++ b/src/java/org/apache/cassandra/auth/MutualTlsCertificateValidityPeriodValidator.java @@ -0,0 +1,85 @@ +/* + * 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.auth; + +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.apache.cassandra.config.DurationSpec; +import org.apache.cassandra.exceptions.AuthenticationException; +import org.apache.cassandra.utils.FBUtilities; + +public class MutualTlsCertificateValidityPeriodValidator +{ + @Nonnull + private final int maxCertificateValidityPeriodMinutes; + + public MutualTlsCertificateValidityPeriodValidator(@Nullable DurationSpec.IntMinutesBound maxCertificateValidityPeriod) + { + maxCertificateValidityPeriodMinutes = maxCertificateValidityPeriod != null + ? maxCertificateValidityPeriod.toMinutes() + // Sufficiently large value that exceeds any reasonable valid configuration + : Integer.MAX_VALUE; + } + + /** + * Validates that the certificate's validity period does not exceed the {@code #maxCertificateValidityPeriod} + * and returns the number of minutes to certificate expiration since the verification started. + * + * @param certificates the certificate chain + * @return the number of minutes to certificate expiration since the verification started, or {@code -1} + * if no certificate chain was provided + * @throws AuthenticationException when the {@link X509Certificate#getNotBefore()} or + * {@link X509Certificate#getNotAfter()} dates are null, or when the certificate + * validity exceeds the maximum allowed certificate validity + */ + public int validate(Certificate[] certificates) throws AuthenticationException + { + X509Certificate[] x509Certificates = MutualTlsUtil.castCertsToX509(certificates); + if (x509Certificates == null || x509Certificates.length == 0) + { + return -1; + } + + Date notAfter = x509Certificates[0].getNotAfter(); + + int minutesToCertificateExpiration = (int) ChronoUnit.MINUTES.between(FBUtilities.now(), notAfter.toInstant()); + int certificateValidityPeriodMinutes = certificateValidityPeriodInMinutes(x509Certificates[0]); + if (certificateValidityPeriodMinutes > maxCertificateValidityPeriodMinutes) + { + String errorMessage = String.format("The validity period of the provided certificate (%s) exceeds " + + "the maximum allowed validity period of %s", + MutualTlsUtil.toHumanReadableCertificateExpiration(certificateValidityPeriodMinutes), + MutualTlsUtil.toHumanReadableCertificateExpiration(maxCertificateValidityPeriodMinutes)); + throw new AuthenticationException(errorMessage); + } + + return minutesToCertificateExpiration; + } + + int certificateValidityPeriodInMinutes(X509Certificate certificate) + { + return (int) ChronoUnit.MINUTES.between(certificate.getNotBefore().toInstant(), + certificate.getNotAfter().toInstant()); + } +} diff --git a/src/java/org/apache/cassandra/auth/MutualTlsInternodeAuthenticator.java b/src/java/org/apache/cassandra/auth/MutualTlsInternodeAuthenticator.java index cf5d764c9d..c1fcbd6eab 100644 --- a/src/java/org/apache/cassandra/auth/MutualTlsInternodeAuthenticator.java +++ b/src/java/org/apache/cassandra/auth/MutualTlsInternodeAuthenticator.java @@ -32,6 +32,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import javax.annotation.Nonnull; import com.google.common.annotations.VisibleForTesting; import org.apache.commons.lang3.StringUtils; @@ -40,33 +41,42 @@ import org.slf4j.LoggerFactory; import org.apache.cassandra.config.Config; import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.config.DurationSpec; import org.apache.cassandra.config.EncryptionOptions; import org.apache.cassandra.config.ParameterizedClass; import org.apache.cassandra.exceptions.AuthenticationException; import org.apache.cassandra.exceptions.ConfigurationException; +import org.apache.cassandra.metrics.MutualTlsMetrics; import org.apache.cassandra.utils.NoSpamLogger; import static org.apache.cassandra.config.EncryptionOptions.ClientAuth.REQUIRED; -/* +/** * Performs mTLS authentication for internode connections by extracting identities from the certificates of incoming * connection and verifying them against a list of authorized peers. Authorized peers can be configured in * trusted_peer_identities in cassandra yaml, otherwise authenticator trusts connections from peers which has the same * identity as the one that the node uses for making outbound connections. * - * Optionally cassandra can validate the identity extracted from outbound keystore with node_identity that is configured + *

Optionally cassandra can validate the identity extracted from outbound keystore with node_identity that is configured * in cassandra.yaml to avoid any configuration errors. * - * Authenticator & Certificate validator can be configured using cassandra.yaml, operators can write their own mTLS + *

Authenticator & Certificate validator can be configured using cassandra.yaml, operators can write their own mTLS * certificate validator and configure it in cassandra.yaml.Below is an example on how to configure validator. * Note that this example uses SPIFFE based validator, it could be any other validator with any defined identifier format. * + *

Optionally, the authenticator can be configured to restrict the validity period of the client certificates. + * This allows for better server-side controls for authentication. In some cases, clients can provide certificates + * that expire multiple months/years after the certificate was issued. For those use cases, it is desirable to reject + * the certificate if the expiration date is too far away in the future. + * + *

  * internode_authenticator:
- *   class_name : org.apache.cassandra.auth.AllowAllInternodeAuthenticator
- *   parameters :
+ *   class_name: org.apache.cassandra.auth.MutualTlsInternodeAuthenticator
+ *   parameters:
  *     validator_class_name: org.apache.cassandra.auth.SpiffeCertificateValidator
  *     trusted_peer_identities: "spiffe1,spiffe2"
  *     node_identity: "spiffe1"
+ * 
*/ public class MutualTlsInternodeAuthenticator implements IInternodeAuthenticator { @@ -77,6 +87,9 @@ public class MutualTlsInternodeAuthenticator implements IInternodeAuthenticator private final NoSpamLogger noSpamLogger = NoSpamLogger.getLogger(logger, 30L, TimeUnit.SECONDS); private final MutualTlsCertificateValidator certificateValidator; private final List trustedIdentities; + @Nonnull + private final MutualTlsCertificateValidityPeriodValidator certificateValidityPeriodValidator; + private final DurationSpec.IntMinutesBound certificateValidityWarnThreshold; public MutualTlsInternodeAuthenticator(Map parameters) { @@ -107,10 +120,10 @@ public class MutualTlsInternodeAuthenticator implements IInternodeAuthenticator config.server_encryption_options.store_type); // optionally, if node_identity is configured in the yaml, validate the identity extracted from outbound // keystore to avoid any configuration errors - if(parameters.containsKey(NODE_IDENTITY)) + if (parameters.containsKey(NODE_IDENTITY)) { String nodeIdentity = parameters.get(NODE_IDENTITY); - if(!trustedIdentities.contains(nodeIdentity)) + if (!trustedIdentities.contains(nodeIdentity)) { throw new ConfigurationException("Configured node identity is not matching identity extracted" + "from the keystore"); @@ -129,6 +142,9 @@ public class MutualTlsInternodeAuthenticator implements IInternodeAuthenticator logger.info(message); throw new ConfigurationException(message); } + + certificateValidityPeriodValidator = new MutualTlsCertificateValidityPeriodValidator(config.server_encryption_options.max_certificate_validity_period); + certificateValidityWarnThreshold = config.server_encryption_options.certificate_validity_warn_threshold; } @Override @@ -156,7 +172,6 @@ public class MutualTlsInternodeAuthenticator implements IInternodeAuthenticator logger.error(msg); throw new ConfigurationException(msg); } - } protected boolean authenticateInternodeWithMtls(InetAddress remoteAddress, int remotePort, Certificate[] certificates, @@ -171,11 +186,23 @@ public class MutualTlsInternodeAuthenticator implements IInternodeAuthenticator return false; } - if(!trustedIdentities.contains(identity)) + if (!trustedIdentities.contains(identity)) { noSpamLogger.error("Unable to authenticate user {}", identity); return false; } + + int minutesToCertificateExpiration = certificateValidityPeriodValidator.validate(certificates); + + if (certificateValidityWarnThreshold != null + && minutesToCertificateExpiration < certificateValidityWarnThreshold.toMinutes()) + { + noSpamLogger.warn("Certificate from {}:{} with identity '{}' will expire in {}", + remoteAddress, remotePort, identity, + MutualTlsUtil.toHumanReadableCertificateExpiration(minutesToCertificateExpiration)); + } + MutualTlsMetrics.instance.internodeCertificateExpirationDays.update(MutualTlsUtil.minutesToDays(minutesToCertificateExpiration)); + return true; } // Outbound connections don't need to be authenticated again in certificate based connections. SSL handshake @@ -221,5 +248,4 @@ public class MutualTlsInternodeAuthenticator implements IInternodeAuthenticator } return allUsers; } - } diff --git a/src/java/org/apache/cassandra/auth/MutualTlsUtil.java b/src/java/org/apache/cassandra/auth/MutualTlsUtil.java new file mode 100644 index 0000000000..23fbbf3b38 --- /dev/null +++ b/src/java/org/apache/cassandra/auth/MutualTlsUtil.java @@ -0,0 +1,83 @@ +/* + * 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.auth; + +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + +public final class MutualTlsUtil +{ + private static final int ONE_DAY_IN_MINUTES = (int) TimeUnit.DAYS.toMinutes(1); + private static final int ONE_HOUR_IN_MINUTES = (int) TimeUnit.HOURS.toMinutes(1); + + /** + * Filters instances of {@link X509Certificate} certificates and returns the certificate chain as + * {@link X509Certificate} certificates. + * + * @param clientCertificateChain client certificate chain + * @return an array of certificates that were cast to {@link X509Certificate} + */ + public static X509Certificate[] castCertsToX509(Certificate[] clientCertificateChain) + { + if (clientCertificateChain == null || clientCertificateChain.length == 0) + { + return null; + } + return Arrays.stream(clientCertificateChain) + .filter(certificate -> certificate instanceof X509Certificate) + .toArray(X509Certificate[]::new); + } + + public static String toHumanReadableCertificateExpiration(int minutesToExpiration) + { + if (minutesToExpiration >= ONE_DAY_IN_MINUTES) + { + return formatHelper(minutesToDays(minutesToExpiration), "day") + + maybeAppendRemainder(minutesToExpiration % ONE_DAY_IN_MINUTES); + } + if (minutesToExpiration >= ONE_HOUR_IN_MINUTES) + { + return formatHelper((int) TimeUnit.MINUTES.toHours(minutesToExpiration), "hour") + + maybeAppendRemainder(minutesToExpiration % ONE_HOUR_IN_MINUTES); + } + return formatHelper(minutesToExpiration, "minute"); + } + + public static int minutesToDays(int minutes) + { + return (int) TimeUnit.MINUTES.toDays(minutes); + } + + static String formatHelper(int unit, String singularForm) + { + if (unit == 1) + return unit + " " + singularForm; + // assumes plural form just adds s at the end + return unit + " " + singularForm + 's'; + } + + static String maybeAppendRemainder(int remainderInMinutes) + { + if (remainderInMinutes == 0) + return ""; + return ' ' + toHumanReadableCertificateExpiration(remainderInMinutes); + } +} diff --git a/src/java/org/apache/cassandra/auth/SpiffeCertificateValidator.java b/src/java/org/apache/cassandra/auth/SpiffeCertificateValidator.java index 9260ce38a4..ebe9891fbb 100644 --- a/src/java/org/apache/cassandra/auth/SpiffeCertificateValidator.java +++ b/src/java/org/apache/cassandra/auth/SpiffeCertificateValidator.java @@ -21,37 +21,47 @@ package org.apache.cassandra.auth; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; -import java.util.Arrays; import java.util.Collection; import java.util.List; import org.apache.cassandra.exceptions.AuthenticationException; +import static org.apache.cassandra.auth.MutualTlsUtil.castCertsToX509; + /** * This class assumes that the identity of a certificate is SPIFFE which is a URI that is present as part of the SAN * of the client certificate. It has logic to extract identity (Spiffe) out of a certificate & knows how to validate * the client certificates. - *

* - *

- * Example: + *

Example: + *

  * internode_authenticator:
- * class_name : org.apache.cassandra.auth.MutualTlsAuthenticator
- * parameters :
- * validator_class_name: org.apache.cassandra.auth.SpiffeCertificateValidator
+ *   class_name: org.apache.cassandra.auth.MutualTlsAuthenticator
+ *   parameters:
+ *     validator_class_name: org.apache.cassandra.auth.SpiffeCertificateValidator
+ * 
+ * + *
  * authenticator:
- * class_name : org.apache.cassandra.auth.MutualTlsInternodeAuthenticator
- * parameters :
- * validator_class_name: org.apache.cassandra.auth.SpiffeCertificateValidator
+ *   class_name: org.apache.cassandra.auth.MutualTlsInternodeAuthenticator
+ *   parameters:
+ *     validator_class_name: org.apache.cassandra.auth.SpiffeCertificateValidator
+ * 
*/ public class SpiffeCertificateValidator implements MutualTlsCertificateValidator { + /** + * {@inheritDoc} + */ @Override public boolean isValidCertificate(Certificate[] clientCertificateChain) { return true; } + /** + * {@inheritDoc} + */ @Override public String identity(Certificate[] clientCertificateChain) throws AuthenticationException { @@ -86,9 +96,4 @@ public class SpiffeCertificateValidator implements MutualTlsCertificateValidator } throw new CertificateException("Unable to extract Spiffe from the certificate"); } - - private static X509Certificate[] castCertsToX509(Certificate[] clientCertificateChain) - { - return Arrays.asList(clientCertificateChain).toArray(new X509Certificate[0]); - } } diff --git a/src/java/org/apache/cassandra/config/EncryptionOptions.java b/src/java/org/apache/cassandra/config/EncryptionOptions.java index 91d9ae5ee2..6cbdfb3ba3 100644 --- a/src/java/org/apache/cassandra/config/EncryptionOptions.java +++ b/src/java/org/apache/cassandra/config/EncryptionOptions.java @@ -24,12 +24,10 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; - import javax.annotation.Nullable; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -123,6 +121,12 @@ public class EncryptionOptions public final String store_type; public final String require_client_auth; public final boolean require_endpoint_verification; + // CASSANDRA-18951: optional configuration to restrict the validity period of certificate + @Nullable + public final DurationSpec.IntMinutesBound max_certificate_validity_period; + // When the validity period of the certificate falls under the warn threshold, a log entry will be emmitted + public final DurationSpec.IntMinutesBound certificate_validity_warn_threshold; + // ServerEncryptionOptions does not use the enabled flag at all instead using the existing // internode_encryption option. So we force this private and expose through isEnabled // so users of ServerEncryptionOptions can't accidentally use this when they should use isEnabled @@ -158,7 +162,9 @@ public class EncryptionOptions REQUIRE_CLIENT_AUTH("require_client_auth"), REQUIRE_ENDPOINT_VERIFICATION("require_endpoint_verification"), ENABLED("enabled"), - OPTIONAL("optional"); + OPTIONAL("optional"), + MAX_CERTIFICATE_VALIDITY_PERIOD("max_certificate_validity_period"), + CERTIFICATE_VALIDITY_WARN_THRESHOLD("certificate_validity_warn_threshold"); final String keyName; @@ -200,13 +206,16 @@ public class EncryptionOptions require_endpoint_verification = false; enabled = null; optional = null; + max_certificate_validity_period = null; + certificate_validity_warn_threshold = null; } public EncryptionOptions(ParameterizedClass ssl_context_factory, String keystore, String keystore_password, String truststore, String truststore_password, List cipher_suites, String protocol, List accepted_protocols, String algorithm, String store_type, String require_client_auth, boolean require_endpoint_verification, Boolean enabled, - Boolean optional) + Boolean optional, DurationSpec.IntMinutesBound max_certificate_validity_period, + DurationSpec.IntMinutesBound certificate_validity_warn_threshold) { this.ssl_context_factory = ssl_context_factory; this.keystore = keystore; @@ -222,6 +231,8 @@ public class EncryptionOptions this.require_endpoint_verification = require_endpoint_verification; this.enabled = enabled; this.optional = optional; + this.max_certificate_validity_period = max_certificate_validity_period; + this.certificate_validity_warn_threshold = certificate_validity_warn_threshold; } public EncryptionOptions(EncryptionOptions options) @@ -239,7 +250,9 @@ public class EncryptionOptions require_client_auth = options.require_client_auth; require_endpoint_verification = options.require_endpoint_verification; enabled = options.enabled; - this.optional = options.optional; + optional = options.optional; + max_certificate_validity_period = options.max_certificate_validity_period; + certificate_validity_warn_threshold = options.certificate_validity_warn_threshold; } /* Computes enabled and optional before use. Because the configuration can be loaded @@ -318,6 +331,7 @@ public class EncryptionOptions putSslContextFactoryParameter(sslContextFactoryParameters, ConfigKey.REQUIRE_ENDPOINT_VERIFICATION, this.require_endpoint_verification); putSslContextFactoryParameter(sslContextFactoryParameters, ConfigKey.ENABLED, this.enabled); putSslContextFactoryParameter(sslContextFactoryParameters, ConfigKey.OPTIONAL, this.optional); + putSslContextFactoryParameter(sslContextFactoryParameters, ConfigKey.MAX_CERTIFICATE_VALIDITY_PERIOD, this.max_certificate_validity_period); } private void initializeSslContextFactory() @@ -371,6 +385,7 @@ public class EncryptionOptions * Sets if encryption should be enabled for this channel. Note that this should only be called by * the configuration parser or tests. It is public only for that purpose, mutating enabled state * is probably a bad idea. + * * @param enabled value to set */ public void setEnabled(Boolean enabled) @@ -384,8 +399,9 @@ public class EncryptionOptions * Explicitly providing a value in the configuration take precedent. * If no optional value is set and !isEnabled(), then optional connections are allowed * if a keystore exists. Without it, it would be impossible to establish the connections. - * + *

* Return type is Boolean even though it can never be null so that snakeyaml can find it + * * @return if the channel may be encrypted */ public Boolean getOptional() @@ -398,6 +414,7 @@ public class EncryptionOptions * Sets if encryption should be optional for this channel. Note that this should only be called by * the configuration parser or tests. It is public only for that purpose, mutating enabled state * is probably a bad idea. + * * @param optional value to set */ public void setOptional(Boolean optional) @@ -410,6 +427,7 @@ public class EncryptionOptions * Sets accepted TLS protocol for this channel. Note that this should only be called by * the configuration parser or tests. It is public only for that purpose, mutating protocol state * is probably a bad idea. + * * @param protocol value to set */ @VisibleForTesting @@ -427,6 +445,7 @@ public class EncryptionOptions * Sets accepted TLS protocols for this channel. Note that this should only be called by * the configuration parser or tests. It is public only for that purpose, mutating protocol state * is probably a bad idea. The function casing is required for snakeyaml to find this setter for the protected field. + * * @param accepted_protocols value to set */ public void setAcceptedProtocols(List accepted_protocols) @@ -474,25 +493,25 @@ public class EncryptionOptions public EncryptionOptions withSslContextFactory(ParameterizedClass sslContextFactoryClass) { return new EncryptionOptions(sslContextFactoryClass, keystore, keystore_password, truststore, - truststore_password, cipher_suites,protocol, accepted_protocols, algorithm, - store_type, require_client_auth, require_endpoint_verification,enabled, - optional).applyConfig(); + truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, + store_type, require_client_auth, require_endpoint_verification, enabled, + optional, max_certificate_validity_period, max_certificate_validity_period).applyConfig(); } public EncryptionOptions withKeyStore(String keystore) { return new EncryptionOptions(ssl_context_factory, keystore, keystore_password, truststore, - truststore_password, cipher_suites,protocol, accepted_protocols, algorithm, + truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, enabled, - optional).applyConfig(); + optional, max_certificate_validity_period, max_certificate_validity_period).applyConfig(); } public EncryptionOptions withKeyStorePassword(String keystore_password) { return new EncryptionOptions(ssl_context_factory, keystore, keystore_password, truststore, - truststore_password, cipher_suites,protocol, accepted_protocols, algorithm, + truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, enabled, - optional).applyConfig(); + optional, max_certificate_validity_period, max_certificate_validity_period).applyConfig(); } public EncryptionOptions withTrustStore(String truststore) @@ -500,7 +519,7 @@ public class EncryptionOptions return new EncryptionOptions(ssl_context_factory, keystore, keystore_password, truststore, truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, enabled, - optional).applyConfig(); + optional, max_certificate_validity_period, max_certificate_validity_period).applyConfig(); } public EncryptionOptions withTrustStorePassword(String truststore_password) @@ -508,7 +527,7 @@ public class EncryptionOptions return new EncryptionOptions(ssl_context_factory, keystore, keystore_password, truststore, truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, enabled, - optional).applyConfig(); + optional, max_certificate_validity_period, max_certificate_validity_period).applyConfig(); } public EncryptionOptions withCipherSuites(List cipher_suites) @@ -516,15 +535,16 @@ public class EncryptionOptions return new EncryptionOptions(ssl_context_factory, keystore, keystore_password, truststore, truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, enabled, - optional).applyConfig(); + optional, max_certificate_validity_period, max_certificate_validity_period).applyConfig(); } - public EncryptionOptions withCipherSuites(String ... cipher_suites) + public EncryptionOptions withCipherSuites(String... cipher_suites) { return new EncryptionOptions(ssl_context_factory, keystore, keystore_password, truststore, truststore_password, ImmutableList.copyOf(cipher_suites), protocol, accepted_protocols, algorithm, store_type, require_client_auth, - require_endpoint_verification, enabled, optional).applyConfig(); + require_endpoint_verification, enabled, optional, max_certificate_validity_period, + max_certificate_validity_period).applyConfig(); } public EncryptionOptions withProtocol(String protocol) @@ -532,17 +552,17 @@ public class EncryptionOptions return new EncryptionOptions(ssl_context_factory, keystore, keystore_password, truststore, truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, enabled, - optional).applyConfig(); + optional, max_certificate_validity_period, max_certificate_validity_period).applyConfig(); } public EncryptionOptions withAcceptedProtocols(List accepted_protocols) { return new EncryptionOptions(ssl_context_factory, keystore, keystore_password, truststore, - truststore_password, cipher_suites,protocol, accepted_protocols == null ? null : - ImmutableList.copyOf(accepted_protocols), + truststore_password, cipher_suites, protocol, accepted_protocols == null ? null : + ImmutableList.copyOf(accepted_protocols), algorithm, store_type, require_client_auth, require_endpoint_verification, - enabled, optional).applyConfig(); + enabled, optional, max_certificate_validity_period, max_certificate_validity_period).applyConfig(); } @@ -551,7 +571,7 @@ public class EncryptionOptions return new EncryptionOptions(ssl_context_factory, keystore, keystore_password, truststore, truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, enabled, - optional).applyConfig(); + optional, max_certificate_validity_period, max_certificate_validity_period).applyConfig(); } public EncryptionOptions withStoreType(String store_type) @@ -559,7 +579,7 @@ public class EncryptionOptions return new EncryptionOptions(ssl_context_factory, keystore, keystore_password, truststore, truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, enabled, - optional).applyConfig(); + optional, max_certificate_validity_period, max_certificate_validity_period).applyConfig(); } public EncryptionOptions withRequireClientAuth(ClientAuth require_client_auth) @@ -567,7 +587,7 @@ public class EncryptionOptions return new EncryptionOptions(ssl_context_factory, keystore, keystore_password, truststore, truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth.value, require_endpoint_verification, enabled, - optional).applyConfig(); + optional, max_certificate_validity_period, max_certificate_validity_period).applyConfig(); } public EncryptionOptions withRequireEndpointVerification(boolean require_endpoint_verification) @@ -575,7 +595,7 @@ public class EncryptionOptions return new EncryptionOptions(ssl_context_factory, keystore, keystore_password, truststore, truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, enabled, - optional).applyConfig(); + optional, max_certificate_validity_period, max_certificate_validity_period).applyConfig(); } public EncryptionOptions withEnabled(boolean enabled) @@ -583,7 +603,7 @@ public class EncryptionOptions return new EncryptionOptions(ssl_context_factory, keystore, keystore_password, truststore, truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, enabled, - optional).applyConfig(); + optional, max_certificate_validity_period, max_certificate_validity_period).applyConfig(); } public EncryptionOptions withOptional(Boolean optional) @@ -591,7 +611,23 @@ public class EncryptionOptions return new EncryptionOptions(ssl_context_factory, keystore, keystore_password, truststore, truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, enabled, - optional).applyConfig(); + optional, max_certificate_validity_period, max_certificate_validity_period).applyConfig(); + } + + public EncryptionOptions withMaxCertificateValidityPeriod(DurationSpec.IntMinutesBound maxCertificateValidityPeriod) + { + return new EncryptionOptions(ssl_context_factory, keystore, keystore_password, truststore, + truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, + store_type, require_client_auth, require_endpoint_verification, enabled, + optional, maxCertificateValidityPeriod, certificate_validity_warn_threshold).applyConfig(); + } + + public EncryptionOptions withCertificateValidityWarnThreshold(DurationSpec.IntMinutesBound certificateValidityWarnThreshold) + { + return new EncryptionOptions(ssl_context_factory, keystore, keystore_password, truststore, + truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, + store_type, require_client_auth, require_endpoint_verification, enabled, + optional, max_certificate_validity_period, certificateValidityWarnThreshold).applyConfig(); } /** @@ -676,11 +712,13 @@ public class EncryptionOptions List cipher_suites, String protocol, List accepted_protocols, String algorithm, String store_type, String require_client_auth, boolean require_endpoint_verification, Boolean optional, - InternodeEncryption internode_encryption, boolean legacy_ssl_storage_port_enabled) + InternodeEncryption internode_encryption, boolean legacy_ssl_storage_port_enabled, + DurationSpec.IntMinutesBound maxCertificateAgeMinutes, + DurationSpec.IntMinutesBound certificateValidityWarnThreshold) { super(sslContextFactoryClass, keystore, keystore_password, truststore, truststore_password, cipher_suites, - protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, - null, optional); + protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, + null, optional, maxCertificateAgeMinutes, certificateValidityWarnThreshold); this.internode_encryption = internode_encryption; this.legacy_ssl_storage_port_enabled = legacy_ssl_storage_port_enabled; this.outbound_keystore = outbound_keystore; @@ -807,6 +845,7 @@ public class EncryptionOptions return result; } + @Override public ServerEncryptionOptions withSslContextFactory(ParameterizedClass sslContextFactoryClass) { return new ServerEncryptionOptions(sslContextFactoryClass, keystore, keystore_password, @@ -814,9 +853,11 @@ public class EncryptionOptions truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, optional, internode_encryption, - legacy_ssl_storage_port_enabled).applyConfigInternal(); + legacy_ssl_storage_port_enabled, max_certificate_validity_period, + max_certificate_validity_period).applyConfigInternal(); } + @Override public ServerEncryptionOptions withKeyStore(String keystore) { return new ServerEncryptionOptions(ssl_context_factory, keystore, keystore_password, @@ -824,9 +865,11 @@ public class EncryptionOptions truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, optional, internode_encryption, - legacy_ssl_storage_port_enabled).applyConfigInternal(); + legacy_ssl_storage_port_enabled, max_certificate_validity_period, + max_certificate_validity_period).applyConfigInternal(); } + @Override public ServerEncryptionOptions withKeyStorePassword(String keystore_password) { return new ServerEncryptionOptions(ssl_context_factory, keystore, keystore_password, @@ -834,9 +877,11 @@ public class EncryptionOptions truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, optional, internode_encryption, - legacy_ssl_storage_port_enabled).applyConfigInternal(); + legacy_ssl_storage_port_enabled, max_certificate_validity_period, + max_certificate_validity_period).applyConfigInternal(); } + @Override public ServerEncryptionOptions withTrustStore(String truststore) { return new ServerEncryptionOptions(ssl_context_factory, keystore, keystore_password, @@ -844,9 +889,11 @@ public class EncryptionOptions truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, optional, internode_encryption, - legacy_ssl_storage_port_enabled).applyConfigInternal(); + legacy_ssl_storage_port_enabled, max_certificate_validity_period, + max_certificate_validity_period).applyConfigInternal(); } + @Override public ServerEncryptionOptions withTrustStorePassword(String truststore_password) { return new ServerEncryptionOptions(ssl_context_factory, keystore, keystore_password, @@ -854,9 +901,11 @@ public class EncryptionOptions truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, optional, internode_encryption, - legacy_ssl_storage_port_enabled).applyConfigInternal(); + legacy_ssl_storage_port_enabled, max_certificate_validity_period, + max_certificate_validity_period).applyConfigInternal(); } + @Override public ServerEncryptionOptions withCipherSuites(List cipher_suites) { return new ServerEncryptionOptions(ssl_context_factory, keystore, keystore_password, @@ -864,9 +913,11 @@ public class EncryptionOptions truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, optional, internode_encryption, - legacy_ssl_storage_port_enabled).applyConfigInternal(); + legacy_ssl_storage_port_enabled, max_certificate_validity_period, + max_certificate_validity_period).applyConfigInternal(); } + @Override public ServerEncryptionOptions withCipherSuites(String... cipher_suites) { return new ServerEncryptionOptions(ssl_context_factory, keystore, keystore_password, @@ -874,9 +925,11 @@ public class EncryptionOptions truststore_password, Arrays.asList(cipher_suites), protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, optional, internode_encryption, - legacy_ssl_storage_port_enabled).applyConfigInternal(); + legacy_ssl_storage_port_enabled, max_certificate_validity_period, + max_certificate_validity_period).applyConfigInternal(); } + @Override public ServerEncryptionOptions withProtocol(String protocol) { return new ServerEncryptionOptions(ssl_context_factory, keystore, keystore_password, @@ -884,9 +937,11 @@ public class EncryptionOptions truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, optional, internode_encryption, - legacy_ssl_storage_port_enabled).applyConfigInternal(); + legacy_ssl_storage_port_enabled, max_certificate_validity_period, + max_certificate_validity_period).applyConfigInternal(); } + @Override public ServerEncryptionOptions withAcceptedProtocols(List accepted_protocols) { return new ServerEncryptionOptions(ssl_context_factory, keystore, keystore_password, @@ -894,9 +949,11 @@ public class EncryptionOptions truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, optional, internode_encryption, - legacy_ssl_storage_port_enabled).applyConfigInternal(); + legacy_ssl_storage_port_enabled, max_certificate_validity_period, + max_certificate_validity_period).applyConfigInternal(); } + @Override public ServerEncryptionOptions withAlgorithm(String algorithm) { return new ServerEncryptionOptions(ssl_context_factory, keystore, keystore_password, @@ -904,9 +961,11 @@ public class EncryptionOptions truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, optional, internode_encryption, - legacy_ssl_storage_port_enabled).applyConfigInternal(); + legacy_ssl_storage_port_enabled, max_certificate_validity_period, + max_certificate_validity_period).applyConfigInternal(); } + @Override public ServerEncryptionOptions withStoreType(String store_type) { return new ServerEncryptionOptions(ssl_context_factory, keystore, keystore_password, @@ -914,9 +973,11 @@ public class EncryptionOptions truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, optional, internode_encryption, - legacy_ssl_storage_port_enabled).applyConfigInternal(); + legacy_ssl_storage_port_enabled, max_certificate_validity_period, + max_certificate_validity_period).applyConfigInternal(); } + @Override public ServerEncryptionOptions withRequireClientAuth(ClientAuth require_client_auth) { return new ServerEncryptionOptions(ssl_context_factory, keystore, keystore_password, @@ -924,9 +985,11 @@ public class EncryptionOptions truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth.value, require_endpoint_verification, optional, internode_encryption, - legacy_ssl_storage_port_enabled).applyConfigInternal(); + legacy_ssl_storage_port_enabled, max_certificate_validity_period, + max_certificate_validity_period).applyConfigInternal(); } + @Override public ServerEncryptionOptions withRequireEndpointVerification(boolean require_endpoint_verification) { return new ServerEncryptionOptions(ssl_context_factory, keystore, keystore_password, @@ -934,7 +997,8 @@ public class EncryptionOptions truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, optional, internode_encryption, - legacy_ssl_storage_port_enabled).applyConfigInternal(); + legacy_ssl_storage_port_enabled, max_certificate_validity_period, + max_certificate_validity_period).applyConfigInternal(); } public ServerEncryptionOptions withOptional(boolean optional) @@ -944,7 +1008,8 @@ public class EncryptionOptions truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, optional, internode_encryption, - legacy_ssl_storage_port_enabled).applyConfigInternal(); + legacy_ssl_storage_port_enabled, max_certificate_validity_period, + max_certificate_validity_period).applyConfigInternal(); } public ServerEncryptionOptions withInternodeEncryption(InternodeEncryption internode_encryption) @@ -954,7 +1019,8 @@ public class EncryptionOptions truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, optional, internode_encryption, - legacy_ssl_storage_port_enabled).applyConfigInternal(); + legacy_ssl_storage_port_enabled, max_certificate_validity_period, + max_certificate_validity_period).applyConfigInternal(); } public ServerEncryptionOptions withLegacySslStoragePort(boolean enable_legacy_ssl_storage_port) @@ -964,7 +1030,8 @@ public class EncryptionOptions truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, optional, internode_encryption, - enable_legacy_ssl_storage_port).applyConfigInternal(); + enable_legacy_ssl_storage_port, max_certificate_validity_period, + max_certificate_validity_period).applyConfigInternal(); } public ServerEncryptionOptions withOutboundKeystore(String outboundKeystore) @@ -974,7 +1041,8 @@ public class EncryptionOptions truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, optional, internode_encryption, - legacy_ssl_storage_port_enabled).applyConfigInternal(); + legacy_ssl_storage_port_enabled, max_certificate_validity_period, + max_certificate_validity_period).applyConfigInternal(); } public ServerEncryptionOptions withOutboundKeystorePassword(String outboundKeystorePassword) @@ -984,7 +1052,20 @@ public class EncryptionOptions truststore_password, cipher_suites, protocol, accepted_protocols, algorithm, store_type, require_client_auth, require_endpoint_verification, optional, internode_encryption, - legacy_ssl_storage_port_enabled).applyConfigInternal(); + legacy_ssl_storage_port_enabled, max_certificate_validity_period, + max_certificate_validity_period).applyConfigInternal(); + } + + @Override + public ServerEncryptionOptions withMaxCertificateValidityPeriod(DurationSpec.IntMinutesBound maxCertificateValidityPeriod) + { + return new ServerEncryptionOptions(ssl_context_factory, keystore, keystore_password, + outbound_keystore, outbound_keystore_password, truststore, + truststore_password, cipher_suites, protocol, accepted_protocols, + algorithm, store_type, require_client_auth, + require_endpoint_verification, optional, internode_encryption, + legacy_ssl_storage_port_enabled, maxCertificateValidityPeriod, + certificate_validity_warn_threshold).applyConfigInternal(); } } } diff --git a/src/java/org/apache/cassandra/metrics/MutualTlsMetrics.java b/src/java/org/apache/cassandra/metrics/MutualTlsMetrics.java new file mode 100644 index 0000000000..bb32f0416f --- /dev/null +++ b/src/java/org/apache/cassandra/metrics/MutualTlsMetrics.java @@ -0,0 +1,50 @@ +/* + * 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.metrics; + +import com.codahale.metrics.Histogram; + +import static org.apache.cassandra.metrics.CassandraMetricsRegistry.Metrics; + +/** + * Captures metrics related to Mutual TLS certificate expiration and certificate age for client + * and internode connections + */ +public class MutualTlsMetrics +{ + private static final MetricNameFactory factory = new DefaultNameFactory("MutualTls"); + + public final static MutualTlsMetrics instance = new MutualTlsMetrics(); + + /** + * Histogram of expiration days for client certificates + */ + public final Histogram clientCertificateExpirationDays; + + /** + * Histogram of expiration days for internode certificates + */ + public final Histogram internodeCertificateExpirationDays; + + public MutualTlsMetrics() + { + clientCertificateExpirationDays = Metrics.histogram(factory.createMetricName("ClientCertificateExpirationDays"), true); + internodeCertificateExpirationDays = Metrics.histogram(factory.createMetricName("InternodeCertificateExpirationDays"), true); + } +} diff --git a/test/data/config/version=5.0-alpha1.yml b/test/data/config/version=5.0-alpha1.yml index 01a5f90ea6..ce1da70bd9 100644 --- a/test/data/config/version=5.0-alpha1.yml +++ b/test/data/config/version=5.0-alpha1.yml @@ -388,6 +388,8 @@ server_encryption_options: keystore: "java.lang.String" truststore: "java.lang.String" algorithm: "java.lang.String" + max_certificate_validity_period: "org.apache.cassandra.config.DurationSpec.IntMinutesBound" + certificate_validity_warn_threshold: "org.apache.cassandra.config.DurationSpec.IntMinutesBound" partition_tombstones_fail_threshold: "java.lang.Long" traverse_auth_from_root: "java.lang.Boolean" denylist_refresh: "org.apache.cassandra.config.DurationSpec.IntSecondsBound" diff --git a/test/distributed/org/apache/cassandra/distributed/shared/ClusterUtils.java b/test/distributed/org/apache/cassandra/distributed/shared/ClusterUtils.java index bed9bc6f39..3d3b9f3958 100644 --- a/test/distributed/org/apache/cassandra/distributed/shared/ClusterUtils.java +++ b/test/distributed/org/apache/cassandra/distributed/shared/ClusterUtils.java @@ -1295,6 +1295,15 @@ public class ClusterUtils return address.getAddress().getHostAddress() + ":" + address.getPort(); } + /** + * @return the native address in host:port format (ex. 127.0.0.1:9042) + */ + public static InetSocketAddress getNativeInetSocketAddress(IInstance target) + { + return new InetSocketAddress(target.config().broadcastAddress().getAddress(), + getIntConfig(target.config(), "native_transport_port", 9042)); + } + /** * Get the broadcast address InetAddess string (ex. localhost/127.0.0.1 or /127.0.0.1) */ @@ -1303,6 +1312,27 @@ public class ClusterUtils return target.config().broadcastAddress().getAddress().toString(); } + /** + * Tries to return the integer configuration from the {@code config}, fallsback to {@code defaultValue} + * when it fails to retrieve the value. + * + * @param config the config instance + * @param configName the name of the configuration + * @param defaultValue the default value + * @return the integer value from the configuration, or the default value when it fails to retrieve it + */ + public static int getIntConfig(IInstanceConfig config, String configName, int defaultValue) + { + try + { + return config.getInt(configName); + } + catch (NullPointerException npe) + { + return defaultValue; + } + } + public static final class RingInstanceDetails { private final String address; diff --git a/test/distributed/org/apache/cassandra/distributed/test/JavaDriverUtils.java b/test/distributed/org/apache/cassandra/distributed/test/JavaDriverUtils.java index c7c478b478..ae25731458 100644 --- a/test/distributed/org/apache/cassandra/distributed/test/JavaDriverUtils.java +++ b/test/distributed/org/apache/cassandra/distributed/test/JavaDriverUtils.java @@ -18,10 +18,16 @@ package org.apache.cassandra.distributed.test; +import java.net.InetSocketAddress; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; + import com.datastax.driver.core.ProtocolVersion; import org.apache.cassandra.distributed.api.Feature; import org.apache.cassandra.distributed.api.ICluster; import org.apache.cassandra.distributed.api.IInstance; +import org.apache.cassandra.distributed.shared.ClusterUtils; public final class JavaDriverUtils { @@ -31,10 +37,16 @@ public final class JavaDriverUtils public static com.datastax.driver.core.Cluster create(ICluster dtest) { - return create(dtest, null); + return create(dtest, null, null); } public static com.datastax.driver.core.Cluster create(ICluster dtest, ProtocolVersion version) + { + return create(dtest, version, null); + } + + public static com.datastax.driver.core.Cluster create(ICluster dtest, ProtocolVersion version, + Consumer overrideBuilder) { if (dtest.size() == 0) throw new IllegalArgumentException("Attempted to open java driver for empty cluster"); @@ -47,13 +59,22 @@ public final class JavaDriverUtils com.datastax.driver.core.Cluster.Builder builder = com.datastax.driver.core.Cluster.builder(); - //TODO support port - //TODO support auth - dtest.stream().forEach(i -> builder.addContactPoint(i.broadcastAddress().getAddress().getHostAddress())); + List contactPoints = buildContactPoints(dtest); + builder.addContactPointsWithPorts(contactPoints); if (version != null) builder.withProtocolVersion(version); + if (overrideBuilder != null) + overrideBuilder.accept(builder); + return builder.build(); } + + public static List buildContactPoints(ICluster dtest) + { + return dtest.stream() + .map(ClusterUtils::getNativeInetSocketAddress) + .collect(Collectors.toList()); + } } diff --git a/test/distributed/org/apache/cassandra/distributed/test/auth/MutualTlsCertificateValidityPeriodTest.java b/test/distributed/org/apache/cassandra/distributed/test/auth/MutualTlsCertificateValidityPeriodTest.java new file mode 100644 index 0000000000..4f65b7ad75 --- /dev/null +++ b/test/distributed/org/apache/cassandra/distributed/test/auth/MutualTlsCertificateValidityPeriodTest.java @@ -0,0 +1,351 @@ +/* + * 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.distributed.test.auth; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.file.Path; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.StreamSupport; +import javax.net.ssl.SSLException; + +import com.google.common.collect.ImmutableMap; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import com.codahale.metrics.Histogram; +import com.datastax.driver.core.PlainTextAuthProvider; +import com.datastax.driver.core.RemoteEndpointAwareJdkSSLOptions; +import com.datastax.driver.core.ResultSet; +import com.datastax.driver.core.Row; +import com.datastax.driver.core.SSLOptions; +import com.datastax.driver.core.Session; +import com.datastax.driver.core.SimpleStatement; +import com.datastax.driver.core.policies.LoadBalancingPolicy; +import org.apache.cassandra.config.EncryptionOptions; +import org.apache.cassandra.distributed.Cluster; +import org.apache.cassandra.distributed.api.Feature; +import org.apache.cassandra.distributed.api.ICluster; +import org.apache.cassandra.distributed.api.IInvokableInstance; +import org.apache.cassandra.distributed.shared.ClusterUtils; +import org.apache.cassandra.distributed.test.JavaDriverUtils; +import org.apache.cassandra.distributed.test.TestBaseImpl; +import org.apache.cassandra.distributed.util.Auth; +import org.apache.cassandra.distributed.util.SingleHostLoadBalancingPolicy; +import org.apache.cassandra.metrics.ClearableHistogram; +import org.apache.cassandra.metrics.MutualTlsMetrics; +import org.apache.cassandra.security.ISslContextFactory; +import org.apache.cassandra.transport.SimpleClientSslContextFactory; +import org.apache.cassandra.utils.tls.CertificateBuilder; +import org.apache.cassandra.utils.tls.CertificateBundle; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.InstanceOfAssertFactories; + +import static org.apache.cassandra.auth.CassandraRoleManager.DEFAULT_SUPERUSER_NAME; +import static org.apache.cassandra.auth.CassandraRoleManager.DEFAULT_SUPERUSER_PASSWORD; +import static org.apache.cassandra.transport.TlsTestUtils.CLIENT_TRUSTSTORE_PASSWORD; +import static org.apache.cassandra.transport.TlsTestUtils.SERVER_KEYSTORE_PASSWORD; +import static org.apache.cassandra.transport.TlsTestUtils.SERVER_TRUSTSTORE_PASSWORD; +import static org.assertj.core.api.Assertions.as; +import static org.assertj.core.api.Assertions.fail; + +/** + * Tests mTLS certificate validity period functionality + */ +public class MutualTlsCertificateValidityPeriodTest extends TestBaseImpl +{ + private static final String IDENTITY = "spiffe://test.cassandra.apache.org/dTest/mtls"; + private static ICluster CLUSTER; + private static final char[] KEYSTORE_PASSWORD = "cassandra".toCharArray(); + + @ClassRule + public static TemporaryFolder tempFolder = new TemporaryFolder(); + + static CertificateBundle CA; + static Path truststorePath; + + @BeforeClass + public static void setupClass() throws Exception + { + Cluster.Builder builder = Cluster.build(1); + + CA = new CertificateBuilder().subject("CN=Apache Cassandra Root CA, OU=Certification Authority, O=Unknown, C=Unknown") + .alias("fakerootca") + .isCertificateAuthority(true) + .buildSelfSigned(); + + truststorePath = CA.toTempKeyStorePath(tempFolder.getRoot().toPath(), + SERVER_TRUSTSTORE_PASSWORD.toCharArray(), + SERVER_TRUSTSTORE_PASSWORD.toCharArray()); + + + CertificateBundle keystore = new CertificateBuilder().subject("CN=Apache Cassandra, OU=ssl_test, O=Unknown, L=Unknown, ST=Unknown, C=Unknown") + .addSanDnsName(InetAddress.getLocalHost().getCanonicalHostName()) + .addSanDnsName(InetAddress.getLocalHost().getHostName()) + .buildIssuedBy(CA); + + Path serverKeystorePath = keystore.toTempKeyStorePath(tempFolder.getRoot().toPath(), + SERVER_KEYSTORE_PASSWORD.toCharArray(), + SERVER_KEYSTORE_PASSWORD.toCharArray()); + + builder.withConfig(c -> c.set("authenticator.class_name", "org.apache.cassandra.auth.MutualTlsWithPasswordFallbackAuthenticator") + .set("authenticator.parameters", Collections.singletonMap("validator_class_name", "org.apache.cassandra.auth.SpiffeCertificateValidator")) + .set("role_manager", "CassandraRoleManager") + .set("authorizer", "CassandraAuthorizer") + .set("client_encryption_options.enabled", "true") + .set("client_encryption_options.require_client_auth", "optional") + .set("client_encryption_options.keystore", serverKeystorePath.toString()) + .set("client_encryption_options.keystore_password", SERVER_KEYSTORE_PASSWORD) + .set("client_encryption_options.truststore", truststorePath.toString()) + .set("client_encryption_options.truststore_password", SERVER_TRUSTSTORE_PASSWORD) + .set("client_encryption_options.require_endpoint_verification", "false") + .set("client_encryption_options.max_certificate_validity_period", "30d") + .set("client_encryption_options.certificate_validity_warn_threshold", "5d") + .with(Feature.NATIVE_PROTOCOL, Feature.GOSSIP)); + CLUSTER = builder.start(); + + configureIdentity(); + } + + @AfterClass + public static void teardown() throws Exception + { + if (CLUSTER != null) + CLUSTER.close(); + } + + @After + public void afterEach() + { + // reset metrics + CLUSTER.get(1).runOnInstance(() -> { + Histogram client = MutualTlsMetrics.instance.clientCertificateExpirationDays; + Histogram internode = MutualTlsMetrics.instance.internodeCertificateExpirationDays; + + if (client instanceof ClearableHistogram) + { + ((ClearableHistogram) client).clear(); + } + + if (internode instanceof ClearableHistogram) + { + ((ClearableHistogram) internode).clear(); + } + }); + } + + @Test + public void testExpiringCertificate() throws Exception + { + Path clientKeystorePath = generateClientCertificate(null); + + com.datastax.driver.core.Cluster driver = JavaDriverUtils.create(CLUSTER, null, b -> b.withSSL(getSSLOptions(clientKeystorePath))); + + testWithDriver(driver, (Session session) -> { + ResultSet clientView = session.execute(new SimpleStatement("SELECT * FROM system_views.clients")); + Assertions.assertThat(clientView).isNotNull().isNotEmpty(); + + Optional thisClient = StreamSupport.stream(clientView.spliterator(), false) + .filter(row -> "cassandra_ssl_test".equals(row.getString("username"))) + .findFirst(); + + Assertions.assertThat(thisClient).isPresent(); + Row row = thisClient.get(); + Map authenticationMetadata = row.getMap("authentication_metadata", String.class, String.class); + + Assertions.assertThat(authenticationMetadata).isNotNull().hasSize(1) + .containsKey("identity") + .extractingByKey("identity", as(InstanceOfAssertFactories.STRING)).isEqualTo(IDENTITY); + Assertions.assertThat(row.getString("authentication_mode")).isEqualTo("MutualTls"); + Assertions.assertThat(CLUSTER.get(1).logs().grep("Certificate with identity '" + IDENTITY + "' will expire").getResult()) + .isNotEmpty(); + CLUSTER.get(1).runOnInstance(() -> Assertions.assertThat(MutualTlsMetrics.instance.clientCertificateExpirationDays.getCount()).isEqualTo(2)); + }); + } + + @Test + public void testCertificateReachingMaxValidityPeriod() throws Exception + { + Path clientKeystorePath = generateClientCertificate(b -> b.notBefore(Instant.now().minus(26, ChronoUnit.DAYS)) + .notAfter(Instant.now().plus(4, ChronoUnit.DAYS).minus(1, ChronoUnit.MINUTES))); + + com.datastax.driver.core.Cluster driver = JavaDriverUtils.create(CLUSTER, null, b -> b.withSSL(getSSLOptions(clientKeystorePath))); + + testWithDriver(driver, (Session session) -> { + ResultSet clientView = session.execute(new SimpleStatement("SELECT * FROM system_views.clients")); + Assertions.assertThat(clientView).isNotNull().isNotEmpty(); + + Optional thisClient = StreamSupport.stream(clientView.spliterator(), false) + .filter(row -> "cassandra_ssl_test".equals(row.getString("username"))) + .findFirst(); + + Assertions.assertThat(thisClient).isPresent(); + Row row = thisClient.get(); + Map authenticationMetadata = row.getMap("authentication_metadata", String.class, String.class); + + Assertions.assertThat(authenticationMetadata).isNotNull().hasSize(1) + .containsKey("identity") + .extractingByKey("identity", as(InstanceOfAssertFactories.STRING)).isEqualTo(IDENTITY); + Assertions.assertThat(row.getString("authentication_mode")).isEqualTo("MutualTls"); + Assertions.assertThat(CLUSTER.get(1).logs().grep("Certificate with identity '" + IDENTITY + "' will expire").getResult()) + .isNotEmpty(); + CLUSTER.get(1).runOnInstance(() -> Assertions.assertThat(MutualTlsMetrics.instance.clientCertificateExpirationDays.getCount()).isGreaterThanOrEqualTo(2)); + }); + } + + @Test + public void testFailsWhenCertificateExceedsMaxAllowedValidityPeriod() throws Exception + { + Path clientKeystorePath = generateClientCertificate(b -> b.notAfter(Instant.now().plus(365, ChronoUnit.DAYS))); + + com.datastax.driver.core.Cluster driver = JavaDriverUtils.create(CLUSTER, null, b -> b.withSSL(getSSLOptions(clientKeystorePath))); + + try + { + testWithDriver(driver, null); + fail("Should not be able to connect when the certificate exceeds the maximum allowed validity period"); + } + catch (com.datastax.driver.core.exceptions.NoHostAvailableException exception) + { + Assertions.assertThat(exception) + .hasMessageContaining("The validity period of the provided certificate (366 days) exceeds the maximum allowed validity period of 30 days"); + } + } + + @Test + public void testFailsWhenCertificateIsExpired() throws Exception + { + Path clientKeystorePath = generateClientCertificate(b -> b.notBefore(Instant.now().minus(30, ChronoUnit.DAYS)) + .notAfter(Instant.now().minus(10, ChronoUnit.DAYS))); + + com.datastax.driver.core.Cluster driver = JavaDriverUtils.create(CLUSTER, null, b -> b.withSSL(getSSLOptions(clientKeystorePath))); + + try + { + testWithDriver(driver, + session -> CLUSTER.get(1).runOnInstance(() -> Assertions.assertThat(MutualTlsMetrics.instance.clientCertificateExpirationDays.getCount()).isZero())); + fail("Should not be able to connect when the certificate is expired"); + } + catch (com.datastax.driver.core.exceptions.NoHostAvailableException exception) + { + Assertions.assertThat(exception).hasMessageContaining("Channel has been closed"); + } + } + + private void testWithDriver(com.datastax.driver.core.Cluster providedDriver, Consumer consumer) + { + try (com.datastax.driver.core.Cluster driver = providedDriver; + Session session = driver.connect()) + { + if (consumer != null) + { + consumer.accept(session); + } + } + } + + public static SSLOptions getSSLOptions(Path keystorePath) throws RuntimeException + { + try + { + return RemoteEndpointAwareJdkSSLOptions.builder() + .withSSLContext(getClientSslContextFactory(keystorePath) + .createJSSESslContext(EncryptionOptions.ClientAuth.OPTIONAL)) + .build(); + } + catch (SSLException e) + { + throw new RuntimeException(e); + } + } + + private static ISslContextFactory getClientSslContextFactory(Path keystorePath) + { + ImmutableMap.Builder params = ImmutableMap.builder() + .put("truststore", truststorePath.toString()) + .put("truststore_password", CLIENT_TRUSTSTORE_PASSWORD); + + if (keystorePath != null) + { + params.put("keystore", keystorePath.toString()) + .put("keystore_password", "cassandra"); + } + + return new SimpleClientSslContextFactory(params.build()); + } + + private static void configureIdentity() + { + withAuthenticatedSession(CLUSTER.get(1), DEFAULT_SUPERUSER_NAME, DEFAULT_SUPERUSER_PASSWORD, session -> { + session.execute("CREATE ROLE cassandra_ssl_test WITH LOGIN = true"); + session.execute(String.format("ADD IDENTITY '%s' TO ROLE 'cassandra_ssl_test'", IDENTITY)); + // GRANT select to cassandra_ssl_test to be able to query the system_views.clients virtual table + session.execute("GRANT SELECT ON ALL KEYSPACES to cassandra_ssl_test"); + }); + } + + static void withAuthenticatedSession(IInvokableInstance instance, String username, String password, Consumer consumer) + { + // wait for existing roles + Auth.waitForExistingRoles(instance); + + InetSocketAddress nativeInetSocketAddress = ClusterUtils.getNativeInetSocketAddress(instance); + InetAddress address = nativeInetSocketAddress.getAddress(); + LoadBalancingPolicy lbc = new SingleHostLoadBalancingPolicy(address); + + com.datastax.driver.core.Cluster.Builder builder = com.datastax.driver.core.Cluster.builder() + .withLoadBalancingPolicy(lbc) + .withSSL(getSSLOptions(null)) + .withAuthProvider(new PlainTextAuthProvider(username, password)) + .addContactPoint(address.getHostAddress()) + .withPort(nativeInetSocketAddress.getPort()); + + try (com.datastax.driver.core.Cluster c = builder.build(); Session session = c.connect()) + { + consumer.accept(session); + } + } + + private Path generateClientCertificate(Function customizeCertificate) throws Exception + { + + CertificateBuilder builder = new CertificateBuilder().subject("CN=Apache Cassandra, OU=ssl_test, O=Unknown, L=Unknown, ST=Unknown, C=Unknown") + .notBefore(Instant.now().minus(1, ChronoUnit.DAYS)) + .notAfter(Instant.now().plus(1, ChronoUnit.DAYS)) + .alias("spiffecert") + .addSanUriName(IDENTITY) + .rsa2048Algorithm(); + if (customizeCertificate != null) + { + builder = customizeCertificate.apply(builder); + } + CertificateBundle ssc = builder.buildIssuedBy(CA); + return ssc.toTempKeyStorePath(tempFolder.getRoot().toPath(), KEYSTORE_PASSWORD, KEYSTORE_PASSWORD); + } +} diff --git a/test/unit/org/apache/cassandra/auth/MutualTlsCertificateValidityPeriodValidatorTest.java b/test/unit/org/apache/cassandra/auth/MutualTlsCertificateValidityPeriodValidatorTest.java new file mode 100644 index 0000000000..7170d3a9e9 --- /dev/null +++ b/test/unit/org/apache/cassandra/auth/MutualTlsCertificateValidityPeriodValidatorTest.java @@ -0,0 +1,86 @@ +/* + * 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.auth; + +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.util.concurrent.TimeUnit; + +import org.junit.Test; + +import org.apache.cassandra.config.DurationSpec; +import org.assertj.core.api.Assertions; + +import static org.apache.cassandra.auth.AuthTestUtils.loadCertificateChain; +import static org.apache.cassandra.auth.SpiffeCertificateValidatorTest.CERTIFICATE_PATH; +import static org.mockito.Mockito.mock; + +/** + * Unit tests for {@link MutualTlsCertificateValidityPeriodValidator} + */ +public class MutualTlsCertificateValidityPeriodValidatorTest +{ + @Test + public void testValidator() throws CertificateException + { + // Create a validator that allows certificates to be issued for 4000 days or less + MutualTlsCertificateValidityPeriodValidator validator = new MutualTlsCertificateValidityPeriodValidator(new DurationSpec.IntMinutesBound("4000d")); + Certificate[] chain = loadCertificateChain(CERTIFICATE_PATH); + Assertions.assertThat(validator.validate(chain)) + .isNotNull() + .isGreaterThan(0) + .isLessThan((int) TimeUnit.DAYS.toMinutes(3650)); + } + + @Test + public void testValidatorWithNullInput() + { + MutualTlsCertificateValidityPeriodValidator validator = new MutualTlsCertificateValidityPeriodValidator(null); + Assertions.assertThat(validator.validate(null)).isEqualTo(-1); + } + + @Test + public void testValidatorWithNoX509Certs() + { + Certificate[] chain = { mock(Certificate.class) }; + MutualTlsCertificateValidityPeriodValidator validator = new MutualTlsCertificateValidityPeriodValidator(null); + Assertions.assertThat(validator.validate(chain)).isEqualTo(-1); + } + + @Test + public void testValidatorWithoutMaxCertificateAge() throws CertificateException + { + // Create a validator that does not validate for certificate age + MutualTlsCertificateValidityPeriodValidator validator = new MutualTlsCertificateValidityPeriodValidator(null); + Certificate[] chain = loadCertificateChain(CERTIFICATE_PATH); + Assertions.assertThat(validator.validate(chain)) + .isGreaterThan(0) + .isLessThan((int) TimeUnit.DAYS.toMinutes(3650)); + } + + @Test + public void testThrowsWhenAgeExceedsMaximumAllowedAge() throws CertificateException + { + // Create a validator that allows certificates to be issued for 30 days or less + MutualTlsCertificateValidityPeriodValidator validator = new MutualTlsCertificateValidityPeriodValidator(new DurationSpec.IntMinutesBound("30d")); + Certificate[] chain = loadCertificateChain(CERTIFICATE_PATH); + Assertions.assertThatThrownBy(() -> validator.validate(chain)) + .hasMessageContaining("The validity period of the provided certificate (3650 days) exceeds the maximum allowed validity period of 30 days"); + } +} diff --git a/test/unit/org/apache/cassandra/auth/MutualTlsUtilTest.java b/test/unit/org/apache/cassandra/auth/MutualTlsUtilTest.java new file mode 100644 index 0000000000..033fa033ed --- /dev/null +++ b/test/unit/org/apache/cassandra/auth/MutualTlsUtilTest.java @@ -0,0 +1,51 @@ +/* + * 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.auth; + +import org.junit.Test; + +import org.assertj.core.api.Assertions; + +import static org.apache.cassandra.auth.MutualTlsUtil.toHumanReadableCertificateExpiration; + +/** + * Unit tests for {@link MutualTlsUtil} + */ +public class MutualTlsUtilTest +{ + @Test + public void testToHumanReadableCertificateExpiration() + { + Assertions.assertThat(toHumanReadableCertificateExpiration(0)).isEqualTo("0 minutes"); + Assertions.assertThat(toHumanReadableCertificateExpiration(1)).isEqualTo("1 minute"); + Assertions.assertThat(toHumanReadableCertificateExpiration(2)).isEqualTo("2 minutes"); + Assertions.assertThat(toHumanReadableCertificateExpiration(10)).isEqualTo("10 minutes"); + Assertions.assertThat(toHumanReadableCertificateExpiration(60)).isEqualTo("1 hour"); + Assertions.assertThat(toHumanReadableCertificateExpiration(61)).isEqualTo("1 hour 1 minute"); + Assertions.assertThat(toHumanReadableCertificateExpiration(80)).isEqualTo("1 hour 20 minutes"); + Assertions.assertThat(toHumanReadableCertificateExpiration(240)).isEqualTo("4 hours"); + Assertions.assertThat(toHumanReadableCertificateExpiration(1440)).isEqualTo("1 day"); + Assertions.assertThat(toHumanReadableCertificateExpiration(1501)).isEqualTo("1 day 1 hour 1 minute"); + Assertions.assertThat(toHumanReadableCertificateExpiration(1740)).isEqualTo("1 day 5 hours"); + Assertions.assertThat(toHumanReadableCertificateExpiration(2880)).isEqualTo("2 days"); + Assertions.assertThat(toHumanReadableCertificateExpiration(3180)).isEqualTo("2 days 5 hours"); + Assertions.assertThat(toHumanReadableCertificateExpiration(525600)).isEqualTo("365 days"); + Assertions.assertThat(toHumanReadableCertificateExpiration(Integer.MAX_VALUE)).isEqualTo("1491308 days 2 hours 7 minutes"); + } +} diff --git a/test/unit/org/apache/cassandra/auth/SpiffeCertificateValidatorTest.java b/test/unit/org/apache/cassandra/auth/SpiffeCertificateValidatorTest.java index fb1083c3f0..981bb99435 100644 --- a/test/unit/org/apache/cassandra/auth/SpiffeCertificateValidatorTest.java +++ b/test/unit/org/apache/cassandra/auth/SpiffeCertificateValidatorTest.java @@ -32,7 +32,7 @@ import static org.junit.Assert.assertEquals; public class SpiffeCertificateValidatorTest { - private static final String CERTIFICATE_PATH = "auth/SampleMtlsClientCertificate.pem"; + static final String CERTIFICATE_PATH = "auth/SampleMtlsClientCertificate.pem"; @Rule public ExpectedException expectedException = ExpectedException.none(); diff --git a/test/unit/org/apache/cassandra/config/EncryptionOptionsTest.java b/test/unit/org/apache/cassandra/config/EncryptionOptionsTest.java index f09527067f..cbfea947fe 100644 --- a/test/unit/org/apache/cassandra/config/EncryptionOptionsTest.java +++ b/test/unit/org/apache/cassandra/config/EncryptionOptionsTest.java @@ -29,6 +29,7 @@ import org.junit.Test; import org.apache.cassandra.exceptions.ConfigurationException; import org.assertj.core.api.Assertions; +import org.yaml.snakeyaml.constructor.ConstructorException; import static org.apache.cassandra.config.EncryptionOptions.ServerEncryptionOptions.InternodeEncryption.all; import static org.apache.cassandra.config.EncryptionOptions.ServerEncryptionOptions.InternodeEncryption.dc; @@ -37,6 +38,7 @@ import static org.apache.cassandra.config.EncryptionOptions.ServerEncryptionOpti import static org.apache.cassandra.config.EncryptionOptions.TlsEncryptionPolicy.ENCRYPTED; import static org.apache.cassandra.config.EncryptionOptions.TlsEncryptionPolicy.OPTIONAL; import static org.apache.cassandra.config.EncryptionOptions.TlsEncryptionPolicy.UNENCRYPTED; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -61,7 +63,7 @@ public class EncryptionOptionsTest new HashMap<>()), keystorePath, "dummypass", "dummytruststore", "dummypass", - Collections.emptyList(), null, null, null, "JKS", "false", false, enabled, optional) + Collections.emptyList(), null, null, null, "JKS", "false", false, enabled, optional, null, null) .applyConfig(), expected, String.format("optional=%s keystore=%s enabled=%s", optional, keystorePath, enabled)); @@ -75,7 +77,7 @@ public class EncryptionOptionsTest customSslContextFactoryParams), keystorePath, "dummypass", "dummytruststore", "dummypass", - Collections.emptyList(), null, null, null, "JKS", "false", false, enabled, optional) + Collections.emptyList(), null, null, null, "JKS", "false", false, enabled, optional, null, null) .applyConfig(), expected, String.format("optional=%s keystore=%s enabled=%s", optional, keystorePath, enabled)); @@ -126,7 +128,7 @@ public class EncryptionOptionsTest { return new ServerEncryptionOptionsTestCase(new EncryptionOptions.ServerEncryptionOptions(new ParameterizedClass("org.apache.cassandra.security.DefaultSslContextFactory", new HashMap<>()), keystorePath, "dummypass", keystorePath, "dummypass", "dummytruststore", "dummypass", - Collections.emptyList(), null, null, null, "JKS", "false", false, optional, internodeEncryption, false) + Collections.emptyList(), null, null, null, "JKS", "false", false, optional, internodeEncryption, false, null, null) .applyConfig(), expected, String.format("optional=%s keystore=%s internode=%s", optional, keystorePath, internodeEncryption)); @@ -161,6 +163,51 @@ public class EncryptionOptionsTest .hasMessage("Invalid yaml. Please remove properties [isOptional] from your cassandra.yaml"); } + @Test + public void testMaxCertificateValidityPeriod() + { + Map yaml = ImmutableMap.of( + "server_encryption_options", ImmutableMap.of( + "max_certificate_validity_period", "2d" + ), + "client_encryption_options", ImmutableMap.of( + "max_certificate_validity_period", "10d" + ) + ); + + Config config = YamlConfigurationLoader.fromMap(yaml, Config.class); + assertEquals(new DurationSpec.IntMinutesBound("2d"), config.server_encryption_options.max_certificate_validity_period); + assertEquals(new DurationSpec.IntMinutesBound("10d"), config.client_encryption_options.max_certificate_validity_period); + } + + @Test + public void testFailsToParseInvalidMaxCertificateValidityPeriodValue() + { + Map yaml = ImmutableMap.of( + "server_encryption_options", ImmutableMap.of( + "max_certificate_validity_period", "not-a-valid-input" + ) + ); + + Assertions.assertThatThrownBy(() -> YamlConfigurationLoader.fromMap(yaml, Config.class)) + .isInstanceOf(ConstructorException.class) + .hasMessageContaining("Cannot create property=server_encryption_options for JavaBean=org.apache.cassandra.config.Config@"); + } + + @Test + public void testFailsToParseNegativeMaxCertificateValidityPeriod() + { + Map yaml = ImmutableMap.of( + "server_encryption_options", ImmutableMap.of( + "max_certificate_validity_period", "-2d" + ) + ); + + Assertions.assertThatThrownBy(() -> YamlConfigurationLoader.fromMap(yaml, Config.class)) + .isInstanceOf(ConstructorException.class) + .hasMessageContaining("Cannot create property=server_encryption_options for JavaBean=org.apache.cassandra.config.Config@"); + } + final ServerEncryptionOptionsTestCase[] serverEncryptionOptionTestCases = { // Optional Keystore Internode Expected diff --git a/test/unit/org/apache/cassandra/utils/tls/CertificateBuilder.java b/test/unit/org/apache/cassandra/utils/tls/CertificateBuilder.java new file mode 100644 index 0000000000..3f8a785f2d --- /dev/null +++ b/test/unit/org/apache/cassandra/utils/tls/CertificateBuilder.java @@ -0,0 +1,234 @@ +/* + * 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.utils.tls; + +import java.io.IOException; +import java.math.BigInteger; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.security.spec.AlgorithmParameterSpec; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.RSAKeyGenParameterSpec; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Objects; +import javax.security.auth.x500.X500Principal; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +/** + * A utility class to generate certificates for tests + */ +public class CertificateBuilder +{ + private static final GeneralName[] EMPTY_SAN = {}; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private boolean isCertificateAuthority; + private String alias; + private X500Name subject; + private SecureRandom random; + private Date notBefore = Date.from(Instant.now().minus(1, ChronoUnit.DAYS)); + private Date notAfter = Date.from(Instant.now().plus(1, ChronoUnit.DAYS)); + private String algorithm; + private AlgorithmParameterSpec algorithmParameterSpec; + private String signatureAlgorithm; + private BigInteger serial; + private final List subjectAlternativeNames = new ArrayList<>(); + + public CertificateBuilder() + { + ecp256Algorithm(); + } + + public CertificateBuilder isCertificateAuthority(boolean isCertificateAuthority) + { + this.isCertificateAuthority = isCertificateAuthority; + return this; + } + + public CertificateBuilder subject(String subject) + { + this.subject = new X500Name(Objects.requireNonNull(subject)); + return this; + } + + public CertificateBuilder notBefore(Instant notBefore) + { + return notBefore(Date.from(Objects.requireNonNull(notBefore))); + } + + private CertificateBuilder notBefore(Date notBefore) + { + this.notBefore = Objects.requireNonNull(notBefore); + return this; + } + + public CertificateBuilder notAfter(Instant notAfter) + { + return notAfter(Date.from(Objects.requireNonNull(notAfter))); + } + + private CertificateBuilder notAfter(Date notAfter) + { + this.notAfter = Objects.requireNonNull(notAfter); + return this; + } + + public CertificateBuilder addSanUriName(String uri) + { + subjectAlternativeNames.add(new GeneralName(GeneralName.uniformResourceIdentifier, uri)); + return this; + } + + public CertificateBuilder addSanDnsName(String dnsName) + { + subjectAlternativeNames.add(new GeneralName(GeneralName.dNSName, dnsName)); + return this; + } + + public CertificateBuilder secureRandom(SecureRandom secureRandom) + { + this.random = Objects.requireNonNull(secureRandom); + return this; + } + + public CertificateBuilder alias(String alias) + { + this.alias = Objects.requireNonNull(alias); + return this; + } + + public CertificateBuilder serial(BigInteger serial) + { + this.serial = serial; + return this; + } + + public CertificateBuilder ecp256Algorithm() + { + this.algorithm = "EC"; + this.algorithmParameterSpec = new ECGenParameterSpec("secp256r1"); + this.signatureAlgorithm = "SHA256WITHECDSA"; + return this; + } + + public CertificateBuilder rsa2048Algorithm() + { + this.algorithm = "RSA"; + this.algorithmParameterSpec = new RSAKeyGenParameterSpec(2048, RSAKeyGenParameterSpec.F4); + this.signatureAlgorithm = "SHA256WITHRSA"; + return this; + } + + public CertificateBundle buildSelfSigned() throws Exception + { + KeyPair keyPair = generateKeyPair(); + + JcaX509v3CertificateBuilder builder = createCertBuilder(subject, subject, keyPair); + addExtensions(builder); + + ContentSigner signer = new JcaContentSignerBuilder(signatureAlgorithm).build(keyPair.getPrivate()); + X509CertificateHolder holder = builder.build(signer); + X509Certificate root = new JcaX509CertificateConverter().getCertificate(holder); + return new CertificateBundle(signatureAlgorithm, new X509Certificate[]{ root }, root, keyPair, alias); + } + + public CertificateBundle buildIssuedBy(CertificateBundle issuer) throws Exception + { + String issuerSignAlgorithm = issuer.signatureAlgorithm(); + return buildIssuedBy(issuer, issuerSignAlgorithm); + } + + public CertificateBundle buildIssuedBy(CertificateBundle issuer, String issuerSignAlgorithm) throws Exception + { + KeyPair keyPair = generateKeyPair(); + + X500Principal issuerPrincipal = issuer.certificate().getSubjectX500Principal(); + X500Name issuerName = X500Name.getInstance(issuerPrincipal.getEncoded()); + JcaX509v3CertificateBuilder builder = createCertBuilder(issuerName, subject, keyPair); + + addExtensions(builder); + + PrivateKey issuerPrivateKey = issuer.keyPair().getPrivate(); + if (issuerPrivateKey == null) + { + throw new IllegalArgumentException("Cannot sign certificate with issuer that does not have a private key."); + } + ContentSigner signer = new JcaContentSignerBuilder(issuerSignAlgorithm).build(issuerPrivateKey); + X509CertificateHolder holder = builder.build(signer); + X509Certificate cert = new JcaX509CertificateConverter().getCertificate(holder); + X509Certificate[] issuerPath = issuer.certificatePath(); + X509Certificate[] path = new X509Certificate[issuerPath.length + 1]; + path[0] = cert; + System.arraycopy(issuerPath, 0, path, 1, issuerPath.length); + return new CertificateBundle(signatureAlgorithm, path, issuer.rootCertificate(), keyPair, alias); + } + + private SecureRandom secureRandom() + { + return Objects.requireNonNullElse(random, SECURE_RANDOM); + } + + private KeyPair generateKeyPair() throws GeneralSecurityException + { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance(algorithm); + keyGen.initialize(algorithmParameterSpec, secureRandom()); + return keyGen.generateKeyPair(); + } + + private JcaX509v3CertificateBuilder createCertBuilder(X500Name issuer, X500Name subject, KeyPair keyPair) + { + BigInteger serial = this.serial != null ? this.serial : new BigInteger(159, secureRandom()); + PublicKey pubKey = keyPair.getPublic(); + return new JcaX509v3CertificateBuilder(issuer, serial, notBefore, notAfter, subject, pubKey); + } + + private void addExtensions(JcaX509v3CertificateBuilder builder) throws IOException + { + if (isCertificateAuthority) + { + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + } + + boolean criticality = false; + if (!subjectAlternativeNames.isEmpty()) + { + builder.addExtension(Extension.subjectAlternativeName, criticality, + new GeneralNames(subjectAlternativeNames.toArray(EMPTY_SAN))); + } + } +} diff --git a/test/unit/org/apache/cassandra/utils/tls/CertificateBundle.java b/test/unit/org/apache/cassandra/utils/tls/CertificateBundle.java new file mode 100644 index 0000000000..f6af56e210 --- /dev/null +++ b/test/unit/org/apache/cassandra/utils/tls/CertificateBundle.java @@ -0,0 +1,109 @@ +/* + * 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.utils.tls; + +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.KeyPair; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.cert.X509Certificate; +import java.util.Objects; + +public class CertificateBundle +{ + private final String signatureAlgorithm; + private final X509Certificate[] chain; + private final X509Certificate root; + private final KeyPair keyPair; + private final String alias; + + public CertificateBundle(String signatureAlgorithm, X509Certificate[] chain, + X509Certificate root, KeyPair keyPair, String alias) + { + this.signatureAlgorithm = Objects.requireNonNull(signatureAlgorithm); + this.chain = chain; + this.root = root; + this.keyPair = keyPair; + this.alias = Objects.requireNonNullElse(alias, "1"); + } + + public KeyStore toKeyStore(char[] keyEntryPassword) throws KeyStoreException + { + KeyStore keyStore; + try + { + keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, null); + } + catch (Exception e) + { + throw new RuntimeException("Failed to initialize PKCS#12 KeyStore.", e); + } + keyStore.setCertificateEntry("1", root); + if (!isCertificateAuthority()) + { + keyStore.setKeyEntry(alias, keyPair.getPrivate(), keyEntryPassword, chain); + } + return keyStore; + } + + public Path toTempKeyStorePath(Path baseDir, char[] pkcs12Password, char[] keyEntryPassword) throws Exception + { + KeyStore keyStore = toKeyStore(keyEntryPassword); + Path tempFile = Files.createTempFile(baseDir, "ks", ".p12"); + try (OutputStream out = Files.newOutputStream(tempFile, StandardOpenOption.WRITE)) + { + keyStore.store(out, pkcs12Password); + } + return tempFile; + } + + public boolean isCertificateAuthority() + { + return chain[0].getBasicConstraints() != -1; + } + + public X509Certificate certificate() + { + return chain[0]; + } + + public KeyPair keyPair() + { + return keyPair; + } + + public X509Certificate[] certificatePath() + { + return chain.clone(); + } + + public X509Certificate rootCertificate() + { + return root; + } + + public String signatureAlgorithm() + { + return signatureAlgorithm; + } +}