Skip to content

Commit

Permalink
feat: Cert loading and rotation utilities and docs (#4359)
Browse files Browse the repository at this point in the history
  • Loading branch information
johanandren authored Mar 28, 2024
1 parent 10d7599 commit 3f3417f
Show file tree
Hide file tree
Showing 10 changed files with 717 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ object ConnectionContext {
@ApiMayChange
def httpsServer(createSSLEngine: () => SSLEngine): HttpsConnectionContext =
new HttpsConnectionContext({
case None => createSSLEngine()
case None =>
val engine = createSSLEngine()
engine.setUseClientMode(false)
engine
case Some(_) => throw new IllegalArgumentException("host and port supplied for connection based on server connection context")
}: Option[(String, Int)] => SSLEngine)

Expand Down Expand Up @@ -72,8 +75,11 @@ object ConnectionContext {
@ApiMayChange
def httpsClient(createSSLEngine: (String, Int) => SSLEngine): HttpsConnectionContext = // ...
new HttpsConnectionContext({
case None => throw new IllegalArgumentException("host and port missing for connection based on client connection context")
case Some((host, port)) => createSSLEngine(host, port)
case None => throw new IllegalArgumentException("host and port missing for connection based on client connection context")
case Some((host, port)) =>
val engine = createSSLEngine(host, port)
engine.setUseClientMode(true)
engine
}: Option[(String, Int)] => SSLEngine)

def noEncryption() = HttpConnectionContext
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* Copyright (C) 2009-2023 Lightbend Inc. <https://www.lightbend.com>
*/

package akka.http.scaladsl.common

import akka.http.scaladsl.ClientTransport
import akka.http.scaladsl.ConnectionContext
import akka.http.scaladsl.Http
import akka.http.scaladsl.HttpsConnectionContext
import akka.http.scaladsl.model.HttpRequest
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.settings.ConnectionPoolSettings
import akka.testkit.AkkaSpec
import akka.testkit.TestProbe
import com.typesafe.config.ConfigFactory
import org.scalatest.matchers.should.Matchers

import java.io.InputStream
import java.net.InetSocketAddress
import java.nio.file.Path
import java.security.KeyStore
import java.security.SecureRandom
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import scala.concurrent.Future
import scala.concurrent.duration.DurationInt

class SSLContextFactorySpec extends AkkaSpec with Matchers {
"The SSLContextUtils" should {
"conveniently load pem files but use the system trust store" in {
SSLContextFactory.createSSLContextFromPem(ConfigFactory.parseString(
"""
my-server {
certificate = "akka-http-tests/src/test/resources/certs/example.com.crt"
private-key = "akka-http-tests/src/test/resources/certs/example.com.key"
trusted-ca-certificates = "system"
}
""").getConfig("my-server"))

SSLContextFactory.createSSLContextFromPem(
Path.of("akka-http-tests/src/test/resources/certs/example.com.crt"),
Path.of("akka-http-tests/src/test/resources/certs/example.com.key"),
Seq(Path.of("akka-http-tests/src/test/resources/certs/exampleca.crt"))
)
}

"conveniently load pem files" in {
SSLContextFactory.createSSLContextFromPem(ConfigFactory.parseString(
"""
my-server {
certificate = "akka-http-tests/src/test/resources/certs/example.com.crt"
private-key = "akka-http-tests/src/test/resources/certs/example.com.key"
trusted-ca-certificates = ["akka-http-tests/src/test/resources/certs/exampleca.crt"]
}
""").getConfig("my-server"))

SSLContextFactory.createSSLContextFromPem(
Path.of("akka-http-tests/src/test/resources/certs/example.com.crt"),
Path.of("akka-http-tests/src/test/resources/certs/example.com.key"),
Seq(Path.of("akka-http-tests/src/test/resources/certs/exampleca.crt"))
)
}

"create a refreshing context provider" in {
val probe = TestProbe()
val provider = SSLContextFactory.refreshingSSLEngineProvider(10.millis) { () =>
probe.ref ! "Constructed"
SSLContextFactory.createSSLContextFromPem(
Path.of("akka-http-tests/src/test/resources/certs/example.com.crt"),
Path.of("akka-http-tests/src/test/resources/certs/example.com.key"),
Seq(Path.of("akka-http-tests/src/test/resources/certs/exampleca.crt"))
)
}
val context1 = provider()
probe.expectMsg("Constructed")
Thread.sleep(20)
val context2 = provider()
probe.expectMsg("Constructed")
context1 shouldNot be theSameInstanceAs (context2)
}

"Work for usage when running HTTPS server" in {
val https = ConnectionContext.httpsServer(SSLContextFactory.refreshingSSLEngineProvider(50.millis) { () =>
SSLContextFactory.createSSLContextFromPem(
Path.of("akka-http-tests/src/test/resources/certs/example.com.crt"),
Path.of("akka-http-tests/src/test/resources/certs/example.com.key"),
Seq(Path.of("akka-http-tests/src/test/resources/certs/exampleca.crt"))
)
})
import akka.http.scaladsl.server.Directives._
val binding = Http(system).newServerAt("127.0.0.1", 8243).enableHttps(https).bind(get {
complete("OK")
}).futureValue

try {
def requestAndCheckResponse(): Unit = {
val response = Http(system).singleRequest(
HttpRequest(uri = "https://example.com:8243/"),
clientHttpsConnectionContext(),
// fake what server we are talking to for the cert validation
ConnectionPoolSettings(system).withUpdatedConnectionSettings(_.withTransport(ClientTransport.withCustomResolver {
case ("example.com", port) => Future.successful(new InetSocketAddress("127.0.0.1", port))
}))
).futureValue

response.status should ===(StatusCodes.OK)
response.entity.discardBytes()
}

requestAndCheckResponse()
Thread.sleep(100)
requestAndCheckResponse()
} finally {
binding.unbind().futureValue
}
}
}

private def clientHttpsConnectionContext(): HttpsConnectionContext = {

val ks: KeyStore = KeyStore.getInstance("JKS")
val keystoreStream: InputStream = getClass.getClassLoader.getResourceAsStream("certs/exampletrust.jks")
if (keystoreStream == null) fail("exampletrust.jkd could not be found")
ks.load(keystoreStream, "changeit".toCharArray)

val tmf: TrustManagerFactory = TrustManagerFactory.getInstance("SunX509")
tmf.init(ks)

val sslContext: SSLContext = SSLContext.getInstance("TLS")
sslContext.init(null, tmf.getTrustManagers, new SecureRandom)
ConnectionContext.httpsClient(sslContext)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* Copyright (C) 2009-2023 Lightbend Inc. <https://www.lightbend.com>
*/

package akka.http.javadsl.common

import akka.annotation.ApiMayChange
import akka.http.scaladsl.common.SSLContextFactory.createSSLContextFromPem

import java.nio.file.Path
import javax.net.ssl.SSLContext
import java.util.{ List => JList }
import akka.http.scaladsl.common.{ SSLContextFactory => ScalaSSLContextFactory }
import akka.util.JavaDurationConverters.JavaDurationOps
import com.typesafe.config.Config

import java.security.SecureRandom
import java.time.Duration
import java.util.Optional
import javax.net.ssl.SSLEngine
import scala.jdk.CollectionConverters._
import scala.jdk.OptionConverters.RichOptional

object SSLContextFactory {

/**
* Convenience factory for constructing an SSLContext out of a certificate file, a private key file and zero or more
* CA-certificate files defined in config.
*
* The provided `Config` is required to have the field `certificate` containing
* a path to a certificate file, `private-key` containing the path to a private key, and the key `trusted-ca-certificates`
* either with the value "system" to use the default JDK truststore or containing a list of zero to many paths to CA certificate files
* to explicitly list what CA certs to trust. All files must contain PEM encoded certificates or keys.
*
* Note that the paths are filesystem paths, not class path,
* certificate files packaged in the JAR cannot be loaded using this method.
*
* Example usage: `createSSLContextFromPem(system.settings.config.getConfig("my-server"))`
*
* API May Change
*/
@ApiMayChange
def createSSLContextFromPem(config: Config): SSLContext = ScalaSSLContextFactory.createSSLContextFromPem(config)

/**
* Convenience factory for constructing an SSLContext out of a certificate file, a private key file but use the
* default JDK trust store. All files must contain PEM encoded certificates or keys.
*
* Note that the paths are filesystem paths, not class path,
* certificate files packaged in the JAR cannot be loaded using this method.
*
* API May Change
*/
@ApiMayChange
def createSSLContextFromPem(
certificatePath: Path,
privateKeyPath: Path): SSLContext = ScalaSSLContextFactory.createSSLContextFromPem(certificatePath, privateKeyPath)

/**
* Convenience factory for constructing an SSLContext out of a certificate file, a private key file and zero or more
* CA-certificate files. All files must contain PEM encoded certificates or keys.
*
* Note that the paths are filesystem paths, not class path,
* certificate files packaged in the JAR cannot be loaded using this method.
*
* API May Change
*/
@ApiMayChange
def createSSLContextFromPem(
certificatePath: Path,
privateKeyPath: Path,
trustedCaCertificatePaths: JList[Path]): SSLContext =
ScalaSSLContextFactory.createSSLContextFromPem(certificatePath, privateKeyPath, trustedCaCertificatePaths.asScala.toVector)

/**
* Convenience factory for constructing an SSLContext out of a certificate file, a private key file and possibly zero or more
* CA-certificate files to trust. All files must contain PEM encoded certificates or keys.
*
* Note that the paths are filesystem paths, not class path,
* certificate files packaged in the JAR cannot be loaded using this method.
*
* @param certificatePath Path to a PEM encoded certificate file
* @param privateKeyPath Path to a PEM encoded key file
* @param trustedCaCertificatePaths empty `Optional` to use the default system trust store, or `Optional` with containing a list of
* one or more CA certificate paths to explicitly control exactly what CAs are trusted
* @param secureRandom a secure random to use for the SSL context or none to use a default instance
*
* API May Change
*/
@ApiMayChange
def createSSLContextFromPem(
certificatePath: Path,
privateKeyPath: Path,
trustedCaCertificatePaths: Optional[Seq[Path]],
secureRandom: Optional[SecureRandom]): SSLContext =
ScalaSSLContextFactory.createSSLContextFromPem(certificatePath, privateKeyPath, trustedCaCertificatePaths.toScala.map(_.toVector), secureRandom.toScala)

/**
* Keeps a created SSLContext around for a `refreshAfter` period, sharing it among connections, then creates a new
* context. Useful for rotating certificates.
*
* @param refreshAfter Keep a created context around this long, then recreate it
* @param construct A factory method to create the context when recreating is needed
* @return An SSLEngine provider function to use with Akka HTTP `ConnectionContext.httpsServer()` and `ConnectionContext.httpsClient`.
*
* API May Change
*/
@ApiMayChange
def refreshingSSLEngineProvider(refreshAfter: Duration)(construct: akka.japi.function.Creator[SSLContext]): akka.japi.function.Creator[SSLEngine] =
() => ScalaSSLContextFactory.refreshingSSLEngineProvider(refreshAfter.asScala)(construct.create _)()

/**
* Keeps a created SSLContext around for a `refreshAfter` period, sharing it among connections, then creates a new
* context. Actually constructing the `SSLEngine` is left to caller, to allow additional customization of the `SSLEngine`,
* for example to require client certificates in a server application.
*
* @param refreshAfter Keep a created context around this long, then recreate it
* @param construct A factory method to create the context when recreating is needed
* @return An SSLEngine provider function to use with Akka HTTP `ConnectionContext.httpsServer()` and `ConnectionContext.httpsClient`.
*
* API May Change
*/
@ApiMayChange
def refreshingSSLContextProvider(refreshAfter: Duration)(construct: akka.japi.function.Creator[SSLContext]): akka.japi.function.Creator[SSLContext] =
() => ScalaSSLContextFactory.refreshingSSLContextProvider(refreshAfter.asScala)(construct.create _)()
}
Loading

0 comments on commit 3f3417f

Please sign in to comment.