Skip to content

Instantly share code, notes, and snippets.

@ShahOdin
Last active May 10, 2025 00:06
Show Gist options
  • Save ShahOdin/08e07981a9ad6761c7a0d5ebeef1a113 to your computer and use it in GitHub Desktop.
Save ShahOdin/08e07981a9ad6761c7a0d5ebeef1a113 to your computer and use it in GitHub Desktop.
mutual TLS in http4s
//> using scala "3.3.1"
//> using dep "org.typelevel::cats-core:2.13.0"
//> using dep "org.typelevel::cats-effect:3.6.1"
//> using dep "org.http4s::http4s-dsl:0.23.30"
//> using dep "org.http4s::http4s-ember-server:0.23.30"
//> using dep "org.http4s::http4s-ember-client:0.23.30"
//> using dep "org.http4s::http4s-blaze-client:0.23.17"
//> using dep "org.http4s::http4s-blaze-server:0.23.17"
import scala.sys.process.*
import java.io.File
import javax.net.ssl.{KeyManagerFactory, SSLContext, TrustManagerFactory}
import java.io.InputStream
import java.security.KeyStore
import scala.util.Try
import org.http4s.*
import org.http4s.blaze.client.BlazeClientBuilder
import org.http4s.blaze.server.BlazeServerBuilder
import org.http4s.dsl.impl./
import org.http4s.dsl.io.*
import org.http4s.ember.client.EmberClientBuilder
import org.http4s.ember.server.EmberServerBuilder
import org.http4s.HttpRoutes
import org.http4s.Method.GET
import cats.effect.{Async, IO}
import cats.syntax.all.*
import cats.{ApplicativeThrow, MonadThrow}
import fs2.io.net.tls.{TLSContext, TLSParameters}
import java.io.FileNotFoundException
import java.nio.file.{Files, Paths}
import java.io.InputStream
import scala.util.Try
trait TLSProvider[F[_]]:
def sslContext: F[SSLContext]
object TLSProvider:
extension [F[_]: Async](instance: TLSProvider[F])
def fs2TSLContext: F[TLSContext[F]] =
instance.sslContext.map(TLSContext.Builder.forAsync[F].fromSSLContext)
sealed trait ViaKeyStore[F[_]](keyStoreStream: InputStream, password: String)(using
F: MonadThrow[F],
keyStoreType: ViaKeyStore.WithType
) extends TLSProvider[F]:
override def sslContext: F[SSLContext] = F
.fromTry:
for
keyStore <- Try:
KeyStore.getInstance(keyStoreType.toString)
.flatTap: ks =>
Try(ks.load(keyStoreStream, password.toCharArray))
keyManagerFactory <- Try:
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm)
.flatTap(kmf => Try(kmf.init(keyStore, password.toCharArray)))
sslContext = SSLContext.getInstance("TLS")
_ <- Try:
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm)
.flatTap: tmf =>
Try:
tmf.init(keyStore)
*> (
Try(keyManagerFactory.getKeyManagers),
Try(tmf.getTrustManagers)
).flatMapN: (keyManagers, trustManagers) =>
Try:
sslContext.init(
keyManagers,
trustManagers,
new java.security.SecureRandom()
)
yield sslContext
object ViaKeyStore:
enum WithType:
case PKCS12
def viaPkcs12KeyStore[F[_]](keyStoreStream: InputStream, password: String)(using
F: MonadThrow[F]
): ViaKeyStore[F] =
given WithType = WithType.PKCS12
new ViaKeyStore[F](keyStoreStream, password) {}
def createKeys(): Unit =
val certDir = File("certs")
certDir.mkdirs()
def run(cmd: String): Unit =
val result = Process(cmd, certDir).!
if result != 0 then
throw RuntimeException(s"Command failed: $cmd")
// ▶ Generate a private key for the Certificate Authority (CA)
run("openssl genrsa -out ca.key 2048")
// ▶ Create a self-signed certificate for the CA
run("""openssl req -x509 -new -nodes -key ca.key -subj "/CN=TestCA" -days 365 -out ca.pem""")
// ▶ Generate server's private key and certificate signing request (CSR)
run("openssl genrsa -out server.key 2048")
run("""openssl req -new -key server.key -subj "/CN=localhost" -out server.csr""")
// ▶ Sign server CSR with the CA to issue server certificate
run("openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out server.crt -days 365")
// ▶ Create a PKCS#12 keystore for the server containing its key and certificate
run("""openssl pkcs12 -export -out server.p12 -inkey server.key -in server.crt -certfile ca.pem -password pass:password""")
// ▶ Generate client private key and CSR
run("openssl genrsa -out client.key 2048")
run("""openssl req -new -key client.key -subj "/CN=TestClient" -out client.csr""")
// ▶ Sign client CSR with the CA to issue client certificate
run("openssl x509 -req -in client.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out client.crt -days 365")
// ▶ Create a PKCS#12 keystore for the client containing its key and certificate
run("""openssl pkcs12 -export -out client.p12 -inkey client.key -in client.crt -certfile ca.pem -password pass:password""")
// ▶ Re-export the server PKCS#12 to ensure it includes the full certificate chain (defensive)
run("""openssl pkcs12 -export -out server.p12 -inkey server.key -in server.crt -certfile ca.pem -password pass:password""")
// ▶ Add the CA certificate as a trusted entry in the server keystore
run("keytool -importcert -alias TestCA -file ca.pem -keystore server.p12 -storetype PKCS12 -storepass password -noprompt")
// ▶ Add the CA certificate as a trusted entry in the client keystore
run("keytool -importcert -alias TestCA -file ca.pem -keystore client.p12 -storetype PKCS12 -storepass password -noprompt")
println("✅ Certificates successfully created in ./certs")
def clientKeyStoreStream[F[_]](using F: ApplicativeThrow[F]): F[InputStream] =
F.fromTry(Try(Files.newInputStream(Paths.get("certs/client.p12"))))
def serverKeyStoreStream[F[_]](using F: ApplicativeThrow[F]): F[InputStream] =
F.fromTry(Try(Files.newInputStream(Paths.get("certs/server.p12"))))
def checkCertificates: IO[Unit] =
List(
clientKeyStoreStream[IO],
serverKeyStoreStream[IO]
).traverse_ :
_.flatTap: s =>
TLSProvider.ViaKeyStore
.viaPkcs12KeyStore[IO](
keyStoreStream = s,
password = "password"
)
.sslContext
.flatTap: c =>
assert(c.getProtocol === "TLS").pure
.flatTap: c =>
assert(c.getSupportedSSLParameters.getProtocols.nonEmpty).pure
.flatTap: c =>
assert(Option(c.getSocketFactory).isDefined).pure
.flatTap: c =>
assert(c.getSupportedSSLParameters.getProtocols.nonEmpty).pure
def blazeHandshake: IO[Unit] =
List(
clientKeyStoreStream[IO],
serverKeyStoreStream[IO]
).traverse:
_.flatMap: s =>
TLSProvider.ViaKeyStore
.viaPkcs12KeyStore[IO](
keyStoreStream = s,
password = "password"
)
.sslContext
.flatMap: ssls =>
val List(clientSSL, serverSSL) = ssls
BlazeServerBuilder[IO]
.withSslContext(serverSSL)
.withHttpApp:
HttpRoutes
.of[IO]:
case GET -> Root / "test" => Ok("Hello!")
.orNotFound
.resource
.use: _ =>
IO.fromEither:
Uri.fromString:
s"https://localhost:8080/test"
.>>=(uri =>
BlazeClientBuilder[IO]
.withSslContext(clientSSL)
.resource
.use:
_.status(Request().withUri(uri))
)
.>>=(s => assert(s.code === 200).pure)
def emberHandShake: IO[Unit] =
List(
clientKeyStoreStream[IO],
serverKeyStoreStream[IO]
).traverse:
_.flatMap: s =>
TLSProvider.ViaKeyStore
.viaPkcs12KeyStore[IO](
keyStoreStream = s,
password = "password"
)
.fs2TSLContext
.flatMap: ssls =>
val List(clientSSL, serverSSL) = ssls
EmberServerBuilder
.default[IO]
.withTLS(
tlsContext = serverSSL,
tlsParameters = TLSParameters(needClientAuth = false, wantClientAuth = false)
)
.withHttpApp:
HttpRoutes
.of[IO]:
case GET -> Root / "test" => Ok("Hello!")
.orNotFound
.build
.use: _ =>
IO.fromEither:
Uri.fromString:
s"https://localhost:8080/test"
.>>=(uri =>
EmberClientBuilder
.default[IO]
.withTLSContext(clientSSL)
.build
.use:
_.status(Request().withUri(uri))
)
.>>=(s => assert(s.code === 200).pure)
@main def run(): Unit =
createKeys()
import cats.effect.unsafe.implicits.global
checkCertificates.unsafeRunSync()
blazeHandshake.unsafeRunSync()
emberHandShake.unsafeRunSync()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment