This is an automated email from the ASF dual-hosted git repository.
pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko.git
The following commit(s) were added to refs/heads/main by this push:
new f9f8db5c74 feat: load every certificate in the rotating keys engine
ca-cert-file (#3528)
f9f8db5c74 is described below
commit f9f8db5c74847087d2174f79d8270ef92e797ca5
Author: PJ Fanning <[email protected]>
AuthorDate: Mon Sep 7 21:25:18 2026 +0100
feat: load every certificate in the rotating keys engine ca-cert-file
(#3528)
* feat: load every certificate in the rotating keys engine ca-cert-file
Motivation:
`RotatingKeysSSLEngineProvider` read `ca-cert-file` with
`CertificateFactory.generateCertificate`, which stops at the first PEM
block. A CA file that bundles more than one certificate - a root plus the
intermediates it delegates to, or two roots while a CA is being rotated -
was therefore only half trusted, and peers presenting a certificate from
any of the other CAs in the bundle were rejected.
Modification:
Add `PemManagersProvider.loadCertificates`, which uses
`generateCertificates` and closes the stream. `buildTrustManagers` and
`buildKeyManagers` now take the whole `Seq[Certificate]`; every CA
certificate is stored under its own alias so all of them become trust
anchors. The chain attached to the key entry is built by walking
issuer -> subject from the node certificate upwards, so CA certificates
that did not issue it are trusted without being sent to the peer; when
none of them matches the issuer the previous behaviour (all of them) is
kept. `readFiles` now fails with `SslTransportException` when the CA file
yields no certificate at all, instead of passing a null certificate on.
Result:
`ca-cert-file` may bundle any number of certificates and all of them are
trusted. Single-certificate configurations behave as before.
Tests:
- sbt "remote/testOnly
org.apache.pekko.remote.artery.tcp.ssl.PemManagersProviderSpec
org.apache.pekko.remote.artery.tcp.ssl.TlsResourcesSpec
org.apache.pekko.remote.artery.tcp.ssl.*Rotating*" - passed
- sbt "remote/mimaReportBinaryIssues" - passed
- both were run before this change was merged with the keystore-password
change on main; verification of the merged state is left to CI
References:
None - follow-up on multi-certificate `ca-cert-file` support
* fix: pick the CA certificate by signature, not by subject alone
A CA rotation that keeps the distinguished name leaves two certificates
with the same subject in `ca-cert-file`, and only one of them signed the
node certificate. Keying the candidate issuers by subject picked whichever
came last in the file, so the chain sent to the peer could carry an
intermediate that does not verify the certificate below it.
Walk the chain by verifying the signature instead, and send the node
certificate on its own when no CA in the bundle issued it rather than
sending the whole bundle. Also close the stream in `loadCertificate`.
`gen-ca-bundles.sh` generates the multi-certificate samples, including the
new rotated CA sharing exampleCA's subject, and is called from
`gencerts.sh` so the bundles survive a regeneration.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix: close the input streams handed to KeyStore.load
Motivation:
`KeyStore.load` does not close the stream it is given, so every call that
passes
a freshly opened stream inline leaks the handle. Review of #3528 raised
this for
`PemManagersProvider.loadCertificate`, which the previous commit fixed; the
same
shape is in the TLS setup code elsewhere in the tree, where it was left in
place.
Modification:
Bind the stream, load through a `try`, and close in a `finally`, at all five
remaining sites: two in `TlsSpec.initWithTrust`, one in `TcpSpec`'s
`sslContext`,
and two in `TlsBenchmark.initSslContext`. `getResourceAsStream` returns
null for a
missing resource, so the close is guarded to avoid masking the real failure
with a
NullPointerException.
`PemManagersProvider.loadCertificate` and `loadCertificates` already close
their
streams, and the other keystore loaders
(`ConfigSSLEngineProvider.loadKeystore` and
the netty `SSLEngineProvider.loadKeystore`) already close theirs.
Result:
No TLS keystore load leaks a file descriptor.
Tests:
- `sbt "remote/testOnly org.apache.pekko.remote.artery.tcp.ssl.*"` -- 16
passed, 1 ignored
- `sbt "stream-tests/testOnly org.apache.pekko.stream.io.TlsSpec"` -- 113
passed, which
is the directional check that the keystores still load after the streams
are closed
- `sbt "stream-tests/Test/compile" "bench-jmh/compile"` -- clean
- `sbt "stream-tests/scalafmtCheckAll" "bench-jmh/scalafmtCheckAll"
"remote/scalafmtCheckAll"` -- clean
References:
Refs #3528
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../org/apache/pekko/stream/io/TlsBenchmark.scala | 9 ++-
remote/src/main/resources/reference.conf | 4 +-
.../artery/tcp/ssl/PemManagersProvider.scala | 71 ++++++++++++++++++++--
.../tcp/ssl/RotatingKeysSSLEngineProvider.scala | 17 ++++--
remote/src/test/resources/ssl/README.md | 6 ++
remote/src/test/resources/ssl/exampleca-bundle.crt | 33 ++++++++++
.../resources/ssl/exampleca-rotated-bundle.crt | 28 +++++++++
.../src/test/resources/ssl/exampleca-rotated.crt | 14 +++++
remote/src/test/resources/ssl/gen-ca-bundles.sh | 42 +++++++++++++
remote/src/test/resources/ssl/gencerts.sh | 3 +
.../artery/tcp/ssl/PemManagersProviderSpec.scala | 63 +++++++++++++++++--
.../scala/org/apache/pekko/stream/io/TcpSpec.scala | 5 +-
.../scala/org/apache/pekko/stream/io/TlsSpec.scala | 9 ++-
13 files changed, 281 insertions(+), 23 deletions(-)
diff --git
a/bench-jmh/src/main/scala/org/apache/pekko/stream/io/TlsBenchmark.scala
b/bench-jmh/src/main/scala/org/apache/pekko/stream/io/TlsBenchmark.scala
index 64968581df..b29a8c9e39 100644
--- a/bench-jmh/src/main/scala/org/apache/pekko/stream/io/TlsBenchmark.scala
+++ b/bench-jmh/src/main/scala/org/apache/pekko/stream/io/TlsBenchmark.scala
@@ -198,11 +198,16 @@ object TlsBenchmark {
def initSslContext(protocol: String): SSLContext = {
val password = "changeme"
+ // KeyStore.load does not close the stream it is given
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType)
- keyStore.load(getClass.getResourceAsStream("/keystore"),
password.toCharArray)
+ val keyStoreStream = getClass.getResourceAsStream("/keystore")
+ try keyStore.load(keyStoreStream, password.toCharArray)
+ finally if (keyStoreStream ne null) keyStoreStream.close()
val trustStore = KeyStore.getInstance(KeyStore.getDefaultType)
- trustStore.load(getClass.getResourceAsStream("/truststore"),
password.toCharArray)
+ val trustStoreStream = getClass.getResourceAsStream("/truststore")
+ try trustStore.load(trustStoreStream, password.toCharArray)
+ finally if (trustStoreStream ne null) trustStoreStream.close()
val keyManagerFactory =
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm)
keyManagerFactory.init(keyStore, password.toCharArray)
diff --git a/remote/src/main/resources/reference.conf
b/remote/src/main/resources/reference.conf
index dff6972bc2..4f7702b322 100644
--- a/remote/src/main/resources/reference.conf
+++ b/remote/src/main/resources/reference.conf
@@ -1287,7 +1287,9 @@ pekko {
# The absolute path to the PEM file of the certificate for the
private key above.
cert-file =
${pekko.remote.artery.ssl.rotating-keys-engine.secret-mount-point}/tls.crt
# The absolute path to the PEM file of the certificate of the CA
that emitted
- # the node certificate above.
+ # the node certificate above. The file may bundle more than one
certificate
+ # (for example a root CA and the intermediates it delegates to, or
several
+ # roots while a CA is being rotated); all of them are trusted.
ca-cert-file =
${pekko.remote.artery.ssl.rotating-keys-engine.secret-mount-point}/ca.crt
# There are two options, and the default SecureRandom is recommended:
diff --git
a/remote/src/main/scala/org/apache/pekko/remote/artery/tcp/ssl/PemManagersProvider.scala
b/remote/src/main/scala/org/apache/pekko/remote/artery/tcp/ssl/PemManagersProvider.scala
index fe3d49c27b..2727ad8f8e 100644
---
a/remote/src/main/scala/org/apache/pekko/remote/artery/tcp/ssl/PemManagersProvider.scala
+++
b/remote/src/main/scala/org/apache/pekko/remote/artery/tcp/ssl/PemManagersProvider.scala
@@ -20,7 +20,10 @@ import java.security.{ KeyStore, PrivateKey }
import java.security.cert.{ Certificate, CertificateFactory, X509Certificate }
import javax.net.ssl.{ KeyManager, KeyManagerFactory, TrustManager,
TrustManagerFactory }
+import scala.annotation.tailrec
import scala.concurrent.blocking
+import scala.jdk.CollectionConverters._
+import scala.util.control.NonFatal
import org.apache.pekko
import pekko.annotation.InternalApi
@@ -39,15 +42,15 @@ private[ssl] object PemManagersProvider {
private[ssl] def buildKeyManagers(
privateKey: PrivateKey,
cert: X509Certificate,
- cacert: Certificate,
+ cacerts: Seq[Certificate],
keystorePassword: String = "changeit"): Array[KeyManager] = {
val keyStore = KeyStore.getInstance("JKS")
keyStore.load(null)
val passwordChars = keystorePassword.toCharArray
keyStore.setCertificateEntry("cert", cert)
- keyStore.setCertificateEntry("cacert", cacert)
- keyStore.setKeyEntry("private-key", privateKey, passwordChars, Array(cert,
cacert))
+ cacerts.zipWithIndex.foreach { case (cacert, idx) =>
keyStore.setCertificateEntry(caCertAlias(idx), cacert) }
+ keyStore.setKeyEntry("private-key", privateKey, passwordChars,
buildCertificateChain(cert, cacerts))
val kmf =
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm)
@@ -60,10 +63,10 @@ private[ssl] object PemManagersProvider {
* INTERNAL API
*/
@InternalApi
- private[ssl] def buildTrustManagers(cacert: Certificate):
Array[TrustManager] = {
+ private[ssl] def buildTrustManagers(cacerts: Seq[Certificate]):
Array[TrustManager] = {
val trustStore = KeyStore.getInstance("JKS")
trustStore.load(null)
- trustStore.setCertificateEntry("cacert", cacert)
+ cacerts.zipWithIndex.foreach { case (cacert, idx) =>
trustStore.setCertificateEntry(caCertAlias(idx), cacert) }
val tmf =
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm)
@@ -87,7 +90,63 @@ private[ssl] object PemManagersProvider {
*/
@InternalApi
private[ssl] def loadCertificate(filename: String): Certificate = blocking {
- certFactory.generateCertificate(Files.newInputStream(new
File(filename).toPath))
+ val stream = Files.newInputStream(new File(filename).toPath)
+ try certFactory.generateCertificate(stream)
+ finally stream.close()
}
+ /**
+ * Loads every certificate found in the given PEM file. A file may bundle
more than one
+ * certificate, e.g. a root CA together with the intermediates it delegates
to, or several
+ * roots while a CA is being rotated.
+ *
+ * INTERNAL API
+ */
+ @InternalApi
+ private[ssl] def loadCertificates(filename: String): Seq[Certificate] =
blocking {
+ val stream = Files.newInputStream(new File(filename).toPath)
+ try certFactory.generateCertificates(stream).asScala.toList
+ finally stream.close()
+ }
+
+ private def caCertAlias(idx: Int): String = if (idx == 0) "cacert" else
s"cacert-$idx"
+
+ /**
+ * Builds the certificate chain to present during the handshake: `cert`
followed by the
+ * certificates from `cacerts` that actually issued it, ordered from the
issuer of `cert` up
+ * to the root. Certificates in `cacerts` that are not part of that chain
(an unrelated root
+ * kept around for a CA rotation, say) are trusted but not sent to the peer.
+ *
+ * Sends `cert` on its own when none of `cacerts` issued it.
+ */
+ private def buildCertificateChain(cert: X509Certificate, cacerts:
Seq[Certificate]): Array[Certificate] = {
+ val candidates: Seq[X509Certificate] = cacerts.collect { case x509:
X509Certificate => x509 }
+
+ // A rotation that keeps the distinguished name leaves two CA certificates
with the same
+ // subject in the bundle, and a self-signed root is even its own issuer by
subject, so the
+ // signature and not the subject alone decides which certificate issued
which.
+ def issuerOf(subject: X509Certificate, chain: List[Certificate]):
Option[X509Certificate] =
+ candidates.find { candidate =>
+ candidate.getSubjectX500Principal == subject.getIssuerX500Principal &&
+ candidate != subject && !chain.contains(candidate) &&
signedBy(subject, candidate)
+ }
+
+ @tailrec
+ def issuersOf(current: X509Certificate, acc: List[Certificate]):
List[Certificate] =
+ issuerOf(current, acc) match {
+ case Some(issuer) => issuersOf(issuer, issuer :: acc)
+ case None => acc
+ }
+
+ (cert +: issuersOf(cert, Nil).reverse).toArray
+ }
+
+ private def signedBy(subject: X509Certificate, issuer: X509Certificate):
Boolean =
+ try {
+ subject.verify(issuer.getPublicKey)
+ true
+ } catch {
+ case NonFatal(_) => false
+ }
+
}
diff --git
a/remote/src/main/scala/org/apache/pekko/remote/artery/tcp/ssl/RotatingKeysSSLEngineProvider.scala
b/remote/src/main/scala/org/apache/pekko/remote/artery/tcp/ssl/RotatingKeysSSLEngineProvider.scala
index 95ef6f970a..b047711ff0 100644
---
a/remote/src/main/scala/org/apache/pekko/remote/artery/tcp/ssl/RotatingKeysSSLEngineProvider.scala
+++
b/remote/src/main/scala/org/apache/pekko/remote/artery/tcp/ssl/RotatingKeysSSLEngineProvider.scala
@@ -112,11 +112,11 @@ final class RotatingKeysSSLEngineProvider(val config:
Config, protected val log:
// Construct the cached instance
private def constructContext(): ConfiguredContext = {
- val (privateKey, cert, cacert) = readFiles()
+ val (privateKey, cert, cacerts) = readFiles()
try {
val keyManagers: Array[KeyManager] =
- PemManagersProvider.buildKeyManagers(privateKey, cert, cacert,
SSLKeystorePassword)
- val trustManagers: Array[TrustManager] =
PemManagersProvider.buildTrustManagers(cacert)
+ PemManagersProvider.buildKeyManagers(privateKey, cert, cacerts,
SSLKeystorePassword)
+ val trustManagers: Array[TrustManager] =
PemManagersProvider.buildTrustManagers(cacerts)
val sessionVerifier = new PeerSubjectVerifier(cert)
@@ -133,12 +133,17 @@ final class RotatingKeysSSLEngineProvider(val config:
Config, protected val log:
}
}
- private def readFiles(): (PrivateKey, X509Certificate, Certificate) = {
+ private def readFiles(): (PrivateKey, X509Certificate, Seq[Certificate]) = {
try {
- val cacert: Certificate =
PemManagersProvider.loadCertificate(SSLCACertFile)
+ // the CA file may bundle several certificates, all of them are trusted
+ val cacerts: Seq[Certificate] =
PemManagersProvider.loadCertificates(SSLCACertFile)
+ if (cacerts.isEmpty)
+ throw new SslTransportException(
+ s"Server SSL connection could not be established because no
certificate was found in [$SSLCACertFile]",
+ null)
val cert: X509Certificate =
PemManagersProvider.loadCertificate(SSLCertFile).asInstanceOf[X509Certificate]
val privateKey: PrivateKey =
PemManagersProvider.loadPrivateKey(SSLKeyFile)
- (privateKey, cert, cacert)
+ (privateKey, cert, cacerts)
} catch {
case e: FileNotFoundException =>
throw new SslTransportException(
diff --git a/remote/src/test/resources/ssl/README.md
b/remote/src/test/resources/ssl/README.md
index d305c7aac5..fcd576bbeb 100644
--- a/remote/src/test/resources/ssl/README.md
+++ b/remote/src/test/resources/ssl/README.md
@@ -5,3 +5,9 @@ those from
https://github.com/playframework/play-samples/tree/2.8.x/play-scala-t
The resources in this folder are build with a few restrictions. See
`TlsResourcesSpec.scala` for some
tests asserting the certificates and keys in this folder fulfill the required
restrictions.
+
+`exampleca-bundle.crt` and `exampleca-rotated-bundle.crt` cover the case of a
CA file bundling more
+than one certificate. The first bundles two unrelated CAs (`exampleca.crt` and
+`pem/selfsigned-certificate.pem`); the second bundles `exampleca.crt` and
`exampleca-rotated.crt`,
+which share a subject but hold different keys, as a CA rotation that keeps the
distinguished name
+would leave them. Both are built by `gen-ca-bundles.sh`.
diff --git a/remote/src/test/resources/ssl/exampleca-bundle.crt
b/remote/src/test/resources/ssl/exampleca-bundle.crt
new file mode 100644
index 0000000000..a51eea3e8a
--- /dev/null
+++ b/remote/src/test/resources/ssl/exampleca-bundle.crt
@@ -0,0 +1,33 @@
+-----BEGIN CERTIFICATE-----
+MIICNDCCAdigAwIBAgIEKYhR+DAMBggqhkjOPQQDAgUAMH4xCzAJBgNVBAYTAlVT
+MRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2NvMRgw
+FgYDVQQKEw9FeGFtcGxlIENvbXBhbnkxFDASBgNVBAsTC0V4YW1wbGUgT3JnMRIw
+EAYDVQQDEwlleGFtcGxlQ0EwHhcNMjAwNjAxMTU1MDEzWhcNNDcxMDE3MTU1MDEz
+WjB+MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMN
+U2FuIEZyYW5jaXNjbzEYMBYGA1UEChMPRXhhbXBsZSBDb21wYW55MRQwEgYDVQQL
+EwtFeGFtcGxlIE9yZzESMBAGA1UEAxMJZXhhbXBsZUNBMFkwEwYHKoZIzj0CAQYI
+KoZIzj0DAQcDQgAE9K81J2BKaFGTI/4qgUr31mr3Jpl6YK+7pCmy2LNiW0Jufova
+p41ERTrT9IynJF3NlJpzuKhmuA+80ZM5NSR4QqNCMEAwDwYDVR0TAQH/BAUwAwEB
+/zAOBgNVHQ8BAf8EBAMCAgQwHQYDVR0OBBYEFNkRJB4AQDAmCIvHYS0AKD4/AqUU
+MAwGCCqGSM49BAMCBQADSAAwRQIhAP2Uf206YbhJ2pb5MvehuLanJzFydOAq5uC4
+AXmspW+EAiAuPLFIAPyB+6gpr/mQ8V+yyh3aCJV3bvFq8aorrQyC3Q==
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIDCzCCAfOgAwIBAgIQfEHPfR1p1xuW9TQlfxAugjANBgkqhkiG9w0BAQsFADAv
+MS0wKwYDVQQDEyQwZDIwN2I2OC05YTIwLTRlZTgtOTJjYi1iZjk2OTk1ODFjZjgw
+HhcNMTkxMDExMTMyODUzWhcNMjQxMDA5MTQyODUzWjAvMS0wKwYDVQQDEyQwZDIw
+N2I2OC05YTIwLTRlZTgtOTJjYi1iZjk2OTk1ODFjZjgwggEiMA0GCSqGSIb3DQEB
+AQUAA4IBDwAwggEKAoIBAQDhD0BxlDzEOzcp45lPHL60lnM6k3puEGb2lKHL5/nR
+F94FCnZL0FH8EdxWzzAYgys+kUwSdo4QMuWuvjY2Km4Wob6k4uAeYEFTCfBdi4/z
+r4kpWzu8xLz+uZWimLQrjqVytNNK3DMv6ebWUJ/92VTDS4yzWk4YV0MVr2b2OgMK
+SgMvaFQ8L/CwyML72PBWIqU67+MMvvcTLxQdyEgQTTjP0bbiXMLDvfZDarLJojsW
+SNBz7AIkznhGkzIGGdhAa41PnPu9XaBFhaqx9Qe3+MG2/k1l/46eHtmxCqhOUde1
+i0vy6ZfgcGifua1tg1UBI/oT4S0dsq24dq7K1MYLyHTrAgMBAAGjIzAhMA4GA1Ud
+DwEB/wQEAwICBDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAa
+5YOlvob4wqps3HLaOIi7VzDihNHciP+BI0mzxHa7D3bGaecRPSeG3xEoD/Uxs9o4
+8cByPpYx1Wl8LLRx14wDcK0H+UPpo4gCI6h6q92cJj0YRjcSUUt8EIu3qnFMjtM+
+sl/uc21fGlq6cvMRZXqtVYENoSNTDHi5a5eEXRa2eZ8XntjvOanOhIKWmxvr8r4a
+Voz4WdnXx1C8/BzB62UBoMu4QqMGMLk5wXP0D6hECUuespMest+BeoJAVhTq7wZs
+rSP9q08n1stZFF4+bEBaxcqIqdhOLQdHcYELN+a5v0Mcwdsy7jJMagmNPfsKoOKC
+hLOsmNYKHdmWg37Jib5o
+-----END CERTIFICATE-----
\ No newline at end of file
diff --git a/remote/src/test/resources/ssl/exampleca-rotated-bundle.crt
b/remote/src/test/resources/ssl/exampleca-rotated-bundle.crt
new file mode 100644
index 0000000000..2f1521e9a5
--- /dev/null
+++ b/remote/src/test/resources/ssl/exampleca-rotated-bundle.crt
@@ -0,0 +1,28 @@
+-----BEGIN CERTIFICATE-----
+MIICNDCCAdigAwIBAgIEKYhR+DAMBggqhkjOPQQDAgUAMH4xCzAJBgNVBAYTAlVT
+MRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2NvMRgw
+FgYDVQQKEw9FeGFtcGxlIENvbXBhbnkxFDASBgNVBAsTC0V4YW1wbGUgT3JnMRIw
+EAYDVQQDEwlleGFtcGxlQ0EwHhcNMjAwNjAxMTU1MDEzWhcNNDcxMDE3MTU1MDEz
+WjB+MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMN
+U2FuIEZyYW5jaXNjbzEYMBYGA1UEChMPRXhhbXBsZSBDb21wYW55MRQwEgYDVQQL
+EwtFeGFtcGxlIE9yZzESMBAGA1UEAxMJZXhhbXBsZUNBMFkwEwYHKoZIzj0CAQYI
+KoZIzj0DAQcDQgAE9K81J2BKaFGTI/4qgUr31mr3Jpl6YK+7pCmy2LNiW0Jufova
+p41ERTrT9IynJF3NlJpzuKhmuA+80ZM5NSR4QqNCMEAwDwYDVR0TAQH/BAUwAwEB
+/zAOBgNVHQ8BAf8EBAMCAgQwHQYDVR0OBBYEFNkRJB4AQDAmCIvHYS0AKD4/AqUU
+MAwGCCqGSM49BAMCBQADSAAwRQIhAP2Uf206YbhJ2pb5MvehuLanJzFydOAq5uC4
+AXmspW+EAiAuPLFIAPyB+6gpr/mQ8V+yyh3aCJV3bvFq8aorrQyC3Q==
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIICNzCCAd2gAwIBAgIJAPWsa43goqe8MAoGCCqGSM49BAMCMH4xCzAJBgNVBAYT
+AlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2Nv
+MRgwFgYDVQQKEw9FeGFtcGxlIENvbXBhbnkxFDASBgNVBAsTC0V4YW1wbGUgT3Jn
+MRIwEAYDVQQDEwlleGFtcGxlQ0EwIBcNMjYwOTA2MTE1MDA1WhgPMjA1NDAxMjEx
+MTUwMDVaMH4xCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYD
+VQQHEw1TYW4gRnJhbmNpc2NvMRgwFgYDVQQKEw9FeGFtcGxlIENvbXBhbnkxFDAS
+BgNVBAsTC0V4YW1wbGUgT3JnMRIwEAYDVQQDEwlleGFtcGxlQ0EwWTATBgcqhkjO
+PQIBBggqhkjOPQMBBwNCAATRL1wmoaOO6rT3Zwi95iRIz2Sj/cirUhl6GxKXLq29
+qs9b8EQda9W0pf3SACXYpHD2fnZPNjfUDBK33LZxPsvUo0IwQDAdBgNVHQ4EFgQU
+QXT30Tb4ZL0FBYZdJiDLN3fbCY0wDgYDVR0PAQH/BAQDAgIEMA8GA1UdEwEB/wQF
+MAMBAf8wCgYIKoZIzj0EAwIDSAAwRQIgT2BIynxGBiWt4aMZgXsz+QzQrMWFmMOT
+vgAh2MCW08QCIQCwqOHh/ep8Ew++kRCxvXxsTWvIeQpA43XXjJTjx0UdsA==
+-----END CERTIFICATE-----
diff --git a/remote/src/test/resources/ssl/exampleca-rotated.crt
b/remote/src/test/resources/ssl/exampleca-rotated.crt
new file mode 100644
index 0000000000..a79bdeddde
--- /dev/null
+++ b/remote/src/test/resources/ssl/exampleca-rotated.crt
@@ -0,0 +1,14 @@
+-----BEGIN CERTIFICATE-----
+MIICNzCCAd2gAwIBAgIJAPWsa43goqe8MAoGCCqGSM49BAMCMH4xCzAJBgNVBAYT
+AlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2Nv
+MRgwFgYDVQQKEw9FeGFtcGxlIENvbXBhbnkxFDASBgNVBAsTC0V4YW1wbGUgT3Jn
+MRIwEAYDVQQDEwlleGFtcGxlQ0EwIBcNMjYwOTA2MTE1MDA1WhgPMjA1NDAxMjEx
+MTUwMDVaMH4xCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYD
+VQQHEw1TYW4gRnJhbmNpc2NvMRgwFgYDVQQKEw9FeGFtcGxlIENvbXBhbnkxFDAS
+BgNVBAsTC0V4YW1wbGUgT3JnMRIwEAYDVQQDEwlleGFtcGxlQ0EwWTATBgcqhkjO
+PQIBBggqhkjOPQMBBwNCAATRL1wmoaOO6rT3Zwi95iRIz2Sj/cirUhl6GxKXLq29
+qs9b8EQda9W0pf3SACXYpHD2fnZPNjfUDBK33LZxPsvUo0IwQDAdBgNVHQ4EFgQU
+QXT30Tb4ZL0FBYZdJiDLN3fbCY0wDgYDVR0PAQH/BAQDAgIEMA8GA1UdEwEB/wQF
+MAMBAf8wCgYIKoZIzj0EAwIDSAAwRQIgT2BIynxGBiWt4aMZgXsz+QzQrMWFmMOT
+vgAh2MCW08QCIQCwqOHh/ep8Ew++kRCxvXxsTWvIeQpA43XXjJTjx0UdsA==
+-----END CERTIFICATE-----
diff --git a/remote/src/test/resources/ssl/gen-ca-bundles.sh
b/remote/src/test/resources/ssl/gen-ca-bundles.sh
new file mode 100755
index 0000000000..71cde5e5a2
--- /dev/null
+++ b/remote/src/test/resources/ssl/gen-ca-bundles.sh
@@ -0,0 +1,42 @@
+#!/bin/bash
+
+export PW=`cat password`
+
+# A CA rotation that keeps the distinguished name produces a second CA
certificate sharing the
+# subject of ./genca.sh's exampleCA but holding a different key pair. Only the
original one
+# signed the certificates generated by ./gencerts.sh.
+keytool -genkeypair -v \
+ -alias exampleca-rotated \
+ -dname "CN=exampleCA, OU=Example Org, O=Example Company, L=San Francisco,
ST=California, C=US" \
+ -keystore exampleca-rotated.p12 \
+ -storetype PKCS12 \
+ -keypass:env PW \
+ -storepass:env PW \
+ -keyalg EC \
+ -keysize 256 \
+ -ext KeyUsage:critical="keyCertSign" \
+ -ext BasicConstraints:critical="ca:true" \
+ -validity 9999
+
+keytool -export -v \
+ -alias exampleca-rotated \
+ -file exampleca-rotated.crt \
+ -keypass:env PW \
+ -storepass:env PW \
+ -keystore exampleca-rotated.p12 \
+ -storetype PKCS12 \
+ -rfc
+
+# keytool writes CRLF on some platforms, the other certificates here use LF
+tr -d '\r' < exampleca-rotated.crt > exampleca-rotated.crt.tmp
+mv exampleca-rotated.crt.tmp exampleca-rotated.crt
+
+# the rotated CA never signs anything, so its private key is not kept around
+rm exampleca-rotated.p12
+
+# A `ca-cert-file` bundling two unrelated CA certificates.
+cat exampleca.crt pem/selfsigned-certificate.pem > exampleca-bundle.crt
+
+# A `ca-cert-file` bundling two CA certificates with the same subject, as seen
mid-rotation.
+# The one that signed the node certificates comes first, so picking the last
match is wrong.
+cat exampleca.crt exampleca-rotated.crt > exampleca-rotated-bundle.crt
diff --git a/remote/src/test/resources/ssl/gencerts.sh
b/remote/src/test/resources/ssl/gencerts.sh
index beee48132a..b8a3217bd5 100755
--- a/remote/src/test/resources/ssl/gencerts.sh
+++ b/remote/src/test/resources/ssl/gencerts.sh
@@ -25,3 +25,6 @@ createExampleRSAKeySet "rsa-client" "clientAuth"
"DNS:rsa-client.example.com,DNS
## a certificate valid for both server and client (peer-to-peer)
## with RSA keys
./gen-artery-nodes.example.com.sh
+
+## the multi-certificate `ca-cert-file` samples, built on top of the
certificates above
+./gen-ca-bundles.sh
diff --git
a/remote/src/test/scala/org/apache/pekko/remote/artery/tcp/ssl/PemManagersProviderSpec.scala
b/remote/src/test/scala/org/apache/pekko/remote/artery/tcp/ssl/PemManagersProviderSpec.scala
index f55a7b7cea..6247cf2da1 100644
---
a/remote/src/test/scala/org/apache/pekko/remote/artery/tcp/ssl/PemManagersProviderSpec.scala
+++
b/remote/src/test/scala/org/apache/pekko/remote/artery/tcp/ssl/PemManagersProviderSpec.scala
@@ -16,9 +16,14 @@ package org.apache.pekko.remote.artery.tcp.ssl
import java.security.PrivateKey
import java.security.cert.Certificate
import java.security.cert.X509Certificate
+import javax.net.ssl.X509KeyManager
+import javax.net.ssl.X509TrustManager
import scala.annotation.nowarn
+import org.apache.pekko
+import pekko.testkit.PekkoSpec.resourcePath
+
import org.scalatest.matchers.must.Matchers
import org.scalatest.wordspec.AnyWordSpec
@@ -35,26 +40,74 @@ class PemManagersProviderSpec extends AnyWordSpec with
Matchers {
// during the SSLHandshake.
withFiles("ssl/pem/pkcs1.pem", "ssl/pem/selfsigned-certificate.pem",
"ssl/pem/selfsigned-certificate.pem") {
(pk, cert, cacert) =>
- PemManagersProvider.buildKeyManagers(pk, cert, cacert).length must
be(1)
- PemManagersProvider.buildTrustManagers(cacert).length must be(1)
+ PemManagersProvider.buildKeyManagers(pk, cert, Seq(cacert)).length
must be(1)
+ PemManagersProvider.buildTrustManagers(Seq(cacert)).length must be(1)
cert.getSubjectDN.getName must
be("CN=0d207b68-9a20-4ee8-92cb-bf9699581cf8")
}
}
"load stores reading files setup in config (keytool samples)" in {
withFiles("ssl/node.example.com.pem", "ssl/node.example.com.crt",
"ssl/exampleca.crt") { (pk, cert, cacert) =>
- PemManagersProvider.buildKeyManagers(pk, cert, cacert).length must
be(1)
- PemManagersProvider.buildTrustManagers(cacert).length must be(1)
+ PemManagersProvider.buildKeyManagers(pk, cert, Seq(cacert)).length
must be(1)
+ PemManagersProvider.buildTrustManagers(Seq(cacert)).length must be(1)
cert.getSubjectDN.getName must be(
"CN=node.example.com, OU=Example Org, O=Example Company, L=San
Francisco, ST=California, C=US")
}
}
+ "trust every certificate in a CA file bundling more than one" in {
+ val cacerts =
PemManagersProvider.loadCertificates(resourcePath("ssl/exampleca-bundle.crt"))
+ cacerts.size must be(2)
+
+ val trustManagers = PemManagersProvider.buildTrustManagers(cacerts)
+ trustManagers.length must be(1)
+ val acceptedIssuers =
trustManagers.head.asInstanceOf[X509TrustManager].getAcceptedIssuers
+ acceptedIssuers.map(_.getSubjectX500Principal).toSet must be(
+
cacerts.map(_.asInstanceOf[X509Certificate].getSubjectX500Principal).toSet)
+ }
+
+ "only present the CA certificates that issued the node certificate" in {
+ val pk =
PemManagersProvider.loadPrivateKey(resourcePath("ssl/node.example.com.pem"))
+ val cert =
+
PemManagersProvider.loadCertificate(resourcePath("ssl/node.example.com.crt")).asInstanceOf[X509Certificate]
+ val cacerts =
PemManagersProvider.loadCertificates(resourcePath("ssl/exampleca-bundle.crt"))
+
+ val keyManager = PemManagersProvider.buildKeyManagers(pk, cert,
cacerts).head.asInstanceOf[X509KeyManager]
+ val aliases = Option(keyManager.getClientAliases(pk.getAlgorithm,
null)).getOrElse(Array.empty[String])
+ aliases must not be empty
+
+ // the unrelated, self-signed certificate in the bundle is trusted but
not part of the chain
+ val chain = keyManager.getCertificateChain(aliases.head)
+ chain.length must be(2)
+ chain(0) must be(cert)
+ chain(1).asInstanceOf[X509Certificate].getSubjectX500Principal must be(
+ cacerts.head.asInstanceOf[X509Certificate].getSubjectX500Principal)
+ }
+
+ "present the CA certificate that signed the node certificate, not merely
one with a matching subject" in {
+ val pk =
PemManagersProvider.loadPrivateKey(resourcePath("ssl/node.example.com.pem"))
+ val cert =
+
PemManagersProvider.loadCertificate(resourcePath("ssl/node.example.com.crt")).asInstanceOf[X509Certificate]
+ // a CA rotation that keeps the distinguished name: both CA certificates
share a subject,
+ // only the first one signed the node certificate
+ val cacerts =
PemManagersProvider.loadCertificates(resourcePath("ssl/exampleca-rotated-bundle.crt"))
+ cacerts.size must be(2)
+
cacerts.map(_.asInstanceOf[X509Certificate].getSubjectX500Principal).distinct.size
must be(1)
+
+ val keyManager = PemManagersProvider.buildKeyManagers(pk, cert,
cacerts).head.asInstanceOf[X509KeyManager]
+ val aliases = Option(keyManager.getClientAliases(pk.getAlgorithm,
null)).getOrElse(Array.empty[String])
+ aliases must not be empty
+
+ val chain = keyManager.getCertificateChain(aliases.head)
+ chain.length must be(2)
+ chain(0) must be(cert)
+ chain(1) must be(cacerts.head)
+ }
+
}
private def withFiles(keyFile: String, certFile: String, caCertFile: String)(
block: (PrivateKey, X509Certificate, Certificate) => Unit) = {
- import org.apache.pekko.testkit.PekkoSpec._
block(
PemManagersProvider.loadPrivateKey(resourcePath(keyFile)),
PemManagersProvider.loadCertificate(resourcePath(certFile)).asInstanceOf[X509Certificate],
diff --git
a/stream-tests/src/test/scala/org/apache/pekko/stream/io/TcpSpec.scala
b/stream-tests/src/test/scala/org/apache/pekko/stream/io/TcpSpec.scala
index 3d5fb1954b..2e44c88f5e 100644
--- a/stream-tests/src/test/scala/org/apache/pekko/stream/io/TcpSpec.scala
+++ b/stream-tests/src/test/scala/org/apache/pekko/stream/io/TcpSpec.scala
@@ -962,7 +962,10 @@ class TcpSpec extends StreamSpec("""
// trust store and keys in one keystore
val keyStore = KeyStore.getInstance("PKCS12")
- keyStore.load(getClass.getResourceAsStream("/tcp-spec-keystore.p12"),
password)
+ // KeyStore.load does not close the stream it is given
+ val keyStoreStream =
getClass.getResourceAsStream("/tcp-spec-keystore.p12")
+ try keyStore.load(keyStoreStream, password)
+ finally if (keyStoreStream ne null) keyStoreStream.close()
val trustManagerFactory = TrustManagerFactory.getInstance("SunX509")
trustManagerFactory.init(keyStore)
diff --git
a/stream-tests/src/test/scala/org/apache/pekko/stream/io/TlsSpec.scala
b/stream-tests/src/test/scala/org/apache/pekko/stream/io/TlsSpec.scala
index 5a7209e7c1..24e5a79af8 100644
--- a/stream-tests/src/test/scala/org/apache/pekko/stream/io/TlsSpec.scala
+++ b/stream-tests/src/test/scala/org/apache/pekko/stream/io/TlsSpec.scala
@@ -53,11 +53,16 @@ object TlsSpec {
def initWithTrust(trustPath: String, protocol: String): SSLContext = {
val password = "changeme"
+ // KeyStore.load does not close the stream it is given
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType)
- keyStore.load(getClass.getResourceAsStream("/keystore"),
password.toCharArray)
+ val keyStoreStream = getClass.getResourceAsStream("/keystore")
+ try keyStore.load(keyStoreStream, password.toCharArray)
+ finally if (keyStoreStream ne null) keyStoreStream.close()
val trustStore = KeyStore.getInstance(KeyStore.getDefaultType)
- trustStore.load(getClass.getResourceAsStream(trustPath),
password.toCharArray)
+ val trustStoreStream = getClass.getResourceAsStream(trustPath)
+ try trustStore.load(trustStoreStream, password.toCharArray)
+ finally if (trustStoreStream ne null) trustStoreStream.close()
val keyManagerFactory =
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm)
keyManagerFactory.init(keyStore, password.toCharArray)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]