Last active
May 10, 2025 00:06
-
-
Save ShahOdin/08e07981a9ad6761c7a0d5ebeef1a113 to your computer and use it in GitHub Desktop.
mutual TLS in http4s
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//> 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