From 0bd460c9908f66ef4d82e8cd2c75d96b6681f092 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Wed, 15 Jul 2026 15:20:03 +0200 Subject: [PATCH] feature/Add dev-only in-process mTLS termination to the http4s server Successor of the RunMTLSWebApp.scala launcher removed with the Jetty teardown, as a props toggle on the normal server instead of a separate launcher: - Http4sMtls.scala: mtls.* props (honoured only when run.mode=development), SSLContext from JKS keystore/truststore, fs2 TLSContext + TLSParameters (client_auth need/want), and an HttpApp middleware that strips any client-supplied PSD2-CERT header and injects the handshake-verified client certificate as canonical PEM. - Http4sServer.scala: mtls.enabled branch on the Ember builder (withTLS + middleware); plain-HTTP path is unchanged when the prop is off. Warns when hostname is still http://. - sample.props.template: commented mtls.* block next to the other keystore props. - Http4sMtlsTest: unit tests for PEM encoding, header injection/stripping, SSLContext construction from the checked-in dev keystores. - Http4sMtlsHandshakeTest: end-to-end proof over a real TLS handshake that Ember populates ServerRequestKeys.SecureSession, the verified client cert surfaces as PSD2-CERT (spoofed header replaced), and certless handshakes are rejected under client_auth=need. - docs/MTLS_DEV_MODE.md: how-to (cert generation, props, smoke tests, consumer pinning, troubleshooting, production proxy guidance). Verified manually end-to-end: HTTPS on dev.port, certless handshake rejected, DirectLogin over mTLS, and /my/mtls/certificate/current returning the handshake certificate while ignoring a spoofed header. --- docs/MTLS_DEV_MODE.md | 220 ++++++++++++++++++ .../resources/props/sample.props.template | 15 ++ .../scala/bootstrap/http4s/Http4sMtls.scala | 122 ++++++++++ .../scala/bootstrap/http4s/Http4sServer.scala | 34 ++- .../http4s/Http4sMtlsHandshakeTest.scala | 116 +++++++++ .../bootstrap/http4s/Http4sMtlsTest.scala | 99 ++++++++ 6 files changed, 596 insertions(+), 10 deletions(-) create mode 100644 docs/MTLS_DEV_MODE.md create mode 100644 obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala create mode 100644 obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsHandshakeTest.scala create mode 100644 obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsTest.scala diff --git a/docs/MTLS_DEV_MODE.md b/docs/MTLS_DEV_MODE.md new file mode 100644 index 0000000000..8febd8147a --- /dev/null +++ b/docs/MTLS_DEV_MODE.md @@ -0,0 +1,220 @@ +# Running OBP-API in mTLS Mode (Dev Feature) + +OBP-API can terminate **mutual TLS (mTLS) in-process** for local development: the http4s (Ember) +server itself does the TLS handshake, requires a client certificate, and hands the verified +certificate to the application as the `PSD2-CERT` request header. No reverse proxy needed. + +> **Dev only.** The feature is honoured **only when `run.mode=development`**. In any other run +> mode `mtls.enabled=true` is ignored with a boot warning. In production, terminate mTLS at a +> reverse proxy (nginx/HAProxy/Apache) that forwards the verified client certificate as the +> `PSD2-CERT` header — see [Production deployments](#production-deployments) below. + +This is the http4s successor of the old `RunMTLSWebApp.scala` launcher, which was removed with +the Lift/Jetty teardown. Instead of a separate launcher, it is a props toggle on the normal +server (`bootstrap.http4s.Http4sServer`). + +## How it works + +``` +curl --cert client.crt ── TLS handshake ──► Ember server (mtls.enabled=true) + │ verifies client cert against mtls.truststore + │ exposes it via ServerRequestKeys.SecureSession + ▼ + Http4sMtls.injectClientCertificate middleware + │ strips any client-supplied PSD2-CERT header (anti-spoofing) + │ injects the verified cert as PSD2-CERT (PEM) + ▼ + OBP application layer (unchanged) + • consumer lookup by certificate + • consent pinning (consumer_validation_method_for_consent) + • GET /my/mtls/certificate/current +``` + +Everything downstream of the header is the pre-existing OBP machinery — the same code path a +production reverse proxy feeds. Implementation: `bootstrap/http4s/Http4sMtls.scala`, wired in +`bootstrap/http4s/Http4sServer.scala`. + +## Quick start + +### 1. Generate certificates + +You need: a server keypair (JKS), a truststore containing the client certificate (JKS), and a +client certificate + private key in PEM form for curl. + +```sh +mkdir -p ~/obp-mtls && cd ~/obp-mtls + +# Server keypair (CN + SAN localhost so curl can verify it without -k) +keytool -genkeypair -alias server -keyalg RSA -keysize 2048 -validity 365 \ + -dname "CN=localhost" -ext "SAN=DNS:localhost,IP:127.0.0.1" \ + -keystore server.jks -storepass 123456 -keypass 123456 + +# Export the server certificate — curl's --cacert +keytool -exportcert -rfc -alias server -keystore server.jks -storepass 123456 -file server.crt + +# Client ("TPP") keypair + self-signed certificate in PEM +openssl req -newkey rsa:2048 -nodes -keyout client.key -out client.csr -subj "/CN=test-tpp" +openssl x509 -req -in client.csr -signkey client.key -days 365 -out client.crt + +# Truststore: the client certificates (or CAs) the server accepts +keytool -importcert -noprompt -alias test-tpp -file client.crt \ + -keystore server.trust.jks -storepass 123456 +``` + +To accept a whole CA instead of individual client certs, import the CA certificate into +`server.trust.jks` and sign client certs with that CA. + +> There is also a keystore pair checked into the repo +> (`obp-api/src/test/resources/cert/server.jks` + `server.trust.jks`, password `123456`), +> but its truststore contains no client certificate you own a private key for, and its server +> certificate has no `localhost` SAN — generating fresh certificates as above is the smoother +> path. + +### 2. Configure props + +In your props file (e.g. `obp-api/src/main/resources/props/default.props`): + +```properties +mtls.enabled=true +mtls.keystore.path=/home/YOU/obp-mtls/server.jks +mtls.keystore.password=123456 +mtls.truststore.path=/home/YOU/obp-mtls/server.trust.jks +mtls.truststore.password=123456 +# need = reject handshakes without a client certificate (default) +# want = client certificate optional (mixed mode; requests without one simply carry no PSD2-CERT header) +mtls.client_auth=need + +# so generated links match the TLS listener +hostname=https://localhost:8080 + +# pin consents to the consumer's certificate (this is the default) +consumer_validation_method_for_consent=CONSUMER_CERTIFICATE +``` + +`run.mode` must be `development` (it is with the standard local run scripts). + +### 3. Run + +```sh +./flushall_build_and_run.sh +``` + +Boot log confirms the mode: + +``` +mTLS termination is ENABLED (dev-only): serving HTTPS on port 8080, client_auth=need, keystore=..., truststore=... +``` + +`dev.port` (default 8080) now speaks **HTTPS only** — plain `http://localhost:8080` requests +will fail. + +### 4. Smoke test + +```sh +curl --cacert server.crt --cert client.crt --key client.key \ + https://localhost:8080/obp/v5.1.0/root +``` + +| Flag | Meaning | +|---|---| +| `--cacert server.crt` | trust the dev server certificate (instead of the system CA bundle) | +| `--cert client.crt` | the client certificate presented in the handshake | +| `--key client.key` | proves ownership of `client.crt` | + +A handshake **without** a client certificate must fail in `need` mode: + +```sh +curl --cacert server.crt https://localhost:8080/obp/v5.1.0/root +# curl: (56) ... alert certificate required (or similar handshake error) +``` + +To see exactly what certificate OBP received, use the diagnostic endpoint (requires a logged-in +user, see next step): + +```sh +curl --cacert server.crt --cert client.crt --key client.key \ + https://localhost:8080/obp/v5.1.0/my/mtls/certificate/current \ + -H "Authorization: DirectLogin token=$TOKEN" +# → subject CN=test-tpp, issuer, validity dates... +``` + +Note that any `PSD2-CERT` header you send yourself is discarded — the middleware always replaces +it with the certificate from the TLS handshake. + +### 5. Pin a Consumer to the certificate + +mTLS identifies the **application** (Consumer); users still authenticate normally (DirectLogin, +OAuth, ...). To make consumer-by-certificate lookup and consent pinning work, the Consumer +record's *Client Certificate* field must contain the PEM of `client.crt`: + +* register via API Explorer's consumer registration page (`/consumers/register`) + and paste the contents of `client.crt` into the **Client Certificate** field, or +* update an existing consumer via the API (`PUT .../management/consumers/CONSUMER_ID` + consumer-certificate endpoints), or +* for full PSD2-style onboarding, `POST /obp/v5.1.0/dynamic-registration/consumers`. + +Then get a user token over the same mTLS connection: + +```sh +TOKEN=$(curl -s --cacert server.crt --cert client.crt --key client.key \ + -X POST https://localhost:8080/my/logins/direct \ + -H "Authorization: DirectLogin username=\"YOUR_USER\", password=\"YOUR_PASSWORD\", consumer_key=\"YOUR_CONSUMER_KEY\"" \ + | jq -r .token) +``` + +With `consumer_validation_method_for_consent=CONSUMER_CERTIFICATE`, every consent-authenticated +call now requires the handshake certificate to match the certificate stored on the consent's +Consumer — presenting a different client certificate is rejected. + +## Props reference + +| Prop | Default | Meaning | +|---|---|---| +| `mtls.enabled` | `false` | Master switch. Only honoured when `run.mode=development`. | +| `mtls.keystore.path` | — (required) | JKS with the server's private key + certificate. | +| `mtls.keystore.password` | — (required) | Password for keystore and key. | +| `mtls.truststore.path` | — (required) | JKS with client certificates / CAs the server accepts. | +| `mtls.truststore.password` | — (required) | Truststore password. | +| `mtls.client_auth` | `need` | `need` rejects certless handshakes; `want` makes the client certificate optional. | + +## Troubleshooting + +| Symptom | Cause | +|---|---| +| Boot warning `mtls.enabled=true is ignored` | `run.mode` is not `development`. | +| Boot fails with `requires the props value 'mtls.…'` | One of the four keystore/truststore props is missing. | +| `curl: (35)` / `alert certificate required` | No (or untrusted) client certificate in `need` mode — check the cert is in `server.trust.jks`. | +| `curl: (60) SSL certificate problem` | curl doesn't trust the server cert — pass `--cacert server.crt` (or `-k` for a quick look). | +| Plain `http://` requests hang or error | The port serves HTTPS when mTLS is enabled — use `https://`. | +| Cert reaches OBP but consumer lookup fails | The Consumer's Client Certificate field doesn't contain the client cert's PEM (lookup is by PEM match, with a whitespace-normalized fallback). | +| DirectLogin returns `OBP-20073: The user email has not been validated` | A freshly created user must validate their email first. In local dev, either click the link from the validation email (see the `mail.*` props) or set the flag directly in the DB: `update authuser set validated=true where username='YOUR_USER';` | + +## Production deployments + +Do **not** use this feature in production (it is disabled outside development mode by design). +Terminate mTLS at a reverse proxy and forward the verified certificate as the `PSD2-CERT` +header. Two important rules for any proxy config: + +1. The header value must be **plain PEM** — OBP does not URL-decode. nginx's + `$ssl_client_escaped_cert` is URL-encoded and needs decoding (e.g. via njs) before + forwarding; HAProxy can rebuild a single-line PEM directly: + `http-request set-header PSD2-CERT "-----BEGIN CERTIFICATE-----%[ssl_c_der,base64]-----END CERTIFICATE-----"`. +2. The proxy must **overwrite** any client-supplied `PSD2-CERT` header, and the OBP port must + not be reachable except through the proxy — otherwise the header can be spoofed. + +## Implementation & tests + +| File | Role | +|---|---| +| `obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala` | Props, SSLContext/TLSContext construction, `PSD2-CERT` injection middleware. | +| `obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala` | `mtls.enabled` branch on the Ember builder. | +| `obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsTest.scala` | Unit tests: PEM encoding, header injection/stripping, SSLContext from the checked-in keystores. | +| `obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsHandshakeTest.scala` | End-to-end: real Ember server + real mTLS handshake; proves the verified client cert surfaces as `PSD2-CERT` and certless handshakes are rejected. | + +Run the tests: + +```sh +JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 mvn -pl obp-api -am test \ + -DwildcardSuites=bootstrap.http4s.Http4sMtlsTest,bootstrap.http4s.Http4sMtlsHandshakeTest \ + -DfailIfNoTests=false +``` diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index cc8df3854a..12356d2cb6 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -161,6 +161,21 @@ jwt.use.ssl=false #truststore.path=/path/to/api.truststore.jks +## In-process mTLS termination for the http4s server (DEV ONLY). +## Honoured only when run.mode=development — in production terminate mTLS at a reverse proxy +## that forwards the verified client certificate as the PSD2-CERT request header. +## When enabled, dev.port serves HTTPS with mutual TLS and the verified client certificate is +## injected as the PSD2-CERT header (any client-supplied PSD2-CERT header is stripped). +## The paths below point at the JKS pair checked into the repo for local development. +# mtls.enabled=true +# mtls.keystore.path=obp-api/src/test/resources/cert/server.jks +# mtls.keystore.password=123456 +# mtls.truststore.path=obp-api/src/test/resources/cert/server.trust.jks +# mtls.truststore.password=123456 +## need = reject handshakes without a client certificate; want = client certificate optional +# mtls.client_auth=need + + ## Enable mTLS for Redis, if set to true must set paths for the keystore and truststore locations # redis.use.ssl=false ## Client diff --git a/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala b/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala new file mode 100644 index 0000000000..6060652d63 --- /dev/null +++ b/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala @@ -0,0 +1,122 @@ +package bootstrap.http4s + +import java.io.FileInputStream +import java.nio.charset.StandardCharsets +import java.security.KeyStore +import java.security.cert.X509Certificate +import javax.net.ssl.{KeyManagerFactory, SSLContext, TrustManagerFactory} + +import cats.data.Kleisli +import cats.effect.IO +import code.api.{CertificateConstants, RequestHeader} +import code.api.util.APIUtil +import code.util.Helper.MdcLoggable +import fs2.io.net.tls.{TLSContext, TLSParameters} +import net.liftweb.util.Props +import org.http4s.{Header, HttpApp} +import org.http4s.server.ServerRequestKeys +import org.typelevel.ci.CIString + +/** + * Dev-only in-process mTLS termination for the http4s server. + * + * The old Lift/Jetty stack had RunMTLSWebApp.scala (removed with the Jetty teardown): an embedded + * server that terminated mutual TLS itself and injected the verified client certificate into the + * request as the PSD2-CERT header. This object is its http4s equivalent, toggled by props instead + * of a separate launcher: + * + * mtls.enabled=true + * mtls.keystore.path=/path/to/server.jks (server keypair) + * mtls.keystore.password=... + * mtls.truststore.path=/path/to/server.trust.jks (CAs allowed to sign client certificates) + * mtls.truststore.password=... + * mtls.client_auth=need (need = reject certless handshakes; want = optional) + * + * The prop is honoured ONLY in Development run mode. In production OBP must sit behind a reverse + * proxy that terminates mTLS and forwards the client certificate as the PSD2-CERT header — this + * feature exists so local development does not need that proxy. + */ +object Http4sMtls extends MdcLoggable { + + case class MtlsConfig( + keystorePath: String, + keystorePassword: String, + truststorePath: String, + truststorePassword: String, + needClientAuth: Boolean + ) + + /** True only when mtls.enabled=true AND run.mode is development. */ + lazy val enabled: Boolean = { + val propEnabled = APIUtil.getPropsAsBoolValue("mtls.enabled", false) + if (propEnabled && Props.mode != Props.RunModes.Development) { + logger.warn("mtls.enabled=true is ignored: in-process mTLS termination is a dev-only feature " + + s"and run.mode is ${Props.mode}. In production terminate mTLS at a reverse proxy that sets the " + + s"${RequestHeader.`PSD2-CERT`} header.") + false + } else propEnabled + } + + lazy val config: MtlsConfig = { + def required(name: String): String = APIUtil.getPropsValue(name) + .openOrThrowException(s"mtls.enabled=true requires the props value '$name' to be set") + MtlsConfig( + keystorePath = required("mtls.keystore.path"), + keystorePassword = required("mtls.keystore.password"), + truststorePath = required("mtls.truststore.path"), + truststorePassword = required("mtls.truststore.password"), + needClientAuth = APIUtil.getPropsValue("mtls.client_auth", "need").toLowerCase != "want" + ) + } + + def buildSslContext(config: MtlsConfig): SSLContext = { + def loadJks(path: String, password: String): KeyStore = { + val keyStore = KeyStore.getInstance("JKS") + val in = new FileInputStream(path) + try keyStore.load(in, password.toCharArray) finally in.close() + keyStore + } + val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm) + keyManagerFactory.init(loadJks(config.keystorePath, config.keystorePassword), config.keystorePassword.toCharArray) + val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm) + trustManagerFactory.init(loadJks(config.truststorePath, config.truststorePassword)) + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(keyManagerFactory.getKeyManagers, trustManagerFactory.getTrustManagers, null) + sslContext + } + + def tlsContext: TLSContext[IO] = TLSContext.Builder.forAsync[IO].fromSSLContext(buildSslContext(config)) + + def tlsParameters: TLSParameters = + if (config.needClientAuth) TLSParameters(needClientAuth = true) + else TLSParameters(wantClientAuth = true) + + private val psd2CertHeaderName = CIString(RequestHeader.`PSD2-CERT`) + + // Canonical PEM: 64-column base64 with \n separators, the format developers paste when + // registering a Consumer, so the verbatim clientCertificate DB lookup can match. The + // normalizePemX509Certificate fallback and the consent removeBreakLines compare cover the rest. + private val pemEncoder = java.util.Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.US_ASCII)) + + def toPem(certificate: X509Certificate): String = + s"${CertificateConstants.BEGIN_CERT}\n${pemEncoder.encodeToString(certificate.getEncoded)}\n${CertificateConstants.END_CERT}" + + /** + * Replaces the Jetty customizer of the old RunMTLSWebApp: takes the client certificate that + * Ember verified during the TLS handshake (exposed via ServerRequestKeys.SecureSession) and + * injects it as the PSD2-CERT header. Any client-supplied PSD2-CERT header is always removed + * first — when OBP terminates TLS itself, that header can only be a spoofing attempt. + */ + def injectClientCertificate(httpApp: HttpApp[IO]): HttpApp[IO] = Kleisli { req => + val stripped = req.removeHeader(psd2CertHeaderName) + val leafCertificate: Option[X509Certificate] = req.attributes + .lookup(ServerRequestKeys.SecureSession) + .flatten + .flatMap(_.X509Certificate.headOption) + val requestForApp = leafCertificate match { + case Some(certificate) => stripped.putHeaders(Header.Raw(psd2CertHeaderName, toPem(certificate))) + case None => stripped + } + httpApp(requestForApp) + } +} diff --git a/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala b/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala index e8262339ee..bc45aaf2d1 100644 --- a/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala +++ b/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala @@ -3,15 +3,16 @@ package bootstrap.http4s import cats.effect._ import code.api.util.APIUtil import code.api.util.http4s.{Http4sApp, Http4sConfigUtil} +import code.util.Helper.MdcLoggable import com.comcast.ip4s._ import org.http4s.ember.server._ -object Http4sServer extends IOApp { +object Http4sServer extends IOApp with MdcLoggable { //Start OBP relevant objects and settings; this step MUST be executed first // new bootstrap.http4s.Http4sBoot().boot new bootstrap.liftweb.Boot().boot - + // Get bind address: use bind_address prop if set, otherwise parse from hostname // Note: hostname prop must remain unchanged as it may be used for local_provider_name fallback val host = Http4sConfigUtil.parseHostname(APIUtil.getPropsValue("bind_address",code.api.Constant.HostName)) @@ -20,12 +21,25 @@ object Http4sServer extends IOApp { // Use shared httpApp configuration (same as tests) val httpApp = Http4sApp.httpApp - override def run(args: List[String]): IO[ExitCode] = EmberServerBuilder - .default[IO] - .withHost(Host.fromString(host).get) - .withPort(Port.fromInt(port).get) - .withHttpApp(httpApp) - .build - .use(_ => IO.never) - .as(ExitCode.Success) + override def run(args: List[String]): IO[ExitCode] = { + val builder = EmberServerBuilder + .default[IO] + .withHost(Host.fromString(host).get) + .withPort(Port.fromInt(port).get) + val configuredBuilder = if (Http4sMtls.enabled) { + logger.info(s"mTLS termination is ENABLED (dev-only): serving HTTPS on port $port, " + + s"client_auth=${if (Http4sMtls.config.needClientAuth) "need" else "want"}, " + + s"keystore=${Http4sMtls.config.keystorePath}, truststore=${Http4sMtls.config.truststorePath}") + if (code.api.Constant.HostName.startsWith("http://")) + logger.warn("mtls.enabled=true but the hostname prop still starts with http:// — set it to https:// so generated links match the TLS listener.") + builder + .withTLS(Http4sMtls.tlsContext, Http4sMtls.tlsParameters) + .withHttpApp(Http4sMtls.injectClientCertificate(httpApp)) + } else { + builder.withHttpApp(httpApp) + } + configuredBuilder.build + .use(_ => IO.never) + .as(ExitCode.Success) + } } diff --git a/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsHandshakeTest.scala b/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsHandshakeTest.scala new file mode 100644 index 0000000000..e037436b88 --- /dev/null +++ b/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsHandshakeTest.scala @@ -0,0 +1,116 @@ +package bootstrap.http4s + +import java.io.{File, FileOutputStream} +import java.net.URL +import java.security.KeyStore +import java.security.cert.X509Certificate +import javax.net.ssl.{HttpsURLConnection, KeyManagerFactory, SSLContext, TrustManagerFactory} + +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import code.api.util.SelfSignedCertificateUtil.generateSelfSignedCert +import com.comcast.ip4s.{Host, Port} +import fs2.io.net.tls.{TLSContext, TLSParameters} +import org.http4s.{HttpApp, Response, Status} +import org.http4s.ember.server.EmberServerBuilder +import org.scalatest.{BeforeAndAfterAll, FlatSpec, Matchers} +import org.typelevel.ci.CIString + +/** + * End-to-end proof of the dev-only mTLS mode: a real Ember server built the same way as + * Http4sServer's mtls.enabled branch (buildSslContext from JKS files -> TLSContext -> + * withTLS(needClientAuth) -> injectClientCertificate middleware), exercised over a real + * TLS handshake with a client certificate. This is the only place Ember's population of + * ServerRequestKeys.SecureSession is actually verified. + */ +class Http4sMtlsHandshakeTest extends FlatSpec with Matchers with BeforeAndAfterAll { + + private val storePassword = "123456" + + private val (serverKey, serverCert) = generateSelfSignedCert("localhost") + private val (clientKey, clientCert) = generateSelfSignedCert("test-tpp") + + private def writeJks(fill: KeyStore => Unit): File = { + val keyStore = KeyStore.getInstance("JKS") + keyStore.load(null, null) + fill(keyStore) + val file = File.createTempFile("mtls-test-", ".jks") + file.deleteOnExit() + val out = new FileOutputStream(file) + try keyStore.store(out, storePassword.toCharArray) finally out.close() + file + } + + private val serverKeystore = writeJks(_.setKeyEntry("server", serverKey, storePassword.toCharArray, Array(serverCert))) + private val serverTruststore = writeJks(_.setCertificateEntry("client", clientCert)) + + private val echoApp: HttpApp[IO] = HttpApp[IO] { req => + val headerValue = req.headers.get(CIString("PSD2-CERT")).map(_.head.value).getOrElse("NONE") + IO.pure(Response[IO](Status.Ok).withEntity(headerValue)) + } + + private var shutdown: IO[Unit] = IO.unit + private var serverPort: Int = 0 + + override def beforeAll(): Unit = { + val sslContext = Http4sMtls.buildSslContext(Http4sMtls.MtlsConfig( + keystorePath = serverKeystore.getAbsolutePath, + keystorePassword = storePassword, + truststorePath = serverTruststore.getAbsolutePath, + truststorePassword = storePassword, + needClientAuth = true + )) + val (server, release) = EmberServerBuilder + .default[IO] + .withHost(Host.fromString("127.0.0.1").get) + .withPort(Port.fromInt(0).get) + .withTLS(TLSContext.Builder.forAsync[IO].fromSSLContext(sslContext), TLSParameters(needClientAuth = true)) + .withHttpApp(Http4sMtls.injectClientCertificate(echoApp)) + .build + .allocated + .unsafeRunSync() + serverPort = server.address.getPort + shutdown = release + } + + override def afterAll(): Unit = shutdown.unsafeRunSync() + + private def clientSslContext(withClientCert: Boolean): SSLContext = { + val trustStore = KeyStore.getInstance("JKS") + trustStore.load(null, null) + trustStore.setCertificateEntry("server", serverCert) + val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm) + trustManagerFactory.init(trustStore) + val keyManagers = if (withClientCert) { + val clientStore = KeyStore.getInstance("JKS") + clientStore.load(null, null) + clientStore.setKeyEntry("client", clientKey, storePassword.toCharArray, Array(clientCert)) + val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm) + keyManagerFactory.init(clientStore, storePassword.toCharArray) + keyManagerFactory.getKeyManagers + } else null + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(keyManagers, trustManagerFactory.getTrustManagers, null) + sslContext + } + + private def get(withClientCert: Boolean): String = { + val connection = new URL(s"https://127.0.0.1:$serverPort/anything") + .openConnection().asInstanceOf[HttpsURLConnection] + connection.setSSLSocketFactory(clientSslContext(withClientCert).getSocketFactory) + // the throwaway server cert has no SAN; a custom verifier also disables the JDK's + // in-handshake endpoint identification, which would otherwise reject it + connection.setHostnameVerifier((_, _) => true) + connection.setRequestProperty("PSD2-CERT", "spoofed-value") + try scala.io.Source.fromInputStream(connection.getInputStream).mkString + finally connection.disconnect() + } + + "a TLS handshake with a client certificate" should "surface that certificate as the PSD2-CERT header" in { + get(withClientCert = true) shouldEqual Http4sMtls.toPem(clientCert.asInstanceOf[X509Certificate]) + } + + "a TLS handshake without a client certificate" should "be rejected when client_auth=need" in { + an[Exception] should be thrownBy get(withClientCert = false) + } +} diff --git a/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsTest.scala b/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsTest.scala new file mode 100644 index 0000000000..781346e2de --- /dev/null +++ b/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsTest.scala @@ -0,0 +1,99 @@ +package bootstrap.http4s + +import java.security.KeyStore +import java.security.cert.X509Certificate + +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import code.api.CertificateConstants +import code.api.util.CertificateUtil +import com.nimbusds.jose.util.X509CertUtils +import org.http4s.{HttpApp, Method, Request, Response, Status} +import org.http4s.server.{SecureSession, ServerRequestKeys} +import org.scalatest.{FlatSpec, Matchers} +import org.typelevel.ci.CIString + +class Http4sMtlsTest extends FlatSpec with Matchers { + + private val psd2CertHeader = CIString("PSD2-CERT") + + private def loadTestKeystore: KeyStore = { + val keyStore = KeyStore.getInstance("JKS") + val in = getClass.getResourceAsStream("/cert/server.jks") + try keyStore.load(in, "123456".toCharArray) finally in.close() + keyStore + } + + private lazy val testCertificate: X509Certificate = { + val keyStore = loadTestKeystore + val aliases = keyStore.aliases() + var certificate: X509Certificate = null + while (aliases.hasMoreElements && certificate == null) { + keyStore.getCertificate(aliases.nextElement()) match { + case x509: X509Certificate => certificate = x509 + case _ => + } + } + certificate should not be null + certificate + } + + // Echoes the PSD2-CERT header value the app actually sees, or NONE + private val echoApp: HttpApp[IO] = HttpApp[IO] { req => + val headerValue = req.headers.get(psd2CertHeader).map(_.head.value).getOrElse("NONE") + IO.pure(Response[IO](Status.Ok).withEntity(headerValue)) + } + + private def run(req: Request[IO]): String = + Http4sMtls.injectClientCertificate(echoApp).apply(req).flatMap(_.as[String]).unsafeRunSync() + + private def secureSessionWith(cert: X509Certificate): Some[SecureSession] = + Some(SecureSession("session-id", "TLS_AES_128_GCM_SHA256", 128, List(cert))) + + "toPem" should "produce a parseable canonical PEM" in { + val pem = Http4sMtls.toPem(testCertificate) + pem should startWith(CertificateConstants.BEGIN_CERT + "\n") + pem should endWith("\n" + CertificateConstants.END_CERT) + X509CertUtils.parse(pem) should not be null + // the normalize fallback used by the consumer lookup must round-trip it + X509CertUtils.parse(CertificateUtil.normalizePemX509Certificate(pem)) should not be null + } + + "injectClientCertificate" should "inject the handshake certificate as the PSD2-CERT header" in { + val req = Request[IO](Method.GET) + .withAttribute(ServerRequestKeys.SecureSession, secureSessionWith(testCertificate)) + run(req) shouldEqual Http4sMtls.toPem(testCertificate) + } + + it should "strip a client-supplied PSD2-CERT header when there is no client certificate" in { + val req = Request[IO](Method.GET).withHeaders("PSD2-CERT" -> "spoofed-value") + run(req) shouldEqual "NONE" + } + + it should "replace a client-supplied PSD2-CERT header with the handshake certificate" in { + val req = Request[IO](Method.GET) + .withHeaders("PSD2-CERT" -> "spoofed-value") + .withAttribute(ServerRequestKeys.SecureSession, secureSessionWith(testCertificate)) + run(req) shouldEqual Http4sMtls.toPem(testCertificate) + } + + it should "pass the request through untouched when the TLS session has no peer certificate" in { + val req = Request[IO](Method.GET) + .withAttribute(ServerRequestKeys.SecureSession, Some(SecureSession("session-id", "TLS_AES_128_GCM_SHA256", 128, List.empty[X509Certificate]))) + run(req) shouldEqual "NONE" + } + + "buildSslContext" should "build an SSLContext from the checked-in dev keystores" in { + val certDir = new java.io.File(getClass.getResource("/cert/server.jks").toURI).getParent + val config = Http4sMtls.MtlsConfig( + keystorePath = s"$certDir/server.jks", + keystorePassword = "123456", + truststorePath = s"$certDir/server.trust.jks", + truststorePassword = "123456", + needClientAuth = true + ) + val sslContext = Http4sMtls.buildSslContext(config) + sslContext should not be null + sslContext.getProtocol shouldEqual "TLS" + } +}