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]

Reply via email to