This is an automated email from the ASF dual-hosted git repository.
frankgh pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/cassandra.git
The following commit(s) were added to refs/heads/trunk by this push:
new a0af41f666 CASSANDRA-18951: Add option for MutualTlsAuthenticator to
restrict the certificate validity period
a0af41f666 is described below
commit a0af41f666c23a840d9df3f06729ed5fd2c06cd1
Author: Francisco Guerrero <[email protected]>
AuthorDate: Thu Feb 15 13:19:28 2024 -0800
CASSANDRA-18951: Add option for MutualTlsAuthenticator to restrict the
certificate validity period
In this commit, we introduce two new optional options for the
`server_encryption_options`
and the `client_encryption_options`. The options are
`max_certificate_validity_period` and
`certificate_validity_warn_threshold`. Both options can be configured as a
duration
configuration parameter as defined by the `DurationSpec` (see
CASSANDRA-15234). The resolution
for these new properties is minutes.
When specified, the certificate validation implementation will take that
information
and reject certificates that are older than the maximum allowed certificate
validity period,
translating into a rejection from the authenticating user.
The `certificate_validity_warn_threshold` option can be configured to emit
warnings (log entries)
when the certificate exceeds the validity threshold.
patch by Francisco Guerrero; reviewed by Andy Tolbert, Abe Ratnofsky,
Dinesh Joshi for CASSANDRA-18951
---
conf/cassandra.yaml | 16 +
.../cassandra/auth/MutualTlsAuthenticator.java | 45 ++-
.../auth/MutualTlsCertificateValidator.java | 27 +-
...utualTlsCertificateValidityPeriodValidator.java | 85 +++++
.../auth/MutualTlsInternodeAuthenticator.java | 46 ++-
.../org/apache/cassandra/auth/MutualTlsUtil.java | 83 +++++
.../cassandra/auth/SpiffeCertificateValidator.java | 35 +-
.../apache/cassandra/config/EncryptionOptions.java | 179 ++++++++---
.../apache/cassandra/metrics/MutualTlsMetrics.java | 50 +++
test/data/config/version=5.0-alpha1.yml | 2 +
.../cassandra/distributed/shared/ClusterUtils.java | 30 ++
.../distributed/test/JavaDriverUtils.java | 29 +-
.../MutualTlsCertificateValidityPeriodTest.java | 351 +++++++++++++++++++++
...lTlsCertificateValidityPeriodValidatorTest.java | 86 +++++
.../apache/cassandra/auth/MutualTlsUtilTest.java | 51 +++
.../auth/SpiffeCertificateValidatorTest.java | 2 +-
.../cassandra/config/EncryptionOptionsTest.java | 53 +++-
.../cassandra/utils/tls/CertificateBuilder.java | 234 ++++++++++++++
.../cassandra/utils/tls/CertificateBundle.java | 109 +++++++
19 files changed, 1412 insertions(+), 101 deletions(-)
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
+ * <p>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
+ * <p>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:
+ * <p>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).
+ *
+ * <p>Example:
+ * <pre>
* authenticator:
* class_name : org.apache.cassandra.auth.MutualTlsAuthenticator
* parameters :
* validator_class_name:
org.apache.cassandra.auth.SpiffeCertificateValidator
+ * </pre>
*/
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<AuthenticationMode> 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<String, String> 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.
- *
+ * <p>
* 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
+ * <p>For example:
+ * <ul>
+ * <li>Verifying CA information
+ * <li>Checking CN information
+ * <li>Validating Issuer information
+ * <li>Checking organization information etc
+ * </ul>
*
* @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
+ * <p>An example of identity could be the following:
+ * <ul>
+ * <li>an identifier in SAN of the certificate like SPIFFE
+ * <li>CN of the certificate
+ * <li>any other fields in the certificate can be combined and be used as
identifier of the certificate
+ * </ul>
*
* @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
+ * <p>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
+ * <p>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.
*
+ * <p>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.
+ *
+ * <pre>
* 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"
+ * </pre>
*/
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<String> trustedIdentities;
+ @Nonnull
+ private final MutualTlsCertificateValidityPeriodValidator
certificateValidityPeriodValidator;
+ private final DurationSpec.IntMinutesBound
certificateValidityWarnThreshold;
public MutualTlsInternodeAuthenticator(Map<String, String> 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.
- * <p>
*
- * <p>
- * Example:
+ * <p>Example:
+ * <pre>
* 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
+ * </pre>
+ *
+ * <pre>
* 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
+ * </pre>
*/
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<String> cipher_suites,
String protocol, List<String> 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.
- *
+ * <p>
* 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<String> 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<String> 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<String>
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<String> cipher_suites, String
protocol, List<String> 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<String>
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<String>
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<? extends
IInstance> dtest)
{
- return create(dtest, null);
+ return create(dtest, null, null);
}
public static com.datastax.driver.core.Cluster create(ICluster<? extends
IInstance> dtest, ProtocolVersion version)
+ {
+ return create(dtest, version, null);
+ }
+
+ public static com.datastax.driver.core.Cluster create(ICluster<? extends
IInstance> dtest, ProtocolVersion version,
+
Consumer<com.datastax.driver.core.Cluster.Builder> 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<InetSocketAddress> contactPoints = buildContactPoints(dtest);
+ builder.addContactPointsWithPorts(contactPoints);
if (version != null)
builder.withProtocolVersion(version);
+ if (overrideBuilder != null)
+ overrideBuilder.accept(builder);
+
return builder.build();
}
+
+ public static List<InetSocketAddress> buildContactPoints(ICluster<?
extends IInstance> 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<IInvokableInstance> 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<Row> 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<String, String> 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<Row> 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<String, String> 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<Session> 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<String, Object> params = ImmutableMap.<String,
Object>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<Session> 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<CertificateBuilder,
CertificateBuilder> 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<String, Object> 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<String, Object> 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<String, Object> 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<GeneralName> 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;
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]