From 6682ca8a9442d4b6cdc562b35e7c98bb8793abc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Tue, 21 Jul 2026 09:10:19 +0200 Subject: [PATCH 01/15] feat(mtls): make dev-mode mTLS a one-command toggle Turning on the dev-only in-process mTLS termination previously meant editing five props. Reduce it to a single switch, and stop the switch from silently breaking local logins. - Http4sMtls: only mtls.enabled is required now. The four store props fall back to the checked-in dev pair (server.jks / server.trust.jks), each fallback logged at WARN naming the prop and its OBP_* environment equivalent, so a default is never silent. A missing store now fails with the resolved absolute path and the working directory in the message -- the defaults are repo-relative, so "launched from the wrong directory" is the realistic failure and a bare FileNotFoundException did not say so. - Http4sMtls.keyStoreTypeOf: load .p12/.pfx as PKCS12 and everything else as JKS, for keystore and truststore alike. The type cannot be sniffed cheaply and guessing wrong fails with an opaque parse error. This makes localhost_san_dns_ip.pfx usable -- the only local cert carrying DNS:localhost + IP:127.0.0.1, where the server.jks default has no SAN at all and relies on clients falling back to CN. - scripts/mtls_env.sh: exports the OBP_MTLS_* overrides. No new machinery was needed -- APIUtil.getPropsValue already reads OBP_ from the environment ahead of the props file, so every prop is env-overridable. Pre-exported values win. It also pins local_identity_provider. Enabling TLS means hostname must become https://, but hostname is the default for local_identity_provider (constant.scala), which is the provider column every AuthUser / ResourceUser row is keyed on -- moving the scheme alone gives existing local users a different provider string and orphans them. The pin reads the hostname via Lift's own resolution order: Lift maps Development to an EMPTY modeName, so development.default.props is never loaded and default.props is. Reading the wrong file there reintroduces exactly the orphaning this pin prevents. - scripts/java_env.sh: select a JDK >= 17 before any Maven work. The build compiles with -release 17 and a default JDK 11 fails with "'17' is not a valid choice for '-release'", which reads like a source error. An adequate JAVA_HOME is honoured; an inadequate one is replaced with an announcement. - Both build_and_run scripts: --mtls flag following the existing --clean/--online/--no-flush/--background idiom. mtls_env.sh is sourced after the build so it can never affect compilation. - Tests: extension-to-store-type mapping, and buildSslContext loading the .pfx. Verified live: plain HTTP rejected (curl 52), certless handshake rejected under client_auth=need (curl 56), 200 with a real client certificate, and a spoofed PSD2-CERT header replaced by the handshake certificate (1252-char exact match). Both scripts build and boot from a JDK 11 shell. --- docs/MTLS_DEV_MODE.md | 55 ++++++++++-- flushall_build_and_run.sh | 15 ++++ flushall_fast_build_and_run.sh | 15 ++++ .../resources/props/sample.props.template | 4 +- .../scala/bootstrap/http4s/Http4sMtls.scala | 71 ++++++++++++--- .../bootstrap/http4s/Http4sMtlsTest.scala | 23 +++++ scripts/java_env.sh | 73 +++++++++++++++ scripts/mtls_env.sh | 89 +++++++++++++++++++ 8 files changed, 325 insertions(+), 20 deletions(-) create mode 100755 scripts/java_env.sh create mode 100755 scripts/mtls_env.sh diff --git a/docs/MTLS_DEV_MODE.md b/docs/MTLS_DEV_MODE.md index 8febd8147a..867826bf6f 100644 --- a/docs/MTLS_DEV_MODE.md +++ b/docs/MTLS_DEV_MODE.md @@ -72,7 +72,22 @@ To accept a whole CA instead of individual client certs, import the CA certifica ### 2. Configure props -In your props file (e.g. `obp-api/src/main/resources/props/default.props`): +**The short way — no props edit at all:** + +```sh +./flushall_fast_build_and_run.sh --mtls +``` + +The flag sources `scripts/mtls_env.sh`, which exports the `OBP_MTLS_*` environment overrides +(every OBP prop is settable as `OBP_` with dots replaced by underscores — +`APIUtil.getPropsValue` reads the environment ahead of the props file) pointing at the dev +keystore pair checked into the repo. It also flips `hostname` to `https://` **and** pins +`local_identity_provider` to the pre-toggle hostname — see the note under "Props reference" +for why that pinning matters. Anything you pre-export wins, so +`OBP_MTLS_CLIENT_AUTH=want ./flushall_fast_build_and_run.sh --mtls` works. + +To make the mode permanent instead, put it in your props file +(e.g. `obp-api/src/main/resources/props/default.props`): ```properties mtls.enabled=true @@ -91,12 +106,20 @@ hostname=https://localhost:8080 consumer_validation_method_for_consent=CONSUMER_CERTIFICATE ``` +Only `mtls.enabled` is actually required. The four store props default to the checked-in dev +pair (`obp-api/src/test/resources/cert/server.jks` / `server.trust.jks`, password `123456`), +resolved relative to the working directory — so they work when the server is launched from the +repo root, which is what the run scripts do. Each fallback is logged at WARN naming the resolved +absolute file, and a missing store fails at startup with the resolved path and working directory +in the message rather than a bare `FileNotFoundException`. + `run.mode` must be `development` (it is with the standard local run scripts). ### 3. Run ```sh -./flushall_build_and_run.sh +./flushall_build_and_run.sh --mtls # or: ./flushall_fast_build_and_run.sh --mtls +./flushall_build_and_run.sh # props-driven, if you configured them above ``` Boot log confirms the mode: @@ -170,21 +193,35 @@ Consumer — presenting a different client certificate is rejected. | 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.enabled` | `false` | Master switch. Only honoured when `run.mode=development`. The one genuinely required prop. | +| `mtls.keystore.path` | `obp-api/src/test/resources/cert/server.jks` | Store with the server's private key + certificate. `.p12`/`.pfx` are read as PKCS12, anything else as JKS. | +| `mtls.keystore.password` | `123456` | Password for keystore and key. | +| `mtls.truststore.path` | `obp-api/src/test/resources/cert/server.trust.jks` | Store with client certificates / CAs the server accepts. Same type detection as the keystore. | +| `mtls.truststore.password` | `123456` | Truststore password. | | `mtls.client_auth` | `need` | `need` rejects certless handshakes; `want` makes the client certificate optional. | +Each of these is also settable from the environment as `OBP_MTLS_ENABLED`, +`OBP_MTLS_KEYSTORE_PATH`, … which is what `--mtls` uses. + +> **`hostname` and `local_identity_provider` move together.** Turning mTLS on means generated +> links should say `https://`, so `hostname` changes. But `hostname` is also the default for +> `local_identity_provider` (`code/api/constant/constant.scala`), which is the `provider` column +> every `AuthUser` / `ResourceUser` row is keyed on. Changing `hostname` alone gives your existing +> local users a different provider string, orphaning them — logins start failing after a toggle. +> `scripts/mtls_env.sh` handles this by pinning `local_identity_provider` to the pre-toggle +> hostname. If you configure mTLS through props instead, set `local_identity_provider` explicitly +> to whatever `hostname` was before you added the `https://`. + ## 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. | +| Boot fails with `points at '…', which does not exist` | The store file isn't there. If you relied on the defaults, the server was launched from somewhere other than the repo root — the message prints the resolved path and the working directory. Use `--mtls` (absolute paths) or set the props absolutely. | +| Logins that worked over plain HTTP now fail | `hostname` changed scheme without `local_identity_provider` being pinned — see the note under "Props reference". | | `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). | +| Client rejects the server cert with "no alternative certificate subject name matches" | The default `server.jks` leaf has **no SAN**. curl/OpenSSL and Java's `DefaultHostnameVerifier` fall back to CN (`localhost`) so both accept it, but stricter clients won't. Point `mtls.keystore.path` at `obp-api/src/test/resources/cert/localhost_san_dns_ip.pfx` (password `123456`), which carries `DNS:localhost, IP:127.0.0.1`. | | 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';` | @@ -208,6 +245,8 @@ header. Two important rules for any proxy config: |---|---| | `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. | +| `scripts/mtls_env.sh` | The `OBP_MTLS_*` environment overrides behind the `--mtls` flag of both `flushall_*build_and_run.sh` scripts. Sourceable on its own. | +| `scripts/java_env.sh` | Selects a JDK >= 17 for the run scripts. The build compiles with `-release 17`; on a default JDK 11 it otherwise fails with the misleading `'17' is not a valid choice for '-release'`. | | `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. | diff --git a/flushall_build_and_run.sh b/flushall_build_and_run.sh index 13a8bfa6c5..3843dc9fa9 100755 --- a/flushall_build_and_run.sh +++ b/flushall_build_and_run.sh @@ -11,6 +11,7 @@ # ./build_and_run.sh - Build and run with Redis flush # ./build_and_run.sh --no-flush - Build and run without Redis flush # ./build_and_run.sh --background - Run server in background +# ./build_and_run.sh --mtls - Serve HTTPS with mutual TLS (dev only) # # The HTTP4S server runs on the port configured in your props file # (default: 8080 for dev.port, or 8086 for hostname port) @@ -21,6 +22,7 @@ set -e # Exit on error # Parse arguments FLUSH_REDIS=true RUN_BACKGROUND=false +USE_MTLS=false for arg in "$@"; do case $arg in @@ -32,9 +34,16 @@ for arg in "$@"; do RUN_BACKGROUND=true echo ">>> Server will run in background" ;; + --mtls) + USE_MTLS=true + echo ">>> mTLS mode requested (dev-only in-process TLS termination)" + ;; esac done +# Select a JDK >= 17 before any Maven work (the build compiles with -release 17). +. "$(dirname "${BASH_SOURCE[0]}")/scripts/java_env.sh" + ################################################################################ # FLUSH REDIS CACHE (OPTIONAL) ################################################################################ @@ -132,6 +141,12 @@ echo "" # RUN HTTP4S SERVER ################################################################################ +# Applied after the build so it only affects the running server, never compilation. +if [ "$USE_MTLS" = true ]; then + . "$(dirname "${BASH_SOURCE[0]}")/scripts/mtls_env.sh" + echo "" +fi + echo "==========================================" if [ "$RUN_BACKGROUND" = true ]; then echo "Starting HTTP4S server (background)..." diff --git a/flushall_fast_build_and_run.sh b/flushall_fast_build_and_run.sh index 88669ab6e9..d3c5874b78 100755 --- a/flushall_fast_build_and_run.sh +++ b/flushall_fast_build_and_run.sh @@ -17,6 +17,7 @@ # ./fast_build_and_run.sh --online - Check remote repositories # ./fast_build_and_run.sh --no-flush - Skip Redis flush # ./fast_build_and_run.sh --background - Run server in background +# ./fast_build_and_run.sh --mtls - Serve HTTPS with mutual TLS (dev only) # # Typical speedup: 2-5x faster than regular build for incremental changes ################################################################################ @@ -28,6 +29,7 @@ DO_CLEAN="" OFFLINE_FLAG="-o" # Default to offline mode for faster builds FLUSH_REDIS=true RUN_BACKGROUND=false +USE_MTLS=false for arg in "$@"; do case $arg in @@ -47,9 +49,16 @@ for arg in "$@"; do RUN_BACKGROUND=true echo ">>> Server will run in background" ;; + --mtls) + USE_MTLS=true + echo ">>> mTLS mode requested (dev-only in-process TLS termination)" + ;; esac done +# Select a JDK >= 17 before any Maven work (the build compiles with -release 17). +. "$(dirname "${BASH_SOURCE[0]}")/scripts/java_env.sh" + # Show offline mode status if not explicitly set if [ "$OFFLINE_FLAG" = "-o" ]; then echo ">>> Using offline mode (default) - use --online to check remote repos" @@ -300,6 +309,12 @@ echo "" # RUN HTTP4S SERVER ################################################################################ +# Applied after the build so it only affects the running server, never compilation. +if [ "$USE_MTLS" = true ]; then + . "$(dirname "${BASH_SOURCE[0]}")/scripts/mtls_env.sh" + echo "" +fi + echo "==========================================" if [ "$RUN_BACKGROUND" = true ]; then echo "Starting HTTP4S server (background)..." diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index c5ac75d4c4..0409a9dd82 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -166,7 +166,9 @@ jwt.use.ssl=false ## 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. +## Only mtls.enabled is required: the four store props below default to exactly these values, +## the JKS pair checked into the repo for local development. Set them to use your own certificates. +## Easiest switch is not to edit this file at all: ./flushall_fast_build_and_run.sh --mtls # mtls.enabled=true # mtls.keystore.path=obp-api/src/test/resources/cert/server.jks # mtls.keystore.password=123456 diff --git a/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala b/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala index 6060652d63..56fc785810 100644 --- a/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala +++ b/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala @@ -1,6 +1,6 @@ package bootstrap.http4s -import java.io.FileInputStream +import java.io.{File, FileInputStream} import java.nio.charset.StandardCharsets import java.security.KeyStore import java.security.cert.X509Certificate @@ -32,6 +32,12 @@ import org.typelevel.ci.CIString * mtls.truststore.password=... * mtls.client_auth=need (need = reject certless handshakes; want = optional) * + * Only `mtls.enabled` is mandatory: the four store props fall back to the checked-in dev keystore + * pair (see DevKeystorePath below) so that switching a local server to mTLS is a single toggle. + * Every fallback is logged at WARN naming the resolved file, so the default is never silent. + * Like every OBP prop these are also settable from the environment as OBP_MTLS_ENABLED, + * OBP_MTLS_KEYSTORE_PATH, ... (see APIUtil.getPropsValue, which reads the environment first). + * * 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. @@ -57,29 +63,72 @@ object Http4sMtls extends MdcLoggable { } else propEnabled } + // The checked-in dev pair: a CN=localhost server keypair and the TESOBE CA that signs the dev + // client certificates. Repo-relative, so they resolve when the server is launched from the repo + // root (which is what the build_and_run scripts do). Only ever reached in Development run mode. + private val DevKeystorePath = "obp-api/src/test/resources/cert/server.jks" + private val DevTruststorePath = "obp-api/src/test/resources/cert/server.trust.jks" + private val DevStorePassword = "123456" + + /** The OBP_-prefixed environment variable APIUtil.getPropsValue consults ahead of the props file. */ + private def envVarOf(propName: String): String = "OBP_" + propName.replace('.', '_').toUpperCase + 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") + def prop(name: String): Option[String] = + APIUtil.getPropsValue(name).toOption.map(_.trim).filter(_.nonEmpty) + + // Returns the absolute store path plus its password, defaulting both to the dev pair. + def store(pathProp: String, devPath: String, passwordProp: String): (String, String) = { + val path = prop(pathProp).getOrElse { + logger.warn(s"'$pathProp' is not set — falling back to the checked-in dev store '$devPath'. " + + s"Set '$pathProp' (or ${envVarOf(pathProp)}) to use your own certificates.") + devPath + } + val file = new File(path) + if (!file.isFile) throw new RuntimeException( + s"mtls.enabled=true but '$pathProp' points at '$path', which does not exist " + + s"(resolved to ${file.getAbsolutePath} from working directory ${new File(".").getAbsolutePath}). " + + s"Launch from the repo root or set '$pathProp' to an absolute path.") + val password = prop(passwordProp).getOrElse { + logger.warn(s"'$passwordProp' is not set — falling back to the checked-in dev store password.") + DevStorePassword + } + (file.getAbsolutePath, password) + } + + val (keystorePath, keystorePassword) = store("mtls.keystore.path", DevKeystorePath, "mtls.keystore.password") + val (truststorePath, truststorePassword) = store("mtls.truststore.path", DevTruststorePath, "mtls.truststore.password") MtlsConfig( - keystorePath = required("mtls.keystore.path"), - keystorePassword = required("mtls.keystore.password"), - truststorePath = required("mtls.truststore.path"), - truststorePassword = required("mtls.truststore.password"), + keystorePath = keystorePath, + keystorePassword = keystorePassword, + truststorePath = truststorePath, + truststorePassword = truststorePassword, needClientAuth = APIUtil.getPropsValue("mtls.client_auth", "need").toLowerCase != "want" ) } + /** + * KeyStore type for a store file, by extension: `.p12` / `.pfx` are PKCS12, everything else JKS. + * Both formats are common for the certificates a TPP hands over — `obp-api/src/test/resources/cert` + * carries examples of each — and guessing wrong fails at load with an opaque parse error. + */ + private[http4s] def keyStoreTypeOf(path: String): String = + path.toLowerCase match { + case p if p.endsWith(".p12") || p.endsWith(".pfx") => "PKCS12" + case _ => "JKS" + } + def buildSslContext(config: MtlsConfig): SSLContext = { - def loadJks(path: String, password: String): KeyStore = { - val keyStore = KeyStore.getInstance("JKS") + def loadStore(path: String, password: String): KeyStore = { + val keyStore = KeyStore.getInstance(keyStoreTypeOf(path)) 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) + keyManagerFactory.init(loadStore(config.keystorePath, config.keystorePassword), config.keystorePassword.toCharArray) val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm) - trustManagerFactory.init(loadJks(config.truststorePath, config.truststorePassword)) + trustManagerFactory.init(loadStore(config.truststorePath, config.truststorePassword)) val sslContext = SSLContext.getInstance("TLS") sslContext.init(keyManagerFactory.getKeyManagers, trustManagerFactory.getTrustManagers, null) sslContext diff --git a/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsTest.scala b/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsTest.scala index 781346e2de..f38ac157f5 100644 --- a/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsTest.scala +++ b/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsTest.scala @@ -96,4 +96,27 @@ class Http4sMtlsTest extends FlatSpec with Matchers { sslContext should not be null sslContext.getProtocol shouldEqual "TLS" } + + // A TPP's certificates arrive as often in PKCS12 as in JKS, and the store type cannot be + // sniffed from the bytes cheaply — getting it wrong fails at load with an opaque parse error. + "keyStoreTypeOf" should "select PKCS12 for .p12/.pfx and JKS otherwise" in { + Http4sMtls.keyStoreTypeOf("/path/server.p12") shouldEqual "PKCS12" + Http4sMtls.keyStoreTypeOf("/path/server.PFX") shouldEqual "PKCS12" + Http4sMtls.keyStoreTypeOf("/path/server.jks") shouldEqual "JKS" + Http4sMtls.keyStoreTypeOf("/path/server") shouldEqual "JKS" + } + + it should "let buildSslContext load a PKCS12 keystore" in { + val certDir = new java.io.File(getClass.getResource("/cert/server.jks").toURI).getParent + // localhost_san_dns_ip.pfx carries SAN DNS:localhost + IP:127.0.0.1, which JKS server.jks + // lacks — clients that do not fall back to CN need this one. + val config = Http4sMtls.MtlsConfig( + keystorePath = s"$certDir/localhost_san_dns_ip.pfx", + keystorePassword = "123456", + truststorePath = s"$certDir/server.trust.jks", + truststorePassword = "123456", + needClientAuth = true + ) + Http4sMtls.buildSslContext(config) should not be null + } } diff --git a/scripts/java_env.sh b/scripts/java_env.sh new file mode 100755 index 0000000000..d99b4e04d0 --- /dev/null +++ b/scripts/java_env.sh @@ -0,0 +1,73 @@ +#!/bin/bash +################################################################################ +# OBP-API JDK selection +# +# The build compiles with `-release 17`. A JDK older than 17 fails the build with +# +# ERROR '17' is not a valid choice for '-release' +# ERROR bad option: '-release' +# +# which is easy to misread as a source error. Distributions where the default +# `java` is still 11 hit this on every build, so the run scripts source this file +# to select a >= 17 JDK before invoking Maven. +# +# An existing JAVA_HOME is honoured whenever it is new enough; it is only replaced +# when it would fail the build, and the replacement is announced. Sets both +# JAVA_HOME and PATH, because the run scripts invoke a bare `java` for the server. +################################################################################ + +# Echoes the feature version (e.g. 17) of the java binary passed in, or nothing. +java_major_of() { + local java_bin="$1" + [ -x "$java_bin" ] || return 0 + "$java_bin" -version 2>&1 \ + | head -1 \ + | sed -nE 's/.*version "([0-9]+)(\.[0-9]+)*.*/\1/p' +} + +java_env_required=17 +java_env_selected="" + +# 1. An adequate JAVA_HOME already in the environment wins — do not second-guess it. +if [ -n "$JAVA_HOME" ]; then + java_env_current="$(java_major_of "$JAVA_HOME/bin/java")" + if [ -n "$java_env_current" ] && [ "$java_env_current" -ge "$java_env_required" ] 2>/dev/null; then + java_env_selected="$JAVA_HOME" + else + echo ">>> JAVA_HOME points at Java ${java_env_current:-unknown}, but the build needs >= $java_env_required — looking for another JDK" + fi +fi + +# 2. Otherwise search the usual locations (Linux distro paths, then macOS). +if [ -z "$java_env_selected" ]; then + for java_env_candidate in \ + /usr/lib/jvm/java-17-openjdk-amd64 \ + /usr/lib/jvm/java-17-openjdk \ + /usr/lib/jvm/java-21-openjdk-amd64 \ + /usr/lib/jvm/java-21-openjdk \ + "$(/usr/libexec/java_home -v 17 2>/dev/null)" \ + "$(/usr/libexec/java_home -v 21 2>/dev/null)"; do + [ -n "$java_env_candidate" ] || continue + java_env_candidate_major="$(java_major_of "$java_env_candidate/bin/java")" + if [ -n "$java_env_candidate_major" ] && [ "$java_env_candidate_major" -ge "$java_env_required" ] 2>/dev/null; then + java_env_selected="$java_env_candidate" + break + fi + done +fi + +# 3. Fall back to whatever `java` is on PATH, warning if it cannot build. +if [ -n "$java_env_selected" ]; then + export JAVA_HOME="$java_env_selected" + export PATH="$JAVA_HOME/bin:$PATH" + echo ">>> Using JDK $(java_major_of "$JAVA_HOME/bin/java") at $JAVA_HOME" +else + java_env_path_major="$(java_major_of "$(command -v java 2>/dev/null)")" + echo ">>> WARNING: no JDK >= $java_env_required found (java on PATH is ${java_env_path_major:-missing})." + echo " The build will fail with \"'17' is not a valid choice for '-release'\"." + echo " Install a JDK $java_env_required or set JAVA_HOME to one." +fi + +# Sourced under `set -e`: end on a command that always succeeds so a false test +# in the block above can never take down the caller. +true diff --git a/scripts/mtls_env.sh b/scripts/mtls_env.sh new file mode 100755 index 0000000000..aff7ed8ef2 --- /dev/null +++ b/scripts/mtls_env.sh @@ -0,0 +1,89 @@ +#!/bin/bash +################################################################################ +# OBP-API dev-only mTLS environment +# +# Exports the OBP_* overrides that switch the http4s server into in-process mTLS +# termination (see obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala and +# docs/MTLS_DEV_MODE.md). Sourced by the --mtls flag of the build_and_run +# scripts; can also be sourced by hand for a jar that is already built: +# +# . scripts/mtls_env.sh && java -jar obp-api/target/obp-api.jar +# +# Every OBP prop is overridable from the environment as OBP_ with dots +# replaced by underscores (APIUtil.getPropsValue reads the environment ahead of +# the props file), which is what makes this a no-edit toggle. +# +# All values below are defaults only: anything already exported wins, so +# OBP_MTLS_CLIENT_AUTH=want ./flushall_fast_build_and_run.sh --mtls +# behaves as expected. +################################################################################ + +MTLS_REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Mirror Lift's own props resolution, in its order. In Development, Lift's `modeName` +# is the EMPTY string, so its candidate list collapses to the user/host-specific files +# and then default.props. A file named `development.default.props` is therefore never +# loaded in Development — despite the name, it is inert, and reading the hostname from +# it silently pins local_identity_provider to a value the running app does not use. +# (`test.default.props` does work, because in Test mode modeName is "test".) +mtls_props_dir="$MTLS_REPO_ROOT/obp-api/src/main/resources/props" +mtls_user="$(id -un 2>/dev/null)" +mtls_host="$(hostname 2>/dev/null)" +mtls_props_file="" +for mtls_candidate in \ + "$mtls_props_dir/$mtls_user.$mtls_host.props" \ + "$mtls_props_dir/$mtls_user.props" \ + "$mtls_props_dir/$mtls_host.props" \ + "$mtls_props_dir/default.props"; do + if [ -f "$mtls_candidate" ]; then mtls_props_file="$mtls_candidate"; break; fi +done + +# Echoes the value of a prop from that file, or nothing when it is absent/commented out. +mtls_prop() { + [ -n "$mtls_props_file" ] || return 0 + grep -E "^[[:space:]]*$1[[:space:]]*=" "$mtls_props_file" \ + | tail -1 | cut -d= -f2- | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' +} + +# --- the toggle itself ------------------------------------------------------- +# Http4sMtls defaults the four store props to this same checked-in dev pair, so +# strictly only OBP_MTLS_ENABLED is required. They are set explicitly here as +# absolute paths so that the server also starts correctly from another working +# directory, where the repo-relative defaults would not resolve. +export OBP_MTLS_ENABLED=true +: "${OBP_MTLS_KEYSTORE_PATH:=$MTLS_REPO_ROOT/obp-api/src/test/resources/cert/server.jks}" +: "${OBP_MTLS_KEYSTORE_PASSWORD:=123456}" +: "${OBP_MTLS_TRUSTSTORE_PATH:=$MTLS_REPO_ROOT/obp-api/src/test/resources/cert/server.trust.jks}" +: "${OBP_MTLS_TRUSTSTORE_PASSWORD:=123456}" +: "${OBP_MTLS_CLIENT_AUTH:=need}" +export OBP_MTLS_KEYSTORE_PATH OBP_MTLS_KEYSTORE_PASSWORD +export OBP_MTLS_TRUSTSTORE_PATH OBP_MTLS_TRUSTSTORE_PASSWORD OBP_MTLS_CLIENT_AUTH + +# --- hostname, and why local_identity_provider has to be pinned with it ------- +# The listener now speaks TLS, so generated links must say https://. But hostname +# is also the default for local_identity_provider (code/api/constant/constant.scala), +# which is the `provider` column every AuthUser / ResourceUser row is keyed on. +# Flipping the scheme alone would give the same local users a different provider +# string on every toggle, orphaning them. So pin local_identity_provider to the +# pre-toggle hostname and let only the scheme move. Skipped when the props file +# already sets local_identity_provider explicitly — then there is nothing to pin. +mtls_props_hostname="$(mtls_prop hostname)" +if [ -n "$mtls_props_hostname" ]; then + if [ -z "$(mtls_prop local_identity_provider)" ]; then + : "${OBP_LOCAL_IDENTITY_PROVIDER:=$mtls_props_hostname}" + export OBP_LOCAL_IDENTITY_PROVIDER + fi + : "${OBP_HOSTNAME:=$(echo "$mtls_props_hostname" | sed 's|^http://|https://|')}" + export OBP_HOSTNAME +fi + +echo ">>> mTLS enabled (dev-only in-process TLS termination)" +echo " keystore : $OBP_MTLS_KEYSTORE_PATH" +echo " truststore : $OBP_MTLS_TRUSTSTORE_PATH" +echo " client_auth: $OBP_MTLS_CLIENT_AUTH" +echo " hostname : ${OBP_HOSTNAME:-}" +# Plain `if`, not `[ … ] && echo`: this is the last command of a sourced file, and the callers +# run under `set -e` — a false test would take its exit status and abort the caller. +if [ -n "$OBP_LOCAL_IDENTITY_PROVIDER" ]; then + echo " local_identity_provider pinned to: $OBP_LOCAL_IDENTITY_PROVIDER" +fi From be9c226dc1a67cbd4bfd0b872794b9b50acf624d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Tue, 21 Jul 2026 09:10:53 +0200 Subject: [PATCH 02/15] fix(build): stop set -e from swallowing build failures in the run scripts Both build_and_run scripts run under `set -e`, and both call mvn without guarding it. A failing build therefore aborted the script at the mvn line, so everything written to handle that failure never ran: - flushall_fast_build_and_run.sh captured BUILD_EXIT_CODE=$? and had a whole "detect incremental compilation cache issue, retry with a clean build" block keyed off it. None of it was reachable. The script exited silently with no diagnosis and no retry. - flushall_build_and_run.sh could not reach its BUILD_EXIT check, so it never printed the log tail it promises on failure. Bracket each mvn invocation with set +e / set -e so the exit code is captured and the existing handling runs. The symptom is easy to misread: the scripts pipe their output, so the exit code a caller sees is the pipeline's, not the script's -- a failed build could look like a successful run that simply produced no server. Verified by putting a failing mvn on PATH and running the real script: it now reports the failure, detects the cache-issue signature, retries with a clean build, tails the log and exits 1. --- flushall_build_and_run.sh | 4 ++++ flushall_fast_build_and_run.sh | 11 ++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/flushall_build_and_run.sh b/flushall_build_and_run.sh index 3843dc9fa9..ae6485bdca 100755 --- a/flushall_build_and_run.sh +++ b/flushall_build_and_run.sh @@ -118,8 +118,12 @@ echo "Build output will be saved to: build.log" # Show last 3 lines of build output in real-time tail -n 3 -f build.log & TAIL_PID=$! +# set +e: this script runs under `set -e`, which would abort at a failing build +# before the BUILD_EXIT check below could print the log tail. +set +e mvn -pl obp-api -am clean package -DskipTests=true -Dmaven.test.skip=true -T 4 > build.log 2>&1 BUILD_EXIT=$? +set -e kill $TAIL_PID 2>/dev/null || true wait $TAIL_PID 2>/dev/null || true diff --git a/flushall_fast_build_and_run.sh b/flushall_fast_build_and_run.sh index d3c5874b78..7483e2ce7b 100755 --- a/flushall_fast_build_and_run.sh +++ b/flushall_fast_build_and_run.sh @@ -174,6 +174,10 @@ fi } > fast_build.log # Run Maven build and append to log +# set +e around the build: this script runs under `set -e`, which would abort here +# on a failing build and make BUILD_EXIT_CODE — and the auto-retry-with-clean block +# below — unreachable. We want to inspect the failure, not die at it. +set +e mvn -pl obp-api -am \ $DO_CLEAN \ package \ @@ -184,8 +188,8 @@ mvn -pl obp-api -am \ -Dcheckstyle.skip=true \ -Dspotbugs.skip=true \ -Dpmd.skip=true >> fast_build.log 2>&1 - BUILD_EXIT_CODE=$? +set -e # Record build end time and calculate duration if command -v gdate &> /dev/null; then @@ -246,7 +250,8 @@ if [ $BUILD_EXIT_CODE -ne 0 ] && [ -z "$DO_CLEAN" ]; then echo "" } > fast_build.log - # Run clean build + # Run clean build (set +e for the same reason as the first invocation) + set +e mvn -pl obp-api -am \ clean \ package \ @@ -257,8 +262,8 @@ if [ $BUILD_EXIT_CODE -ne 0 ] && [ -z "$DO_CLEAN" ]; then -Dcheckstyle.skip=true \ -Dspotbugs.skip=true \ -Dpmd.skip=true >> fast_build.log 2>&1 - BUILD_EXIT_CODE=$? + set -e # Record retry end time and calculate duration if command -v gdate &> /dev/null; then From 3c863d318095e1e35b14f6994aad5721a819e08b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Tue, 21 Jul 2026 09:18:19 +0200 Subject: [PATCH 03/15] fix(psd2): return 401, not 500, when TPP-Signature-Certificate is unusable With requirePsd2Certificates=ONLINE, any request reaching passesPsd2Aisp without a TPP-Signature-Certificate header produced 500 OBP-50000: Unknown Error.: Illegal base64 character 2d getCertificateFromTppSignatureCertificate read the header via getHeaderValue, which substitutes SecureRandomUtil.csprng.nextLong().toString when a header is absent. That is a serviceable "never matches" sentinel for the string comparisons it was written for (Digest, Signature), but the value here goes straight into Base64.getDecoder: a negative Long renders with a leading '-', which is 0x2d, and the decoder throws. The bug was also flaky by construction. Only about half of calls got a negative Long; the rest base64-decoded to garbage and failed later in getCertificatePem with a different error. Same missing header, two symptoms. Make getCertificateFromTppSignatureCertificate return Box[X509Certificate] instead of throwing, and look the header up directly rather than through getHeaderValue. Each failure mode is now a Failure: header absent, not base64, base64 of something that is not a PEM, or a PEM that is not a certificate. Call sites: - APIUtil.passesPsd2ServiceProviderCommon: fail closed. passesPsd2ServiceProvider already maps a Failure to 401 -- this is the path that produced the 500. - BerlinGroupSigning.verifySignedRequest: checkRequestIsSigned only proves the header is present, not usable, so an unusable one is a 401. - BerlinGroupSigning.getOrCreateConsumer: leave the caller's result untouched. Consumer creation is a side effect of a signed request, not the thing being authorised; verifySignedRequest is what rejects a bad certificate. - BerlinGroupCheck: without a certificate there is no serial number to compare keyId.SN against, so it is an invalid signature header (400). Found by running a real TPP (OBP-Hola) through the UK Open Banking consent journey. Verified: the identical POST to /open-banking/v3.1/account-access-consents now returns 401 with no "Illegal base64" in the log; RegulatedEntityTest passes. Note this changes only the failure mode, not the gate: a caller with no TPP-Signature-Certificate is still rejected, just with the correct status. --- .../main/scala/code/api/util/APIUtil.scala | 50 ++++--- .../code/api/util/BerlinGroupCheck.scala | 15 ++- .../code/api/util/BerlinGroupSigning.scala | 124 ++++++++++++------ 3 files changed, 120 insertions(+), 69 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index cd283759a1..c17eb54dcc 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -3854,27 +3854,37 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ case value if value.toUpperCase == "ONLINE" => val requestHeaders = cc.map(_.requestHeaders).getOrElse(Nil) val consumerName = cc.flatMap(_.consumer.map(_.name.get)).getOrElse("") - val certificate = getCertificateFromTppSignatureCertificate(requestHeaders) - for { - tpps <- BerlinGroupSigning.getRegulatedEntityByCertificate(certificate, cc) - } yield { - tpps match { - case Nil => - ObpApiFailure(RegulatedEntityNotFoundByCertificate, 401, cc) - case single :: Nil => - logger.debug(s"Regulated entity by certificate: $single") - // Only one match, proceed to role check - if (single.services.contains(serviceProvider)) { - logger.debug(s"Regulated entity by certificate (single.services: ${single.services}, serviceProvider: $serviceProvider): ") - Full(true) - } else { - ObpApiFailure(X509ActionIsNotAllowed, 403, cc) + getCertificateFromTppSignatureCertificate(requestHeaders) match { + // No usable TPP-Signature-Certificate: fail closed. passesPsd2ServiceProvider maps a + // Failure to a 401 -- this used to throw out of the base64 decode and become a 500. + case failure: Failure => + logger.debug(s"passesPsd2ServiceProvider: no usable TPP-Signature-Certificate: $failure") + Future(failure) + case Empty => + logger.debug("passesPsd2ServiceProvider: no TPP-Signature-Certificate header") + Future(Failure(X509CannotGetCertificate)) + case Full(certificate) => + for { + tpps <- BerlinGroupSigning.getRegulatedEntityByCertificate(certificate, cc) + } yield { + tpps match { + case Nil => + ObpApiFailure(RegulatedEntityNotFoundByCertificate, 401, cc) + case single :: Nil => + logger.debug(s"Regulated entity by certificate: $single") + // Only one match, proceed to role check + if (single.services.contains(serviceProvider)) { + logger.debug(s"Regulated entity by certificate (single.services: ${single.services}, serviceProvider: $serviceProvider): ") + Full(true) + } else { + ObpApiFailure(X509ActionIsNotAllowed, 403, cc) + } + case multiple => + // Ambiguity detected: more than one TPP matches the certificate + val names = multiple.map(e => s"'${e.entityName}' (Code: ${e.entityCode})").mkString(", ") + ObpApiFailure(s"$RegulatedEntityAmbiguityByCertificate: multiple TPPs found: $names", 401, cc) } - case multiple => - // Ambiguity detected: more than one TPP matches the certificate - val names = multiple.map(e => s"'${e.entityName}' (Code: ${e.entityCode})").mkString(", ") - ObpApiFailure(s"$RegulatedEntityAmbiguityByCertificate: multiple TPPs found: $names", 401, cc) - } + } } case value if value.toUpperCase == "CERTIFICATE" => Future { `getPSD2-CERT`(cc.map(_.requestHeaders).getOrElse(Nil)) match { diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala index 98026bf4ff..95706d8f3a 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupCheck.scala @@ -141,16 +141,19 @@ object BerlinGroupCheck extends MdcLoggable { logger.debug(s" Algorithm: ${parsed.algorithm}") logger.debug(s" Signature: ${parsed.signature}") - val certificate = getCertificateFromTppSignatureCertificate(reqHeaders) - val certSerialNumber = certificate.getSerialNumber + // A Signature header without a usable TPP-Signature-Certificate cannot have its keyId.SN + // checked against anything, so it is an invalid signature header (400) — not a 500. + val certSerialNumber = getCertificateFromTppSignatureCertificate(reqHeaders).map(_.getSerialNumber) - logger.debug(s"Certificate serial number (decimal): ${certSerialNumber.toString}") - logger.debug(s"Certificate serial number (hex): ${certSerialNumber.toString(16).toUpperCase}") + logger.debug(s"Certificate serial number (decimal): ${certSerialNumber.map(_.toString)}") + logger.debug(s"Certificate serial number (hex): ${certSerialNumber.map(sn => sn.toString(16).toUpperCase)}") - val snMatches = BerlinGroupSignatureHeaderParser.doesSerialNumberMatch(parsed.keyId.sn, certSerialNumber) + val snMatches = certSerialNumber + .map(BerlinGroupSignatureHeaderParser.doesSerialNumberMatch(parsed.keyId.sn, _)) + .getOrElse(false) if (!snMatches) { - logger.debug(s"Serial number mismatch. Parsed SN: ${parsed.keyId.sn}, Certificate decimal: ${certSerialNumber.toString}, Certificate hex: ${certSerialNumber.toString(16).toUpperCase}") + logger.debug(s"Serial number mismatch (or no usable certificate). Parsed SN: ${parsed.keyId.sn}, Certificate decimal: ${certSerialNumber.map(_.toString)}, Certificate hex: ${certSerialNumber.map(sn => sn.toString(16).toUpperCase)}") Some( ( fullBoxOrException( diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala index 0ea72011a7..f183de602e 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala @@ -168,40 +168,46 @@ object BerlinGroupSigning extends MdcLoggable { forwardResult case true => val requestHeaders = forwardResult._2.map(_.requestHeaders).getOrElse(Nil) - val certificate = getCertificateFromTppSignatureCertificate(requestHeaders) - X509.validateCertificate(certificate) match { - case Full(true) => // PEM certificate is ok - val generatedDigest = generateDigest(body.getOrElse("")) - val requestHeaderDigest = getHeaderValue(RequestHeader.Digest, requestHeaders) - if(generatedDigest == requestHeaderDigest) { // Verifying the Hash in the Digest Field - val signatureHeaderValue = getHeaderValue(RequestHeader.Signature, requestHeaders) - val signature = parseSignatureHeader(signatureHeaderValue).getOrElse("signature", "NONE") - val headersToSign = parseSignatureHeader(signatureHeaderValue).getOrElse("headers", "").split(" ").toList - val sn = parseSignatureHeader(signatureHeaderValue).getOrElse("keyId", "").split(" ").toList - val headers = headersToSign.map(h => - if (h.toLowerCase() == RequestHeader.Digest.toLowerCase()) { - s"$h: $generatedDigest" - } else { - s"$h: ${getHeaderValue(h, requestHeaders)}" + // checkRequestIsSigned only proves the header is present, not that it holds a usable + // certificate — an unparseable one is a 401, not a 500. + getCertificateFromTppSignatureCertificate(requestHeaders) match { + case Full(certificate) => + X509.validateCertificate(certificate) match { + case Full(true) => // PEM certificate is ok + val generatedDigest = generateDigest(body.getOrElse("")) + val requestHeaderDigest = getHeaderValue(RequestHeader.Digest, requestHeaders) + if(generatedDigest == requestHeaderDigest) { // Verifying the Hash in the Digest Field + val signatureHeaderValue = getHeaderValue(RequestHeader.Signature, requestHeaders) + val signature = parseSignatureHeader(signatureHeaderValue).getOrElse("signature", "NONE") + val headersToSign = parseSignatureHeader(signatureHeaderValue).getOrElse("headers", "").split(" ").toList + val sn = parseSignatureHeader(signatureHeaderValue).getOrElse("keyId", "").split(" ").toList + val headers = headersToSign.map(h => + if (h.toLowerCase() == RequestHeader.Digest.toLowerCase()) { + s"$h: $generatedDigest" + } else { + s"$h: ${getHeaderValue(h, requestHeaders)}" + } + ) + val signingString = headers.mkString("\n") + val isVerified = verifySignature(signingString, signature, certificate.getPublicKey) + val isValidated = CertificateVerifier.validateCertificate(certificate) + val bypassValidation = APIUtil.getPropsAsBoolValue("bypass_tpp_signature_validation", defaultValue = false) + (isVerified, isValidated) match { + case (true, true) => forwardResult + case (true, false) if bypassValidation => forwardResult + case (true, false) => apiFailure(ErrorMessages.X509PublicKeyCannotBeValidated, 401)(forwardResult) + case (false, _) => apiFailure(ErrorMessages.X509PublicKeyCannotVerify, 401)(forwardResult) + } + } else { // The two DIGEST hashes do NOT match, the integrity of the request body is NOT confirmed. + logger.debug(s"Generated digest: $generatedDigest") + logger.debug(s"Request header digest: $requestHeaderDigest") + apiFailure(ErrorMessages.X509PublicKeyCannotVerify, 401)(forwardResult) } - ) - val signingString = headers.mkString("\n") - val isVerified = verifySignature(signingString, signature, certificate.getPublicKey) - val isValidated = CertificateVerifier.validateCertificate(certificate) - val bypassValidation = APIUtil.getPropsAsBoolValue("bypass_tpp_signature_validation", defaultValue = false) - (isVerified, isValidated) match { - case (true, true) => forwardResult - case (true, false) if bypassValidation => forwardResult - case (true, false) => apiFailure(ErrorMessages.X509PublicKeyCannotBeValidated, 401)(forwardResult) - case (false, _) => apiFailure(ErrorMessages.X509PublicKeyCannotVerify, 401)(forwardResult) - } - } else { // The two DIGEST hashes do NOT match, the integrity of the request body is NOT confirmed. - logger.debug(s"Generated digest: $generatedDigest") - logger.debug(s"Request header digest: $requestHeaderDigest") - apiFailure(ErrorMessages.X509PublicKeyCannotVerify, 401)(forwardResult) + case Failure(msg, t, c) => (Failure(msg, t, c), forwardResult._2) // PEM certificate is not valid + case _ => apiFailure(ErrorMessages.X509GeneralError, 401)(forwardResult) // PEM certificate cannot be validated } - case Failure(msg, t, c) => (Failure(msg, t, c), forwardResult._2) // PEM certificate is not valid - case _ => apiFailure(ErrorMessages.X509GeneralError, 401)(forwardResult) // PEM certificate cannot be validated + case Failure(msg, t, c) => (Failure(msg, t, c), forwardResult._2) // certificate header unusable + case _ => apiFailure(ErrorMessages.X509CannotGetCertificate, 401)(forwardResult) } } } @@ -210,15 +216,40 @@ object BerlinGroupSigning extends MdcLoggable { requestHeaders.find(_.name.toLowerCase() == name.toLowerCase()).map(_.values.mkString) .getOrElse(SecureRandomUtil.csprng.nextLong().toString) } - def getCertificateFromTppSignatureCertificate(requestHeaders: List[HTTPParam]): X509Certificate = { - val certificate = getHeaderValue(RequestHeader.`TPP-Signature-Certificate`, requestHeaders) - // Decode the Base64 string - val decodedBytes = Base64.getDecoder.decode(certificate) - // Convert the bytes to a string (it could be PEM format for public key) - val decodedString = new String(decodedBytes, StandardCharsets.UTF_8) - - val certificatePemString = getCertificatePem(decodedString) - parseCertificate(certificatePemString) + /** + * The `TPP-Signature-Certificate` header parsed into an X509 certificate, or a Failure saying why + * it could not be. + * + * Returns a Box rather than throwing. Every step here consumes caller-supplied input and can fail + * on data we do not control: the header may be absent, not base64, base64 of something that is + * not a PEM, or a PEM that is not a certificate. Callers map the Failure to a 401; an exception + * would surface as a 500 instead. + * + * In particular this must NOT use `getHeaderValue`, which substitutes a random Long for an absent + * header. That is a serviceable "never matches" sentinel for the string comparisons it was + * written for (Digest, Signature), but not for a value about to be Base64-decoded: a negative + * Long renders with a leading '-', and `Base64.getDecoder` rejects it with + * "Illegal base64 character 2d" — so a merely missing header became a 500, and only for the + * ~half of calls where the random Long happened to be negative. + */ + def getCertificateFromTppSignatureCertificate(requestHeaders: List[HTTPParam]): Box[X509Certificate] = { + requestHeaders + .find(_.name.equalsIgnoreCase(RequestHeader.`TPP-Signature-Certificate`)) + .map(_.values.mkString.trim) + .filter(_.nonEmpty) match { + case None => + Failure(ErrorMessages.X509CannotGetCertificate) + case Some(headerValue) => + // Decode the Base64 string, then pull the PEM certificate out of whatever it decoded to. + Helpers.tryo(new String(Base64.getDecoder.decode(headerValue), StandardCharsets.UTF_8)) match { + case Full(decodedString) => + getCertificatePem(decodedString) match { + case "" => Failure(ErrorMessages.X509CannotGetCertificate) + case certificatePemString => Helpers.tryo(parseCertificate(certificatePemString)) ?~ ErrorMessages.X509GeneralError + } + case _ => Failure(ErrorMessages.X509GeneralError) + } + } } private def getCertificatePem(decodedString: String) = { @@ -276,8 +307,15 @@ object BerlinGroupSigning extends MdcLoggable { val tppSignatureCert: String = APIUtil.getRequestHeader(RequestHeader.`TPP-Signature-Certificate`, requestHeaders) if (tppSignatureCert.isEmpty) { Future(forwardResult) - } else { // Dynamic consumer creation/update works in case that RequestHeader.`TPP-Signature-Certificate` is present - val certificate = getCertificateFromTppSignatureCertificate(requestHeaders) + } else getCertificateFromTppSignatureCertificate(requestHeaders) match { + // Header present but unusable: leave the caller's result untouched rather than throwing. + // Consumer creation is a side effect of a signed request, not the thing being authorised — + // verifySignedRequest is what rejects a bad certificate. + case unusable if unusable.isEmpty => + logger.debug(s"getOrCreateConsumer: unusable TPP-Signature-Certificate: $unusable") + Future(forwardResult) + // Dynamic consumer creation/update, when TPP-Signature-Certificate holds a real certificate + case Full(certificate) => val extractedEmail = emailPattern.findFirstMatchIn(certificate.getSubjectDN.getName).map(_.group(1)) val extractOrganisation = organisationlPattern.findFirstMatchIn(certificate.getSubjectDN.getName).map(_.group(1)) From bc08fc098524b07c347fd06724e3e6e3cbef451b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Tue, 21 Jul 2026 14:15:18 +0200 Subject: [PATCH 04/15] fix(psd2): identify the TPP by the certificate its API standard actually uses BEHAVIOUR CHANGE for UK Open Banking endpoints when requirePsd2Certificates=ONLINE. Read the note at the end before upgrading. The PSD2 gate (passesPsd2Aisp / passesPsd2Pisp) identified the TPP from the TPP-Signature-Certificate header regardless of which API standard was being served. That header is Berlin Group NextGenPSD2. UK Open Banking does not use it at all: OBIE identifies the TPP by the mTLS transport certificate, which reaches OBP as PSD2-CERT -- set by the reverse proxy that terminates mTLS, or by bootstrap.http4s.Http4sMtls in development. A correctly behaving OBIE client was therefore rejected for omitting a header its specification never mentions. BerlinGroupCheck.validate already scopes every OTHER piece of Berlin Group machinery to Berlin Group URLs -- the mandatory headers, the request-signature verification, the on-the-fly consumer creation. The PSD2 gate was the single place that crossed that boundary. tppCertificateForStandard brings it in line: UK Open Banking URLs read PSD2-CERT, everything else keeps the previous code path byte for byte. One call site changes, which covers all 10 UK enforcement sites (5 in v3.1.0, 5 in v4.0.1) and any added later. Berlin Group (37 sites) and the OBP-native endpoints (4) are untouched. Only the certificate SOURCE changes, not what is done with it: both branches feed the same regulated-entity lookup, which matches on issuer CN + serial number and works with any X509 certificate. No eIDAS QCStatement is required -- that is only needed by the CERTIFICATE mode, which is unaffected. Verified end to end against a real TPP (OBP-Hola) over mutual TLS: - before registering the entity: 401 OBP-34102 (regulated entity not found), proving the mTLS certificate is now the one being looked up, where previously the same call returned OBP-20306 (no certificate in the request at all); - after registering it: the UK v4.0.1 consent is created, Status AWAITINGAUTHORISATION, with the gate logging the matched entity and PSP_AI; - /berlin-group/v1.3/consents still rejects on missing tpp-signature-certificate, unchanged. RegulatedEntityTest and the mTLS suites pass (13 tests). No test in this repo sends TPP-Signature-Certificate to a UK endpoint -- only the Berlin Group suites reference that header. UPGRADE NOTE. Under eIDAS a TPP holds two different certificates: the QWAC, used for TLS and therefore surfacing as PSD2-CERT, and the QSEAL, used to sign and therefore carried in TPP-Signature-Certificate. They have different serial numbers and often different issuing CAs. Since regulated entities are matched on issuer CN + serial, a deployment that runs UK endpoints with requirePsd2Certificates=ONLINE and onboarded its TPPs by QSEAL will now match a different entity, or none: - no PSD2-CERT present (no mTLS proxy) -> previously working TPPs get 401; - QWAC registered as a different entity -> that entity's services decide the call. This cannot authorise an unregistered party: both paths require a CA + serial match and fail closed. Deployments on requirePsd2Certificates=NONE are unaffected because the gate does not run. Register the QWAC alongside the QSEAL to migrate. --- .../main/scala/code/api/util/APIUtil.scala | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index c17eb54dcc..685f90979a 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -3849,19 +3849,56 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ currentSupportFormats.toStream.map(_.parse(date, parsePosition)).find(null.!=) } + /** + * The certificate identifying the TPP, taken from the channel the request's API standard + * actually uses. + * + * Berlin Group signs requests and carries the signing certificate in TPP-Signature-Certificate. + * UK Open Banking does not use that header at all: OBIE identifies the TPP by the mTLS transport + * certificate, which reaches us as PSD2-CERT (set by the reverse proxy that terminates mTLS, or + * by bootstrap.http4s.Http4sMtls in development). Reading the Berlin Group header on a UK + * endpoint therefore rejects a correctly-behaving OBIE client for missing a header its + * specification never mentions. + * + * BerlinGroupCheck.validate already scopes every OTHER piece of Berlin Group machinery -- the + * mandatory headers, the request-signature verification, the on-the-fly consumer creation -- to + * Berlin Group URLs. The PSD2 gate was the one place that crossed that boundary; this brings it + * in line. Non-UK standards keep the previous behaviour byte for byte. + * + * Note this changes only WHERE the certificate comes from, not what is then done with it: both + * paths feed the same regulated-entity lookup, which matches on issuer CN + serial number and so + * works with any X509 certificate. + */ + private def tppCertificateForStandard(cc: Option[CallContext]): Box[java.security.cert.X509Certificate] = { + val requestHeaders = cc.map(_.requestHeaders).getOrElse(Nil) + val url = cc.map(_.url).getOrElse("") + val isUkOpenBanking = url.contains(s"/${ApiVersion.ukOpenBankingV31.urlPrefix}/") + if (isUkOpenBanking) { + `getPSD2-CERT`(requestHeaders) match { + case Some(pem) => + tryo(BerlinGroupSigning.parseCertificate(pem)) ?~ X509GeneralError + case None => + logger.debug(s"tppCertificateForStandard: UK Open Banking request with no ${RequestHeader.`PSD2-CERT`} header") + Failure(X509CannotGetCertificate) + } + } else { + BerlinGroupSigning.getCertificateFromTppSignatureCertificate(requestHeaders) + } + } + private def passesPsd2ServiceProviderCommon(cc: Option[CallContext], serviceProvider: String) = { val result = getPropsValue("requirePsd2Certificates", "NONE") match { case value if value.toUpperCase == "ONLINE" => val requestHeaders = cc.map(_.requestHeaders).getOrElse(Nil) val consumerName = cc.flatMap(_.consumer.map(_.name.get)).getOrElse("") - getCertificateFromTppSignatureCertificate(requestHeaders) match { - // No usable TPP-Signature-Certificate: fail closed. passesPsd2ServiceProvider maps a - // Failure to a 401 -- this used to throw out of the base64 decode and become a 500. + tppCertificateForStandard(cc) match { + // No usable certificate: fail closed. passesPsd2ServiceProvider maps a Failure to a 401 + // -- this used to throw out of the base64 decode and become a 500. case failure: Failure => - logger.debug(s"passesPsd2ServiceProvider: no usable TPP-Signature-Certificate: $failure") + logger.debug(s"passesPsd2ServiceProvider: no usable TPP certificate: $failure") Future(failure) case Empty => - logger.debug("passesPsd2ServiceProvider: no TPP-Signature-Certificate header") + logger.debug("passesPsd2ServiceProvider: no TPP certificate header") Future(Failure(X509CannotGetCertificate)) case Full(certificate) => for { From e8a08af3e2aba8fcdbfadb5b588d4c42f0b71376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 23 Jul 2026 10:41:53 +0200 Subject: [PATCH 05/15] style(psd2): tidy the leftovers of the certificate Box conversion Two cosmetic loose ends from 3c863d3, no behaviour change. - APIUtil imported getCertificateFromTppSignatureCertificate for a call site that now qualifies it through BerlinGroupSigning, leaving the import as the only reference to a name the file no longer uses. - BerlinGroupSigning.getOrCreateConsumer gained a `case Full(certificate) =>` whose body was left at the indentation of the `else` block it came from, so ~55 lines sat one level out from the case that owns them and read as if they followed the match rather than belonging to a branch of it. Verified with a full compile. --- .../main/scala/code/api/util/APIUtil.scala | 1 - .../code/api/util/BerlinGroupSigning.scala | 113 +++++++++--------- 2 files changed, 56 insertions(+), 58 deletions(-) diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index 685f90979a..46ee81e2ed 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -46,7 +46,6 @@ import code.api.dynamic.entity.helper.DynamicEntityHelper import code.api.util.APIUtil.ResourceDoc.{findPathVariableNames, isPathVariable} import code.api.util.ApiRole._ import code.api.util.ApiTag.{ResourceDocTag, apiTagBank} -import code.api.util.BerlinGroupSigning.getCertificateFromTppSignatureCertificate import code.api.util.Consent.getConsumerKey import code.api.util.FutureUtil.{EndpointContext, EndpointTimeout} import code.api.util.Glossary.GlossaryItem diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala index f183de602e..47ecbd70fb 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupSigning.scala @@ -316,64 +316,63 @@ object BerlinGroupSigning extends MdcLoggable { Future(forwardResult) // Dynamic consumer creation/update, when TPP-Signature-Certificate holds a real certificate case Full(certificate) => - - val extractedEmail = emailPattern.findFirstMatchIn(certificate.getSubjectDN.getName).map(_.group(1)) - val extractOrganisation = organisationlPattern.findFirstMatchIn(certificate.getSubjectDN.getName).map(_.group(1)) - - for { - entities <- getRegulatedEntityByCertificate(certificate, forwardResult._2) - } yield { - entities match { - case Nil => - (ObpApiFailure(ErrorMessages.RegulatedEntityNotFoundByCertificate, 401, forwardResult._2), forwardResult._2) - - case single :: Nil => - val idno = single.entityCode - val entityName = Option(single.entityName) - - val consumer: Box[Consumer] = Consumers.consumers.vend.getOrCreateConsumer( - consumerId = None, - key = Some(Helpers.randomString(40).toLowerCase), - secret = Some(Helpers.randomString(40).toLowerCase), - aud = None, - azp = Some(idno), - iss = Some(RequestHeader.`TPP-Signature-Certificate`), - sub = None, - Some(true), - name = entityName, - appType = None, - description = Some(s"Certificate serial number:${certificate.getSerialNumber}"), - developerEmail = extractedEmail, - redirectURL = None, - createdByUserId = None, - certificate = None, - logoUrl = code.api.Constant.consumerDefaultLogoUrl - ) - - consumer match { - case Full(consumer) => - val certificateFromHeader = getHeaderValue(RequestHeader.`TPP-Signature-Certificate`, requestHeaders) - Consumers.consumers.vend.updateConsumer( - id = consumer.id.get, - name = entityName, - certificate = Some(certificateFromHeader) - ) match { - case Full(updatedConsumer) => - (forwardResult._1, forwardResult._2.map(_.copy(consumer = Full(updatedConsumer)))) - case error => - logger.debug(error) - (Failure(s"${ErrorMessages.CreateConsumerError} Regulated entity: $idno"), forwardResult._2) - } - case error => - logger.debug(error) - (Failure(s"${ErrorMessages.CreateConsumerError} Regulated entity: $idno"), forwardResult._2) - } - - case multiple => - val names = multiple.map(e => s"'${e.entityName}' (Code: ${e.entityCode})").mkString(", ") - (ObpApiFailure(s"${ErrorMessages.RegulatedEntityAmbiguityByCertificate}: multiple TPPs found: $names", 401, forwardResult._2), forwardResult._2) + val extractedEmail = emailPattern.findFirstMatchIn(certificate.getSubjectDN.getName).map(_.group(1)) + val extractOrganisation = organisationlPattern.findFirstMatchIn(certificate.getSubjectDN.getName).map(_.group(1)) + + for { + entities <- getRegulatedEntityByCertificate(certificate, forwardResult._2) + } yield { + entities match { + case Nil => + (ObpApiFailure(ErrorMessages.RegulatedEntityNotFoundByCertificate, 401, forwardResult._2), forwardResult._2) + + case single :: Nil => + val idno = single.entityCode + val entityName = Option(single.entityName) + + val consumer: Box[Consumer] = Consumers.consumers.vend.getOrCreateConsumer( + consumerId = None, + key = Some(Helpers.randomString(40).toLowerCase), + secret = Some(Helpers.randomString(40).toLowerCase), + aud = None, + azp = Some(idno), + iss = Some(RequestHeader.`TPP-Signature-Certificate`), + sub = None, + Some(true), + name = entityName, + appType = None, + description = Some(s"Certificate serial number:${certificate.getSerialNumber}"), + developerEmail = extractedEmail, + redirectURL = None, + createdByUserId = None, + certificate = None, + logoUrl = code.api.Constant.consumerDefaultLogoUrl + ) + + consumer match { + case Full(consumer) => + val certificateFromHeader = getHeaderValue(RequestHeader.`TPP-Signature-Certificate`, requestHeaders) + Consumers.consumers.vend.updateConsumer( + id = consumer.id.get, + name = entityName, + certificate = Some(certificateFromHeader) + ) match { + case Full(updatedConsumer) => + (forwardResult._1, forwardResult._2.map(_.copy(consumer = Full(updatedConsumer)))) + case error => + logger.debug(error) + (Failure(s"${ErrorMessages.CreateConsumerError} Regulated entity: $idno"), forwardResult._2) + } + case error => + logger.debug(error) + (Failure(s"${ErrorMessages.CreateConsumerError} Regulated entity: $idno"), forwardResult._2) + } + + case multiple => + val names = multiple.map(e => s"'${e.entityName}' (Code: ${e.entityCode})").mkString(", ") + (ObpApiFailure(s"${ErrorMessages.RegulatedEntityAmbiguityByCertificate}: multiple TPPs found: $names", 401, forwardResult._2), forwardResult._2) + } } - } } } From 7c2259924d359632df1f3d734d693e36083138a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 23 Jul 2026 11:19:39 +0200 Subject: [PATCH 06/15] docs(mtls): design proposal for terminating mTLS in production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not implemented — a proposal, marked as such at the top of the document and at both links to it. Production terminates mutual TLS at nginx and forwards the TPP certificate to OBP as the PSD2-CERT header over a plain HTTP hop. The proposal is to run mutual TLS on that hop too, so OBP sees a certificate for nginx as well as the forwarded one for the App. The question it raises — OBP now knows two parties by certificate, is that two Consumers? — is answered no: the App certificate is an identity we authorise on, the proxy certificate is a trust decision made once and discarded. Consumer is what rate limiting, metering, entitlements and consent binding key on, so admitting the proxy as one would make every request appear to come from a single Consumer. Four deployments are in scope and all are treated as legitimate: OBP as the TLS edge or behind nginx, in development or in production. They collapse onto one code path by asking, per request, whether the TLS peer is the caller or a forwarder trusted to name the caller — the X-Forwarded-For trust model applied to certificates. The distinguishing config is one axis, an allowlist that is empty for edge deployments, so run mode stops being a topology axis and the Development gate on the existing middleware has nothing left to encode. Two findings worth the reader's attention: - The PSD2-CERT path performs no chain or revocation validation. CertificateVerifier (PKIX + CRL) is reached only from the Berlin Group signature path, and JSSE disables revocation by default, so a deployment where OBP is the public TLS edge would accept a revoked but unexpired TPP certificate. nginx does this check today; taking the edge takes the obligation with it. Established by call site, not yet by test. - The same certificate arrives in three encodings depending on who terminated TLS, and three separate places downstream compensate ad hoc. Normalising once on ingress makes each an exact comparison and removes the encoding bugs that only appear in production. Linked from MTLS_DEV_MODE.md at the production-deployment rule that motivates it and from that document's file table. --- docs/MTLS_DEV_MODE.md | 8 ++ docs/MTLS_TOPOLOGIES.md | 272 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 docs/MTLS_TOPOLOGIES.md diff --git a/docs/MTLS_DEV_MODE.md b/docs/MTLS_DEV_MODE.md index 867826bf6f..466207d155 100644 --- a/docs/MTLS_DEV_MODE.md +++ b/docs/MTLS_DEV_MODE.md @@ -239,6 +239,13 @@ header. Two important rules for any proxy config: 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. +Rule 2 is the reason [`MTLS_TOPOLOGIES.md`](MTLS_TOPOLOGIES.md) exists: it makes the header only as +trustworthy as the network isolation around the OBP port. That document is a **proposal** (nothing +in it is implemented) to run mutual TLS on the proxy → OBP hop as well, so the forwarded header is +trusted because an authenticated peer sent it. It generalises the middleware described here to +cover OBP as the TLS edge or behind a proxy, in development or production, without a per-deployment +code path. + ## Implementation & tests | File | Role | @@ -248,6 +255,7 @@ header. Two important rules for any proxy config: | `scripts/mtls_env.sh` | The `OBP_MTLS_*` environment overrides behind the `--mtls` flag of both `flushall_*build_and_run.sh` scripts. Sourceable on its own. | | `scripts/java_env.sh` | Selects a JDK >= 17 for the run scripts. The build compiles with `-release 17`; on a default JDK 11 it otherwise fails with the misleading `'17' is not a valid choice for '-release'`. | | `obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsTest.scala` | Unit tests: PEM encoding, header injection/stripping, SSLContext from the checked-in keystores. | +| [`docs/MTLS_TOPOLOGIES.md`](MTLS_TOPOLOGIES.md) | **Proposal, not implemented.** Generalising the above to mTLS on the proxy → OBP hop, and to OBP as the TLS edge in production. | | `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: diff --git a/docs/MTLS_TOPOLOGIES.md b/docs/MTLS_TOPOLOGIES.md new file mode 100644 index 0000000000..c57d27027b --- /dev/null +++ b/docs/MTLS_TOPOLOGIES.md @@ -0,0 +1,272 @@ +# mTLS topologies: peer vs caller + +**Status: proposal.** Nothing here is implemented. `docs/MTLS_DEV_MODE.md` documents the mTLS +support that exists today; this document proposes generalising it so that OBP-API can terminate +mutual TLS in production as well as development, behind a proxy or as the edge, without a separate +code path per deployment. + +## 1. What prompted this + +The current production topology is: TPP application → mTLS → nginx, which verifies the client +certificate and forwards it to OBP-API as the `PSD2-CERT` header over a plain HTTP hop. The +proposal under discussion is to *also* run mutual TLS on the nginx → OBP-API hop, so OBP-API +terminates that connection itself and sees a certificate for nginx as well as the forwarded +certificate for the App. + +Four deployments are in scope, all of them considered legitimate: + +| | OBP-API is the TLS edge | OBP-API behind nginx | +|----------------|-------------------------|----------------------| +| **development** | supported today (`--mtls`) | **not currently possible** | +| **production** | blocked by the run-mode gate | supported today (plain HTTP hop) | + +## 2. Is the proxy a second Consumer? + +No. The question arises naturally — OBP ends up holding two certificates per request — but the two +certificates answer different questions and only one of them is an identity that OBP authorises on. + +| | App / TPP certificate | nginx certificate | +|---|---|---| +| Answers | *who is calling* | *is this our proxy* | +| Varies per | request | never — one certificate for the platform | +| Drives | Consumer lookup, consent binding, the PSD2 regulated-entity gate and AISP/PISP roles, rate limiting, metrics | nothing | + +Consumer is the key that rate limiting, metering, entitlements and consent binding are all keyed +on. Modelling the proxy as a Consumer would make every request in the system appear to originate +from one Consumer, so this is not a neutral modelling choice — it would break those features. + +The proxy certificate should instead be a **trust decision** that is made once, at the edge of the +request, and then discarded. It never becomes an identity. + +## 3. The unifying rule + +Dev-as-edge and prod-behind-nginx look like different modes only if the question is "which +deployment am I". They are the same problem under a different question: + +> Is my TLS peer the caller, or a forwarder I trust to tell me who the caller is? + +This is the `X-Forwarded-For` trust model applied to certificates. It yields one rule: + +- **Peer is in the trusted-forwarder set** → the peer is *not* the caller. Take the caller identity + from the forwarded `PSD2-CERT` header, which is trustworthy precisely *because* the forwarder + authenticated itself in the handshake. +- **Peer is not a trusted forwarder** → the peer *is* the caller. Its handshake certificate becomes + the caller identity, and any inbound `PSD2-CERT` header is a spoofing attempt and is stripped. + +### Per-request decision table + +| TLS peer | `PSD2-CERT` inbound | Caller identity | Notes | +|---|---|---|---| +| trusted forwarder | present | header certificate | production behind nginx | +| trusted forwarder | absent | none | legitimate — most OBP endpoints need no certificate | +| not a forwarder | present | handshake certificate, header stripped | dev today; a spoof attempt in production | +| not a forwarder | absent | handshake certificate | edge deployment with `client_auth=want` | +| no TLS at all | present | **policy decision — see §5.4** | where production lives today | + +### Why this satisfies the uniformity objective + +All four deployments in §1 are spanned by this one rule, distinguished by exactly one axis: + +- forwarder allowlist **empty** → OBP is the edge (dev-as-edge, prod-as-edge) +- forwarder allowlist **non-empty** → peer is the proxy (dev-behind-nginx, prod-behind-nginx) + +Development and production then differ only in keystore paths and in how strict the fail-closed +defaults are. There is no topology mode, no run-mode branch, and no second middleware to keep in +step with the first. + +**Corollary: run mode is not a topology axis.** The existing `Props.mode == Development` gate +(`Http4sMtls.scala:56`) encodes an assumption about deployment that all four supported cases +falsify. §5.3 proposes what should replace it. + +**Corollary: do not add a topology prop.** An explicit `mtls.topology=edge|behind_proxy` would +reintroduce exactly what this design removes: two pieces of state that can disagree with each +other. The topology is *inferred* from whether the allowlist is empty. + +## 4. What the current implementation does, and why it does not generalise + +`Http4sMtls.injectClientCertificate` (`Http4sMtls.scala:159`) strips any inbound `PSD2-CERT` and +replaces it with the certificate from the TLS handshake. That is correct for the one deployment it +was written for — OBP as the mTLS edge in development, where an inbound `PSD2-CERT` can only be a +spoof — and it is deliberately confined there (`Http4sMtls.scala:56-64`). + +Its role is to **mimic nginx**: terminate the handshake, translate the verified client certificate +into the single representation OBP consumes downstream (a PEM `PSD2-CERT` header, +`Http4sMtls.scala:148-151`), and hand the request to the identical code path production uses. There +is no second authentication mechanism in development, and no second identity — the client +certificate simply *is* the App. + +That is also why enabling it in production as-is would be wrong: it would faithfully do its job and +overwrite the App certificate forwarded by nginx with nginx's own handshake certificate. Every +request would arrive as the proxy. The trusted-forwarder rule in §3 is what generalises the +behaviour rather than special-casing it. + +## 5. Design choices + +### 5.1 How a forwarder is recognised + +| Option | Trade-off | +|---|---| +| (a) Second truststore — peers verified against `mtls.proxy.truststore` are forwarders, against `mtls.truststore` are callers | Semantically cleanest; doubles the ops surface (two stores to distribute and rotate) | +| (b) One truststore plus an allowlist of subject DNs or SHA-256 fingerprints | One store; explicit, auditable, greppable during an incident | +| (c) Certificate extension or OU convention | Fragile, implicit, hard to audit | + +**Recommendation: (b).** + +A sub-choice within (b) has real operational consequences: + +- **leaf fingerprint** — tightest, but every proxy certificate rotation requires an OBP config change; +- **issuer CA + subject DN** — rotation under the internal CA is free, at the cost that anything + that CA signs can act as a forwarder. + +**Recommendation: issuer CA + subject DN** for an internal CA, stated explicitly in the operator +documentation so the implication is not a surprise. + +### 5.2 Normalize the certificate on ingress + +This is the largest uniformity win available and it is independent of everything else in this +document. + +Today the same logical certificate arrives in at least three encodings — canonical 64-column PEM +from the dev injector, URL-encoded from nginx's `$ssl_client_escaped_cert`, single-line PEM from +HAProxy — and downstream code copes with that in three different ad-hoc ways: + +- `ConsentUtil.scala:154-163` — Consumer lookup on the raw header value; +- `ConsentUtil.scala:164-167` — a retry via `CertificateUtil.normalizePemX509Certificate` + (`CertificateUtil.scala:233`); +- `ConsentUtil.scala:207` + `removeBreakLines` (`ConsentUtil.scala:182`) — a third comparison in + the consent/Consumer match. + +Parsing the certificate once in the middleware and re-emitting one canonical form makes every one +of those an exact comparison, and removes the entire "worked in dev, failed in production" class of +encoding bugs. The helper already exists; it is simply applied late and inconsistently. + +### 5.3 What replaces the run-mode gate + +Dropping the `Development` gate is required by §3. But the gate is guarding something real — it is +just not the feature. The concrete risk is the **checked-in development keystore reaching +production** (`obp-api/src/test/resources/cert/server.jks`, password `123456`, now also the silent +fallback for the store props). + +**Recommendation:** replace the gate with a fingerprint check that refuses to boot in Production run +mode when the configured keystore is the repository's dev pair. Narrower than the current gate, and +it catches the failure that would actually cause harm. + +### 5.4 Migration for the existing plain-HTTP hop + +Existing production deployments run nginx → OBP over plain HTTP and trust the `PSD2-CERT` header +unconditionally. The only thing protecting that today is the network rule in +`docs/MTLS_DEV_MODE.md` ("the OBP port must not be reachable except through the proxy"): anything +that can route to the port can currently forge any TPP identity. Removing that exposure is the +central security argument for this work — it converts *trust the network* into *trust an +authenticated peer*. + +That cannot be a flag day. **Recommendation:** an explicit +`mtls.trust_forwarded_header_without_tls` prop, defaulting to today's behaviour, so the insecure +case is named and opt-in rather than implicit, and can be switched off per environment as each +gains the mTLS hop. + +### 5.5 Observability + +Record, per request, which branch of §3 resolved (direct caller vs forwarded) and the peer subject, +and carry it on the `CallContext` so metrics can record it. Without this, diagnosing "why is this +TPP suddenly anonymous" is guesswork and there is no audit trail for the trust decision. + +## 6. Deployment-specific considerations + +### 6.1 dev-behind-nginx — the cell that is missing, and why it is worth building + +It costs almost nothing once the forwarder rule exists (it is the same code with a non-empty +allowlist), and it is what makes the production cells testable. Today the production path has no +local reproduction at all. With it, the following become reproducible in a docker-compose: + +- the header encoding disagreement — nginx sends URL-encoded, the dev injector sends canonical PEM, + and today nobody finds out they differ until production; +- forwarder allowlist behaviour, including a proxy certificate rotating out of the allowlist; +- header-spoofing attempts arriving *through* the proxy; +- the "nginx failed to overwrite `PSD2-CERT`" misconfiguration. + +### 6.2 prod-as-edge — needs genuinely new security work + +This is not "dev-as-edge with different certificates". When nginx is the edge, `ssl_verify_client` +performs chain validation and can perform CRL/OCSP checks against the TPP's CA. If OBP is the +public edge, that responsibility moves to OBP — and the `PSD2-CERT` path does not do it today. + +Chain and revocation validation (`CertificateVerifier`, PKIX with CRL checking toggled by +`use_tpp_signature_revocation_list`, `CertificateVerifier.scala:83-86`) is invoked from exactly one +place: `BerlinGroupSigning.scala:193`, on the Berlin Group **signature** path. The `PSD2-CERT` path +does none of it — the PSD2 gate goes straight to the regulated-entity lookup on issuer CN + serial +(`APIUtil.scala:3871`, `APIUtil.scala:3888`), and the Consumer lookup is a PEM string match. JSSE +performs chain validation during the handshake against the configured truststore, but revocation +checking is disabled there by default unless explicitly enabled. + +**Consequence: in a prod-as-edge deployment a revoked but unexpired TPP certificate would currently +be accepted.** In a regulated deployment that is the difference between "OBP terminates mTLS" being +a configuration change and being a compliance question. The remedy is not large — enable revocation +on the TLS context and/or run the handshake certificate through `CertificateVerifier` — but it +should be scoped in from the start. dev-as-edge does not care, which is why it has not surfaced. + +Remaining prod-as-edge concerns are ordinary operations rather than security gaps: a publicly +trusted server certificate and its rotation; cipher and protocol policy that used to be nginx's; +load-balancer health probes that cannot present a client certificate (`want` vs `need`, or a +separate probe port); and the denial-of-service surface of terminating public TLS in the JVM. + +## 7. Rollout + +Each phase is independently shippable and backwards compatible. + +1. **Normalize `PSD2-CERT` on ingress** (§5.2). No behaviour change; removes the ad-hoc downstream + normalizations. +2. **Add peer-vs-forwarder resolution** (§3), with `mtls.trust_forwarded_header_without_tls` + defaulting to current behaviour (§5.4), and the dev-keystore boot check replacing the run-mode + gate (§5.3). Still no behaviour change in any existing deployment. +3. **dev-behind-nginx** (§6.1) — same code, non-empty allowlist; lands the compose setup and the + test matrix in CI. +4. **prod-behind-nginx** — enable the mTLS hop in one environment, set its forwarder allowlist, turn + `trust_forwarded_header_without_tls` off there; then roll to the rest and remove the legacy + default. +5. **prod-as-edge** — separately, once the revocation question in §6.2 has an answer. + +Building phase 3 before phase 4 means production-behind-nginx ships with a local reproduction +already in CI. + +## 8. Testing + +The four deployments × the five peer/header states in §3 form the matrix. The existing end-to-end +harness (`obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsHandshakeTest.scala`) already starts a +real Ember server and performs a real handshake, so it extends naturally: add a client that presents +a certificate *in* the forwarder allowlist together with a forwarded `PSD2-CERT`, which simulates +nginx without needing nginx in the unit tier. Cases that must be covered explicitly: + +- forwarded header preserved when the peer is a trusted forwarder; +- forwarded header **stripped** when the peer is not; +- fail closed when the peer presents no certificate under `client_auth=need`; +- every accepted header encoding normalizing to one canonical form (§5.2); +- a proxy certificate outside the allowlist being rejected rather than silently treated as a caller. + +## 9. Open questions + +- **Proxy certificate rotation without an OBP restart** — argues for CA-based allowlisting (§5.1) or + a reloadable truststore. +- **Which certificate nginx forwards under eIDAS.** A TPP holds a QWAC (used for TLS, so it is what + the handshake and `PSD2-CERT` carry) and a QSEAL (used for signing, so it is what + `TPP-Signature-Certificate` carries). They have different serial numbers and often different + issuing CAs, and the regulated-entity lookup matches on issuer CN + serial — the same split + addressed for UK Open Banking in commit `bc08fc098`. +- **Is authenticating the hop required in its own right** (audit, regulator, zero-trust posture), or + is network isolation currently considered sufficient? This materially changes the priority of + phases 4 and 5. +- **Does anything need to authorise on the proxy identity**, or only to prove "this is our proxy"? + If the latter — as §2 assumes — the two-Consumers question does not arise. +- **Cost of TLS termination in Ember** relative to per-request database work. Expected to be + negligible, but worth a measurement before it is asked about. + +## 10. Reference + +| File | Role | +|---|---| +| `obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala` | Props, `SSLContext`/`TLSContext`, the `PSD2-CERT` injection middleware this document generalises | +| `obp-api/src/main/scala/code/api/util/APIUtil.scala` | `getPSD2-CERT` (`:286`), `tppCertificateForStandard` (`:3871`), the PSD2 gate (`:3888`) | +| `obp-api/src/main/scala/code/api/util/ConsentUtil.scala` | Consumer lookup by certificate and the consent/Consumer match — the three normalizations of §5.2 | +| `obp-api/src/main/scala/code/api/util/CertificateUtil.scala` | `normalizePemX509Certificate` (`:233`) | +| `obp-api/src/main/scala/code/api/util/CertificateVerifier.scala` | PKIX chain + CRL validation; currently reached only from the Berlin Group signature path | +| `docs/MTLS_DEV_MODE.md` | The mTLS support that exists today, including the proxy configuration rules | From 59771092f9b9c8b34bb14b099d16c2f4b5b5b1c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 23 Jul 2026 14:11:01 +0200 Subject: [PATCH 07/15] docs(mtls): add the implementation plan to the topologies proposal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 11. Five sequenced changes, still a proposal — no code accompanies it. Two findings drive the sequencing. First, the certificate middleware is wired only when mtls.enabled, so in the topology production actually runs today — nginx over a plain HTTP hop — no certificate middleware runs at all. The work cannot extend the existing wrapper in place; the resolution middleware has to wrap httpApp unconditionally, with the TLS branch only supplying the peer certificate. Second, expressing the peer-vs-caller rule as a pure function makes the decision table a unit test with no server and no TLS, which is what keeps the later phases cheap. The first two PRs are behaviour-preserving in every existing deployment: an empty forwarder allowlist reproduces dev-as-edge exactly, and the legacy plain-hop prop defaults to what production does today. All of the actual change is deferred to per-environment configuration, gated on the logs showing requests resolving as forwarded before the insecure default is switched off anywhere. Records the one genuine regression path found while planning: normalising the PSD2-CERT header at ingress would break Consumers registered with a non-canonical PEM, which today match through the raw-value lookup that runs first. Both sides of the comparison have to be normalised, which is strictly more permissive than today. Noted explicitly because moving the normalisation and deleting the fallback looks equivalent and is not. Three decisions are called out as needing an answer before the main PR starts, one of them — how a forwarder is identified — because it is the only choice that is hard to reverse once operators have deployed the config format. --- docs/MTLS_TOPOLOGIES.md | 116 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/docs/MTLS_TOPOLOGIES.md b/docs/MTLS_TOPOLOGIES.md index c57d27027b..6e3d916837 100644 --- a/docs/MTLS_TOPOLOGIES.md +++ b/docs/MTLS_TOPOLOGIES.md @@ -270,3 +270,119 @@ nginx without needing nginx in the unit tier. Cases that must be covered explici | `obp-api/src/main/scala/code/api/util/CertificateUtil.scala` | `normalizePemX509Certificate` (`:233`) | | `obp-api/src/main/scala/code/api/util/CertificateVerifier.scala` | PKIX chain + CRL validation; currently reached only from the Berlin Group signature path | | `docs/MTLS_DEV_MODE.md` | The mTLS support that exists today, including the proxy configuration rules | + +## 11. Implementation plan + +Five changes, sequenced. Each is independently shippable, and PRs 1 and 2 are behaviour-preserving +in every existing deployment — the rollout is entirely in PR 4's configuration. + +### 11.1 Two decisions that shape all of it + +**The middleware must be wired unconditionally.** `injectClientCertificate` runs only when +`Http4sMtls.enabled` (`Http4sServer.scala:29-40`). In today's production topology — nginx over a +plain HTTP hop — no certificate middleware runs at all and the header passes straight through to +`ConsentUtil`. So this work cannot be done by extending the existing wrapper in place: the +resolution middleware has to wrap `Http4sApp.httpApp` unconditionally, with the TLS branch only +supplying the peer certificate. That refactor is what makes §11.2 and §11.3 possible at all. + +**The rule of §3 should be a pure function, not middleware logic.** + +```scala +sealed trait Resolution +case class DirectCaller(cert: X509Certificate) extends Resolution +case class ForwardedCaller(cert: X509Certificate, via: X509Certificate) extends Resolution +case class NoCaller(reason: String) extends Resolution +case class Rejected(reason: String) extends Resolution + +def resolve(peer: Option[X509Certificate], + forwarded: Option[String], + config: TrustConfig): Resolution +``` + +The decision table in §3 then becomes a table-driven unit test needing no server and no TLS, and the +middleware reduces to a shell that calls `resolve` and rewrites the header. Every later phase gets +cheaper for it. + +### 11.2 PR 1 — normalize `PSD2-CERT` on ingress + +Implements §5.2. Adds the always-on middleware; parses the header once and re-emits canonical PEM +via the existing `Http4sMtls.toPem`; removes the ad-hoc compensations at `ConsentUtil.scala:159-167` +and `ConsentUtil.scala:207`. + +**This is the one phase with a genuine regression path.** A Consumer registered with a +non-canonical PEM currently matches through the raw-value lookup that runs first; normalizing the +header alone would make that comparison fail. The mitigation is to normalize **both sides** — the +incoming header and the stored `clientCertificate` — at comparison time, which is strictly more +permissive than today, so no existing match can break. Moving normalization to ingress and deleting +the fallback is *not* equivalent and must not be done. + +Tests: one table covering URL-encoded (nginx), single-line PEM (HAProxy), canonical PEM (the dev +injector) and whitespace-mangled input all reducing to one value, plus a regression test for a +non-canonically stored Consumer. + +Small; mostly deletion downstream. + +### 11.3 PR 2 — peer-vs-forwarder resolution + +Implements §3, §5.1, §5.3, §5.4 and §5.5 — the bulk of the design. Adds `resolve` and its config, +reduces `injectClientCertificate` to a caller of it, and introduces: + +| Prop | Default | Meaning | +|---|---|---| +| `mtls.trusted_proxy_issuers` | empty | issuer CN + subject DN pairs treated as forwarders (§5.1) | +| `mtls.trust_forwarded_header_without_tls` | `true` | today's behaviour on a plain hop, now named (§5.4) | + +An empty allowlist reproduces current dev-as-edge behaviour exactly; `true` on the legacy prop +reproduces current production behaviour exactly. **Net behaviour change in every existing +deployment: none** — which is what makes this safe to merge well ahead of any rollout. + +Also in this PR: the run-mode gate at `Http4sMtls.scala:56` is replaced by the dev-keystore +fingerprint refusal of §5.3, and the resolution is recorded on the `CallContext` for metrics and +audit per §5.5. + +Tests: the five-state table as pure unit tests, plus an extension of +`Http4sMtlsHandshakeTest.scala` with a proxy certificate in the allowlist and a forwarded header, +asserting the header survives. That harness already generates certificates on the fly and builds a +real Ember server, so simulating nginx costs one additional generated keypair and no nginx. + +Medium. + +### 11.4 PR 3 — dev-behind-nginx + +Implements §6.1. No production code: a docker-compose with real nginx in front, a make target and +the CI job. Exercises the encoding disagreement, allowlist rotation, spoofing attempts arriving +through the proxy, and the missed-overwrite misconfiguration. + +Cheap once PR 2 exists, and it is what gives PRs 4 and 5 a local reproduction. Worth resisting the +temptation to defer: this is the phase that pays for the others. + +### 11.5 PR 4 — prod-behind-nginx rollout + +Configuration rather than code, ordered per environment: enable the mTLS hop, set that +environment's forwarder allowlist, confirm from the §5.5 logs that requests resolve as +`ForwardedCaller`, and only then set `trust_forwarded_header_without_tls` to `false` there. Roll to +the remaining environments, then delete the legacy default in a small follow-up. + +The observability from PR 2 is the gate: do not flip the prop in an environment until its logs show +every request resolving as forwarded. + +### 11.6 PR 5 — prod-as-edge, blocked on §6.2 + +Enable revocation checking on the TLS context and/or route the handshake certificate through +`CertificateVerifier`, whose CRL machinery and `use_tpp_signature_revocation_list` toggle already +exist (`CertificateVerifier.scala:83-86`) and are simply never reached from this path. Needs a test +with an actually revoked certificate — the piece that turns §6.2 from inference into fact. + +Sizing depends on the answers in §11.7, so this is deliberately left unscheduled. + +### 11.7 Decisions needed before PR 2 starts + +1. **Allowlist by issuer CA + subject DN, or by leaf fingerprint?** §5.1 recommends the former. This + is the only choice here that is hard to reverse, because it shapes a config format operators will + already have deployed. +2. **Is `trust_forwarded_header_without_tls=true` an acceptable default to ship?** It preserves + current behaviour, which is the safe engineering answer, but it ships a prop whose default is the + insecure setting. The alternative — defaulting it false — requires every existing deployment to + set it in the same release, i.e. a flag day. +3. **Does prod-as-edge have a real consumer?** If nobody is asking for it, PR 5 stays a documented + gap rather than scheduled work. From 6dfe26154340c514ca04259a3d52da162d3abe23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 23 Jul 2026 14:22:05 +0200 Subject: [PATCH 08/15] feat(mtls): canonicalise PSD2-CERT on ingress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First phase of docs/MTLS_TOPOLOGIES.md (§11.2). No behaviour change is intended for any certificate that authenticates today; what changes is that several more now do. The same certificate reaches OBP in whichever encoding the deployment's TLS terminator happens to produce: nginx percent-encodes $ssl_client_escaped_cert, HAProxy rebuilds a single-line PEM, the dev-mode in-process terminator injects canonical PEM, a hand-built client may send bare base64. Everything downstream compares certificates as strings -- the Consumer lookup, the consent/Consumer match, the PSD2 regulated-entity gate -- so each encoding behaves like a different certificate. That is the mechanism behind "it works in development and fails in production", and it is why the proxy documentation had to require a decoding step before OBP would accept nginx's output at all. Psd2CertIngress parses the header once and rewrites it to one canonical form, so those comparisons become exact. nginx's percent-encoded form is now understood directly and the njs step is no longer needed. A header that is not a parseable certificate is passed through untouched: normalisation is not authentication, and rejecting here would move an access-control decision into a formatting concern and take the error code away from the layer that owns it. Wired unconditionally into Http4sApp.httpApp rather than into Http4sServer's mtls.enabled branch. The deployment that needs this most -- a proxy forwarding the header over a plain HTTP hop -- runs no TLS middleware at all, so the existing branch could never have covered it. Http4sMtls.toPem now delegates to CertificateUtil.toPem so an injected certificate and a forwarded one are byte-identical by construction rather than by coincidence. Percent-decoding is hand-rolled because java.net.URLDecoder maps '+' to a space. That is correct for form encoding and silently corrupts a base64 payload; the test asserts a certificate carrying a '+' survives. The plan called for deleting the ad-hoc normalisations this makes redundant. Kept instead: the Consumer lookup reads the database, so only the request side can be normalised -- the stored value is whatever was pasted at registration. The consent check's removeBreakLines comparison is therefore now an alternative to comparePemX509Certificates rather than a replacement, since neither subsumes the other for every stored form and accepting either cannot break a Consumer that matched before. Doing this properly means normalising stored certificates at write time plus a migration, which is its own change. Tests: the encoding table, the '+' case, non-certificates, middleware pass- through, and the regression this had to be designed around -- a Consumer registered with a single-line PEM still matching once the header is canonical. 17 tests in the mTLS suites, plus 54 in the consent, regulated-entity and consumer-resolution suites, all pass. --- docs/MTLS_DEV_MODE.md | 9 +- docs/MTLS_TOPOLOGIES.md | 30 +++-- .../scala/bootstrap/http4s/Http4sMtls.scala | 17 ++- .../scala/code/api/util/CertificateUtil.scala | 67 ++++++++++- .../scala/code/api/util/ConsentUtil.scala | 15 ++- .../code/api/util/http4s/Http4sApp.scala | 6 +- .../api/util/http4s/Psd2CertIngress.scala | 58 ++++++++++ .../api/util/http4s/Psd2CertIngressTest.scala | 108 ++++++++++++++++++ 8 files changed, 285 insertions(+), 25 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/util/http4s/Psd2CertIngress.scala create mode 100644 obp-api/src/test/scala/code/api/util/http4s/Psd2CertIngressTest.scala diff --git a/docs/MTLS_DEV_MODE.md b/docs/MTLS_DEV_MODE.md index 466207d155..5520a63d01 100644 --- a/docs/MTLS_DEV_MODE.md +++ b/docs/MTLS_DEV_MODE.md @@ -232,10 +232,13 @@ Do **not** use this feature in production (it is disabled outside development mo 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: +1. The header value may be PEM (multi-line or single-line), nginx's percent-encoded + `$ssl_client_escaped_cert`, or bare base64 — all of them are decoded and rewritten to one + canonical PEM on ingress (`Psd2CertIngress`), so no njs decoding step is needed. HAProxy can + also rebuild a single-line PEM directly: `http-request set-header PSD2-CERT "-----BEGIN CERTIFICATE-----%[ssl_c_der,base64]-----END CERTIFICATE-----"`. + A value that is not a parseable certificate is passed through untouched and rejected later by + the authorisation code, not by this layer. 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. diff --git a/docs/MTLS_TOPOLOGIES.md b/docs/MTLS_TOPOLOGIES.md index 6e3d916837..d8cfe5069e 100644 --- a/docs/MTLS_TOPOLOGIES.md +++ b/docs/MTLS_TOPOLOGIES.md @@ -1,9 +1,10 @@ # mTLS topologies: peer vs caller -**Status: proposal.** Nothing here is implemented. `docs/MTLS_DEV_MODE.md` documents the mTLS -support that exists today; this document proposes generalising it so that OBP-API can terminate -mutual TLS in production as well as development, behind a proxy or as the edge, without a separate -code path per deployment. +**Status: proposal, first phase implemented.** §11.2 (PR 1, ingress normalisation) is merged; +everything else here — the peer-vs-forwarder rule itself included — is still a proposal. +`docs/MTLS_DEV_MODE.md` documents the mTLS support that exists today; this document proposes +generalising it so that OBP-API can terminate mutual TLS in production as well as development, +behind a proxy or as the edge, without a separate code path per deployment. ## 1. What prompted this @@ -303,11 +304,24 @@ The decision table in §3 then becomes a table-driven unit test needing no serve middleware reduces to a shell that calls `resolve` and rewrites the header. Every later phase gets cheaper for it. -### 11.2 PR 1 — normalize `PSD2-CERT` on ingress +### 11.2 PR 1 — normalize `PSD2-CERT` on ingress — **implemented** -Implements §5.2. Adds the always-on middleware; parses the header once and re-emits canonical PEM -via the existing `Http4sMtls.toPem`; removes the ad-hoc compensations at `ConsentUtil.scala:159-167` -and `ConsentUtil.scala:207`. +Implements §5.2. `Psd2CertIngress.canonicalize` runs unconditionally from `Http4sApp.httpApp`; +`CertificateUtil.canonicalizePemX509Certificate` percent-decodes, parses and re-emits canonical PEM +through `CertificateUtil.toPem`, which `Http4sMtls.toPem` now delegates to so the dev injector and +a forwarded certificate produce byte-identical values. + +Two deviations from the plan as written, both deliberate: + +- **The downstream compensations were kept, not removed.** `ConsentUtil.scala:159-167` looks the + Consumer up in the database, so only the request side of that comparison can be normalised; the + stored certificate is whatever was pasted at registration. The `removeBreakLines` comparison in + the consent check is now *alternative* to `comparePemX509Certificates` rather than replaced by it + — neither subsumes the other for every stored form, and accepting either keeps the change + strictly more permissive. Removing them safely needs the stored certificates normalised at write + time plus a migration, which is its own change. +- **Percent-decoding is hand-rolled rather than `java.net.URLDecoder`.** URLDecoder maps `+` to a + space, which is correct for form encoding and silently corrupts a base64 payload. **This is the one phase with a genuine regression path.** A Consumer registered with a non-canonical PEM currently matches through the raw-value lookup that runs first; normalizing the diff --git a/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala b/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala index 56fc785810..1241b28dcb 100644 --- a/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala +++ b/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala @@ -1,15 +1,14 @@ package bootstrap.http4s import java.io.{File, 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.api.RequestHeader +import code.api.util.{APIUtil, CertificateUtil} import code.util.Helper.MdcLoggable import fs2.io.net.tls.{TLSContext, TLSParameters} import net.liftweb.util.Props @@ -142,13 +141,11 @@ object Http4sMtls extends MdcLoggable { 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}" + // Canonical PEM, via the one emitter every path shares (CertificateUtil.toPem): the format + // developers paste when registering a Consumer, so the verbatim clientCertificate DB lookup can + // match. Ingress normalisation rewrites forwarded certificates into this same form, so a + // certificate injected here and the same certificate arriving through a proxy are byte-identical. + def toPem(certificate: X509Certificate): String = CertificateUtil.toPem(certificate) /** * Replaces the Jetty customizer of the old RunMTLSWebApp: takes the client certificate that diff --git a/obp-api/src/main/scala/code/api/util/CertificateUtil.scala b/obp-api/src/main/scala/code/api/util/CertificateUtil.scala index 8da1975553..e3fcb4bec4 100644 --- a/obp-api/src/main/scala/code/api/util/CertificateUtil.scala +++ b/obp-api/src/main/scala/code/api/util/CertificateUtil.scala @@ -256,13 +256,78 @@ object CertificateUtil extends MdcLoggable { val normalizedPem2 = normalizePemX509Certificate(pem2) val result = normalizedPem1 == normalizedPem2 - if(!result) { + if(!result) { logger.debug(s"normalizedPem1: ${normalizedPem1}") logger.debug(s"normalizedPem2: ${normalizedPem2}") } result } + // Canonical PEM: 64-column base64 between the standard header and footer, "\n" separated. This is + // the single form every part of OBP should see, whoever terminated TLS — see canonicalizePemX509Certificate. + private val canonicalPemEncoder = + java.util.Base64.getMimeEncoder(64, "\n".getBytes(java.nio.charset.StandardCharsets.US_ASCII)) + + /** An X509 certificate rendered as canonical PEM. */ + def toPem(certificate: X509Certificate): String = + s"${CertificateConstants.BEGIN_CERT}\n${canonicalPemEncoder.encodeToString(certificate.getEncoded)}\n${CertificateConstants.END_CERT}" + + /** + * Percent-decoding that leaves '+' alone. + * + * `java.net.URLDecoder` decodes '+' to a space, which is correct for form encoding and wrong + * here: '+' is a base64 alphabet character, so a certificate carrying one would be corrupted. + * nginx's `$ssl_client_escaped_cert` percent-escapes everything it needs to, '+' included, so + * there is nothing to gain from the form-encoding rule and a certificate to lose. + */ + private def percentDecode(value: String): String = { + val out = new java.io.ByteArrayOutputStream(value.length) + var i = 0 + while (i < value.length) { + value.charAt(i) match { + case '%' if i + 2 < value.length => + try { + out.write(Integer.parseInt(value.substring(i + 1, i + 3), 16)) + i += 3 + } catch { + case _: NumberFormatException => out.write('%'.toInt); i += 1 // not an escape, keep it + } + case c => + out.write(c.toString.getBytes(java.nio.charset.StandardCharsets.UTF_8)) + i += 1 + } + } + new String(out.toByteArray, java.nio.charset.StandardCharsets.UTF_8) + } + + /** + * Whatever representation of an X509 certificate arrived, rendered as canonical PEM — or None if + * it is not a certificate at all. + * + * The same certificate reaches OBP in several encodings depending on who terminated TLS: nginx's + * `$ssl_client_escaped_cert` is percent-encoded, HAProxy rebuilds a single-line PEM, the dev-mode + * in-process terminator injects canonical PEM, and a hand-built client may send bare base64 with + * no PEM markers at all. Downstream code then compares certificates as strings, so each encoding + * silently behaves like a different certificate — which is how a deployment works in development + * and fails in production. + * + * Normalising once on ingress makes every one of those comparisons exact. Note this is stricter + * than [[normalizePemX509Certificate]], which only rewrites whitespace and cannot tell a + * certificate from any other string: this parses, and so also rejects non-certificates. + */ + def canonicalizePemX509Certificate(raw: String): Option[String] = { + val trimmed = raw.trim + if (trimmed.isEmpty) None + else { + val decoded = if (trimmed.contains('%')) percentDecode(trimmed) else trimmed + // X509CertUtils.parse wants the PEM markers; supply them for bare base64. + val withMarkers = + if (decoded.contains(CertificateConstants.BEGIN_CERT)) decoded + else s"${CertificateConstants.BEGIN_CERT}\n$decoded\n${CertificateConstants.END_CERT}" + Option(X509CertUtils.parse(withMarkers)).map(toPem) + } + } + def main(args: Array[String]): Unit = { diff --git a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index 2f13ade77a..4b294c1774 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -207,8 +207,19 @@ object Consent extends MdcLoggable { val clientCert: String = APIUtil.`getPSD2-CERT`(callContext.requestHeaders).getOrElse(SecureRandomUtil.csprng.nextLong().toString) logger.debug(s"| Consent.checkConsumerIsActiveAndMatched | clientCert | $clientCert |") logger.debug(s"| Consent.checkConsumerIsActiveAndMatched | consumerFromConsent.clientCertificate | ${consumerFromConsent.clientCertificate} |") - if (removeBreakLines(clientCert) == removeBreakLines(consumerFromConsent.clientCertificate.get)) { - logger.debug(s"| removeBreakLines(clientCert) == removeBreakLines(consumerFromConsent.clientCertificate.get | true |") + // Normalise BOTH sides. PSD2-CERT is canonicalised on ingress (Psd2CertIngress), but the + // stored certificate is whatever was pasted when the Consumer was registered, so only + // the request side is under our control. The removeBreakLines comparison is kept as an + // alternative rather than replaced: it strips line breaks only, where + // comparePemX509Certificates rewrites whitespace between the PEM markers and leaves a + // marker-less value untouched. Neither subsumes the other for every stored form, so + // accepting either keeps this strictly more permissive than before — no Consumer that + // matched previously can stop matching. + val certificateMatches = + CertificateUtil.comparePemX509Certificates(clientCert, consumerFromConsent.clientCertificate.get) || + removeBreakLines(clientCert) == removeBreakLines(consumerFromConsent.clientCertificate.get) + if (certificateMatches) { + logger.debug(s"| Consent.checkConsumerIsActiveAndMatched | certificate matches | true |") Full(true) // This consent can be used by current application } else { // This consent can NOT be used by current application Failure(s"${ErrorMessages.ConsentDoesNotMatchConsumer} CONSUMER_CERTIFICATE") diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala index 72ca127695..a92f1624c9 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala @@ -162,7 +162,11 @@ object Http4sApp extends MdcLoggable { def httpApp: HttpApp[IO] = { val app = baseServices.orNotFound - Kleisli { req: Request[IO] => + Kleisli { rawReq: Request[IO] => + // Canonicalise PSD2-CERT before anything reads it. Unconditional on purpose: the deployment + // that needs it most — a proxy forwarding the header over a plain HTTP hop — enables no TLS + // middleware at all, so it cannot live in the mtls.enabled branch of Http4sServer. + val req = Psd2CertIngress.canonicalize(rawReq) app.run(req) .map(resp => stripBodyForHead(req, Http4sStandardHeaders(req, resp))) .handleErrorWith { e => diff --git a/obp-api/src/main/scala/code/api/util/http4s/Psd2CertIngress.scala b/obp-api/src/main/scala/code/api/util/http4s/Psd2CertIngress.scala new file mode 100644 index 0000000000..11dc8a3e0d --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/http4s/Psd2CertIngress.scala @@ -0,0 +1,58 @@ +package code.api.util.http4s + +import cats.effect.IO +import code.api.RequestHeader +import code.api.util.CertificateUtil +import code.util.Helper.MdcLoggable +import org.http4s.{Header, Request} +import org.typelevel.ci.CIString + +/** + * Rewrites the `PSD2-CERT` request header into canonical PEM, whoever terminated TLS. + * + * The same certificate reaches OBP in several encodings depending on the deployment: nginx's + * `$ssl_client_escaped_cert` is percent-encoded, HAProxy rebuilds a single-line PEM, the dev-mode + * in-process terminator (bootstrap.http4s.Http4sMtls) injects canonical PEM, and a hand-built + * client may send bare base64 with no PEM markers. Everything downstream — the Consumer lookup by + * certificate, the consent/Consumer match, the PSD2 regulated-entity gate — then compares + * certificates as strings, so each encoding behaves like a different certificate. That is the + * mechanism behind "it works in development and fails in production". + * + * Normalising here, once, at the outermost layer, makes all of those comparisons exact. It also + * removes the requirement documented in docs/MTLS_DEV_MODE.md that a proxy must hand OBP plain PEM: + * nginx's percent-encoded form is understood directly, so no njs decoding step is needed. + * + * This layer never rejects a request. A header that cannot be parsed as a certificate is passed + * through untouched, for the authorisation code to reject with its own error — normalisation is + * not authentication, and conflating them here would move an access-control decision into a + * formatting concern. + * + * Applied unconditionally, in [[Http4sApp.httpApp]], because the deployment that most needs it — + * a proxy forwarding the header over a plain HTTP hop — runs no TLS middleware at all. + */ +object Psd2CertIngress extends MdcLoggable { + + private val psd2CertHeaderName = CIString(RequestHeader.`PSD2-CERT`) + + /** + * The request with its `PSD2-CERT` header canonicalised. Returns the request unchanged when the + * header is absent, already canonical, or not parseable as a certificate. + */ + def canonicalize(req: Request[IO]): Request[IO] = + req.headers.get(psd2CertHeaderName).map(_.head.value) match { + case None => req + case Some(rawValue) => + CertificateUtil.canonicalizePemX509Certificate(rawValue) match { + case Some(canonical) if canonical == rawValue => + req + case Some(canonical) => + logger.debug(s"${RequestHeader.`PSD2-CERT`} canonicalised on ingress " + + s"(${rawValue.length} chars in, ${canonical.length} out)") + req.putHeaders(Header.Raw(psd2CertHeaderName, canonical)) + case None => + // Left for the authorisation layer to reject: it owns the error code and the audit trail. + logger.debug(s"${RequestHeader.`PSD2-CERT`} is not a parseable X509 certificate; passing it through unchanged") + req + } + } +} diff --git a/obp-api/src/test/scala/code/api/util/http4s/Psd2CertIngressTest.scala b/obp-api/src/test/scala/code/api/util/http4s/Psd2CertIngressTest.scala new file mode 100644 index 0000000000..3c5a9f8748 --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/http4s/Psd2CertIngressTest.scala @@ -0,0 +1,108 @@ +package code.api.util.http4s + +import java.security.cert.X509Certificate + +import cats.effect.IO +import code.api.CertificateConstants +import code.api.util.CertificateUtil +import code.api.util.SelfSignedCertificateUtil.generateSelfSignedCert +import org.http4s.{Header, Method, Request, Uri} +import org.scalatest.{FlatSpec, Matchers} +import org.typelevel.ci.CIString + +/** + * The point of ingress normalisation: the same certificate arrives in whichever encoding the + * deployment's TLS terminator happens to produce, and everything downstream compares certificates + * as strings. These tests pin every encoding we know of to one canonical form. + */ +class Psd2CertIngressTest extends FlatSpec with Matchers { + + private val psd2CertHeader = CIString("PSD2-CERT") + + private val certificate: X509Certificate = + generateSelfSignedCert("test-tpp")._2.asInstanceOf[X509Certificate] + + /** What every encoding below must reduce to. */ + private val canonical: String = CertificateUtil.toPem(certificate) + + private val base64Body: String = canonical + .replace(CertificateConstants.BEGIN_CERT, "") + .replace(CertificateConstants.END_CERT, "") + .replaceAll("\\s", "") + + private def singleLinePem: String = + s"${CertificateConstants.BEGIN_CERT}$base64Body${CertificateConstants.END_CERT}" + + /** nginx's $ssl_client_escaped_cert: percent-encoded, newlines as %0A. */ + private def percentEncoded: String = canonical + .replace("\n", "%0A") + .replace(" ", "%20") + .replace("+", "%2B") + .replace("/", "%2F") + .replace("=", "%3D") + + private def canonicalizedHeaderOf(rawValue: String): Option[String] = { + val req = Request[IO](Method.GET, Uri.unsafeFromString("/obp/v5.1.0/root")) + .putHeaders(Header.Raw(psd2CertHeader, rawValue)) + Psd2CertIngress.canonicalize(req).headers.get(psd2CertHeader).map(_.head.value) + } + + "canonicalizePemX509Certificate" should "reduce every encoding a TLS terminator produces to one form" in { + // The dev-mode in-process terminator (Http4sMtls) already injects this form. + CertificateUtil.canonicalizePemX509Certificate(canonical) shouldEqual Some(canonical) + // HAProxy rebuilds a single-line PEM. + CertificateUtil.canonicalizePemX509Certificate(singleLinePem) shouldEqual Some(canonical) + // nginx forwards $ssl_client_escaped_cert, which needed an njs decoding step before this. + CertificateUtil.canonicalizePemX509Certificate(percentEncoded) shouldEqual Some(canonical) + // A hand-built client may send bare base64 with no PEM markers at all. + CertificateUtil.canonicalizePemX509Certificate(base64Body) shouldEqual Some(canonical) + // Whitespace damage in transit. + CertificateUtil.canonicalizePemX509Certificate(s" $singleLinePem ") shouldEqual Some(canonical) + } + + it should "not corrupt a certificate whose base64 contains '+'" in { + // java.net.URLDecoder maps '+' to a space, which is right for form encoding and wrong for a + // base64 payload. The key is generated, so cancel rather than pass vacuously if this one has + // no '+' to be corrupted. + assume(base64Body.contains("+"), "generated certificate has no '+' in its base64") + val escapedExceptPlus = canonical.replace("\n", "%0A") + CertificateUtil.canonicalizePemX509Certificate(escapedExceptPlus) shouldEqual Some(canonical) + } + + it should "return None for values that are not certificates" in { + CertificateUtil.canonicalizePemX509Certificate("") shouldBe None + CertificateUtil.canonicalizePemX509Certificate(" ") shouldBe None + CertificateUtil.canonicalizePemX509Certificate("not a certificate") shouldBe None + CertificateUtil.canonicalizePemX509Certificate("-4611686018427387904") shouldBe None // the getHeaderValue sentinel + } + + "the ingress middleware" should "canonicalise the PSD2-CERT header in place" in { + canonicalizedHeaderOf(percentEncoded) shouldEqual Some(canonical) + canonicalizedHeaderOf(singleLinePem) shouldEqual Some(canonical) + canonicalizedHeaderOf(canonical) shouldEqual Some(canonical) + } + + it should "leave a request with no PSD2-CERT header untouched" in { + val req = Request[IO](Method.GET, Uri.unsafeFromString("/obp/v5.1.0/root")) + Psd2CertIngress.canonicalize(req).headers.get(psd2CertHeader) shouldBe None + } + + it should "pass an unparseable header through unchanged rather than rejecting it" in { + // Normalisation is not authentication: the authorisation layer owns the error code, so a + // garbage header must survive to reach it rather than being dropped or 400'd here. + canonicalizedHeaderOf("not a certificate") shouldEqual Some("not a certificate") + } + + // The regression this phase had to be designed around. Consumers are registered by pasting a PEM, + // so the stored value may be single-line; the lookup used to match it because the raw header was + // single-line too. Canonicalising the header changes that first comparison, and the whitespace- + // normalising fallback has to be what catches it — otherwise those Consumers stop authenticating. + "a Consumer stored with a single-line PEM" should "still match a canonicalised header after normalisation" in { + val storedByConsumerRegistration = singleLinePem + val headerAfterIngress = canonicalizedHeaderOf(percentEncoded).get + + headerAfterIngress should not equal storedByConsumerRegistration // the exact-match lookup misses + CertificateUtil.comparePemX509Certificates(headerAfterIngress, storedByConsumerRegistration) shouldBe true + CertificateUtil.normalizePemX509Certificate(headerAfterIngress) shouldEqual storedByConsumerRegistration + } +} From 89c3f7457991a24ec619e637462d0164147dc60d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 23 Jul 2026 14:33:30 +0200 Subject: [PATCH 09/15] docs(mtls): record the four design decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Taken 2026-07-23. Written into the sections the code will be built against rather than kept as an answered-questions list, so PR 2 has one place to read. - A forwarder is recognised by issuer CA + subject DN. §5.1 now carries the concrete prop format: indexed pairs, because a DN contains commas and any comma-separated list of DNs is ambiguous the first time someone writes a real one. Records the two things the format depends on — canonical X500Principal comparison does not reorder RDNs, so a differently-ordered DN silently fails to match, and a rejected peer must log its canonical issuer and subject, because the failure mode is every TPP behind that proxy going anonymous at once and it should be one log line to diagnose. - trust_forwarded_header_without_tls defaults to true, preserving current behaviour, with a boot warning so the permissive state is noisy rather than silent. A false default was rejected: it would need every deployment to set the prop in the same release, and anything missed fails closed, which means TPP traffic stops. - prod-as-edge was enumerated for completeness. Nobody is asking for it and the nginx to OBP connection is already secured by other means, so PR 5 is not scheduled and the revocation gap stays documented rather than blocking. Kept in the document, with the condition that reopens it stated: any deployment making OBP the public TLS edge. - Stored certificates are not normalised at write time. PR 1 normalises the request side; the fallbacks on the stored side stay. Two open questions are resolved by decision 3 and struck through with their answers. One is added in their place: "already secured by other means" needs to be pinned down, because a hop carrying TLS without client authentication puts that deployment on the fourth row of the §3 table rather than the fifth, where trust_forwarded_header_without_tls — which keys off no TLS at all — is not the prop that governs it. --- docs/MTLS_TOPOLOGIES.md | 99 +++++++++++++++++++++++++++++++---------- 1 file changed, 76 insertions(+), 23 deletions(-) diff --git a/docs/MTLS_TOPOLOGIES.md b/docs/MTLS_TOPOLOGIES.md index d8cfe5069e..c7010a8e28 100644 --- a/docs/MTLS_TOPOLOGIES.md +++ b/docs/MTLS_TOPOLOGIES.md @@ -119,8 +119,39 @@ A sub-choice within (b) has real operational consequences: - **issuer CA + subject DN** — rotation under the internal CA is free, at the cost that anything that CA signs can act as a forwarder. -**Recommendation: issuer CA + subject DN** for an internal CA, stated explicitly in the operator -documentation so the implication is not a surprise. +**Decided (2026-07-23): (b), keyed on issuer CA + subject DN.** + +Indexed prop pairs, because a DN contains commas and any comma-separated list of DNs is ambiguous +the first time someone writes a real one: + +```properties +# Peers whose handshake certificate makes them a forwarder rather than a caller. +# Empty (the default) = OBP is the TLS edge: the handshake certificate IS the caller. +mtls.trusted_proxy.1.issuer=CN=TESOBE Internal CA,O=TESOBE GmbH,C=DE +mtls.trusted_proxy.1.subject=CN=nginx-prod-1,OU=Edge,O=TESOBE GmbH,C=DE + +mtls.trusted_proxy.2.issuer=CN=TESOBE Internal CA,O=TESOBE GmbH,C=DE +mtls.trusted_proxy.2.subject=CN=nginx-prod-2,OU=Edge,O=TESOBE GmbH,C=DE +``` + +Settable from the environment like every OBP prop: +`OBP_MTLS_TRUSTED_PROXY_1_ISSUER`, `OBP_MTLS_TRUSTED_PROXY_1_SUBJECT`. + +`subject=*` accepts any subject signed by that issuer. This is the configuration that makes proxy +rotation genuinely free — sign the new proxy with the same CA, change nothing in OBP — and it is +also the configuration where "what else does this CA sign" becomes the entire security argument. +Support it; do not default to it; log a warning at boot when it is in use. + +Implementation notes that the format depends on: + +- Compare `X500Principal.getName(X500Principal.CANONICAL)` on both sides. It normalises case and + whitespace, so operators need not match those exactly — but it does **not** reorder RDNs, so + components written in a different order than the certificate silently fail to match. +- Document the command that prints exactly what to paste: + `openssl x509 -in nginx-prod-1.crt -noout -issuer -subject -nameopt RFC2253`. +- When a peer is rejected as not-a-forwarder, log its canonical issuer and subject. The failure + mode is "all TPP traffic through this proxy is suddenly anonymous", and it should be diagnosable + from one log line rather than a debugging session. ### 5.2 Normalize the certificate on ingress @@ -161,10 +192,16 @@ that can route to the port can currently forge any TPP identity. Removing that e central security argument for this work — it converts *trust the network* into *trust an authenticated peer*. -That cannot be a flag day. **Recommendation:** an explicit -`mtls.trust_forwarded_header_without_tls` prop, defaulting to today's behaviour, so the insecure -case is named and opt-in rather than implicit, and can be switched off per environment as each -gains the mTLS hop. +That cannot be a flag day. **Decided (2026-07-23):** an explicit +`mtls.trust_forwarded_header_without_tls` prop **defaulting to `true`** — today's behaviour — so the +insecure case is named rather than implicit, and can be switched off per environment as each gains +the mTLS hop. Defaulting it to `false` was rejected: it would require every deployment to set the +prop in the same release, and anything missed fails closed, i.e. TPP traffic stops. + +The cost of that choice is shipping a security-relevant prop whose default is the permissive +setting. Mitigate it by logging a warning at boot whenever it resolves to `true`, so the state is +noisy rather than silent, and remove the default entirely once §11.5 has rolled through every +environment. ### 5.5 Observability @@ -253,13 +290,22 @@ nginx without needing nginx in the unit tier. Cases that must be covered explici `TPP-Signature-Certificate` carries). They have different serial numbers and often different issuing CAs, and the regulated-entity lookup matches on issuer CN + serial — the same split addressed for UK Open Banking in commit `bc08fc098`. -- **Is authenticating the hop required in its own right** (audit, regulator, zero-trust posture), or - is network isolation currently considered sufficient? This materially changes the priority of - phases 4 and 5. -- **Does anything need to authorise on the proxy identity**, or only to prove "this is our proxy"? - If the latter — as §2 assumes — the two-Consumers question does not arise. - **Cost of TLS termination in Ember** relative to per-request database work. Expected to be negligible, but worth a measurement before it is asked about. +- **What "the nginx → OBP connection is already secured" means concretely** (§11.7 decision 3). If + that hop already carries TLS without client authentication, OBP terminates TLS but has no peer + certificate — the fourth row of the §3 table, not the fifth — and + `mtls.trust_forwarded_header_without_tls`, which keys off *no TLS at all*, would not be the prop + governing it. Worth pinning down before §11.5, because it decides which row that deployment + lands on. + +Answered on 2026-07-23, kept for the record: + +- ~~Is authenticating the hop required in its own right, or is network isolation sufficient?~~ The + hop is already secured by other means; this work removes the dependence on that, and is not + urgent remediation of an open exposure. +- ~~Does anything need to authorise on the proxy identity?~~ No — it need only prove "this is our + proxy", so the two-Consumers question of §2 does not arise. ## 10. Reference @@ -380,7 +426,11 @@ the remaining environments, then delete the legacy default in a small follow-up. The observability from PR 2 is the gate: do not flip the prop in an environment until its logs show every request resolving as forwarded. -### 11.6 PR 5 — prod-as-edge, blocked on §6.2 +### 11.6 PR 5 — prod-as-edge — **not scheduled** (§11.7 decision 3) + +Kept here because the gap it closes is real and will matter if the deployment assumption ever +changes. Nothing below is planned work. + Enable revocation checking on the TLS context and/or route the handshake certificate through `CertificateVerifier`, whose CRL machinery and `use_tpp_signature_revocation_list` toggle already @@ -389,14 +439,17 @@ with an actually revoked certificate — the piece that turns §6.2 from inferen Sizing depends on the answers in §11.7, so this is deliberately left unscheduled. -### 11.7 Decisions needed before PR 2 starts - -1. **Allowlist by issuer CA + subject DN, or by leaf fingerprint?** §5.1 recommends the former. This - is the only choice here that is hard to reverse, because it shapes a config format operators will - already have deployed. -2. **Is `trust_forwarded_header_without_tls=true` an acceptable default to ship?** It preserves - current behaviour, which is the safe engineering answer, but it ships a prop whose default is the - insecure setting. The alternative — defaulting it false — requires every existing deployment to - set it in the same release, i.e. a flag day. -3. **Does prod-as-edge have a real consumer?** If nobody is asking for it, PR 5 stays a documented - gap rather than scheduled work. +### 11.7 Decisions taken (2026-07-23) + +1. **How a forwarder is recognised** → issuer CA + subject DN, in the prop format now written out in + §5.1. Leaf fingerprints were rejected for the config change they impose on every proxy rotation. +2. **`trust_forwarded_header_without_tls` default** → `true`, preserving current behaviour, with a + boot warning when it resolves that way. See §5.4 for why a `false` default was rejected. +3. **prod-as-edge** → enumerated for completeness; nobody is asking for it, and the nginx → OBP + connection is already secured by other means. PR 5 (§11.6) is therefore **not scheduled**, and + the revocation gap in §6.2 stays a documented gap rather than blocking work. It must be + reopened before any deployment makes OBP the public TLS edge. +4. **Normalising stored certificates at write time** → not scheduled. PR 1 normalises the request + side; the compensating fallbacks on the stored side stay, with the comments in `ConsentUtil` + explaining why they are not redundant. Revisit if certificate-mismatch incidents suggest the + stored values are the problem. From 5794e6af8d51fa7dbf5dad9f9398d7315669c05a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 23 Jul 2026 15:01:02 +0200 Subject: [PATCH 10/15] feat(mtls): identify the caller by asking whether the TLS peer is a forwarder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second phase of docs/MTLS_TOPOLOGIES.md (§11.3). Behaviour-preserving in every existing deployment: the shipped defaults — an empty forwarder allowlist and mtls.trust_forwarded_header_without_tls=true — reproduce exactly what development-as-edge and production-behind-a-plain-hop did before. All of the change is deferred to per-environment configuration (§11.5). OBP terminates TLS in some deployments and sits behind a proxy that terminates it in others. These look like different problems only while the question is "which deployment is this". The question that unifies them is asked per request: is my TLS peer the caller, or a forwarder I trust to tell me who the caller is? That is the X-Forwarded-For trust model applied to certificates, and it collapses development-as-edge and production-behind-nginx onto one code path distinguished by one axis — whether the allowlist is empty. There is no topology mode to keep in step with the configuration, because the topology is inferred from it. PeerTrust.resolve is deliberately pure and free of http4s: the branch that matters most in production, a forwarded header arriving over a hop with no client certificate, is otherwise the hardest one to exercise. CallerCertificate is the shell that connects it to a request, and it replaces Http4sMtls.injectClientCertificate, which implemented one branch of the rule — "overwrite the header with the handshake certificate" — as though it were the whole of it. Enabled in production unchanged, that would have replaced the App's certificate with nginx's and made every request arrive as the proxy; a test now pins it. Wired unconditionally into Http4sApp.httpApp rather than Http4sServer's mtls.enabled branch, for the reason PR 1 had to move too: the deployment where the answer is least obvious runs no TLS middleware at all. The run-mode gate is gone. It encoded an assumption about deployment that all four supported topologies falsify, and terminating mTLS in production is now a supported one. What it was really standing in for — the dev keypair in this public repository reaching a production server — is now checked directly, by digest, so the store is recognised wherever it has been copied to. A test asserts the digests still match the files, so regenerating the dev pair fails the build rather than silently disarming the check. Two things surfaced while building it, both now pinned by tests: - The empty string is a valid X.500 name. X500Principal("") parses and canonicalises to "", so a blank trusted_proxy subject would have become a rule matching any certificate with an empty subject rather than a configuration error. - An HTTP header value cannot contain newlines, so a proxy can only ever forward a single-line PEM — the client rejects canonical multi-line PEM before it reaches the wire. This is what makes PR 1's ingress canonicalisation load-bearing, and it is why its regression analysis holds: the header side of every stored-certificate comparison was always single-line. Deviation from the plan: the sketched ADT's fourth case, Rejected, is not implemented because nothing produces it. A peer that is not a trusted forwarder is simply the caller, and an unusable certificate belongs to the authorisation layer to reject. Three cases, no dead branch. Tests: the §3 decision table row by row without a server, the middleware wiring, the production topology over a real handshake, and the dev-store digest guard. 38 in the mTLS suites; 106 across the consent, regulated-entity, consumer-resolution and DirectLogin suites; 114 in Http4s700RoutesTest as a whole-chain smoke test, since this middleware now runs for every request. --- docs/MTLS_DEV_MODE.md | 56 +++-- docs/MTLS_TOPOLOGIES.md | 70 +++--- .../resources/props/sample.props.template | 9 + .../scala/bootstrap/http4s/Http4sMtls.scala | 105 +++++---- .../scala/bootstrap/http4s/Http4sServer.scala | 5 +- .../main/scala/code/api/util/ApiSession.scala | 6 +- .../main/scala/code/api/util/PeerTrust.scala | 202 ++++++++++++++++++ .../api/util/http4s/CallerCertificate.scala | 61 ++++++ .../code/api/util/http4s/Http4sApp.scala | 10 +- .../code/api/util/http4s/Http4sSupport.scala | 14 +- .../http4s/Http4sMtlsHandshakeTest.scala | 54 ++++- .../bootstrap/http4s/Http4sMtlsTest.scala | 55 ++--- .../scala/code/api/util/PeerTrustTest.scala | 118 ++++++++++ .../util/http4s/CallerCertificateTest.scala | 117 ++++++++++ 14 files changed, 740 insertions(+), 142 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/util/PeerTrust.scala create mode 100644 obp-api/src/main/scala/code/api/util/http4s/CallerCertificate.scala create mode 100644 obp-api/src/test/scala/code/api/util/PeerTrustTest.scala create mode 100644 obp-api/src/test/scala/code/api/util/http4s/CallerCertificateTest.scala diff --git a/docs/MTLS_DEV_MODE.md b/docs/MTLS_DEV_MODE.md index 5520a63d01..f3a88538dc 100644 --- a/docs/MTLS_DEV_MODE.md +++ b/docs/MTLS_DEV_MODE.md @@ -1,13 +1,14 @@ -# Running OBP-API in mTLS Mode (Dev Feature) +# Running OBP-API in mTLS Mode -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. +OBP-API can terminate **mutual TLS (mTLS) in-process**: the http4s (Ember) server itself does the +TLS handshake, requires a client certificate, and identifies the caller from it. No reverse proxy +needed. This is the usual way to develop against mTLS locally. -> **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. +> **Who the certificate identifies depends on the deployment.** When OBP is the TLS edge, the +> handshake certificate IS the caller. When a reverse proxy terminates mTLS and forwards the +> client certificate as `PSD2-CERT`, the handshake certificate is the *proxy* and the header names +> the caller. Both are supported and both are decided by one rule per request — see +> [`MTLS_TOPOLOGIES.md`](MTLS_TOPOLOGIES.md) and the `mtls.trusted_proxy.*` props 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 @@ -20,9 +21,11 @@ curl --cert client.crt ── TLS handshake ──► Ember server (mtls.enabled │ 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) + Psd2CertIngress + CallerCertificate middleware + │ canonicalises any forwarded PSD2-CERT header + │ peer is not a trusted proxy, so it IS the caller: + │ strips that header (anti-spoofing) and injects + │ the verified handshake cert as PSD2-CERT (PEM) ▼ OBP application layer (unchanged) • consumer lookup by certificate @@ -31,8 +34,10 @@ curl --cert client.crt ── TLS handshake ──► Ember server (mtls.enabled ``` 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`. +production reverse proxy feeds. Implementation: `bootstrap/http4s/Http4sMtls.scala` (TLS context and +stores, wired in `bootstrap/http4s/Http4sServer.scala`) plus `code/api/util/PeerTrust.scala` and +`code/api/util/http4s/CallerCertificate.scala` (who the caller is), which run for every request +whether or not TLS terminates here. ## Quick start @@ -113,7 +118,9 @@ repo root, which is what the run scripts do. Each fallback is logged at WARN nam absolute file, and a missing store fails at startup with the resolved path and working directory in the message rather than a bare `FileNotFoundException`. -`run.mode` must be `development` (it is with the standard local run scripts). +Any `run.mode` works. A Production server refuses to boot on the development certificates checked +into this repository — they are recognised by digest wherever they are copied to, since the private +key is public and the password is in the source. ### 3. Run @@ -125,7 +132,8 @@ in the message rather than a bare `FileNotFoundException`. Boot log confirms the mode: ``` -mTLS termination is ENABLED (dev-only): serving HTTPS on port 8080, client_auth=need, keystore=..., truststore=... +mTLS termination is ENABLED: serving HTTPS on port 8080, client_auth=need, keystore=..., truststore=... +No mtls.trusted_proxy.N.issuer configured: OBP treats its TLS peer as the caller (it is the edge). ``` `dev.port` (default 8080) now speaks **HTTPS only** — plain `http://localhost:8080` requests @@ -161,8 +169,9 @@ curl --cacert server.crt --cert client.crt --key client.key \ # → 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. +Note that any `PSD2-CERT` header you send yourself is discarded — with no trusted proxies +configured, OBP is the edge, so your TLS peer is the caller and a forwarded certificate can only be +a spoofing attempt. Configure `mtls.trusted_proxy.*` and the header is honoured instead. ### 5. Pin a Consumer to the certificate @@ -193,12 +202,19 @@ Consumer — presenting a different client certificate is rejected. | Prop | Default | Meaning | |---|---|---| -| `mtls.enabled` | `false` | Master switch. Only honoured when `run.mode=development`. The one genuinely required prop. | +| `mtls.enabled` | `false` | Master switch, honoured in every run mode. The one genuinely required prop. | | `mtls.keystore.path` | `obp-api/src/test/resources/cert/server.jks` | Store with the server's private key + certificate. `.p12`/`.pfx` are read as PKCS12, anything else as JKS. | | `mtls.keystore.password` | `123456` | Password for keystore and key. | | `mtls.truststore.path` | `obp-api/src/test/resources/cert/server.trust.jks` | Store with client certificates / CAs the server accepts. Same type detection as the keystore. | | `mtls.truststore.password` | `123456` | Truststore password. | | `mtls.client_auth` | `need` | `need` rejects certless handshakes; `want` makes the client certificate optional. | +| `mtls.trusted_proxy.N.issuer` | — | Issuer DN of a peer allowed to forward someone else's certificate. Indexed from 1; scanning stops at the first missing index. Empty (the default) means OBP is the edge. | +| `mtls.trusted_proxy.N.subject` | any | Subject DN of that peer. `*` or unset accepts any subject the issuer signed — free proxy rotation, but only as tight as that CA. | +| `mtls.trust_forwarded_header_without_tls` | `true` | Whether a `PSD2-CERT` header is trusted when the sender presented no client certificate. `true` is the pre-existing behaviour of a plain proxy hop; set it to `false` once the proxy authenticates itself. | + +DNs are compared in canonical form, so case and spacing do not matter — but **RDN order does**. +Print the exact values to paste with +`openssl x509 -in proxy.crt -noout -issuer -subject -nameopt RFC2253`. Each of these is also settable from the environment as `OBP_MTLS_ENABLED`, `OBP_MTLS_KEYSTORE_PATH`, … which is what `--mtls` uses. @@ -216,7 +232,9 @@ Each of these is also settable from the environment as `OBP_MTLS_ENABLED`, | Symptom | Cause | |---|---| -| Boot warning `mtls.enabled=true is ignored` | `run.mode` is not `development`. | +| Boot fails with `which is one of the development stores checked into the OBP-API repository` | `run.mode=production` with the repo's dev keystore or truststore. Supply your own certificates. | +| Every request logs `none: PSD2-CERT was sent over a hop with no client certificate` | `mtls.trust_forwarded_header_without_tls=false` but the proxy is not presenting a client certificate. | +| A TPP behind the proxy is suddenly anonymous | The proxy's certificate is not matching `mtls.trusted_proxy.N.*`, so it is being treated as the caller and its forwarded header discarded. The rejection is logged with the peer's canonical issuer and subject — paste those into the props. | | Boot fails with `points at '…', which does not exist` | The store file isn't there. If you relied on the defaults, the server was launched from somewhere other than the repo root — the message prints the resolved path and the working directory. Use `--mtls` (absolute paths) or set the props absolutely. | | Logins that worked over plain HTTP now fail | `hostname` changed scheme without `local_identity_provider` being pinned — see the note under "Props reference". | | `curl: (35)` / `alert certificate required` | No (or untrusted) client certificate in `need` mode — check the cert is in `server.trust.jks`. | diff --git a/docs/MTLS_TOPOLOGIES.md b/docs/MTLS_TOPOLOGIES.md index c7010a8e28..7b10118263 100644 --- a/docs/MTLS_TOPOLOGIES.md +++ b/docs/MTLS_TOPOLOGIES.md @@ -1,10 +1,11 @@ # mTLS topologies: peer vs caller -**Status: proposal, first phase implemented.** §11.2 (PR 1, ingress normalisation) is merged; -everything else here — the peer-vs-forwarder rule itself included — is still a proposal. -`docs/MTLS_DEV_MODE.md` documents the mTLS support that exists today; this document proposes -generalising it so that OBP-API can terminate mutual TLS in production as well as development, -behind a proxy or as the edge, without a separate code path per deployment. +**Status: §11.2 and §11.3 implemented; §11.4 onward still proposed.** The peer-vs-forwarder rule of +§3 lives in `code.api.util.PeerTrust` and runs for every request. What remains is the +dev-behind-nginx harness (§11.4) and the per-environment rollout (§11.5), which is where any +deployment's behaviour actually changes — the shipped defaults reproduce what every deployment did +before. `docs/MTLS_DEV_MODE.md` is the operator-facing guide; this document is why it looks the way +it does. ## 1. What prompted this @@ -18,8 +19,11 @@ Four deployments are in scope, all of them considered legitimate: | | OBP-API is the TLS edge | OBP-API behind nginx | |----------------|-------------------------|----------------------| -| **development** | supported today (`--mtls`) | **not currently possible** | -| **production** | blocked by the run-mode gate | supported today (plain HTTP hop) | +| **development** | supported before this work (`--mtls`) | needs the harness of §11.4 | +| **production** | possible since §11.3; not scheduled (§11.7) | supported; mTLS on the hop is §11.5 | + +At the time of writing, the second column's production cell ran over a plain HTTP hop and the +run-mode gate blocked the first column's. ## 2. Is the proxy a second Consumer? @@ -75,31 +79,32 @@ Development and production then differ only in keystore paths and in how strict defaults are. There is no topology mode, no run-mode branch, and no second middleware to keep in step with the first. -**Corollary: run mode is not a topology axis.** The existing `Props.mode == Development` gate -(`Http4sMtls.scala:56`) encodes an assumption about deployment that all four supported cases -falsify. §5.3 proposes what should replace it. +**Corollary: run mode is not a topology axis.** The `Props.mode == Development` gate encoded an +assumption about deployment that all four supported cases falsify. §5.3 records what replaced it. **Corollary: do not add a topology prop.** An explicit `mtls.topology=edge|behind_proxy` would reintroduce exactly what this design removes: two pieces of state that can disagree with each other. The topology is *inferred* from whether the allowlist is empty. -## 4. What the current implementation does, and why it does not generalise +## 4. What the original implementation did, and why it did not generalise + +Kept as the rationale for §3; the code described here was replaced in §11.3. -`Http4sMtls.injectClientCertificate` (`Http4sMtls.scala:159`) strips any inbound `PSD2-CERT` and -replaces it with the certificate from the TLS handshake. That is correct for the one deployment it -was written for — OBP as the mTLS edge in development, where an inbound `PSD2-CERT` can only be a -spoof — and it is deliberately confined there (`Http4sMtls.scala:56-64`). +`Http4sMtls.injectClientCertificate` stripped any inbound `PSD2-CERT` and replaced it with the +certificate from the TLS handshake. That is correct for the one deployment it was written for — OBP +as the mTLS edge in development, where an inbound `PSD2-CERT` can only be a spoof — and it was +deliberately confined there by a run-mode gate. -Its role is to **mimic nginx**: terminate the handshake, translate the verified client certificate -into the single representation OBP consumes downstream (a PEM `PSD2-CERT` header, -`Http4sMtls.scala:148-151`), and hand the request to the identical code path production uses. There -is no second authentication mechanism in development, and no second identity — the client -certificate simply *is* the App. +Its role was to **mimic nginx**: terminate the handshake, translate the verified client certificate +into the single representation OBP consumes downstream (a PEM `PSD2-CERT` header), and hand the +request to the identical code path production uses. There was no second authentication mechanism in +development and no second identity — the client certificate simply *was* the App. -That is also why enabling it in production as-is would be wrong: it would faithfully do its job and -overwrite the App certificate forwarded by nginx with nginx's own handshake certificate. Every -request would arrive as the proxy. The trusted-forwarder rule in §3 is what generalises the -behaviour rather than special-casing it. +That is also why enabling it in production as-is would have been wrong: it would faithfully do its +job and overwrite the App certificate forwarded by nginx with nginx's own handshake certificate. +Every request would arrive as the proxy. The trusted-forwarder rule of §3 generalises the behaviour +instead of special-casing it, and that failure is now pinned by a test +(`CallerCertificateTest`: "keep the App's certificate, not the proxy's"). ## 5. Design choices @@ -382,7 +387,7 @@ non-canonically stored Consumer. Small; mostly deletion downstream. -### 11.3 PR 2 — peer-vs-forwarder resolution +### 11.3 PR 2 — peer-vs-forwarder resolution — **implemented** Implements §3, §5.1, §5.3, §5.4 and §5.5 — the bulk of the design. Adds `resolve` and its config, reduces `injectClientCertificate` to a caller of it, and introduces: @@ -405,7 +410,20 @@ Tests: the five-state table as pure unit tests, plus an extension of asserting the header survives. That harness already generates certificates on the fly and builds a real Ember server, so simulating nginx costs one additional generated keypair and no nginx. -Medium. +Two things found while building it, both now pinned by tests: + +- **The empty string is a valid X.500 name.** `X500Principal("")` parses and canonicalises back to + `""`, so a blank `mtls.trusted_proxy.N.subject` would have become a rule matching any certificate + with an empty subject rather than a configuration error. `canonicalDn` rejects blanks explicitly. +- **An HTTP header value cannot contain newlines**, so a proxy can only ever forward a single-line + PEM — canonical multi-line PEM is rejected by the client before it reaches the wire. This is + what makes §5.2's ingress canonicalisation load-bearing rather than cosmetic, and it is the + reason PR 1's regression analysis holds: the header side of every stored-certificate comparison + was always single-line. + +Deviation from the plan: the `Rejected` case of the sketched ADT was not implemented. Nothing +produces it — a peer that is not a trusted forwarder is simply the caller, and an unusable +certificate is the authorisation layer's to reject, not this layer's. Three cases, no dead branch. ### 11.4 PR 3 — dev-behind-nginx diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 0409a9dd82..3a8a949e71 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -169,6 +169,15 @@ jwt.use.ssl=false ## Only mtls.enabled is required: the four store props below default to exactly these values, ## the JKS pair checked into the repo for local development. Set them to use your own certificates. ## Easiest switch is not to edit this file at all: ./flushall_fast_build_and_run.sh --mtls +## +## Honoured in every run mode; a Production server refuses to boot on the dev pair below. +## If a reverse proxy terminates mTLS and forwards the client certificate as PSD2-CERT, name it +## here so its own handshake certificate is not mistaken for the caller's (see docs/MTLS_TOPOLOGIES.md): +# mtls.trusted_proxy.1.issuer=CN=Your Internal CA,O=Your Org,C=DE +# mtls.trusted_proxy.1.subject=CN=nginx-prod-1,O=Your Org,C=DE +## Whether a forwarded PSD2-CERT is trusted when the sender presented no client certificate. +## True is the long-standing behaviour of a plain proxy hop; set false once the proxy uses mTLS. +# mtls.trust_forwarded_header_without_tls=true # mtls.enabled=true # mtls.keystore.path=obp-api/src/test/resources/cert/server.jks # mtls.keystore.password=123456 diff --git a/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala b/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala index 1241b28dcb..5cb380dac8 100644 --- a/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala +++ b/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala @@ -5,19 +5,14 @@ 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.RequestHeader import code.api.util.{APIUtil, CertificateUtil} 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. + * 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 @@ -37,9 +32,15 @@ import org.typelevel.ci.CIString * Like every OBP prop these are also settable from the environment as OBP_MTLS_ENABLED, * OBP_MTLS_KEYSTORE_PATH, ... (see APIUtil.getPropsValue, which reads the environment first). * - * 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. + * This object only builds the TLS context and the store configuration. Deciding whether the peer it + * authenticates IS the caller or a proxy speaking for one belongs to + * code.api.util.PeerTrust / code.api.util.http4s.CallerCertificate, which run for every request + * whether or not TLS terminates here — see docs/MTLS_TOPOLOGIES.md. + * + * Honoured in every run mode. It was Development-only while OBP-as-the-edge was assumed to be a + * local convenience; the guard that replaced the run-mode gate refuses to boot a Production server + * on the development certificates checked into this repository, which is the risk the mode gate was + * really standing in for. */ object Http4sMtls extends MdcLoggable { @@ -51,24 +52,63 @@ object Http4sMtls extends MdcLoggable { 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 - } + /** True when mtls.enabled=true, in any run mode. */ + lazy val enabled: Boolean = APIUtil.getPropsAsBoolValue("mtls.enabled", false) // The checked-in dev pair: a CN=localhost server keypair and the TESOBE CA that signs the dev // client certificates. Repo-relative, so they resolve when the server is launched from the repo - // root (which is what the build_and_run scripts do). Only ever reached in Development run mode. + // root (which is what the build_and_run scripts do). private val DevKeystorePath = "obp-api/src/test/resources/cert/server.jks" private val DevTruststorePath = "obp-api/src/test/resources/cert/server.trust.jks" private val DevStorePassword = "123456" + /** + * SHA-256 of the checked-in dev stores, so they can be recognised wherever they are copied to. + * + * These are public: the private key is in the repository and the password is in this file. A + * production server using either would accept forged client certificates and present a + * certificate anyone can impersonate, so [[assertNotADevStoreInProduction]] refuses to boot. + * + * Http4sMtlsTest asserts these digests still match the files, so regenerating the dev pair fails + * the build here rather than silently disarming the check. + */ + private[http4s] val DevKeystoreSha256 = "c51d3c1694b3d3a5cb9fd7d41011b75ec22c2a35ef48f9713f9b0e00d54d78eb" + private[http4s] val DevTruststoreSha256 = "f47613f7ec4de4e291668c070047c256bf4578cfa9b1dabe5a61d056461473d0" + + private[http4s] def sha256Of(file: File): String = { + val digest = java.security.MessageDigest.getInstance("SHA-256") + val in = new FileInputStream(file) + try { + val buffer = new Array[Byte](8192) + var read = in.read(buffer) + while (read != -1) { + digest.update(buffer, 0, read) + read = in.read(buffer) + } + } finally in.close() + digest.digest().map("%02x".format(_)).mkString + } + + /** + * Refuses to boot a Production server on the repository's development certificates. + * + * This replaces the run-mode gate that used to disable mTLS termination outside Development. That + * gate was guarding the wrong thing: terminating mTLS in production is a supported topology (see + * docs/MTLS_TOPOLOGIES.md), while doing it with a keypair published in a public repository is + * not. Checking the file rather than the run mode also catches the store being copied elsewhere, + * which a path check would miss. + */ + private[http4s] def assertNotADevStoreInProduction(propName: String, file: File): Unit = + if (Props.mode == Props.RunModes.Production) { + val digest = sha256Of(file) + if (digest == DevKeystoreSha256 || digest == DevTruststoreSha256) { + throw new RuntimeException( + s"'$propName' points at ${file.getAbsolutePath}, which is one of the development stores " + + "checked into the OBP-API repository. Its private key is public and its password is " + + "'123456', so it cannot be used with run.mode=production. Supply your own certificates.") + } + } + /** The OBP_-prefixed environment variable APIUtil.getPropsValue consults ahead of the props file. */ private def envVarOf(propName: String): String = "OBP_" + propName.replace('.', '_').toUpperCase @@ -88,6 +128,7 @@ object Http4sMtls extends MdcLoggable { s"mtls.enabled=true but '$pathProp' points at '$path', which does not exist " + s"(resolved to ${file.getAbsolutePath} from working directory ${new File(".").getAbsolutePath}). " + s"Launch from the repo root or set '$pathProp' to an absolute path.") + assertNotADevStoreInProduction(pathProp, file) val password = prop(passwordProp).getOrElse { logger.warn(s"'$passwordProp' is not set — falling back to the checked-in dev store password.") DevStorePassword @@ -139,30 +180,10 @@ object Http4sMtls extends MdcLoggable { if (config.needClientAuth) TLSParameters(needClientAuth = true) else TLSParameters(wantClientAuth = true) - private val psd2CertHeaderName = CIString(RequestHeader.`PSD2-CERT`) - // Canonical PEM, via the one emitter every path shares (CertificateUtil.toPem): the format // developers paste when registering a Consumer, so the verbatim clientCertificate DB lookup can // match. Ingress normalisation rewrites forwarded certificates into this same form, so a - // certificate injected here and the same certificate arriving through a proxy are byte-identical. + // certificate presented in a handshake and the same certificate arriving through a proxy are + // byte-identical. def toPem(certificate: X509Certificate): String = CertificateUtil.toPem(certificate) - - /** - * 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 bc45aaf2d1..9babbaffb5 100644 --- a/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala +++ b/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala @@ -32,9 +32,12 @@ object Http4sServer extends IOApp with MdcLoggable { 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.") + // No certificate middleware here: Http4sApp.httpApp resolves the caller for every request, + // TLS or not (code.api.util.http4s.CallerCertificate). Ember exposes the handshake + // certificate on the request, which is all this branch needs to contribute. builder .withTLS(Http4sMtls.tlsContext, Http4sMtls.tlsParameters) - .withHttpApp(Http4sMtls.injectClientCertificate(httpApp)) + .withHttpApp(httpApp) } else { builder.withHttpApp(httpApp) } diff --git a/obp-api/src/main/scala/code/api/util/ApiSession.scala b/obp-api/src/main/scala/code/api/util/ApiSession.scala index 470ff8c66c..aa604aaca7 100644 --- a/obp-api/src/main/scala/code/api/util/ApiSession.scala +++ b/obp-api/src/main/scala/code/api/util/ApiSession.scala @@ -60,7 +60,11 @@ case class CallContext( view: Option[View] = None, counterparty: Option[CounterpartyTrait] = None, // Set when the request is authenticated via a consent. Persisted on metric rows for search/audit. - consentReferenceId: Option[String] = None + consentReferenceId: Option[String] = None, + // How the caller's certificate was established: "direct", "forwarded via ", + // or "none: ". See PeerTrust.Resolution — the audit trail for the trust + // decision that turned a TLS peer plus a header into an identity. + certificateTrust: Option[String] = None ) extends MdcLoggable { override def toString: String = SecureLogging.maskSensitive( s"${this.getClass.getSimpleName}(${this.productIterator.mkString(", ")})" diff --git a/obp-api/src/main/scala/code/api/util/PeerTrust.scala b/obp-api/src/main/scala/code/api/util/PeerTrust.scala new file mode 100644 index 0000000000..5ac529d344 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/PeerTrust.scala @@ -0,0 +1,202 @@ +package code.api.util + +import java.security.cert.X509Certificate +import javax.security.auth.x500.X500Principal + +import code.util.Helper.MdcLoggable +import net.liftweb.util.Helpers.tryo + +/** + * Who is calling, given the TLS peer and the forwarded certificate header. + * + * OBP terminates TLS in some deployments and sits behind a proxy that terminates it in others, and + * the two look like different problems only while the question is "which deployment is this". The + * question that unifies them is asked per request: + * + * is my TLS peer the caller, or a forwarder I trust to tell me who the caller is? + * + * This is the `X-Forwarded-For` trust model applied to certificates: + * + * - peer is a trusted forwarder -> the peer is NOT the caller; the forwarded `PSD2-CERT` header + * names the caller, and is trustworthy precisely because the forwarder authenticated itself; + * - peer is anything else -> the peer IS the caller; any inbound `PSD2-CERT` is a spoofing + * attempt and is discarded. + * + * Development-as-edge and production-behind-nginx are then the same code path with a different + * allowlist, which is the point: see docs/MTLS_TOPOLOGIES.md. + * + * Kept deliberately pure and free of http4s so the whole decision table can be tested without a + * server, a socket or a handshake. + */ +object PeerTrust extends MdcLoggable { + + /** + * A peer allowed to speak for someone else. + * + * @param issuer canonical issuer DN — the CA that signed the proxy certificate + * @param subject canonical subject DN, or None for "any subject this issuer signs" + */ + case class TrustedProxy(issuer: String, subject: Option[String]) + + case class TrustConfig( + trustedProxies: List[TrustedProxy] = Nil, + trustForwardedHeaderWithoutTls: Boolean = true + ) { + def isForwarder(certificate: X509Certificate): Boolean = { + val certIssuer = canonicalOf(certificate.getIssuerX500Principal) + val certSubject = canonicalOf(certificate.getSubjectX500Principal) + trustedProxies.exists { proxy => + proxy.issuer == certIssuer && proxy.subject.forall(_ == certSubject) + } + } + } + + /** The outcome of the question above. */ + sealed trait Resolution { + /** The certificate identifying the caller, as it should reach the rest of OBP. */ + def callerPem: Option[String] + /** One line for the log and the metric row. */ + def describe: String + } + + /** The TLS peer is the caller: OBP is the edge. */ + case class DirectCaller(pem: String) extends Resolution { + val callerPem: Option[String] = Some(pem) + val describe: String = "direct" + } + + /** A trusted forwarder named the caller. */ + case class ForwardedCaller(pem: String, via: String) extends Resolution { + val callerPem: Option[String] = Some(pem) + val describe: String = s"forwarded via $via" + } + + /** Nobody is identified by a certificate. Most OBP endpoints do not need one. */ + case class NoCaller(reason: String) extends Resolution { + val callerPem: Option[String] = None + val describe: String = s"none: $reason" + } + + private def canonicalOf(principal: X500Principal): String = principal.getName(X500Principal.CANONICAL) + + /** + * A configured DN in the same canonical form the certificates are compared in, or None when it + * cannot be parsed as a DN at all. + * + * Note that `X500Principal.CANONICAL` normalises case and whitespace but does NOT reorder the + * RDNs, so a DN whose components are written in a different order than the certificate's will not + * match. `openssl x509 -noout -issuer -subject -nameopt RFC2253` prints the form to copy. + */ + def canonicalDn(dn: String): Option[String] = { + // The empty string is a VALID X.500 name — an empty RDN sequence — so X500Principal accepts it + // and canonicalises it back to "". Left to that, a blank `mtls.trusted_proxy.N.subject` would + // become a rule matching any certificate with an empty subject rather than a configuration + // error. Reject it here. + if (dn.trim.isEmpty) None + else tryo(new X500Principal(dn.trim).getName(X500Principal.CANONICAL)).toOption + } + + /** + * The rule. Every branch is covered by the decision table in docs/MTLS_TOPOLOGIES.md §3. + * + * `forwarded` is the inbound `PSD2-CERT` value, already canonicalised by Psd2CertIngress. It is + * passed through as a string rather than a parsed certificate on purpose: an unparseable value + * must still reach the authorisation layer, which owns the error code. Deciding who the caller is + * and deciding whether their certificate is any good are different jobs. + */ + def resolve(peer: Option[X509Certificate], forwarded: Option[String], config: TrustConfig): Resolution = + peer match { + case Some(peerCertificate) if config.isForwarder(peerCertificate) => + val via = canonicalOf(peerCertificate.getSubjectX500Principal) + forwarded match { + case Some(pem) => ForwardedCaller(pem, via) + // Legitimate: most endpoints need no certificate, and the proxy forwards what it got. + case None => NoCaller(s"trusted forwarder $via sent no certificate") + } + + case Some(peerCertificate) => + // The peer authenticated as itself, so it is the caller. Any inbound header is discarded: + // when we are the edge, a forwarded certificate can only be a spoofing attempt. + if (forwarded.isDefined) { + logger.debug("Discarding an inbound PSD2-CERT header: the TLS peer is not a trusted forwarder, " + + s"so it is the caller (subject ${canonicalOf(peerCertificate.getSubjectX500Principal)})") + } + DirectCaller(CertificateUtil.toPem(peerCertificate)) + + case None => + forwarded match { + case Some(pem) if config.trustForwardedHeaderWithoutTls => + ForwardedCaller(pem, "unauthenticated hop") + case Some(_) => + // Fail closed: with no authenticated peer there is nothing to make the header credible. + NoCaller("PSD2-CERT was sent over a hop with no client certificate, and " + + "mtls.trust_forwarded_header_without_tls is false") + case None => + NoCaller("no TLS client certificate and no PSD2-CERT header") + } + } + + private val TrustForwardedWithoutTlsProp = "mtls.trust_forwarded_header_without_tls" + + /** + * The configured trust, read once. + * + * Proxies are indexed pairs — `mtls.trusted_proxy.1.issuer` / `.subject` — rather than one + * delimited list, because a DN contains commas and any comma-separated list of DNs is ambiguous + * the first time somebody writes a real one. `subject=*` accepts any subject that issuer signs. + */ + lazy val config: TrustConfig = fromProps() + + def fromProps(): TrustConfig = { + def prop(name: String): Option[String] = + APIUtil.getPropsValue(name).toOption.map(_.trim).filter(_.nonEmpty) + + val proxies = Stream.from(1) + .map(i => (i, prop(s"mtls.trusted_proxy.$i.issuer"))) + .takeWhile(_._2.isDefined) + .flatMap { case (i, issuerProp) => + val issuer = issuerProp.get + canonicalDn(issuer) match { + case None => + // Fail closed: an unparseable DN matches nothing, so this proxy simply is not trusted. + logger.error(s"mtls.trusted_proxy.$i.issuer is not a valid X.500 distinguished name and " + + s"will never match: '$issuer'. Print the value to use with " + + s"'openssl x509 -in proxy.crt -noout -issuer -nameopt RFC2253'.") + None + case Some(canonicalIssuer) => + prop(s"mtls.trusted_proxy.$i.subject") match { + case None | Some("*") => + logger.warn(s"mtls.trusted_proxy.$i trusts ANY certificate issued by '$issuer' to act " + + s"as a forwarder. Set mtls.trusted_proxy.$i.subject to pin a specific proxy unless " + + s"that CA signs nothing else.") + Some(TrustedProxy(canonicalIssuer, None)) + case Some(subject) => + canonicalDn(subject) match { + case None => + logger.error(s"mtls.trusted_proxy.$i.subject is not a valid X.500 distinguished name " + + s"and will never match: '$subject'.") + None + case Some(canonicalSubject) => Some(TrustedProxy(canonicalIssuer, Some(canonicalSubject))) + } + } + } + }.toList + + val trustWithoutTls = APIUtil.getPropsAsBoolValue(TrustForwardedWithoutTlsProp, defaultValue = true) + + if (proxies.isEmpty) { + logger.info("No mtls.trusted_proxy.N.issuer configured: OBP treats its TLS peer as the caller " + + "(it is the edge). Configure trusted proxies if a reverse proxy forwards PSD2-CERT.") + } else { + logger.info(s"Trusted forwarders: ${proxies.map(p => p.subject.getOrElse("") + " issued by " + p.issuer).mkString("; ")}") + } + if (trustWithoutTls) { + // Deliberately noisy: the default is the permissive setting, chosen so that existing + // deployments upgrade unchanged. It should not be able to sit there unnoticed. + logger.warn(s"$TrustForwardedWithoutTlsProp is true: a PSD2-CERT header is trusted even when the " + + "sender presented no client certificate, so anything that can reach this port can claim any " + + "TPP identity. Set it to false once the proxy authenticates itself over mTLS.") + } + TrustConfig(proxies, trustWithoutTls) + } +} diff --git a/obp-api/src/main/scala/code/api/util/http4s/CallerCertificate.scala b/obp-api/src/main/scala/code/api/util/http4s/CallerCertificate.scala new file mode 100644 index 0000000000..b7cf8e1dd6 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/http4s/CallerCertificate.scala @@ -0,0 +1,61 @@ +package code.api.util.http4s + +import java.security.cert.X509Certificate + +import cats.effect.IO +import code.api.RequestHeader +import code.api.util.PeerTrust +import code.util.Helper.MdcLoggable +import org.http4s.{Header, Request} +import org.http4s.server.ServerRequestKeys +import org.typelevel.ci.CIString + +/** + * Sets `PSD2-CERT` to the certificate of whoever is actually calling. + * + * Thin shell over [[PeerTrust.resolve]]: it collects the two inputs — the certificate Ember + * verified in the TLS handshake, and the forwarded header — hands them to the rule, and rewrites + * the request accordingly. All of the reasoning lives in `PeerTrust`, which is pure and testable + * without a server; this file exists only to connect it to http4s. + * + * Replaces `Http4sMtls.injectClientCertificate`, which did the same job for the single deployment + * it was written for (OBP as the mTLS edge in development) by unconditionally overwriting the + * header with the handshake certificate. That is one branch of the rule rather than the whole of + * it, and applying it behind a proxy would replace the App's certificate with the proxy's. + * + * Applied unconditionally from [[Http4sApp.httpApp]], not from the `mtls.enabled` branch of + * Http4sServer: the deployment where the answer is least obvious — a proxy forwarding the header + * over a hop with no client certificate — enables no TLS middleware at all. + */ +object CallerCertificate extends MdcLoggable { + + private val psd2CertHeaderName = CIString(RequestHeader.`PSD2-CERT`) + + /** Certificate the TLS handshake verified, if this request arrived over mTLS at all. */ + private def peerCertificate(req: Request[IO]): Option[X509Certificate] = + req.attributes + .lookup(ServerRequestKeys.SecureSession) + .flatten + .flatMap(_.X509Certificate.headOption) + + def resolveCaller(req: Request[IO]): Request[IO] = resolveCaller(req, PeerTrust.config) + + /** Overload taking the trust configuration explicitly, so tests can pin a deployment. */ + def resolveCaller(req: Request[IO], config: PeerTrust.TrustConfig): Request[IO] = { + val forwarded = req.headers.get(psd2CertHeaderName).map(_.head.value).filter(_.trim.nonEmpty) + val peer = peerCertificate(req) + + // Nothing to decide and nothing to strip: the overwhelmingly common request. + if (peer.isEmpty && forwarded.isEmpty) req + else { + val resolution = PeerTrust.resolve(peer, forwarded, config) + logger.debug(s"Caller certificate resolved as ${resolution.describe}") + + val withHeader = resolution.callerPem match { + case Some(pem) => req.putHeaders(Header.Raw(psd2CertHeaderName, pem)) + case None => req.removeHeader(psd2CertHeaderName) + } + withHeader.withAttribute(Http4sRequestAttributes.callerCertificateTrustKey, resolution.describe) + } + } +} diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala index a92f1624c9..2bd61744cc 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala @@ -163,10 +163,12 @@ object Http4sApp extends MdcLoggable { def httpApp: HttpApp[IO] = { val app = baseServices.orNotFound Kleisli { rawReq: Request[IO] => - // Canonicalise PSD2-CERT before anything reads it. Unconditional on purpose: the deployment - // that needs it most — a proxy forwarding the header over a plain HTTP hop — enables no TLS - // middleware at all, so it cannot live in the mtls.enabled branch of Http4sServer. - val req = Psd2CertIngress.canonicalize(rawReq) + // Establish who is calling before anything reads PSD2-CERT: canonicalise the header into the + // one form the rest of OBP compares, then decide whether the TLS peer is that caller or a + // trusted forwarder naming it. Unconditional on purpose — the deployment where the answer is + // least obvious, a proxy forwarding the header over a hop with no client certificate, enables + // no TLS middleware at all, so neither step can live in Http4sServer's mtls.enabled branch. + val req = CallerCertificate.resolveCaller(Psd2CertIngress.canonicalize(rawReq)) app.run(req) .map(resp => stripBodyForHead(req, Http4sStandardHeaders(req, resp))) .handleErrorWith { e => diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala index 164e160546..ef426f62cf 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala @@ -66,7 +66,18 @@ object Http4sRequestAttributes { */ val cachedBodyKey: Key[Option[String]] = Key.newKey[IO, Option[String]].unsafeRunSync()(cats.effect.unsafe.IORuntime.global) - + + /** + * Vault key for how the caller's certificate was established — "direct", "forwarded via ", + * or "none: " (see PeerTrust.Resolution.describe). + * + * Carried onto the CallContext so it reaches the metric row. Without it, "why is this TPP suddenly + * anonymous" is answerable only by guessing at the deployment's trust configuration. + */ + val callerCertificateTrustKey: Key[String] = + Key.newKey[IO, String].unsafeRunSync()(cats.effect.unsafe.IORuntime.global) + + /** * Implicit class that adds .callContext accessor to Request[IO]. * @@ -550,6 +561,7 @@ object Http4sCallContextBuilder { correlationId = extractCorrelationId(request), ipAddress = extractIpAddress(request), requestHeaders = extractHeaders(request), + certificateTrust = request.attributes.lookup(Http4sRequestAttributes.callerCertificateTrustKey), httpBody = body, authReqHeaderField = extractAuthHeader(request), directLoginParams = extractDirectLoginParams(request), diff --git a/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsHandshakeTest.scala b/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsHandshakeTest.scala index e037436b88..2272a9af4e 100644 --- a/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsHandshakeTest.scala +++ b/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsHandshakeTest.scala @@ -8,7 +8,9 @@ import javax.net.ssl.{HttpsURLConnection, KeyManagerFactory, SSLContext, TrustMa import cats.effect.IO import cats.effect.unsafe.implicits.global +import code.api.util.{CertificateUtil, PeerTrust} import code.api.util.SelfSignedCertificateUtil.generateSelfSignedCert +import code.api.util.http4s.CallerCertificate import com.comcast.ip4s.{Host, Port} import fs2.io.net.tls.{TLSContext, TLSParameters} import org.http4s.{HttpApp, Response, Status} @@ -17,11 +19,12 @@ 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. + * End-to-end proof of mTLS termination: a real Ember server built the same way as Http4sServer's + * mtls.enabled branch (buildSslContext from JKS files -> TLSContext -> withTLS(needClientAuth)), + * with the caller resolved exactly as Http4sApp.httpApp does it, exercised over a real TLS + * handshake. This is the only place Ember's population of ServerRequestKeys.SecureSession is + * actually verified — and therefore the only place proving the peer certificate the whole + * peer-vs-forwarder rule depends on is really there. */ class Http4sMtlsHandshakeTest extends FlatSpec with Matchers with BeforeAndAfterAll { @@ -49,6 +52,23 @@ class Http4sMtlsHandshakeTest extends FlatSpec with Matchers with BeforeAndAfter IO.pure(Response[IO](Status.Ok).withEntity(headerValue)) } + // Two deployments served by one server, selected by path, so both are exercised over a real + // handshake rather than only in the pure tests: + // /as-edge — nothing is a forwarder, so the handshake certificate is the caller + // /behind-proxy — the client certificate IS the trusted proxy, so the header names the caller + private val asEdge = PeerTrust.TrustConfig(Nil, trustForwardedHeaderWithoutTls = false) + private lazy val behindProxy = { + val proxyDn = PeerTrust.canonicalDn( + clientCert.asInstanceOf[X509Certificate].getSubjectX500Principal.getName).get + PeerTrust.TrustConfig(List(PeerTrust.TrustedProxy(proxyDn, Some(proxyDn))), + trustForwardedHeaderWithoutTls = false) + } + + private val resolvingApp: HttpApp[IO] = HttpApp[IO] { req => + val config = if (req.uri.path.renderString.startsWith("/behind-proxy")) behindProxy else asEdge + echoApp(CallerCertificate.resolveCaller(req, config)) + } + private var shutdown: IO[Unit] = IO.unit private var serverPort: Int = 0 @@ -65,7 +85,7 @@ class Http4sMtlsHandshakeTest extends FlatSpec with Matchers with BeforeAndAfter .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)) + .withHttpApp(resolvingApp) .build .allocated .unsafeRunSync() @@ -94,14 +114,15 @@ class Http4sMtlsHandshakeTest extends FlatSpec with Matchers with BeforeAndAfter sslContext } - private def get(withClientCert: Boolean): String = { - val connection = new URL(s"https://127.0.0.1:$serverPort/anything") + private def get(withClientCert: Boolean, path: String = "/as-edge", + forwarded: String = "spoofed-value"): String = { + val connection = new URL(s"https://127.0.0.1:$serverPort$path") .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") + connection.setRequestProperty("PSD2-CERT", forwarded) try scala.io.Source.fromInputStream(connection.getInputStream).mkString finally connection.disconnect() } @@ -113,4 +134,19 @@ class Http4sMtlsHandshakeTest extends FlatSpec with Matchers with BeforeAndAfter "a TLS handshake without a client certificate" should "be rejected when client_auth=need" in { an[Exception] should be thrownBy get(withClientCert = false) } + + // The production topology, over a real handshake: the peer authenticates as the trusted proxy and + // the certificate it forwards survives. The old middleware would have overwritten it with the + // proxy's own, which is the failure this whole design exists to prevent. + "a request from a trusted forwarder" should "keep the forwarded certificate, not the peer's" in { + // Single-line: an HTTP header value cannot contain newlines, so this is the only shape a real + // proxy can forward — canonical multi-line PEM is rejected outright by the client here. It is + // also why Psd2CertIngress has to canonicalise on the way in rather than assuming a form. + val forwardedPem = CertificateUtil.normalizePemX509Certificate( + CertificateUtil.toPem(serverCert.asInstanceOf[X509Certificate])) + val seenByApp = get(withClientCert = true, path = "/behind-proxy", forwarded = forwardedPem) + + seenByApp shouldEqual forwardedPem + seenByApp should not equal Http4sMtls.toPem(clientCert.asInstanceOf[X509Certificate]) + } } diff --git a/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsTest.scala b/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsTest.scala index f38ac157f5..c414608898 100644 --- a/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsTest.scala +++ b/obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsTest.scala @@ -3,20 +3,13 @@ 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") @@ -38,18 +31,6 @@ class Http4sMtlsTest extends FlatSpec with Matchers { 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") @@ -59,28 +40,24 @@ class Http4sMtlsTest extends FlatSpec with Matchers { 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) + // Header injection moved to code.api.util.http4s.CallerCertificate when the "OBP is the edge" + // assumption was generalised into the peer-vs-forwarder rule. Those scenarios live on in + // CallerCertificateTest, where they are now one row of a table rather than the whole behaviour. + + // The dev-store guard recognises these files by digest wherever they have been copied to, so the + // constants must track the files. Regenerating the dev pair without updating them would leave a + // check that silently matches nothing — this test is what turns that into a build failure. + "the dev store digests" should "still match the checked-in files" in { + val certDir = new java.io.File(getClass.getResource("/cert/server.jks").toURI).getParentFile + Http4sMtls.sha256Of(new java.io.File(certDir, "server.jks")) shouldEqual Http4sMtls.DevKeystoreSha256 + Http4sMtls.sha256Of(new java.io.File(certDir, "server.trust.jks")) shouldEqual Http4sMtls.DevTruststoreSha256 } - 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" + // Outside Production the guard must not fire: development is the whole point of the dev pair. + it should "not block a non-production run" in { + val certDir = new java.io.File(getClass.getResource("/cert/server.jks").toURI).getParentFile + noException should be thrownBy + Http4sMtls.assertNotADevStoreInProduction("mtls.keystore.path", new java.io.File(certDir, "server.jks")) } "buildSslContext" should "build an SSLContext from the checked-in dev keystores" in { diff --git a/obp-api/src/test/scala/code/api/util/PeerTrustTest.scala b/obp-api/src/test/scala/code/api/util/PeerTrustTest.scala new file mode 100644 index 0000000000..0d20ed49dd --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/PeerTrustTest.scala @@ -0,0 +1,118 @@ +package code.api.util + +import java.security.cert.X509Certificate + +import code.api.util.PeerTrust._ +import code.api.util.SelfSignedCertificateUtil.generateSelfSignedCert +import org.scalatest.{FlatSpec, Matchers} + +/** + * The decision table of docs/MTLS_TOPOLOGIES.md §3, one test per row. + * + * Pure: no server, no socket, no handshake. That is the point of keeping the rule in a function + * rather than in middleware — the branch that matters most in production (a forwarded header + * arriving over a hop with no client certificate) is otherwise the hardest one to exercise. + */ +class PeerTrustTest extends FlatSpec with Matchers { + + private def certFor(cn: String): X509Certificate = + generateSelfSignedCert(cn)._2.asInstanceOf[X509Certificate] + + private val proxyCert = certFor("nginx-prod-1") + private val appCert = certFor("some-tpp") + + private def canonical(dn: String) = PeerTrust.canonicalDn(dn).get + + // Self-signed, so issuer == subject. Real proxies are CA-signed; the matching logic is the same. + private val proxyIsTrusted = TrustConfig( + trustedProxies = List(TrustedProxy(canonical("CN=nginx-prod-1"), Some(canonical("CN=nginx-prod-1")))), + trustForwardedHeaderWithoutTls = false + ) + private val nothingIsTrusted = TrustConfig(Nil, trustForwardedHeaderWithoutTls = false) + + private val forwardedPem = CertificateUtil.toPem(appCert) + + "a trusted forwarder that forwards a certificate" should "identify the caller from the header" in { + val resolution = resolve(Some(proxyCert), Some(forwardedPem), proxyIsTrusted) + resolution.callerPem shouldEqual Some(forwardedPem) + resolution shouldBe a[ForwardedCaller] + resolution.describe should include("forwarded via") + } + + "a trusted forwarder that forwards nothing" should "yield no caller rather than falling back to the proxy" in { + // The trap this design exists to avoid: treating the proxy's own certificate as an identity + // would make every un-certificated request look like the same TPP. + val resolution = resolve(Some(proxyCert), None, proxyIsTrusted) + resolution.callerPem shouldBe None + resolution shouldBe a[NoCaller] + } + + "a peer that is not a trusted forwarder" should "be the caller itself" in { + val resolution = resolve(Some(appCert), None, proxyIsTrusted) + resolution.callerPem shouldEqual Some(CertificateUtil.toPem(appCert)) + resolution shouldBe a[DirectCaller] + } + + it should "have any forwarded header discarded as a spoofing attempt" in { + // OBP is the edge here, so a PSD2-CERT header cannot have come from a proxy we trust. + val resolution = resolve(Some(appCert), Some(CertificateUtil.toPem(proxyCert)), proxyIsTrusted) + resolution.callerPem shouldEqual Some(CertificateUtil.toPem(appCert)) + } + + "an empty allowlist" should "make every peer a direct caller — OBP as the TLS edge" in { + // Development's configuration, and the one that reproduces the pre-existing dev behaviour. + resolve(Some(proxyCert), Some(forwardedPem), nothingIsTrusted).callerPem shouldEqual + Some(CertificateUtil.toPem(proxyCert)) + } + + "a forwarded header with no TLS peer" should "be trusted only when the prop allows it" in { + val permissive = TrustConfig(Nil, trustForwardedHeaderWithoutTls = true) + resolve(None, Some(forwardedPem), permissive).callerPem shouldEqual Some(forwardedPem) + + // Fail closed: nothing authenticated the sender, so the header proves nothing. + val strict = TrustConfig(Nil, trustForwardedHeaderWithoutTls = false) + resolve(None, Some(forwardedPem), strict).callerPem shouldBe None + resolve(None, Some(forwardedPem), strict).describe should include("trust_forwarded_header_without_tls") + } + + "no peer and no header" should "be no caller" in { + resolve(None, None, proxyIsTrusted).callerPem shouldBe None + } + + "an unparseable forwarded value" should "still be passed on when the forwarder is trusted" in { + // Deciding WHO is calling and deciding whether their certificate is any good are different + // jobs. The authorisation layer owns the error code, so garbage must reach it. + resolve(Some(proxyCert), Some("not a certificate"), proxyIsTrusted).callerPem shouldEqual + Some("not a certificate") + } + + "issuer matching" should "accept any subject when the allowlist entry has none" in { + val anySubjectFromThatIssuer = + TrustConfig(List(TrustedProxy(canonical("CN=nginx-prod-1"), None)), trustForwardedHeaderWithoutTls = false) + resolve(Some(proxyCert), Some(forwardedPem), anySubjectFromThatIssuer) shouldBe a[ForwardedCaller] + // A different certificate is not covered: its issuer differs. + resolve(Some(appCert), Some(forwardedPem), anySubjectFromThatIssuer) shouldBe a[DirectCaller] + } + + it should "reject a subject that does not match, even from the right issuer" in { + val wrongSubject = TrustConfig( + List(TrustedProxy(canonical("CN=nginx-prod-1"), Some(canonical("CN=nginx-prod-2")))), + trustForwardedHeaderWithoutTls = false + ) + resolve(Some(proxyCert), Some(forwardedPem), wrongSubject) shouldBe a[DirectCaller] + } + + "canonicalDn" should "normalise case and spacing but not RDN order" in { + // What operators can get away with... + canonicalDn("CN=nginx-prod-1, O=TESOBE GmbH,C=DE") shouldEqual + canonicalDn("cn=NGINX-PROD-1,O=tesobe gmbh, c=de") + // ...and what they cannot. Hence the openssl command in the operator docs. + canonicalDn("CN=nginx-prod-1,O=TESOBE GmbH,C=DE") should not equal + canonicalDn("C=DE,O=TESOBE GmbH,CN=nginx-prod-1") + } + + it should "return None for something that is not a distinguished name" in { + canonicalDn("nginx-prod-1") shouldBe None + canonicalDn("") shouldBe None + } +} diff --git a/obp-api/src/test/scala/code/api/util/http4s/CallerCertificateTest.scala b/obp-api/src/test/scala/code/api/util/http4s/CallerCertificateTest.scala new file mode 100644 index 0000000000..e951cb184b --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/http4s/CallerCertificateTest.scala @@ -0,0 +1,117 @@ +package code.api.util.http4s + +import java.security.cert.X509Certificate + +import cats.effect.IO +import code.api.util.{CertificateUtil, PeerTrust} +import code.api.util.SelfSignedCertificateUtil.generateSelfSignedCert +import org.http4s.{Method, Request} +import org.http4s.server.{SecureSession, ServerRequestKeys} +import org.scalatest.{FlatSpec, Matchers} +import org.typelevel.ci.CIString + +/** + * The http4s half of the caller-resolution rule: that the two inputs are collected from the right + * places and the outgoing header is rewritten to match the decision. The decision itself is covered + * exhaustively in `code.api.util.PeerTrustTest`. + * + * The first four scenarios were `Http4sMtlsTest`'s tests of `injectClientCertificate`, which this + * middleware replaced. They are the "OBP is the TLS edge" row of the table, and keeping them is how + * we know that deployment did not change behaviour when the rule was generalised. + */ +class CallerCertificateTest extends FlatSpec with Matchers { + + private val psd2CertHeader = CIString("PSD2-CERT") + + private def certFor(cn: String): X509Certificate = + generateSelfSignedCert(cn)._2.asInstanceOf[X509Certificate] + + private val clientCert = certFor("test-tpp") + private val proxyCert = certFor("nginx-prod-1") + + /** OBP is the edge: nothing is a forwarder. Development, and the pre-existing dev behaviour. */ + private val asEdge = PeerTrust.TrustConfig(Nil, trustForwardedHeaderWithoutTls = false) + + /** Production behind nginx: the proxy is a forwarder, and a bare header is not trusted. */ + private val behindProxy = PeerTrust.TrustConfig( + List(PeerTrust.TrustedProxy( + PeerTrust.canonicalDn("CN=nginx-prod-1").get, + Some(PeerTrust.canonicalDn("CN=nginx-prod-1").get))), + trustForwardedHeaderWithoutTls = false + ) + + /** Today's production: a proxy over a plain hop, header taken on trust. */ + private val plainHop = PeerTrust.TrustConfig(Nil, trustForwardedHeaderWithoutTls = true) + + private def secureSessionWith(certs: List[X509Certificate]): Some[SecureSession] = + Some(SecureSession("session-id", "TLS_AES_128_GCM_SHA256", 128, certs)) + + private def requestWith(peer: Option[X509Certificate], header: Option[String]): Request[IO] = { + val withHeader = header.foldLeft(Request[IO](Method.GET))((r, v) => r.withHeaders("PSD2-CERT" -> v)) + peer.foldLeft(withHeader)((r, c) => r.withAttribute(ServerRequestKeys.SecureSession, secureSessionWith(List(c)))) + } + + private def headerAfter(peer: Option[X509Certificate], header: Option[String], + config: PeerTrust.TrustConfig): Option[String] = + CallerCertificate.resolveCaller(requestWith(peer, header), config) + .headers.get(psd2CertHeader).map(_.head.value) + + "OBP as the TLS edge" should "inject the handshake certificate as the PSD2-CERT header" in { + headerAfter(Some(clientCert), None, asEdge) shouldEqual Some(CertificateUtil.toPem(clientCert)) + } + + it should "strip a client-supplied PSD2-CERT header when there is no client certificate" in { + headerAfter(None, Some("spoofed-value"), asEdge) shouldBe None + } + + it should "replace a client-supplied PSD2-CERT header with the handshake certificate" in { + headerAfter(Some(clientCert), Some("spoofed-value"), asEdge) shouldEqual + Some(CertificateUtil.toPem(clientCert)) + } + + it should "leave the request alone when the TLS session has no peer certificate" in { + val req = Request[IO](Method.GET).withAttribute(ServerRequestKeys.SecureSession, secureSessionWith(Nil)) + CallerCertificate.resolveCaller(req, asEdge).headers.get(psd2CertHeader) shouldBe None + } + + // The production topology this design exists for: nginx authenticates itself in the handshake and + // names the App in the header. Getting this backwards — the failure the old middleware would have + // produced if it had simply been enabled in production — makes every request look like the proxy. + "OBP behind a trusted proxy" should "keep the App's certificate, not the proxy's" in { + val appPem = CertificateUtil.toPem(clientCert) + headerAfter(Some(proxyCert), Some(appPem), behindProxy) shouldEqual Some(appPem) + headerAfter(Some(proxyCert), Some(appPem), behindProxy) should not equal + Some(CertificateUtil.toPem(proxyCert)) + } + + it should "yield no certificate when the proxy forwards none, rather than falling back to the proxy's" in { + headerAfter(Some(proxyCert), None, behindProxy) shouldBe None + } + + it should "treat a peer outside the allowlist as the caller, not as a forwarder" in { + // A certificate from an unknown peer cannot speak for anyone else, so its header is discarded. + val appPem = CertificateUtil.toPem(clientCert) + headerAfter(Some(certFor("someone-else")), Some(appPem), behindProxy) should not equal Some(appPem) + } + + "a plain proxy hop" should "honour the forwarded header when the prop allows it" in { + val appPem = CertificateUtil.toPem(clientCert) + headerAfter(None, Some(appPem), plainHop) shouldEqual Some(appPem) + } + + "the resolution" should "be recorded for the metric row" in { + def trustOf(req: Request[IO], config: PeerTrust.TrustConfig) = + CallerCertificate.resolveCaller(req, config) + .attributes.lookup(Http4sRequestAttributes.callerCertificateTrustKey) + + trustOf(requestWith(Some(clientCert), None), asEdge) shouldEqual Some("direct") + trustOf(requestWith(Some(proxyCert), Some("pem")), behindProxy).get should startWith("forwarded via") + trustOf(requestWith(None, Some("pem")), asEdge).get should startWith("none:") + } + + it should "not be set on a request that carries no certificate material at all" in { + val untouched = CallerCertificate.resolveCaller(Request[IO](Method.GET), asEdge) + untouched.headers.get(psd2CertHeader) shouldBe None + untouched.attributes.lookup(Http4sRequestAttributes.callerCertificateTrustKey) shouldBe None + } +} From c6cd204d5e1fe08a7bcb89abdecfa20d9e7714e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 23 Jul 2026 15:03:05 +0200 Subject: [PATCH 11/15] fix(mtls): drop '(dev-only)' from the boot log The mode is honoured in every run mode since the run-mode gate was replaced by the dev-keystore digest check. The documented boot log was updated with it; this line was not, leaving the server telling operators the opposite of the manual. --- obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala b/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala index 9babbaffb5..d12da825ac 100644 --- a/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala +++ b/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala @@ -27,7 +27,7 @@ object Http4sServer extends IOApp with MdcLoggable { .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, " + + logger.info(s"mTLS termination is ENABLED: 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://")) From 7d95dbe7e639a25dc726a1e87ee2ea5aa8dd187b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 23 Jul 2026 15:41:16 +0200 Subject: [PATCH 12/15] docs(mtls): the checked-in dev certificates are enough to test with The quick start opened by telling the reader to generate a keypair, a client certificate and a truststore, on the grounds that the repository's truststore "contains no client certificate you own a private key for". That has not been true since localhost_san_dns_ip.pfx was added: server.trust.jks trusts CN=TESOBE CA (alias mykey, CA:TRUE), and the .pfx is a client identity signed by exactly that CA with its private key included. Keystore, truststore and a usable client identity have been lining up out of the box, and the note said otherwise. Adds step 0: start the server, unpack the .pfx into a PEM pair, make the call. Both commands were run against a live mTLS server from the repository root and returned 200 and curl exit 56 respectively, which is also what the note's replacement claims. Generating certificates is still documented, now with the reasons to bother: a specific subject for a Consumer or regulated-entity lookup, or a second identity to test rejection with. The remaining real limitation of the checked-in pair is recorded where it belongs -- the server certificate has no SAN, so either use -k or point mtls.keystore.path at the .pfx, which carries DNS:localhost and IP:127.0.0.1. --- docs/MTLS_DEV_MODE.md | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/docs/MTLS_DEV_MODE.md b/docs/MTLS_DEV_MODE.md index f3a88538dc..549d99222b 100644 --- a/docs/MTLS_DEV_MODE.md +++ b/docs/MTLS_DEV_MODE.md @@ -41,6 +41,35 @@ whether or not TLS terminates here. ## Quick start +### 0. The 30-second path — no certificates to generate + +The repository's dev pair is self-sufficient: `server.trust.jks` trusts the `CN=TESOBE CA` +certificate (alias `mykey`, `CA:TRUE`), and `localhost_san_dns_ip.pfx` is a client identity signed +by exactly that CA, private key included, password `123456`. So the checked-in keystore, truststore +and a usable client certificate all line up out of the box. + +```sh +# Start the server with mTLS on +./flushall_fast_build_and_run.sh --mtls + +# Unpack the checked-in client identity for curl (once) +CERT=obp-api/src/test/resources/cert +openssl pkcs12 -in $CERT/localhost_san_dns_ip.pfx -passin pass:123456 -nokeys -clcerts -out /tmp/client.crt +openssl pkcs12 -in $CERT/localhost_san_dns_ip.pfx -passin pass:123456 -nocerts -nodes -out /tmp/client.key + +# 200 +curl -k --cert /tmp/client.crt --key /tmp/client.key https://localhost:8080/obp/v5.1.0/root + +# 56 — a handshake with no client certificate is rejected under client_auth=need +curl -k https://localhost:8080/obp/v5.1.0/root +``` + +`-k` skips verification of the *server* certificate, which is `CN=localhost` with no SAN. Step 1 +below generates a pair with a proper SAN if you would rather use `--cacert` and verify properly. + +Generate your own certificates instead when you need a specific subject (for a Consumer or +regulated-entity lookup that matches on it), or a second identity to test rejection. + ### 1. Generate certificates You need: a server keypair (JKS), a truststore containing the client certificate (JKS), and a @@ -69,11 +98,11 @@ keytool -importcert -noprompt -alias test-tpp -file client.crt \ 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. +> The pair checked into the repo (`obp-api/src/test/resources/cert/server.jks` + +> `server.trust.jks`, password `123456`) already works for a full handshake — see step 0. Its one +> limitation is that the server certificate has no `localhost` SAN, so clients that do not fall +> back to CN need `-k`, or `mtls.keystore.path` pointed at `localhost_san_dns_ip.pfx`, which +> carries `DNS:localhost, IP:127.0.0.1`. ### 2. Configure props From 38520141ff7b81037f81f28cc2a152d5b846566a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 23 Jul 2026 16:05:19 +0200 Subject: [PATCH 13/15] test(mtls): a development certificate set named for the roles it plays Standalone: no production code changes, and the props still default to the old server.jks / server.trust.jks pair, which keeps working. The checked-in certificates had drifted into naming nothing in particular. The identity used as the calling TPP is localhost_san_dns_ip.pfx, subject CN=localhost -- a host name for a party, and the same CN the server itself presents, so a log line saying the caller is CN=localhost reads as the server talking to itself. The CA that makes it all work hides behind keytool's default alias "mykey", which is how a certificate two commits ago was diagnosed as untrusted when it was not. Of the seven entries in server.trust.jks five have expired -- in 2021, 2022 and 2023 -- and two are public web CAs, which in a client-auth truststore means anything Let's Encrypt ever signed would authenticate as a caller. Expired, so inert; still not a file to copy toward production. This matters more since mtls.trusted_proxy.N.subject made a DN into a configuration value an operator reads and types. Three certificates all called CN=localhost cannot express "the peer is the proxy, the header names the App", which is exactly what the dev-behind-nginx harness has to demonstrate. So: one CA, and one identity per role with the role in the filename, the alias and the DN -- obp-server (serverAuth, SAN DNS:localhost + IP:127.0.0.1), tpp-client (clientAuth, O=Example TPP Ltd so the App is a different organisation from the bank), proxy-client (the forwarder the harness needs), and expired-tpp, which is expired on purpose because certificate expiry had no coverage at all. The truststore holds the CA and nothing else. PKCS12 throughout, PEM alongside, since Java wants one and curl and nginx want the other. scripts/generate_dev_certs.sh regenerates the set, because fixtures nobody can rebuild are how the old ones ended up expired. The CA private key is deliberately not kept: everything is reproducible from the script, and a signing key is the one thing worth withholding even from a set that is public by design. DevCertificateSetTest asserts the properties over a real handshake -- the TPP identity authenticating, the expired one rejected, a certless handshake rejected, the proxy identity forwarding the App's certificate, and the server certificate verifying by hostname with no verifier override, which is what makes the documented curl work without -k. It also pins the subjects, because docs/MTLS_TOPOLOGIES.md shows operators typing them. .gitignore excluded obp-api/src/test/resources/** wholesale; the four existing certificates are tracked only because they predate the rule. Without an exception the regenerated set would have been silently untracked and every machine except the one that ran the script would fail these tests. --- .gitignore | 5 + docs/MTLS_DEV_MODE.md | 50 +++-- obp-api/src/test/resources/cert/dev-ca.crt | 21 +++ .../test/resources/cert/dev-truststore.p12 | Bin 0 -> 1238 bytes .../src/test/resources/cert/expired-tpp.crt | 21 +++ .../src/test/resources/cert/expired-tpp.key | 28 +++ .../src/test/resources/cert/expired-tpp.p12 | Bin 0 -> 3642 bytes .../src/test/resources/cert/obp-server.crt | 22 +++ .../src/test/resources/cert/obp-server.key | 28 +++ .../src/test/resources/cert/obp-server.p12 | Bin 0 -> 3672 bytes .../src/test/resources/cert/proxy-client.crt | 21 +++ .../src/test/resources/cert/proxy-client.key | 28 +++ .../src/test/resources/cert/proxy-client.p12 | Bin 0 -> 3644 bytes .../src/test/resources/cert/tpp-client.crt | 21 +++ .../src/test/resources/cert/tpp-client.key | 28 +++ .../src/test/resources/cert/tpp-client.p12 | Bin 0 -> 3640 bytes .../http4s/DevCertificateSetTest.scala | 177 ++++++++++++++++++ scripts/generate_dev_certs.sh | 153 +++++++++++++++ 18 files changed, 585 insertions(+), 18 deletions(-) create mode 100644 obp-api/src/test/resources/cert/dev-ca.crt create mode 100644 obp-api/src/test/resources/cert/dev-truststore.p12 create mode 100644 obp-api/src/test/resources/cert/expired-tpp.crt create mode 100644 obp-api/src/test/resources/cert/expired-tpp.key create mode 100644 obp-api/src/test/resources/cert/expired-tpp.p12 create mode 100644 obp-api/src/test/resources/cert/obp-server.crt create mode 100644 obp-api/src/test/resources/cert/obp-server.key create mode 100644 obp-api/src/test/resources/cert/obp-server.p12 create mode 100644 obp-api/src/test/resources/cert/proxy-client.crt create mode 100644 obp-api/src/test/resources/cert/proxy-client.key create mode 100644 obp-api/src/test/resources/cert/proxy-client.p12 create mode 100644 obp-api/src/test/resources/cert/tpp-client.crt create mode 100644 obp-api/src/test/resources/cert/tpp-client.key create mode 100644 obp-api/src/test/resources/cert/tpp-client.p12 create mode 100644 obp-api/src/test/scala/bootstrap/http4s/DevCertificateSetTest.scala create mode 100755 scripts/generate_dev_certs.sh diff --git a/.gitignore b/.gitignore index 7aed6a8dd6..f909ba2b20 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,11 @@ obp-api/src/main/resources/* obp-api/src/test/resources/** !obp-api/src/test/resources/frozen_type_meta_data !obp-api/src/test/resources/logback-test.xml +# The development certificate set (scripts/generate_dev_certs.sh) is a test fixture and belongs in +# the repository. Without these two lines a regenerated set is silently untracked and the tests +# that depend on it fail everywhere except the machine that ran the script. +!obp-api/src/test/resources/cert/ +!obp-api/src/test/resources/cert/** *.iml obp-api/src/main/resources/log4j.properties obp-api/src/main/scripts/kafka/kafka_* diff --git a/docs/MTLS_DEV_MODE.md b/docs/MTLS_DEV_MODE.md index 549d99222b..91447881ac 100644 --- a/docs/MTLS_DEV_MODE.md +++ b/docs/MTLS_DEV_MODE.md @@ -43,32 +43,44 @@ whether or not TLS terminates here. ### 0. The 30-second path — no certificates to generate -The repository's dev pair is self-sufficient: `server.trust.jks` trusts the `CN=TESOBE CA` -certificate (alias `mykey`, `CA:TRUE`), and `localhost_san_dns_ip.pfx` is a client identity signed -by exactly that CA, private key included, password `123456`. So the checked-in keystore, truststore -and a usable client certificate all line up out of the box. +`obp-api/src/test/resources/cert` carries a development set, one file per role, all signed by one +CA and all with the password `123456`: -```sh -# Start the server with mTLS on -./flushall_fast_build_and_run.sh --mtls +| File | Subject | Role | +|---|---|---| +| `dev-ca.crt` | `CN=OBP Dev CA, O=TESOBE GmbH, C=DE` | signs the rest; the only entry in the truststore | +| `dev-truststore.p12` | — | what the server accepts: the CA, nothing else | +| `obp-server.p12` | `CN=localhost, OU=OBP Server, O=TESOBE GmbH` | what OBP presents (`serverAuth`, SAN `DNS:localhost, IP:127.0.0.1`) | +| `tpp-client.p12` (+ `.crt`/`.key`) | `CN=test-tpp, OU=TPP, O=Example TPP Ltd` | the calling App (`clientAuth`) | +| `proxy-client.p12` | `CN=nginx-dev-1, OU=Edge Proxy, O=TESOBE GmbH` | a reverse proxy forwarding someone else's certificate | +| `expired-tpp.p12` | `CN=expired-tpp, OU=TPP, O=Example TPP Ltd` | expired on purpose, for negative tests | -# Unpack the checked-in client identity for curl (once) -CERT=obp-api/src/test/resources/cert -openssl pkcs12 -in $CERT/localhost_san_dns_ip.pfx -passin pass:123456 -nokeys -clcerts -out /tmp/client.crt -openssl pkcs12 -in $CERT/localhost_san_dns_ip.pfx -passin pass:123456 -nocerts -nodes -out /tmp/client.key +Regenerate with `./scripts/generate_dev_certs.sh`. `DevCertificateSetTest` asserts the properties +above still hold, so a regenerated set that loses a SAN or an EKU fails the build. -# 200 -curl -k --cert /tmp/client.crt --key /tmp/client.key https://localhost:8080/obp/v5.1.0/root +```sh +# Start the server on the role-named set +CERT=$PWD/obp-api/src/test/resources/cert +OBP_MTLS_KEYSTORE_PATH=$CERT/obp-server.p12 \ +OBP_MTLS_TRUSTSTORE_PATH=$CERT/dev-truststore.p12 \ + ./flushall_fast_build_and_run.sh --mtls + +# 200 — and the server certificate verifies properly, no -k needed +curl --cacert $CERT/dev-ca.crt --cert $CERT/tpp-client.crt --key $CERT/tpp-client.key \ + https://localhost:8080/obp/v5.1.0/root # 56 — a handshake with no client certificate is rejected under client_auth=need -curl -k https://localhost:8080/obp/v5.1.0/root +curl --cacert $CERT/dev-ca.crt https://localhost:8080/obp/v5.1.0/root ``` -`-k` skips verification of the *server* certificate, which is `CN=localhost` with no SAN. Step 1 -below generates a pair with a proper SAN if you would rather use `--cacert` and verify properly. +The props still default to the older `server.jks` / `server.trust.jks` pair, which also works — +`server.trust.jks` trusts `CN=TESOBE CA` (alias `mykey`), and `localhost_san_dns_ip.pfx` is a client +identity signed by it. Its drawbacks are why the set above exists: the server leaf has no SAN (so +curl needs `-k`), the client identity is called `CN=localhost` rather than named for a TPP, and the +truststore additionally holds five expired certificates and two public web CAs. -Generate your own certificates instead when you need a specific subject (for a Consumer or -regulated-entity lookup that matches on it), or a second identity to test rejection. +Generate your own instead when you need a specific subject that a Consumer or regulated-entity +lookup matches on. ### 1. Generate certificates @@ -306,6 +318,8 @@ code path. | `scripts/java_env.sh` | Selects a JDK >= 17 for the run scripts. The build compiles with `-release 17`; on a default JDK 11 it otherwise fails with the misleading `'17' is not a valid choice for '-release'`. | | `obp-api/src/test/scala/bootstrap/http4s/Http4sMtlsTest.scala` | Unit tests: PEM encoding, header injection/stripping, SSLContext from the checked-in keystores. | | [`docs/MTLS_TOPOLOGIES.md`](MTLS_TOPOLOGIES.md) | **Proposal, not implemented.** Generalising the above to mTLS on the proxy → OBP hop, and to OBP as the TLS edge in production. | +| `scripts/generate_dev_certs.sh` | Regenerates the role-named development certificate set (CA, server, TPP, proxy, expired fixture). | +| `obp-api/src/test/scala/bootstrap/http4s/DevCertificateSetTest.scala` | Guards that set over a real handshake: names, EKUs, SAN, CA-only truststore, expired certificate rejected. | | `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: diff --git a/obp-api/src/test/resources/cert/dev-ca.crt b/obp-api/src/test/resources/cert/dev-ca.crt new file mode 100644 index 0000000000..daa6f7057b --- /dev/null +++ b/obp-api/src/test/resources/cert/dev-ca.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDYTCCAkmgAwIBAgIUDf/elkWew2mhUWlc1hlSBQ0zccowDQYJKoZIhvcNAQEL +BQAwODELMAkGA1UEBhMCREUxFDASBgNVBAoMC1RFU09CRSBHbWJIMRMwEQYDVQQD +DApPQlAgRGV2IENBMB4XDTI2MDcyMzEzNDgyOVoXDTM2MDcyMDEzNDgyOVowODEL +MAkGA1UEBhMCREUxFDASBgNVBAoMC1RFU09CRSBHbWJIMRMwEQYDVQQDDApPQlAg +RGV2IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvnXiKJWFE2Jk +fatgs79E7p0BKiGIgDyYQXXHtBGKuPBujT2ilcQD9hLGQuhGGz5Ui5FmeCjaRyM9 +1HDd1JD1lkIP8Be5p2N/GRbRtP0QZ9a/EKAEqXrt0TfMy/aSobQuJ2WseTiqC4KP +/tfmdcjbheZIpjlRsrpggmtKtM/6NkYHbBzza2bTheWfjcVRLK+2UavvJ/7VQDHl +MLr2NsAWpkEzYo5AFheWvjjMiJu08O1VR+SBWQGdKp5bg0DuMbxuDtucqBvBYj4v +jtP197gQTGoGUV46gGyfjOnVuzUoKGjhzWY+BnZBJ6BBluyF8J0fWPinCThYF5Z9 +b8lvzjMluwIDAQABo2MwYTAdBgNVHQ4EFgQUdFVEwSBQMs1cOik1P6oq5TtvDsww +HwYDVR0jBBgwFoAUdFVEwSBQMs1cOik1P6oq5TtvDswwDwYDVR0TAQH/BAUwAwEB +/zAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggEBAD/txEfqX2KDZeZK +8T17IIa3bQLnhqnd9gHk4faYNGzpwUlx9ulEAUsJI1JQdPniKJrM9NgKx2vY/CA8 +zj3rZlTE/oOB+TOpROJZqeA80fSMakg5GpTyCDuy+gf3vjyfRHWeWU2hDKXsRP7u +s4NDn7dM5iBi3PJQaWuWazJakyexLOKkPclX6WfnKFOs6LYOZYhuBdu/PFhhUYb7 +8b+liUOjrCwXVUYFr4X446E6lr/KkLP9znfwrrZJ9bcmA8SXKFcesGT2wJH3bOth +Z01/59ULfJw54SOS+dNDH5Uepl7S871jlTqhB7eie+27y4fGxwKUFxKK+Opl5VlQ +muvwDRo= +-----END CERTIFICATE----- diff --git a/obp-api/src/test/resources/cert/dev-truststore.p12 b/obp-api/src/test/resources/cert/dev-truststore.p12 new file mode 100644 index 0000000000000000000000000000000000000000..119f4e53b6f0f212bc7ab3d21989cb4697e4c5ac GIT binary patch literal 1238 zcmV;{1S$J4f&|h60Ru3C1bhYwDuzgg_YDCD0ic2eZ3Kb@X)uBWWiWySVFn2*hDe6@ z4FLxRpn?QaFoFb50s#Opf&@nf2`Yw2hW8Bt2LUi<1_>&LNQU+thDZTr0|Wso1P~!-s}EF8O{TlL-DNJ%a*BY01JHwKPEnKVQ4xHpjnW{uX&`Fn=SN@EuBJGPkLP?(N@+`?9aW{hB0=gxmdtvlk-p$O z@~hL`$VwRqC%RMa?}6mCM_l@Yez$uRunW09(pH!qu;bEswJlFGfCBYc0njay{Mv-; zD;7joIv&0q<98ipm4>2)W&dQkZVn0Z);3mVKo-N^#RMD!$us)@#Z3icDWWj!Q!WJ8 zx2C^5)U~DjOslV}8vfsqU2(Hi{C)F)53PGTsG_I=9HDJh75&7qIYA~qj?-%hAqaLz zhwoux`$>ekMY`r>dd{w_!z?siC|JqJZSy!ZG8Jxd7R0`#@K-4jW=xwplgUUB_4Rx@ zl}Ui0YnQCeoImpXnPMExMYO#eQ3~d~LT}?*FXl)E!>o!7m>ReUyj!N^$H~45$WswP zy=#}&m`2O@t1sfc+6a<@#P_bMhM=rU$&V-{^j|&l%IS+G4wM-%Tc)=!AHrfpss0zStPaG5 zu+m$UXfxi)<``bH91lAgTWB2Mg+-PxW`wd2UIkUcnn0oZ{RUbczvKabsdw%mx?sPv}6FQ3;i#Chlel-l=ql_2Ou#GeWu zPHTxH0Tw=T!8lu7o`s>oYA}1qgBMrr4ki?4yzq_izhaHDgn6Z%ZZvedq>m<^KR8aT zt__VBgg+9ekXt%MT6Exc?&Co1qKZkdWQlPt6K!$@4V`NTgVRC8;PX%YC&O0^w&4OB zYmjEkDkXx3OjLpmC{jL-&EKDM5+Sa?PcvhL(YzfapkOwce7RS=PE1D1kE^n~!-1^6 zHFz~F-uR+&p8=`lT6{C^XjK$C2NW<(FflL<1_@w>NC9O71OfpC00bZ@G*he~HI#wa zjV+(RKcnf)zEz1Zd?Y99)Ji!iL;UUp6ad}wWJCJ5f}?qZ5h*__x2|)ieF6d}5J4zB APyhe` literal 0 HcmV?d00001 diff --git a/obp-api/src/test/resources/cert/expired-tpp.crt b/obp-api/src/test/resources/cert/expired-tpp.crt new file mode 100644 index 0000000000..1552bd6bbd --- /dev/null +++ b/obp-api/src/test/resources/cert/expired-tpp.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDgzCCAmugAwIBAgIUO7QimwpWExB4RbpRPJFVu2iIk1owDQYJKoZIhvcNAQEL +BQAwODELMAkGA1UEBhMCREUxFDASBgNVBAoMC1RFU09CRSBHbWJIMRMwEQYDVQQD +DApPQlAgRGV2IENBMB4XDTIwMDEwMTAwMDAwMFoXDTIxMDEwMTAwMDAwMFowSzEL +MAkGA1UEBhMCREUxGDAWBgNVBAoMD0V4YW1wbGUgVFBQIEx0ZDEMMAoGA1UECwwD +VFBQMRQwEgYDVQQDDAtleHBpcmVkLXRwcDCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBAMJ/X6TdbwU1k5QGAlhUjGQp3RkQ/C63iXWlsHpDyagkEJOk9w8s +G7bcEr+O23VXF3SaF6ap98CDqQ7o/zWix0tA/px4cW9TJAOh9Z+UgYEiL9gcUjG3 +KDpT/nFyK2+YlPHyb5SGDU1uVIoLOZIBzn5ndKuFrUNGuE5PhrtOsZqZs5dKv0W+ +jgHx+Prp7DXRCZMz0GNWeX55MUi/xr8FgKCkD8JqfoQJbUOqk59m8Kxj4zM8WKix +aM4AvL05JaWPyacYvKEhX3VCdDd3K+H6aXR5QVhuCKAh5ScOaAByhlat/nCp/P5D +P5KFEbmDyPDVTSxlBwgOWRVjBf27dc4LwMMCAwEAAaNyMHAwCQYDVR0TBAIwADAO +BgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwHQYDVR0OBBYEFG1W +en6kY3aF5jliFxS2nE5xOycqMB8GA1UdIwQYMBaAFHRVRMEgUDLNXDopNT+qKuU7 +bw7MMA0GCSqGSIb3DQEBCwUAA4IBAQAVSXkFAwnmgMrNMKKfmIUl516xv91YeNEC +5ym82v98LjioR9UF7qxMQgJcY6eLCaslMpojnvZ7V4CnfB2T5GoiCpYfALYDk+6j +4ikAwUj6V84JVyjfVTEiUo7ntxbLS/JKpcXi2NhudSSk4Ly7A7gIfAah60gnID/F +TZfv3vMIHwIEsOquv4EtP3TU2gZ3OHdu8fwW/bp5sMnSvkAtExGITEe8/bbSSrHr +uQmzCTLC43k12wlROrO4DCk7L5Lj0oXu8wTA9effk77yMhemM5T5WoG80Ux/giXI +RLRW2qZMGNzlnf3Wef7CSfdUkhVUPPYq0DTqhKti3anx2Lbin/RS +-----END CERTIFICATE----- diff --git a/obp-api/src/test/resources/cert/expired-tpp.key b/obp-api/src/test/resources/cert/expired-tpp.key new file mode 100644 index 0000000000..3ee3f47660 --- /dev/null +++ b/obp-api/src/test/resources/cert/expired-tpp.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDCf1+k3W8FNZOU +BgJYVIxkKd0ZEPwut4l1pbB6Q8moJBCTpPcPLBu23BK/jtt1Vxd0mhemqffAg6kO +6P81osdLQP6ceHFvUyQDofWflIGBIi/YHFIxtyg6U/5xcitvmJTx8m+Uhg1NblSK +CzmSAc5+Z3Srha1DRrhOT4a7TrGambOXSr9Fvo4B8fj66ew10QmTM9BjVnl+eTFI +v8a/BYCgpA/Can6ECW1DqpOfZvCsY+MzPFiosWjOALy9OSWlj8mnGLyhIV91QnQ3 +dyvh+ml0eUFYbgigIeUnDmgAcoZWrf5wqfz+Qz+ShRG5g8jw1U0sZQcIDlkVYwX9 +u3XOC8DDAgMBAAECggEACswTt60b6iqeEt693Osd+2Yq4ibZZmXa9CnVYQbdWCpf +CeCOXzcVd3JtJK7eC6a2Octfb770xCkARsB0K8/psgmdeNCONN4P3EHD8cQb57Ae +/tIc//8zylUlBWWPTWZ83Ozr+SSd8sP4CSJ0DjZvy6rw75//99b3s/qMjLO4KVBb +1QQGxFQirI7GN3ZHQtAa0hmQOAtecA8pyq4sOLMX0qLuH8OuNojycAU8/kWjn+ne +2ymUH9LcCf6Z01XVu9zPGrVRpOoj8BkwKV5Xj410nCFeoPmzqwxmj8EEvlvpAl17 +Awd0QxC1z7ud0XQCHcOataHa300UxTFnGYhSJ41C0QKBgQDy2EfB91Kx77VJp/ek +UJxtFaLKZNeFQpGQvsrDyYOMbhOSHRgdu/OBMrSl0g3w6YZt7H4YtEnypCkUZl0U +93CQ/5Da/09N4u2KaO6z0IIn4pLR4SPgb7q0AKpjZSKQNYwzX2WSHtppsIegubAM +b42milxKmCUtsIzHiC2uQ/eZMQKBgQDNCJ+gMzVomaiJB1znShJVhkzIMbot5DwH +YHS1KuqDzuvdcigaGK13kT9RmkPWkggymmNvaT8d6ZPWmNzPlBt4iHJQKMXG37x1 +8lZBuvdn+7UOWX2AbsnlEAtMq09BXZOMDghpJbrLyhUpnulPYn3JeHGdbiqspaSI +rOzOXKz8MwKBgGlSAbUO1Y+UPZSnQ1DBIUZyFrsehxYla8pR5NCK6gGSj+xTr+zd +YdtLqWstMZylOwcbhQij0FpqdeKCDqaUNf68yA8ioTtPSuQ3ZCcaLAiuTCy4Lv4c +luWQUFVxPE882gRBwGRh+ynRRNEhF0gdbVqoMSSs3Zr2MegrmFw24ABRAoGBAIA0 +6DuwSbFChBRLOliWBKjd9Z0pGxYfJTonolK2pzYMaYhrHZBT5gRiGonYQJsnbWDX +EV5VHVaC/CKwK0LRhev0xiZBmIom1R2bjzxCwPmQd0KlyshIfo5xXd9vL3vcG6r0 +C2ZUZV2Q23LPH2y4VZdpbQHYJW8XlK6yEtFnOfPpAoGBAMGxhrV2PIELuxyX3AU/ +hoc9sBBIPx5J5zdpU5HC3XMHUgm06GfJvpED9RA2+PGmUqKQF8VESY6mmxDZ1EVl +WQDHrhdXR/rAt9wsKmXUlcB8iOiSxq64G+N39Xtzdva3bAjp6Vp5IycA5hyMWRBz +IqO0T7VHa0mdVaEB80DfRubL +-----END PRIVATE KEY----- diff --git a/obp-api/src/test/resources/cert/expired-tpp.p12 b/obp-api/src/test/resources/cert/expired-tpp.p12 new file mode 100644 index 0000000000000000000000000000000000000000..9c2a92201786f9ce2d6a28b619c1235de0fc20a2 GIT binary patch literal 3642 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 literal 0 HcmV?d00001 diff --git a/obp-api/src/test/resources/cert/obp-server.crt b/obp-api/src/test/resources/cert/obp-server.crt new file mode 100644 index 0000000000..777053243f --- /dev/null +++ b/obp-api/src/test/resources/cert/obp-server.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDojCCAoqgAwIBAgIUbdFhdWs6shOyE3ypwY/cZN+TmSIwDQYJKoZIhvcNAQEL +BQAwODELMAkGA1UEBhMCREUxFDASBgNVBAoMC1RFU09CRSBHbWJIMRMwEQYDVQQD +DApPQlAgRGV2IENBMB4XDTI2MDcyMzEzNDgzMFoXDTM2MDcyMDEzNDgzMFowTDEL +MAkGA1UEBhMCREUxFDASBgNVBAoMC1RFU09CRSBHbWJIMRMwEQYDVQQLDApPQlAg +U2VydmVyMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQDmKwy2iL9OIyS4di4xDavUizUHDA+FHmsqHICUH5ipd/WZg61N +KocVvGqRd1cpChAzUIvDOmhAfqd6uJ1n5eByVmmjKEVX7YOoGsHU0947WHp0dUBU +Ip8+hKGai3XyVcyYlSPaXNkSzmqHjQLK3klnpPUYQUBoSzwpT4eIZIM7tgonJfHR +EXt5A0sMZCUDmpZs5cXFx8E5dgFACZcF4MCewOfN8q50Qysy774/9oiPN8auT6jg +6KmuNwpRuPfGmdV3JPyLlFh5zn2YahWrFWzDK34Y8p5FneW43jlVw52W5gMQjIHf +PgxuN+3VtavG20k8veJOKuZWEywpZQfO9ckVAgMBAAGjgY8wgYwwCQYDVR0TBAIw +ADAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwGgYDVR0RBBMw +EYIJbG9jYWxob3N0hwR/AAABMB0GA1UdDgQWBBS2iM1NF5PgLJnl+dsvnQvoVY3B +ozAfBgNVHSMEGDAWgBR0VUTBIFAyzVw6KTU/qirlO28OzDANBgkqhkiG9w0BAQsF +AAOCAQEAP7O8pQxSfYMvnjBa6TtNwCw4kcEbl8Xf8xQ/xgGsURtBBEn5aPCnctVX +72PwhG99kG8sWw1grBRYydxmS/xAW+EFlwzxhe35wXMrfvR+Xp04P2aKTHsTKq3A +NFfK4A9l08nhSmpnv3D3chUlcsOcNV5h8ks+qA9CgOXj0Jr8512rLD7y2Lgvs/ou +vMUmAiCeq5S0Y/caJWHINDzDpd8pPG5Rvx6s7h4iNzRmXj3in3TxbTm919kSYNBC +eQheqBUMn5K8FbktpQbXkdJ1etOEan5i/WwedS5maCNwk2ERuUFNQgjyLhu8bhQ8 +VWMDinzXwT7lh/bEyAcSZHs7NlkZmg== +-----END CERTIFICATE----- diff --git a/obp-api/src/test/resources/cert/obp-server.key b/obp-api/src/test/resources/cert/obp-server.key new file mode 100644 index 0000000000..cb94b4bd0f --- /dev/null +++ b/obp-api/src/test/resources/cert/obp-server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDmKwy2iL9OIyS4 +di4xDavUizUHDA+FHmsqHICUH5ipd/WZg61NKocVvGqRd1cpChAzUIvDOmhAfqd6 +uJ1n5eByVmmjKEVX7YOoGsHU0947WHp0dUBUIp8+hKGai3XyVcyYlSPaXNkSzmqH +jQLK3klnpPUYQUBoSzwpT4eIZIM7tgonJfHREXt5A0sMZCUDmpZs5cXFx8E5dgFA +CZcF4MCewOfN8q50Qysy774/9oiPN8auT6jg6KmuNwpRuPfGmdV3JPyLlFh5zn2Y +ahWrFWzDK34Y8p5FneW43jlVw52W5gMQjIHfPgxuN+3VtavG20k8veJOKuZWEywp +ZQfO9ckVAgMBAAECggEACLWR+RQ1PO/VKLUGwWzALD405vaHXc+irsi+UByJSnGo +wELRxheOXf2vq0pwk153R3chmrpf4yoyYdE7hN4XaewoJM2Ay4zIDGPazW1TkuZM +V+dvOYpygSCZUX+RGnbcU4e31Wrppy2wApS3iX/zbMcP+SIlnZdUiEb4nL9vkczw +CuuX4C7peZ1XslMkCG3Ln1S1GM2MQz9R1pOJrobpxzebT5VN5UJ1H8PgthBwYGkz +SYLZTVM39HvXgpXRLzAr+k+UpQu9N/Bs1VMapK+rUzeoqyW6M1kUrrrnZwe0c5hw +rrpwbDDvu54fNXWd4C27v4d5c84u7b+FbtrxcxpjzwKBgQDqpLXbvuzI44W63yKR +Ks398cI+IIgA0WWW9SDP+UK7x9E/b8uwGnK4cWtmV5IDIskgxsGf2zKbZEJDORmM +68DsoZi2bH+DN6T75lcJBieCaxk9XQ388m27woHZFXKYuY+BlmRingFLSaPjRSwG +67bNWZ/QC1dC8ynPJc8hD0O0ywKBgQD7HhB6tiIeHYS5Evc/V/uCdlJWDu1ddjug +YU5vZeL2zb49n2r94TOzi7/tpyJSGRS/DeRJzNPprAfx+s5pfr5/y/D2/2aiiRpE +1NGV//cwH7U3r8qLkcl2HbNnmtLEapa0fb13kv64LFpaIaY42BX8vwO/tvvkXHte +CritMRidnwKBgQCTPoU4vpkMf2qeGAQzIK9fmmSQZA20pbKghnbuy7aK7BttOZSS +kCZJhDMnZ/CisyOPw+ohjIY3aTUDxkM4YYERfV69q1xhVXvc73DSouAMQXT3QvvS +LbeaybZ+Ka1eFaPuaBfaotihDDciGXhFZ4mxV3qoLVW3F6y25z0Ru+h6/QKBgQCv +9XcSpUDu3Tw8+t8pEZt45TP4fWkxzkEltklhoYER80TUpToxP3Yc31XTTwOrh0uU +PEW0uMPcvuCqXFX6cgdGQT9Ns9TVG0C+7mkVtFAe9nji1lkUx0jlbRZjCIebfhyv +yFPUz7mQj/OXqHBy8GcnvSkBU1TZxTvkv5p8MSTQfQKBgFEZtMdhxD0x3iGHgMlr +RoDsANG8zljrsW9/nW0rSq+WLbfRZ8cIDqBtr45vqzJ3X4SuRCbaVue7FJE/PhRs +TqUqfX/CW62hjNJY1irttLRW8Z9y6t/FLN/WSHD6IxylMCD+ZuKyyCeF93vjCqgy +6H8R+jMLvqg+DSb2vw/qEgFi +-----END PRIVATE KEY----- diff --git a/obp-api/src/test/resources/cert/obp-server.p12 b/obp-api/src/test/resources/cert/obp-server.p12 new file mode 100644 index 0000000000000000000000000000000000000000..3c040b99dfa893ecb75750a5f1b82d0204967d67 GIT binary patch literal 3672 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 T_(U*u=8z~cc1KX*?|b}z>fOD? literal 0 HcmV?d00001 diff --git a/obp-api/src/test/resources/cert/proxy-client.crt b/obp-api/src/test/resources/cert/proxy-client.crt new file mode 100644 index 0000000000..fd2d9c7712 --- /dev/null +++ b/obp-api/src/test/resources/cert/proxy-client.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDhjCCAm6gAwIBAgIUZOtW9Bhe8rE772GPoboOHFiRVCIwDQYJKoZIhvcNAQEL +BQAwODELMAkGA1UEBhMCREUxFDASBgNVBAoMC1RFU09CRSBHbWJIMRMwEQYDVQQD +DApPQlAgRGV2IENBMB4XDTI2MDcyMzEzNDgzMFoXDTM2MDcyMDEzNDgzMFowTjEL +MAkGA1UEBhMCREUxFDASBgNVBAoMC1RFU09CRSBHbWJIMRMwEQYDVQQLDApFZGdl +IFByb3h5MRQwEgYDVQQDDAtuZ2lueC1kZXYtMTCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBAMAO2yfiwmkU6+APu9zXGKobAcFEyTCfQy+2geI6dtb6jGi/ +EBY4et/Q37eGm6O/AwLBdtQ0gCQYztWXn8xS0HNr/Ka5A9uDCjxcoP7bm4yIJ2Zi +Yxi/CwqLPaTI+9JCIEgZyY2DWzv2049WKw28mMCUZkJhHLRVbkqbUT+NZPgT2zSW +/ZJ/wCR31waSIjKqmpMEKX4yc8I2nipAZNmBd7nRDQ1XE0Camk2lF/w0IA7Wel8Y +0V5553fXJcLgv8NztPuMHeok4UAr6CDMYvcBvs+ovdsipijzxlfcDdcNgAHubDRk +nTWJ0l/cu+6vZT3Zo41Q2LPWM5j3SyEwX2fZqAMCAwEAAaNyMHAwCQYDVR0TBAIw +ADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwHQYDVR0OBBYE +FMqhkcxvaR1dafo3HWrVPUuQ4dz9MB8GA1UdIwQYMBaAFHRVRMEgUDLNXDopNT+q +KuU7bw7MMA0GCSqGSIb3DQEBCwUAA4IBAQB8xkzrb28J4V+4oAO+DZJXt1uQs5HR +MmzoalyImS7LP7EF/wPHkOKKqgFcD5KWR4CFzIQ43JRKYBuGFlW1yKEVtXdonkfJ +84v2G3624i104b1o1vxbnzxBqPghiHGHrYG8Np66Om59GEbvG37bzk9jqCxpUXg4 +6NH1zjKpETxeqsPXaztalYIttPUSECci0gMuEoxQ2nClh/lMsDxMS8FFnMn1WISf +HQHJ7EXEWut1LsqYWMXtPbjAeSxeUOw6c99KdjhIE2zbQgWZKvxVoxcn80ZRIg5g +cw5O+ohbG+kfqTNafvn8bHaCbPADLFx1nDd6VDxP7acm5Yr4XZiWo5Bp +-----END CERTIFICATE----- diff --git a/obp-api/src/test/resources/cert/proxy-client.key b/obp-api/src/test/resources/cert/proxy-client.key new file mode 100644 index 0000000000..a38653c11b --- /dev/null +++ b/obp-api/src/test/resources/cert/proxy-client.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDADtsn4sJpFOvg +D7vc1xiqGwHBRMkwn0MvtoHiOnbW+oxovxAWOHrf0N+3hpujvwMCwXbUNIAkGM7V +l5/MUtBza/ymuQPbgwo8XKD+25uMiCdmYmMYvwsKiz2kyPvSQiBIGcmNg1s79tOP +VisNvJjAlGZCYRy0VW5Km1E/jWT4E9s0lv2Sf8Akd9cGkiIyqpqTBCl+MnPCNp4q +QGTZgXe50Q0NVxNAmppNpRf8NCAO1npfGNFeeed31yXC4L/Dc7T7jB3qJOFAK+gg +zGL3Ab7PqL3bIqYo88ZX3A3XDYAB7mw0ZJ01idJf3Lvur2U92aONUNiz1jOY90sh +MF9n2agDAgMBAAECggEAO5Af7aXIz1gasxmOFLZswsyvZaYUk0zBRHngnC2vj4qS +oyWmMo3/pYwc5ckMWeMyZtdjJ/rERu2er+VfPLnuRe2WpIo6pQhl00SS9ZdcDWBo +f7tBqnoNTY7TZliiqJmzc0j0FjxHvjgVcp2xqof0A73CXRHLGi5ojyDOONx8FOdi +CX30Ht3pncS6mDMl1ZdR4HTwkdMqiywmyvrZIlGi8qaBa/FBZp+rruZo9ko5WR+y +w3ynN/n7BuYq7FzHnQneJEmfopiYv6WOWVsFOE2Dh4T4YB9ZclC3qboDoljsWpTe +wIjJaCbnPew1Wri31Hrao31h7L06hfDgp6fFWNjZoQKBgQDfBI3XK5KkQ5Wt9dfe +DEnsIRKzXcYjLPTB0DfAiYhDRnhWxMqShDwQetKO+IQm2nOJk3dV17tTgxsXOc8s +NyN6p7P6Mt67Wfq/4MHUfLiyuuduTNcu07nIs5dFSemPPH3IrSK8X1m5IBXGXaha +h/vNDI1fyBUU1Jv3Mw0o7IAA0wKBgQDcdiqokH2IjhCYRTcvrFbwePx+nm4ZQpXc +Ao0XxYqGweXat025M8Jtk96qjz3Dey8posQse5UdNXJqBW0RsUy/z5I2E774fW7P +t/6VcFagh3eRAzh9i/BFRz5hZBxC2T/nPFidExhQTjSsc5dgmG9tAA05sNtQrh72 +qu+lxC++EQKBgHxCClGf2nWhnm0ttBfpGurwxn2fuvzwWHMAc3/YYU6ynewie4fF +G75G4Lh+KQuI3aUCwBPZPmtaeiantBG7qw31EKdP3p/ek4KDVRvyXepfjwD62U4i +87mqrpcRhoujNaYxKVBxhAlMojVDC8FdUOO/oamDTpOrnjbOf/+UPgG3AoGASsX5 +afq32Rz2G+897Hlzc1RVr0xk40RmN5zV5f4mIdaZ8zjAr9QSWcARgZ/bvvWE5YZD +KyMvTBzYlYUJnTqZYWUxng3Mc9N3RhSN0Hmtp/zKXPDOtCZGc/jZ+4ZM34930SHy +nDPhxzr/Oo5qBWUuRbYCxlJp0E9+SQNpDLwhxUECgYBf9+Ht2QEP2dJ0lv2WKrZv +XceDDbCowJPCdCfnsStoGNSHDI0I+b5Wqz/yUsLrLVCLFdI9waeH7Hx5cw7C7kjk +s6wNtmaBuO2xp0ueqIIWsxUMlEudQtDunJblutBzjSgm7k25js3GK8a+am+u+HMD +0tums7zx9K5H2/YKHoOm/Q== +-----END PRIVATE KEY----- diff --git a/obp-api/src/test/resources/cert/proxy-client.p12 b/obp-api/src/test/resources/cert/proxy-client.p12 new file mode 100644 index 0000000000000000000000000000000000000000..de65d4aaa5d6e6fda68bf8a5ef05a50c1dc1cf88 GIT binary patch literal 3644 zcmai1XE+;-7ETC4g;=%s-ib}E*wiLftM&-0cBvXS)M%-e8Z9bDYHO{0Rkc@D?OoKW zRbthQk=y&+`+VQe`{O+4dEfUu&-wSB_rTCJN+dv17#gxmPAQgPkZ=kCQUEb%$RY?0 znfpUe!_Z*9eO0T6mCVC|6XQV5jF z71+3rr(UpU83Q=L7tJa<5dKBh@dtTk@x)XWF3K2{eM5HNhrCbcM&<*~$dece#a@{e zB|KeC2M|jMlW{#$)tRIFe>@Y2HU5BB}2;`(rT+NKC^Meba>(1B*oy5Ff3ZC=I^^ zi@TMmY7vQ_4qrO=Nn8V7+i6Yq#Ali7zB(k--5w;&02Zu8@K*CRre@*}Nb=V6U7iQS zq&ctp`mF0Y6;)Jqb39J+In>YfJ?_XOtM=wJ#Bh7!}a)LuPUp!}*-b+S4i-mu{C4f(ex1jtf z*6y0H-F$m}UND=ow?LPxA8xA&{>Z?k{w3y}T8;Nc>%m z+ikl88>@rcF2c^)d3y9=Va3=;jqd|DTtLAllg9xXbZx~NWLF?C zsSKJ?3|gJgrj>}qTiGa z?e*lG5G5O7#dEE?*cb9piPox?OIlRlx?C#oy*G>#vHG(2Q!M{;TO}Uv>vefKnn9Uu z1m2(fgOY4@m&Ix1Bx@A@<$IfRoV!f)9z^wU`VBVsN3g~kWtL5)9__Nq1-CBC$06x{ z)0PCV@Ee89mqj}Qa+N<3Bt%vfR{}(ClD9E2J#=q~j&FCxkC!2Gm11%&b_W`5#1;=t zOWoTXH!DR(DYWAl!whY$)woQNw^i1BnA3xt0h>OftG{$yv39;XIDt4NmW#V$B4r<+ znb-!3eq#lJE%q-5g0-{o$6Gh+K)n{cmq zCFo+wQMR7RD?#Mqkr4wzUn(m23RC8b-xS-jkKue6I7o9oT~-x!zzO?g*}tIfBv-o4 zr*1}2?u(l_GhhXkaT{uNFYHf@L_~<_ZMshVWp|~h*-EV_aFG2*!;|q|Kp^3|<;Vo% zZo)2{6yEm(K1|}y31pX45SUDC$PadXQ;GAFIg`Q0Iz23Ns@A|_ApMs>Kc|eugc4%j znwZ4GMKbc(f_`3YyO3^jh#9-v3yOS0{+)r0xH4&h42*lV?u!>Fk?_5R;O8eb1+lHW z=0E0Z3fGwavdDUr)2~>ca|{V*|`o@#vS18$IZ7i>r33oil*pl4L z>Go@$2sv$w^%R4+%9d<rbVV-}qm)pr!(n{1M@Q zNXh>f7#4MEgwtWM=l?e_w0b3r*A z&j{iP44Sp!FPS|r5KVgxVXa|ymqf@bb{zhKelkAJ;p- z)uh*8UH5h8GKkkXzo_Due8-YK8dwUIQ{$)=-5^2$b&2NTT_G=w!ncT zA70a*c#|=71W@$UhO=wQ^2#xJS7P+KE|u7z!@qGE$;&MQ^I>S9=Iv0Swq+6w4+AxU z-nOG8ZXC{h#NpF~h9SxdKX5!>SX7$Pb!$qVJ4yE7NMnI$gi#6jweX@%^qR?23w|HG z=E?~UK|H6WZ&BM?isI|LcvKJ4$dN|ET(T?gJg&`l95yy-GV5`ZRgD&qD!t%buF_95 z(rjK89;)ibE|C4toP425IMd?{!r?(^BM;Zbo_1PSdckiG$Us0WEgf9=G#=XMc>mX!bc= zl!LnQiPI&)mBYodJ7r)d2JxV_kT`JVFJ9-^@>gHusQ>M(*Lh_;A=$HR>N9&AtyM?3 z_K*>y&hogrEAQBld|VjI6|JBcJ>Lszf?*;Cd%|stk5NTe|)QE*6qKR{Z*T*X{mfOwVR`Qkn`yT6~7*aPv(*+3+~8({&^3 z2Rvo5<5hKfO?A_^Zfj!8@(V7Fr`H4M)p)Ihl^)TYTGn^mZE`BbcdGjtqWD40fTM?f zru?FEs@WS>zs<(2{j9^>^^u+Z_1?Jkp%U z4JzccmNdKt%OkViI$f!n3GJ;1tUAAv&<-`l^7G}sx6l8ap~D`AQ8=A-vs~`gdpt@M zUCOOm*-7Q5E18R7d{BI{c^POBKPm*Hv2)%ge#&@=Wx>vJ`SUF8iQ(m-6mcUeV(OA> zhWB?%Q&OU1K0opi@pwCzB(B(dF3BgC!sYyo>i{Vo+R?7md`_p~>?#!}9k%~Cv~imV zdL=HIbmKuTe~}gb+g5ij)T`aTcFMsd&)6@l7Jw&um3C5YX0+;OWHyZ#g`7rz+@Dqt zE%lHJJ9809cI13BGFGx$!lYonRKEXqNInVvIQ6ZJ+?k~}$Sv=NxUH|>)25$na>uWYt z>s&6G760q_N0O#@!HuwQ)q+D`k(?mIjahENGB)K?Aq}-+)oMl6Rz=l_(Vv~7W=M_NYLpg@SXCoN?bMzrHLGZCstsCd z)`*m%NbT+Qo%hb~_x^k5o_p@O-*?Zsf8Be(3yWg8K}kUci(+1*p_jzyVGfunXen?} z%rn3!=7~S>S6CFC&_5InE{YEH2V$k90Q?Db|1cCVtG^R!W*8hM@lVPG;|0=TnR@HR zw0;CpQqlonOf+===|)LU4S?~{&^uuCC|sz36p}!;Zpy7-YfeRn@S_u#t;cubBcf<= zxMZ*2{=+|SNcI_CEzYASXqi;_MXK0GMdMVQi@)#MQQ;G6ZVKa6_NvBYH+i|XMbI5s zl?Sy1+Az8>gMflDJxRDSVWyj^{7Y}A*<`*-_sslWGDEwirQl#Ii=@kYkxA?;81r}) zv_1FMZ+$PiXZbVH0S*o-lIpx~Cn=x$FqOYW`@ofrv`#E%c0LxypBD|RKg)SK0@dYg zAwPAzPGf4bY!gUIa#eKf)Q!+Pnss9*XdV;|B^BY9_j~x~ zkEk(=hth_^D3zHR>WA+?ci(#=JHG&2z1=^R2R)bL4Xyy((@>v%C7|W+mRQ?BDONAk zHq?JiS;g0yM(%idTvs+dc;-HP-3niG<9Lcq@uHijc_rY-oH=>J79!sl15d=3 zpXweP(yM#}rrZwCkMSI_FN1*d1{e40u30z;+UyT{d@@By(rXhJU4D znK-LnmGVT=i>&16W<{OI<}zto$M|ZGWhq94pq!HcrRUIZ9Ca~CYHwX$Oc?WLwhJd| zTD4=%>^y>D0#dq5fvxWMA91{Cd|^1=sJXQ&4<`}> zj%&#M)lf7}=G@7eqp>uYr8W0i*lWZS{(S`!d4>_2nUlUVEXy#%0tens;9u$+sm-o2 z!>~aHrRH>WvmEXqB6e?qcH8{T!`BMFgcSDubtn|B?_ZcP7pS5Bb?{ns*mYV~Qn2sB z(t}jr!^UR2cb=TS5+CBI)NEs}X-8UwiSQ}6{i?Tn6ft7_)I({GZ7|N^)CJyjT6kWF zZmC_-B`$3rGR-v-9kbP*iBP2uCuMj3^42Fsi4Rg}$PpK`H`7tvQxI@V`nbc3qr_M_ zx%_OvbIZ%*$49Tg+d^vW-EIaE#SAsQ8wFyqJyAx+w{q_&bb2L|O2Rf{xFc1JWLd^? zMzggz5NrNQ$onafxwR}^^1@i;dQrSo2#>HeP0DP>PJMxR4tKkum8d5sm%jB(I(d$5)32hNgY4y&N;^Oc#|9Fq9%^QtdS3?F z$3jIig{GUweyGWgkv3)q7@qGxi7%J-n$T2?)LHNXXLC*Zz$KNrZaVlN4=OYG8$|It&tg5u<)1m_MV}6QUCtu;j z(tiE#wnMYZI5@2n;XvFf`vsBURSM&1h|sWWm7JK~Ii=-` zfTOnb?>RcnL_m`E>aF#)lVm(JP_N68A0e0xBZlUTSdP<__hB}9WW(2KE4{th2Y65WKCvrWh~A@Vvyao9{Ah%CRrU2#;+G;g7gfhhgZ^WOxa+G z3malJ^HskTEV9(?1-`FZ&KI<-JTon7N-mK~&b~-uuHyjVq<+lJ=&%36Hf3-s@(Sm(u6}B1~cA zC0m7dwo!J;AF817x0XJW30 zbiuia=qhfEKJ^}hKF(!HmoK#TJ3Ut!iy)S+b$u6bz3 z>mq?3YNdx@1YyV%)%PGy1DLeDfRT$VYpSk(d_bGzeV>9@*BHbKl#zv&&_6yv9p{Al z-&I)YUQI+^Od2`157;2^veD9~mjfS6+2&|Xeu%@FB8wE>v)1|;BhkYCp~T7i)cYem z#NqI$7@*QiLer|tZD zrhwJq`K(_ECYtnwk*|$_wF6_Lb$`gE!J?=u{=WDB77*0gz$ogBKXB?Fe?i0eUv0s} zKtcJ3l>Gx<{eObts~+PCU4q}<{}l`(lHKUVD~f(hdBa%$Ny8*y6mSHki+S)GpR2Fg7u3ah*i7cr5(v?k_N{`dQjE9P zxswM2zt)<4&i1n>JB_h()IMn3K5m8|FMzpbBR)^eK2{d@zt9*_+Rz}RC)nkg-JtgJ zPzP1vg(78AVnIi3Xu`TL0(Q4ru$%RKAH$}`*B6vbOZ3XNh$ivzJZqasfaRM=O|p&7 zkXr^H!SjRp%54>F%|W}?mDl|?>LjwigrYhwPyLnF-$t5mHSJyIL^7Hl*g1K*N$>l;x;=EF76Ex=#s2&dU64Mq}zi_C6~uP7{l18O8G6@(fqOONeg&0-u4NWPOz zzW?C`vP4~C&Rl`;(ed<<#m>@~RdaD{=D-A3P~vG4Tdy8EV`x#mJbLuG5AVBA^$y}r z%e_fOG?py5zI1=FBQH|5w)|cS0l>n@_^QE}P#6B0QS z|N5^(d034^|B84UKCmkDFy$!=uABQDA?+Q|kczvrK3RW7h z#9Ah3Z~7A2ccHpU`z~QanUIK(JyRK{dDbt^Zm0YbM!#M*Y@K(zv#l5o7mjRv%H{CA zlpZ4Y>lTq@v$c9l_?Ksg0ZyV#^CATfTr$_Vw8vSI8C%ld^qf47-B#~psl(6Jrv~`R z&6CxxZTMR4%PH0|Tw}@&w3L8->R*jXnmNp&k+%x6WUGuaFZQ0~tsfo5OElJCO-3c1 zIU}ffT&37QQa}y(etrR1-<(}iH0xLpTHmAmbXNScN##_#p+IlNBB^q(K;$z2)lpwbP1VT8=YgAhBYYPUB1$J z(4TlL=_Fz(%*faXGOA75h^LKysF+@z(DCkA@*yZeG5-n>yqbMoRH>4^^$856x{cN! zKO~h+fH-E!&Z5Nzwy|}}1pW8N+}Y2KKlWLGRB#)VWZgmDzfU+ap@jfMKpKb37l|B^dO19K-_LQR_ z4NrV5<<&CJas*e@kBE`X*K2pan?bO)cv(11`0oWv&nXM$=L7@*eE!%~fHS}o;0AC3 zcmV?bw}?v?4CLhQ9HsV=-tIG<)$tB6C9YbcKMBG#VX`phzke=D3TgmQurVcV4EoYb qL)q%szQ*JF?>rH;m)Ku4-RU?lsPkc*1dy)yT=;5}9O>xa`}ki?nZx@4 literal 0 HcmV?d00001 diff --git a/obp-api/src/test/scala/bootstrap/http4s/DevCertificateSetTest.scala b/obp-api/src/test/scala/bootstrap/http4s/DevCertificateSetTest.scala new file mode 100644 index 0000000000..09b760dcc3 --- /dev/null +++ b/obp-api/src/test/scala/bootstrap/http4s/DevCertificateSetTest.scala @@ -0,0 +1,177 @@ +package bootstrap.http4s + +import java.io.File +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.{CertificateUtil, PeerTrust} +import code.api.util.http4s.CallerCertificate +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 + +/** + * Guards the development certificate set in `obp-api/src/test/resources/cert`, regenerated by + * `scripts/generate_dev_certs.sh`. + * + * Certificates checked into a repository rot silently: of the seven entries in the older + * `server.trust.jks`, five had expired and two were public web CAs, and nothing failed to tell + * anyone. So this asserts the properties the set is supposed to have — that each identity is + * usable in the role it is named for, and that the names themselves match what the documentation + * and `mtls.trusted_proxy.*` examples say. + */ +class DevCertificateSetTest extends FlatSpec with Matchers with BeforeAndAfterAll { + + private val password = "123456" + private val certDir = new File(getClass.getResource("/cert/dev-ca.crt").toURI).getParentFile + private def path(name: String) = new File(certDir, name).getAbsolutePath + + private def loadP12(name: String): KeyStore = { + val store = KeyStore.getInstance("PKCS12") + val in = new java.io.FileInputStream(path(name)) + try store.load(in, password.toCharArray) finally in.close() + store + } + + private def certOf(name: String): X509Certificate = { + val store = loadP12(name) + store.getCertificate(store.aliases().nextElement()).asInstanceOf[X509Certificate] + } + + private val echoApp: HttpApp[IO] = HttpApp[IO] { req => + val value = req.headers.get(CIString("PSD2-CERT")).map(_.head.value).getOrElse("NONE") + IO.pure(Response[IO](Status.Ok).withEntity(value)) + } + + // The proxy identity exists so a deployment where OBP sits behind nginx can be described at all. + private lazy val behindProxy = { + val proxy = certOf("proxy-client.p12") + // Issuer is the CA, NOT the proxy's own subject. Obvious written down, easy to get wrong: it + // holds for self-signed certificates, so a config that works against a throwaway pair silently + // matches nothing once the proxy is properly CA-signed. + val issuer = PeerTrust.canonicalDn(proxy.getIssuerX500Principal.getName).get + val subject = PeerTrust.canonicalDn(proxy.getSubjectX500Principal.getName).get + PeerTrust.TrustConfig(List(PeerTrust.TrustedProxy(issuer, Some(subject))), + trustForwardedHeaderWithoutTls = false) + } + private val asEdge = PeerTrust.TrustConfig(Nil, trustForwardedHeaderWithoutTls = false) + + private val resolvingApp: HttpApp[IO] = HttpApp[IO] { req => + val config = if (req.uri.path.renderString.startsWith("/behind-proxy")) behindProxy else asEdge + echoApp(CallerCertificate.resolveCaller(req, config)) + } + + private var shutdown: IO[Unit] = IO.unit + private var serverPort: Int = 0 + + override def beforeAll(): Unit = { + // Exactly how Http4sServer builds it, but pointed at the new set. + val sslContext = Http4sMtls.buildSslContext(Http4sMtls.MtlsConfig( + keystorePath = path("obp-server.p12"), + keystorePassword = password, + truststorePath = path("dev-truststore.p12"), + truststorePassword = password, + 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(resolvingApp) + .build + .allocated + .unsafeRunSync() + serverPort = server.address.getPort + shutdown = release + } + + override def afterAll(): Unit = shutdown.unsafeRunSync() + + private def clientContext(identity: Option[String]): SSLContext = { + val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm) + trustManagerFactory.init(loadP12("dev-truststore.p12")) + val keyManagers = identity.map { name => + val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm) + keyManagerFactory.init(loadP12(name), password.toCharArray) + keyManagerFactory.getKeyManagers + }.orNull + val context = SSLContext.getInstance("TLS") + context.init(keyManagers, trustManagerFactory.getTrustManagers, null) + context + } + + private def get(identity: Option[String], path: String = "/as-edge", forwarded: Option[String] = None): String = { + val connection = new URL(s"https://localhost:$serverPort$path").openConnection().asInstanceOf[HttpsURLConnection] + connection.setSSLSocketFactory(clientContext(identity).getSocketFactory) + forwarded.foreach(connection.setRequestProperty("PSD2-CERT", _)) + try scala.io.Source.fromInputStream(connection.getInputStream).mkString + finally connection.disconnect() + } + + "the dev server certificate" should "be verifiable by hostname, with no need to disable checking" in { + // The old server.jks leaf has no SAN, so every documented curl needed -k or a CN fallback. + // Reaching it as "localhost" without a custom HostnameVerifier is the assertion. + get(Some("tpp-client.p12")) shouldEqual CertificateUtil.toPem(certOf("tpp-client.p12")) + } + + "the TPP client identity" should "be trusted through the CA alone" in { + // Nothing is pinned in the truststore: only dev-ca is there, so this passing means the chain + // was built and validated rather than the leaf being listed. + val store = loadP12("dev-truststore.p12") + java.util.Collections.list(store.aliases()).size shouldEqual 1 + store.isCertificateEntry("dev-ca") shouldBe true + } + + "the expired client identity" should "be rejected by the handshake" in { + // Certificate expiry had no coverage before this fixture existed. + an[Exception] should be thrownBy get(Some("expired-tpp.p12")) + } + + "a certless handshake" should "be rejected under client_auth=need" in { + an[Exception] should be thrownBy get(None) + } + + "the proxy identity" should "be usable as a forwarder, keeping the App's certificate" in { + val appPem = CertificateUtil.toPem(certOf("tpp-client.p12")) + get(Some("proxy-client.p12"), "/behind-proxy", Some( + CertificateUtil.normalizePemX509Certificate(appPem))) shouldEqual + CertificateUtil.normalizePemX509Certificate(appPem) + } + + // The DNs are configuration values now — docs/MTLS_TOPOLOGIES.md §5.1 shows operators typing them + // into mtls.trusted_proxy.N.subject. If a regenerated set changes them, the examples go stale + // silently, so pin the names here. + "the identities" should "carry the documented role-named subjects" in { + certOf("obp-server.p12").getSubjectX500Principal.getName should include("CN=localhost") + certOf("tpp-client.p12").getSubjectX500Principal.getName should include("CN=test-tpp") + certOf("proxy-client.p12").getSubjectX500Principal.getName should include("CN=nginx-dev-1") + // The App is a different organisation from the bank: a TPP sharing TESOBE's O would make the + // Consumer and regulated-entity lookups look better tested than they are. + certOf("tpp-client.p12").getSubjectX500Principal.getName should include("O=Example TPP Ltd") + certOf("obp-server.p12").getSubjectX500Principal.getName should include("O=TESOBE GmbH") + } + + it should "separate client and server extended key usage" in { + // serverAuth on a client certificate would let a mix-up work anyway, and hide it. + certOf("obp-server.p12").getExtendedKeyUsage should contain("1.3.6.1.5.5.7.3.1") // serverAuth + certOf("obp-server.p12").getExtendedKeyUsage should not contain "1.3.6.1.5.5.7.3.2" + certOf("tpp-client.p12").getExtendedKeyUsage should contain("1.3.6.1.5.5.7.3.2") // clientAuth + certOf("tpp-client.p12").getExtendedKeyUsage should not contain "1.3.6.1.5.5.7.3.1" + } + + "every identity except the expired fixture" should "be valid for years to come" in { + // Five of the seven entries in the old truststore had expired unnoticed. + val fiveYears = new java.util.Date(System.currentTimeMillis() + 5L * 365 * 24 * 60 * 60 * 1000) + List("obp-server.p12", "tpp-client.p12", "proxy-client.p12").foreach { name => + withClue(s"$name: ") { noException should be thrownBy certOf(name).checkValidity(fiveYears) } + } + } +} diff --git a/scripts/generate_dev_certs.sh b/scripts/generate_dev_certs.sh new file mode 100755 index 0000000000..88265f6156 --- /dev/null +++ b/scripts/generate_dev_certs.sh @@ -0,0 +1,153 @@ +#!/bin/bash +################################################################################ +# OBP-API development certificate set +# +# Regenerates the role-named certificates under obp-api/src/test/resources/cert. +# Everything it writes is DEVELOPMENT-ONLY: the private keys are committed to a +# public repository and the password is in this file. bootstrap.http4s.Http4sMtls +# refuses to boot a Production server on them. +# +# Four roles, so that a deployment can be described rather than guessed at: +# +# dev-ca signs the other three; the only entry in the truststore +# obp-server what OBP presents (CN=localhost + SAN, serverAuth) +# tpp-client the calling App (clientAuth) +# proxy-client a reverse proxy that forwards someone else's certificate +# expired-tpp a deliberately expired client, for negative tests +# +# The older server.jks / server.trust.jks / localhost_san_dns_ip.pfx are left +# alone; they are still what the props default to. +# +# ./scripts/generate_dev_certs.sh [output-dir] +################################################################################ + +set -euo pipefail + +OUT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/obp-api/src/test/resources/cert}" +PASSWORD=123456 +DAYS=3650 + +mkdir -p "$OUT" +cd "$OUT" + +# Distinguished names. Role is in the DN, not only the filename, because with +# mtls.trusted_proxy.N.subject a DN is now a configuration value that an operator +# reads and types (see docs/MTLS_TOPOLOGIES.md §5.1). +CA_DN="/C=DE/O=TESOBE GmbH/CN=OBP Dev CA" +SERVER_DN="/C=DE/O=TESOBE GmbH/OU=OBP Server/CN=localhost" +TPP_DN="/C=DE/O=Example TPP Ltd/OU=TPP/CN=test-tpp" +PROXY_DN="/C=DE/O=TESOBE GmbH/OU=Edge Proxy/CN=nginx-dev-1" +EXPIRED_DN="/C=DE/O=Example TPP Ltd/OU=TPP/CN=expired-tpp" + +echo ">>> Writing development certificates to $OUT" + +# --- the CA ------------------------------------------------------------------ +openssl req -x509 -newkey rsa:2048 -nodes -days "$DAYS" \ + -keyout dev-ca.key -out dev-ca.crt -subj "$CA_DN" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" 2>/dev/null + +# Signs a CSR with the CA. $3 is the extension block: the whole point of the +# separation is that a client certificate gets clientAuth and a server one does +# not, so a mistake shows up as a handshake failure rather than working anyway. +sign() { + local name="$1" dn="$2" ext="$3" + openssl req -newkey rsa:2048 -nodes -keyout "$name.key" -out "$name.csr" -subj "$dn" 2>/dev/null + printf '%s\n' "$ext" > "$name.ext" + openssl x509 -req -in "$name.csr" -CA dev-ca.crt -CAkey dev-ca.key -CAcreateserial \ + -days "$DAYS" -extfile "$name.ext" -out "$name.crt" 2>/dev/null + rm -f "$name.csr" "$name.ext" +} + +sign obp-server "$SERVER_DN" \ +"basicConstraints=CA:FALSE +keyUsage=critical,digitalSignature,keyEncipherment +extendedKeyUsage=serverAuth +subjectAltName=DNS:localhost,IP:127.0.0.1" + +sign tpp-client "$TPP_DN" \ +"basicConstraints=CA:FALSE +keyUsage=critical,digitalSignature +extendedKeyUsage=clientAuth" + +sign proxy-client "$PROXY_DN" \ +"basicConstraints=CA:FALSE +keyUsage=critical,digitalSignature +extendedKeyUsage=clientAuth" + +# --- the expired fixture ----------------------------------------------------- +# `openssl x509` gained -not_before/-not_after only in 3.2; this has to work on +# 3.0, so the one certificate needing explicit dates goes through `openssl ca`. +CADIR="$(mktemp -d)" +trap 'rm -rf "$CADIR"' EXIT +mkdir -p "$CADIR/newcerts" +: > "$CADIR/index.txt" +echo 1000 > "$CADIR/serial" +cat > "$CADIR/openssl.cnf" </dev/null +openssl ca -config "$CADIR/openssl.cnf" -batch -notext \ + -startdate 20200101000000Z -enddate 20210101000000Z \ + -extensions client_ext -in "$CADIR/expired.csr" -out expired-tpp.crt 2>/dev/null + +# --- bundle ------------------------------------------------------------------ +# PKCS12 throughout: JKS is proprietary and keytool warns about it on every use. +bundle() { + local name="$1" + openssl pkcs12 -export -inkey "$name.key" -in "$name.crt" -certfile dev-ca.crt \ + -name "$name" -out "$name.p12" -passout "pass:$PASSWORD" +} +bundle obp-server +bundle tpp-client +bundle proxy-client +bundle expired-tpp + +# The truststore holds the CA and nothing else. Not a grab-bag of pinned leaves +# and public web CAs: a client-auth truststore containing a public CA would let +# anything that CA ever signed authenticate as a caller. +rm -f dev-truststore.p12 +keytool -importcert -noprompt -alias dev-ca -file dev-ca.crt \ + -keystore dev-truststore.p12 -storetype PKCS12 -storepass "$PASSWORD" 2>/dev/null + +# The CA key is deliberately NOT kept: everything is reproducible from this +# script, and a signing key is the one thing worth not committing even in a set +# that is public by design. +rm -f dev-ca.key dev-ca.srl + +# The .crt/.key PEM pairs are kept alongside the .p12 bundles on purpose: Java wants PKCS12, while +# curl and nginx want PEM, and making the reader convert first is friction in both directions. The +# proxy pair in particular is what an nginx container in front of OBP will be handed. + +echo +echo ">>> Done. Password for every store: $PASSWORD" +for f in dev-ca.crt dev-truststore.p12 obp-server.p12 tpp-client.p12 tpp-client.crt \ + tpp-client.key proxy-client.p12 expired-tpp.p12; do + [ -f "$f" ] && echo " $f" +done From 61ba74be5add4749f7d35cd7862790e2e493bf41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 23 Jul 2026 16:58:56 +0200 Subject: [PATCH 14/15] test(mtls): keep the dev CA key so the counterpart app can sign against it Reverses one decision from the previous commit and regenerates the set. The CA key was withheld on the reasoning that everything is reproducible from the script, so a signing key need not be committed even in a set that is public by design. That holds within this repository and fails across two: OBP-Hola is the TPP that calls this API over mutual TLS, and its client certificate has to be one this server's truststore accepts. With the truststore holding only CN=OBP Dev CA, and no key for that CA anywhere, nothing could mint such a certificate. "Regenerate the whole set from a shared script" is not a workable substitute when the two halves live in different repositories. Keeping the key also matches the arrangement being modelled: the TPP holds its own private key and the bank's CA signs the certificate. OBP-Hola generates a keypair and signs against this CA, rather than being handed an identity someone else generated. The key is safe to commit only because it cannot matter: it is named OBP Dev CA wherever it appears, and Http4sMtls refuses to boot a Production server on these stores at all. That guard is what makes the whole set publishable, and it is worth not weakening. --- docs/MTLS_DEV_MODE.md | 9 ++- obp-api/src/test/resources/cert/dev-ca.crt | 34 ++++++------ obp-api/src/test/resources/cert/dev-ca.key | 28 ++++++++++ .../test/resources/cert/dev-truststore.p12 | Bin 1238 -> 1238 bytes .../src/test/resources/cert/expired-tpp.crt | 30 +++++----- .../src/test/resources/cert/expired-tpp.key | 52 +++++++++--------- .../src/test/resources/cert/expired-tpp.p12 | Bin 3642 -> 3642 bytes .../src/test/resources/cert/obp-server.crt | 32 +++++------ .../src/test/resources/cert/obp-server.key | 52 +++++++++--------- .../src/test/resources/cert/obp-server.p12 | Bin 3672 -> 3672 bytes .../src/test/resources/cert/proxy-client.crt | 30 +++++----- .../src/test/resources/cert/proxy-client.key | 52 +++++++++--------- .../src/test/resources/cert/proxy-client.p12 | Bin 3644 -> 3644 bytes .../src/test/resources/cert/tpp-client.crt | 32 +++++------ .../src/test/resources/cert/tpp-client.key | 52 +++++++++--------- .../src/test/resources/cert/tpp-client.p12 | Bin 3640 -> 3640 bytes ...http4s_resource_doc_parity.cpython-310.pyc | Bin 0 -> 14312 bytes ...estore_resource_doc_bodies.cpython-310.pyc | Bin 0 -> 13596 bytes scripts/generate_dev_certs.sh | 17 ++++-- 19 files changed, 231 insertions(+), 189 deletions(-) create mode 100644 obp-api/src/test/resources/cert/dev-ca.key create mode 100644 scripts/__pycache__/check_lift_http4s_resource_doc_parity.cpython-310.pyc create mode 100644 scripts/__pycache__/restore_resource_doc_bodies.cpython-310.pyc diff --git a/docs/MTLS_DEV_MODE.md b/docs/MTLS_DEV_MODE.md index 91447881ac..100d57e298 100644 --- a/docs/MTLS_DEV_MODE.md +++ b/docs/MTLS_DEV_MODE.md @@ -48,7 +48,7 @@ CA and all with the password `123456`: | File | Subject | Role | |---|---|---| -| `dev-ca.crt` | `CN=OBP Dev CA, O=TESOBE GmbH, C=DE` | signs the rest; the only entry in the truststore | +| `dev-ca.crt` + `dev-ca.key` | `CN=OBP Dev CA, O=TESOBE GmbH, C=DE` | signs the rest; the only entry in the truststore. The key is committed so the counterpart app (OBP-Hola) can sign its own client certificate against it — see below. | | `dev-truststore.p12` | — | what the server accepts: the CA, nothing else | | `obp-server.p12` | `CN=localhost, OU=OBP Server, O=TESOBE GmbH` | what OBP presents (`serverAuth`, SAN `DNS:localhost, IP:127.0.0.1`) | | `tpp-client.p12` (+ `.crt`/`.key`) | `CN=test-tpp, OU=TPP, O=Example TPP Ltd` | the calling App (`clientAuth`) | @@ -58,6 +58,13 @@ CA and all with the password `123456`: Regenerate with `./scripts/generate_dev_certs.sh`. `DevCertificateSetTest` asserts the properties above still hold, so a regenerated set that loses a SAN or an EKU fails the build. +> **The CA private key is committed on purpose.** OBP-API owns the truststore, so it owns the trust +> root, and a counterpart application has to be able to obtain a certificate this server will accept +> — which is also the real arrangement, where the TPP holds its own key and the bank's CA signs it. +> OBP-Hola's `scripts/generate_dev_certs.sh` signs against this key. It is safe only because it can +> never matter: it is named `OBP Dev CA` wherever it appears, and `Http4sMtls` refuses to boot a +> Production server on these stores. + ```sh # Start the server on the role-named set CERT=$PWD/obp-api/src/test/resources/cert diff --git a/obp-api/src/test/resources/cert/dev-ca.crt b/obp-api/src/test/resources/cert/dev-ca.crt index daa6f7057b..c01125c687 100644 --- a/obp-api/src/test/resources/cert/dev-ca.crt +++ b/obp-api/src/test/resources/cert/dev-ca.crt @@ -1,21 +1,21 @@ -----BEGIN CERTIFICATE----- -MIIDYTCCAkmgAwIBAgIUDf/elkWew2mhUWlc1hlSBQ0zccowDQYJKoZIhvcNAQEL +MIIDYTCCAkmgAwIBAgIUB0Dy5O9Schyz7//JRohKuKVTpfMwDQYJKoZIhvcNAQEL BQAwODELMAkGA1UEBhMCREUxFDASBgNVBAoMC1RFU09CRSBHbWJIMRMwEQYDVQQD -DApPQlAgRGV2IENBMB4XDTI2MDcyMzEzNDgyOVoXDTM2MDcyMDEzNDgyOVowODEL +DApPQlAgRGV2IENBMB4XDTI2MDcyMzE0NTcyNloXDTM2MDcyMDE0NTcyNlowODEL MAkGA1UEBhMCREUxFDASBgNVBAoMC1RFU09CRSBHbWJIMRMwEQYDVQQDDApPQlAg -RGV2IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvnXiKJWFE2Jk -fatgs79E7p0BKiGIgDyYQXXHtBGKuPBujT2ilcQD9hLGQuhGGz5Ui5FmeCjaRyM9 -1HDd1JD1lkIP8Be5p2N/GRbRtP0QZ9a/EKAEqXrt0TfMy/aSobQuJ2WseTiqC4KP -/tfmdcjbheZIpjlRsrpggmtKtM/6NkYHbBzza2bTheWfjcVRLK+2UavvJ/7VQDHl -MLr2NsAWpkEzYo5AFheWvjjMiJu08O1VR+SBWQGdKp5bg0DuMbxuDtucqBvBYj4v -jtP197gQTGoGUV46gGyfjOnVuzUoKGjhzWY+BnZBJ6BBluyF8J0fWPinCThYF5Z9 -b8lvzjMluwIDAQABo2MwYTAdBgNVHQ4EFgQUdFVEwSBQMs1cOik1P6oq5TtvDsww -HwYDVR0jBBgwFoAUdFVEwSBQMs1cOik1P6oq5TtvDswwDwYDVR0TAQH/BAUwAwEB -/zAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggEBAD/txEfqX2KDZeZK -8T17IIa3bQLnhqnd9gHk4faYNGzpwUlx9ulEAUsJI1JQdPniKJrM9NgKx2vY/CA8 -zj3rZlTE/oOB+TOpROJZqeA80fSMakg5GpTyCDuy+gf3vjyfRHWeWU2hDKXsRP7u -s4NDn7dM5iBi3PJQaWuWazJakyexLOKkPclX6WfnKFOs6LYOZYhuBdu/PFhhUYb7 -8b+liUOjrCwXVUYFr4X446E6lr/KkLP9znfwrrZJ9bcmA8SXKFcesGT2wJH3bOth -Z01/59ULfJw54SOS+dNDH5Uepl7S871jlTqhB7eie+27y4fGxwKUFxKK+Opl5VlQ -muvwDRo= +RGV2IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlAHE9PCDN4lC +by7UtiDuH2yL9Fz7DHezZq1WGfxEyvld+R7qtpQnNyZkWQuk2EOjMQsN9fFifSBv +CcSbrK+jtoR4bXuPAFqhQOfilHuTeLYRjgXyyDnbmh3z+aMEqbeR9NoVodIQXfUg +aKIDsPsNsuYAtW/wTKS+g1JEDBlyVuENLNJxaSgIZhqPqJO2HsiUYDn8+OdZc1Oj +JdZZwnRUbWUDxn3y/Uw9Xg/6hYUwsI0SSleWi8y9iYspisX1QKZFon6CO2dCP8tB ++FUXHzHelRWtXUSNE1ZFU6exg85DXf0/hlH4uADZEKu81VpABMR1hQlROQSlDhf/ +2M88/zwy4wIDAQABo2MwYTAdBgNVHQ4EFgQUzEY05s1wee0U0SYuSqekmLUjexIw +HwYDVR0jBBgwFoAUzEY05s1wee0U0SYuSqekmLUjexIwDwYDVR0TAQH/BAUwAwEB +/zAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZIhvcNAQELBQADggEBAJGLw87NxxoJY1WK +k1/2pU7aMeQGotasqFjl69BF0nuyvWnLhpNbq/s4oRP0hoaEQrI2X/Ycp0Dny3U9 +txomFp8DYMta4ZrOwbzAp2K0JaNPD0vWfXBr53kPT3OA2MOU7kXrXQDe1TR4t/l1 +rCizNKZpzseRoNemR+VjXf+XsTNMWO4drTUfC+KhfyYkMUhkWmNbTRO9d2o7Lbsk +4kgQOfByqXvcwck3Dz6+ZG/56dU0wY/eYHBe6hTF0HOsfmoQmCU27tdILCLQwc7g +hrgG4QwO5YRPlzthM0q9H+KpEn/EqArR5NQe9/qJjJdHN6Sah7d/9ffhUBxyOGWX +stg3x3Q= -----END CERTIFICATE----- diff --git a/obp-api/src/test/resources/cert/dev-ca.key b/obp-api/src/test/resources/cert/dev-ca.key new file mode 100644 index 0000000000..17e5f7efdf --- /dev/null +++ b/obp-api/src/test/resources/cert/dev-ca.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCUAcT08IM3iUJv +LtS2IO4fbIv0XPsMd7NmrVYZ/ETK+V35Huq2lCc3JmRZC6TYQ6MxCw318WJ9IG8J +xJusr6O2hHhte48AWqFA5+KUe5N4thGOBfLIOduaHfP5owSpt5H02hWh0hBd9SBo +ogOw+w2y5gC1b/BMpL6DUkQMGXJW4Q0s0nFpKAhmGo+ok7YeyJRgOfz451lzU6Ml +1lnCdFRtZQPGffL9TD1eD/qFhTCwjRJKV5aLzL2JiymKxfVApkWifoI7Z0I/y0H4 +VRcfMd6VFa1dRI0TVkVTp7GDzkNd/T+GUfi4ANkQq7zVWkAExHWFCVE5BKUOF//Y +zzz/PDLjAgMBAAECggEAHiTEG8y2NMPgQ2uqx9MqHD1Lvb+II9BnOdS4sf8mdZ5X +DMtGq1u+JuXLTzVnY+JWpMDnHX2FfQ1zf+5KdF+rPJt7OXUOOVi7+c+gXnRSoyWV +qrK8cRoThy5th5yzcOthrxgRis0RJ0mqyZShotRmxFZCs7EyJDHzWSSSllJCbr4E +xGvWq3wwCmJLZzl9m2W/7cWyh2narCGZF4QITzGlVuUOA5QRhf+nvj+0bEdvcbyY +zspwVypyB51goyRD9r5DZt8OS5Tduu0gBQWgMUZ/pQCcqjyTJp7Nu1f2EzMiKRp/ +auJRQYwHBHFRzICZzosPZmpwrUUfiCO+WeNs+gp7/QKBgQDL81qYJRUNJ0k/MC4a +LQdnxbCFWCz6DqDgAoCB3ZiqDKas1gCcj/7VAyn0QVfKlgD5whDc2DECk6k4GRX+ +6xbWZCPVz8Cxwv07lr7SxPQQxsGfagI+8fCTbbaXv2mPZJteTOuB5EmHdlwVqx+t +bFfrTI5gDQGOlz+qbBWDIc/PXQKBgQC5x3ZjqOxfUsINKywVRtt5Cyq5SYLMS6dz +m4gY6Jbgjz8dA6/TdScrHcsS5XsNh2aAvzidw7RB3miUSc4hOD1o/QnvB1kD5TFR +oS8OriOWQI6nlPemktOPAfWjYLtoPSaHLZwgRaDFmglCV1JvjS187frlq3H/ajXB +/34DS3UnPwKBgAC4B963ar6UH974JYF0HS2RddDSgb4T5R9FAvzyMgKPbtr3GiNz +InvPugshW9Tb+H7o3zRYErwmlxcah4hRfdAdpD6xr8UwocHfAyctCIsymCYesolU +QvvSDC22wAGAYkfPz5iynEu88BAnfUIYOqsapvnRseq1v8SzAtRmfxwFAoGAQv15 +41X2ZikhcD4xFzsFyRANx+KKF8DwEO/0k4bLYQ5GY+AAdu+3wARuRdIaHTbF74cE +k18OkPHyJAa5HaF6A3G7M0YjAxSSRC0rGtAQZQ3CYwuEgbxQLTE3skIfUec7DWOU ++M4iux5gWFvEjhUKYhIudsLryH40BFBs0CNVi2ECgYA5qs7zGPX0zRJE306+tQGe +4DIhx90K0HSxeOx39W26QAp07oATnjkL5031G8aB7HyzrDBVsaAAJCnUcy3z7cd1 +lna0C/e2T1pwnlK7IRe7KiNKlLSYet2uGwgqwhhi433tTzoC2w0Af0CLAgD9jTOt +28CuM1z6U883nyJFXE52gg== +-----END PRIVATE KEY----- diff --git a/obp-api/src/test/resources/cert/dev-truststore.p12 b/obp-api/src/test/resources/cert/dev-truststore.p12 index 119f4e53b6f0f212bc7ab3d21989cb4697e4c5ac..4475cf3ead7fd0261a5a39622c14e83f1e1df8b3 100644 GIT binary patch delta 1114 zcmV-g1f~1d3DyaaYZMG29dgR)^s)ljSskKxY)%#p^m~ziB!3{rs(!g%2779ucS@=* zbi;sx1JJ3X=ef0?YN0=f?gQU&;8V^ zV=ISCG6OvVZGR&~Z^**YPH<(bncrd2L{DFz%GtzwAi-m9TyU*GU=|6%Wg24|Q8{`$ zRnXHv_Uqy=Ze$Ns&;CXe!Ew<>PXbN4_ZYmHc|P(V5&>cgK8T48!6K z+pJ1FD>mi&UaTZSLxME%_wqA_!5SY)SQyJC{_h3%uzx^HBRsEamxc1ou}U{b^*{o% z<$!8)PaMFKuE3k)vg6o zzu!Ay=|vJYShbZa`sz^zM8-A#(rcpE6tubt#K;F{t8_o^m0hkAr@~0IZ^+}=Pt02UYk#YTfHXLX%{X99fMEm=ldjQN)u=b&!obuoJ#2A`tWfD#xtWCNrl@3j4Et0g zLgP~0bnonh+xPUlqIgY7>!3vc?sTqDAt|);&P~3VqO+uZmmulS_hr%1{5OTRCf57| z#0E+HT9=Zi0(r|DH1qw#+OeM*MNRQg0_+wh_kYfs{%^mdBOYlMSa@xktsJgU2h0$p z4gh{j=i!G z7#~ukp*xr`i2iRXNHvvcrmuXosb{ud-%Dpbn@&vhd!y?xC4mW{E+)5?FVnGGjOEV= zHGfJ>=UMn=CLS9 z0*>;n$`oTklufy8O)r2PlgvVAln&Ee%;3ev#H7$k;BdrZ4kkIhq)dKuH4dH^A!A*e zRw$8a6|u6G50`yNJ5=rwo(!N?6E^N=LVq_xVn*)?@YnjI*mXN1wZoF>-W`Sz(MQf{ zgw1!*sFW|0kkY?#l7rGQ4<46=6trLZA>(Hr6jSP3%Hc|))0})23+;iU7MsXN;V+y0 z;q`C47o8FHj+X?HMM6LvV#G|fR|Ue$BnGQD;1Wr)9SiiI5%t_>DK9(kp6tEhT7R;j zG)*$idZ`EGvyEtW2?;qg`pfWUH=7$qm6q0A2H2B~a>mUicrbBD6&9&@@zw>M?^cez zM-*W2TxGQSYEUqycfC;u_D*pvtQ-AF?vlZy`|*`rQ=Y`UXL^Uu;{*qeeSYyKA^Ts% zUocHDF)$4V31Egu0c8UO0s#d81Un%3=xgN2r>lZ{sNHE=tgmh(P#n3wFM?gtF%3n- g1P^iq6tqU+@`2k delta 1114 zcmV-g1f~1d3DyaaYZOIazv7|sN8w?;nnj#SS2`ZP9e?9@9c7hG+iiI$;fT< zI5aX9ZhvtW#J;8QS1A!@Oq)8B$w(0O^?W*&Nr0ehm#od4Kl1#UVjRsyw7nZq3g)~* zZ{u1o=12v@tcnbn8n_6&Tc+g4$-WB6QxQSEYnRrTPO5Ih=B%gHnao%)oJ3r-)mdIn zvqhFBJ6?ET#Bs9M*Pm+<^Cj3Kl}_|$SR50q)PI5p2O@t1sfc+6a<@ z#P_bMhM=rU$&V-{ z^nYJH^2+IpB@UDsFk7a#FCW5UM5+E4v8)cng|O0Fl;m`v7!AygYX2riyFzWxKz}i! z0!mVBhl^}1fpfok*C46>><0_#pHO%*<_kWIM`dx*cAy-$IVtTUO+^kv^XG&h4BtQz z>HNMve2m~=d5&I_5MvV*&17Ua)vfx_qkn4gjPu-)qv*Bc$ghw+IDi4zffZi1iSIv; z-Fmj%h}x+1rok_t*D%C+=mC`4_ji>b=tRVy3Lj2ui6a3PK61f0TU?%np}}e}d&q+q zSM3fa6lT2ejqtx>jk1J!rJZgxbh@OECZ0byPOPpCjTeMJ5~q+`Iz?J^;CAlgK!5F` zib=3!iE%9xZE^(-oofe!(?P`G^H2RJ!&eNp;Q||LkY>v&C4z=bRDumCQa+B&-=A|5 zA+EnqGh>9&yd5K;U^bb2xmUbSOh(F&tFpVpfvmnYcr`5E_@Z*30jcC#d^7H7RTMf0 z6fjLNF)$4V31Egu0c8UO0s#d81Un!pG*he~HI#wajV+(RKcnf)zEz1Zd?Y99)Ji!i gL;UUp6ad}wWJCJ5f}?qZ5h*__x2|)ieF6d}5O%O73;+NC diff --git a/obp-api/src/test/resources/cert/expired-tpp.crt b/obp-api/src/test/resources/cert/expired-tpp.crt index 1552bd6bbd..803bab9636 100644 --- a/obp-api/src/test/resources/cert/expired-tpp.crt +++ b/obp-api/src/test/resources/cert/expired-tpp.crt @@ -1,21 +1,21 @@ -----BEGIN CERTIFICATE----- -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 -----END CERTIFICATE----- diff --git a/obp-api/src/test/resources/cert/expired-tpp.key b/obp-api/src/test/resources/cert/expired-tpp.key index 3ee3f47660..fc8070f2c1 100644 --- a/obp-api/src/test/resources/cert/expired-tpp.key +++ b/obp-api/src/test/resources/cert/expired-tpp.key @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDCf1+k3W8FNZOU -BgJYVIxkKd0ZEPwut4l1pbB6Q8moJBCTpPcPLBu23BK/jtt1Vxd0mhemqffAg6kO -6P81osdLQP6ceHFvUyQDofWflIGBIi/YHFIxtyg6U/5xcitvmJTx8m+Uhg1NblSK -CzmSAc5+Z3Srha1DRrhOT4a7TrGambOXSr9Fvo4B8fj66ew10QmTM9BjVnl+eTFI -v8a/BYCgpA/Can6ECW1DqpOfZvCsY+MzPFiosWjOALy9OSWlj8mnGLyhIV91QnQ3 -dyvh+ml0eUFYbgigIeUnDmgAcoZWrf5wqfz+Qz+ShRG5g8jw1U0sZQcIDlkVYwX9 -u3XOC8DDAgMBAAECggEACswTt60b6iqeEt693Osd+2Yq4ibZZmXa9CnVYQbdWCpf -CeCOXzcVd3JtJK7eC6a2Octfb770xCkARsB0K8/psgmdeNCONN4P3EHD8cQb57Ae -/tIc//8zylUlBWWPTWZ83Ozr+SSd8sP4CSJ0DjZvy6rw75//99b3s/qMjLO4KVBb -1QQGxFQirI7GN3ZHQtAa0hmQOAtecA8pyq4sOLMX0qLuH8OuNojycAU8/kWjn+ne -2ymUH9LcCf6Z01XVu9zPGrVRpOoj8BkwKV5Xj410nCFeoPmzqwxmj8EEvlvpAl17 -Awd0QxC1z7ud0XQCHcOataHa300UxTFnGYhSJ41C0QKBgQDy2EfB91Kx77VJp/ek -UJxtFaLKZNeFQpGQvsrDyYOMbhOSHRgdu/OBMrSl0g3w6YZt7H4YtEnypCkUZl0U -93CQ/5Da/09N4u2KaO6z0IIn4pLR4SPgb7q0AKpjZSKQNYwzX2WSHtppsIegubAM -b42milxKmCUtsIzHiC2uQ/eZMQKBgQDNCJ+gMzVomaiJB1znShJVhkzIMbot5DwH -YHS1KuqDzuvdcigaGK13kT9RmkPWkggymmNvaT8d6ZPWmNzPlBt4iHJQKMXG37x1 -8lZBuvdn+7UOWX2AbsnlEAtMq09BXZOMDghpJbrLyhUpnulPYn3JeHGdbiqspaSI -rOzOXKz8MwKBgGlSAbUO1Y+UPZSnQ1DBIUZyFrsehxYla8pR5NCK6gGSj+xTr+zd -YdtLqWstMZylOwcbhQij0FpqdeKCDqaUNf68yA8ioTtPSuQ3ZCcaLAiuTCy4Lv4c -luWQUFVxPE882gRBwGRh+ynRRNEhF0gdbVqoMSSs3Zr2MegrmFw24ABRAoGBAIA0 -6DuwSbFChBRLOliWBKjd9Z0pGxYfJTonolK2pzYMaYhrHZBT5gRiGonYQJsnbWDX -EV5VHVaC/CKwK0LRhev0xiZBmIom1R2bjzxCwPmQd0KlyshIfo5xXd9vL3vcG6r0 -C2ZUZV2Q23LPH2y4VZdpbQHYJW8XlK6yEtFnOfPpAoGBAMGxhrV2PIELuxyX3AU/ -hoc9sBBIPx5J5zdpU5HC3XMHUgm06GfJvpED9RA2+PGmUqKQF8VESY6mmxDZ1EVl -WQDHrhdXR/rAt9wsKmXUlcB8iOiSxq64G+N39Xtzdva3bAjp6Vp5IycA5hyMWRBz -IqO0T7VHa0mdVaEB80DfRubL +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCrIszqVRrGIjH9 +2fTDsvnVLyvNf+ldN/sJ443QfbhoBYu+KS4kJGseC7yDvGDUglMm9CHCrR0o2Wsj ++jYMahZIzp4wfyIFs5EIU2j1UJ6tBCZnUGOTq1yL6qptYZdQF2bdTqxiJGTuZvI7 +w+I57k34WE1Oo6Q938k4ttskF1OgdJ5wV/rOpG2dUpGBpGrVmpl8YHKNvYD9J2EN +h/1rdkEAuOkbwLzrUaUt0b0vqMx6ANIJ7HHkNhcM28TEOmcGJotfDyvjlvNS8xan +ZRX+VZabtqEGV7v8xI1a2ZOB73aVbrHn+8yu3nVlOAbtfep9jZ79ghiZ6SUswEFT +I45h0NKRAgMBAAECggEAA/qOJbs5dnZ1umomfPpHQVk/EnWxLj/5RSnrh80JzC6n +fhH6XFAVxsq3ukmskqQ+XKnRCHP6jpLndnGrGgTJZINGkugRxBeAUrTlvCEAtL24 +9YPynLdKMoflKHUxYw+i45hK/Qki6OwEdmE/0Yr5zcFNItE+e0sdpC+UJAsAIGXW +iEPsZYJcMqYZdbteW7mxow3mGVMoumktpYQYUWWU0O07f2b33KdEUSlryKX/m8+B +jMAgPidZAdtSUpP0hAzrIPn8i1z4ilWPg/+xlhINQOLZRfkpWOG6Yr1l89p6SJN+ +/L9tWZhJRoX2wrWlB2uXiDTTDt6XPKuicTY65frhRQKBgQC1gsEL/52DBN70z8bF +gJhLeUiJd2C0ny5W0ZtYv7mImWNad/T2Ho5qiXtMrOqJo5kc+DzUix+yOfNGwR1z +fiuUzyzmpCGKDexvLWBuK2EtXKLWmIfft/UhjNhKoEYwI6fw2ryomakOH9Muiyv4 +sTfWhGwpZQ2N3gshwnXsZBnVzwKBgQDxXhVkvTPFEguJO7k6LaDoH/VGBLvGOQZq +Y/Sftzw1YMYAHKIQdE8F7MvkQwu6qjKcGoiGK7xsIE7lTX9WNiPCxluL3iWetBFD +sXv14QRRdGCMEaqQJn+uQqTtfqCu8PIB673roDgAXYxIAFrWWD5v2b8xn8ZY36uy +C3DZO6NJnwKBgGU9WwOgPXC4pMsToSDECy39pfHWvf/A0Y3nN6iWt5tzQrFROzaT +8IeHy1gHEoJW164K35MFTlaQcBrE1/J1K0XDEJ5MadCg14FjY+fPFlLA2qqrRQ4J +gxaFgpaNMtji6mcy+gtoOZ0BLz9ErO/3mNdz4QhtrtO0R606059YbfiNAoGBAOYy +fOTfLgi99RKlSzsZWWUJVbu5t/yNVnkRa7UH5Pxjl3Nz230l8FgOR0ckSoN031/S +TnLoM4RMK/K1vsRSTjjtRVJYRDjgM7cEBt2yBZRKEeqEzs88aHmiRfnb0xQF6Em8 +tB2NaNuwXKIu9HOJJcM3QQnrABiy+eWM08va/RixAoGARCktzXHMlue5rSzksT4V +UsIJN9718RY0m9FI8IAC+lymYbQ1F9aKsnmVUVCukKVTfQ6gBCNxrNLxGLofZsTK ++hhZlW3tCIqxZSARN04pvZX7Fo6+5hGKmvI2NFvXMM+xsxFWEhlBtz3TwC9Y0ty2 +mt8mGNHxciao+RLXtdRWfO4= -----END PRIVATE KEY----- diff --git a/obp-api/src/test/resources/cert/expired-tpp.p12 b/obp-api/src/test/resources/cert/expired-tpp.p12 index 9c2a92201786f9ce2d6a28b619c1235de0fc20a2..d034371b309f9d6b646d0e4db4c9d25424cec8c9 100644 GIT binary patch delta 3377 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 H0s;sCnm24I delta 3377 zcmV-14bJks9K0NmYX}V*)UoZ8X+o2v2qAw8zjquI;5+yK|Fh_4zfQS;f(M|17c{V{ z63yA6t=MPx-Wq#(n&@eNWeP18nBJlKfkAwYCf^OtsmG~J;vjxN{a(me{~IsS=Gdttf!m^Pyi zs(8C4jW&1uuN^x+`L;c6w1_(B^y{ZIGJn=d_3X-8GL?@r3I3(0v7qBn1RZ~AC6q2q zLBrmOlIfT|L^om)kBw`bL1pw`-k&^WFf!5sU2gg?cnfU3xp@o|KS0*Lw(s{jLL|{* zm;bhTBMUz%YgH9CS%dH+66!j1aizemJ^$JaR-sVpV;!K~!mSp6?=dZ8e1dZxvfMPO zd|VMVEB3PF^|rLAJFK6lkA{B}Y6MKB1yS1u%aoOsbJRJQiYY@iz4L=Sa4JccQQaV8 zv=K#MB_{)y0ICP(_sT`m#&S`q%*7R49M#Q{1jjPb*wi z2EO_3AaBnDn3TuIRI?p7Iy^;~W%ivMKKVywRy9?u@7uA}QD?S=UC)sp>fC3fmZlHA z$~loE>^##s)rnE#Javz?R03svmFJ%#0e$$i{+4BrZq)qL95bCOnC=p1GBuVMBgd9qxPSISC4B^82iB3KzzDVNK?wf(^{6 z#O;5f(&rrgh-Mk<=82eeI9De(`9iXQkTno2zO23c4{3kL<}{4~uV^qGLF19ex_~2& zSvVkQ{^m6_4$U&_LG5lIusAcV!%_Ep)fs*5k*`{uk)rG*G^^x60{gV^{;83d>wNkYWk@YK+Gc_>EcV%E1>{Wn0q3^ye4 zmzsY+W)xm!A_V&KITb#sqts2HFz^|KinARdIuq z=45}_A)m4ARx+4PPU%wL_}CGef~$2 zKeL+6BFSjZwj?Orb=A=g5lUVu_(#OPEM$DwYAl`TyavZuW2daT*sx_rYQp8U&Uio9 z$ln?AIb>!DToca*nAca2CTWY+jS#aR|7j`~*)JTDkd%pkZWD3##%i(%a>u14Pn>`6 zRY4nOs$J2W?d1yMX@s3Av_%kxx{Ua!1x&e#XNbU5IwVK7l3C-aa=v@12$2wer;jh$ ztOFme(%9p|=cI!14m`D*PXD`Z;Mq|Zjr38&Tebu5J=;i+WgPBc)hVL0KA>#O=S$fA(#GFpW9+%C;=+j5v3i~1{u6F{+ekr!tB zLAmd=cD`p73sh(IevPFfZc1mD)f>V*FRUzoMjA@yf+t>I&OQelFyn}s2t{P(V5kje z`6DSe$emrJ?+!4G3SYI+QxVMtv`u+2i3|??sYF2iNB`yc7w-if56ZE79P^5AJjyBz zbE)6$pKMAp7%8oCW!E+J%VSok+)UIyl78fuK?ZZGN4|_F-RTH%0z0-zuh4EhpT!K8!JF&Fe4 zYPo_iL8zgy9FE=JIM_vFxry8MiDev*7!5ym3M6d3A%aaty9SM)^YNJ;m~(VNjt>&O zJ?Y?mBFFoBvjjhwFI6yMm#bEMv6o$c&rD6ZPsL+1JbolDr~4h@tVf4RK)d57&Z}#= ze?f1tq5sSMZY#1~y|>4;(QZqYnVhH~beGv0KG^T)zO3}Nz;w_`!l{F@ z{b+!6owjxu{SPH75+aZc`bmcqu2to4kA#zE&$|{PGkAk%_vT7eZy>0E)2y;a zxw6+gOF*TlouB+n*XD2%Jt0#kIC>8EQnSHWxnHO4!ai(CX(I%?0Q7f%$4(?OQF}!1}z*&2;5arAY%|FU*(9; z=`frHRO$EPtk`wNvjF2@lq^GaPNgFvo3C4)ypu#5cAz%**<(`S#zuV6|0&>`tm5pA z9}%S6e-v+{_UQa^NQBXUCNK_KVC(<7lXs;UrP&^CAmHyR?KBVvgU~uEJ}u=*ZSJRx zjDCN-dTJ;D9lD=ml% zZ<`~cQ|-*@#G4Rjz+17_Tuq!zY<95#?f++|!U^b;zDJXlxznP5boYL^@Yg*xAq zFBU`I(v#2)J}&8)6o8VDyxPIxB{PCiSQ>N!iKBA={8Mt`Ra+kVm!t#;P<1(aV&n%W H0s;sC(us$g diff --git a/obp-api/src/test/resources/cert/tpp-client.crt b/obp-api/src/test/resources/cert/tpp-client.crt index 2d1a1daa6b..e099a6ded8 100644 --- a/obp-api/src/test/resources/cert/tpp-client.crt +++ b/obp-api/src/test/resources/cert/tpp-client.crt @@ -1,21 +1,21 @@ -----BEGIN CERTIFICATE----- -MIIDgDCCAmigAwIBAgIUNacnH5gQ8QEOhz/8Hea7MtCYn9gwDQYJKoZIhvcNAQEL +MIIDgDCCAmigAwIBAgIUBAMDQWbn5qzvAW0MlGIJygIDSOgwDQYJKoZIhvcNAQEL BQAwODELMAkGA1UEBhMCREUxFDASBgNVBAoMC1RFU09CRSBHbWJIMRMwEQYDVQQD -DApPQlAgRGV2IENBMB4XDTI2MDcyMzEzNDgzMFoXDTM2MDcyMDEzNDgzMFowSDEL +DApPQlAgRGV2IENBMB4XDTI2MDcyMzE0NTcyN1oXDTM2MDcyMDE0NTcyN1owSDEL MAkGA1UEBhMCREUxGDAWBgNVBAoMD0V4YW1wbGUgVFBQIEx0ZDEMMAoGA1UECwwD VFBQMREwDwYDVQQDDAh0ZXN0LXRwcDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC -AQoCggEBAJxCGXFH4mvbZslG/e5Qr3N5FPE4jqO8VRfmbkpRLJ1MVlhpB9Go9MTs -05YFvJ5fke+zhbzBnYlldGz401RIvi8nzmnYxd0An9p2+F3g4Ty3waCDCmH0gDYJ -Pe4b++xCy9i8cHI45R3ZD1Ak2VMbAMcjJUP9WchS7NqZIJoz2adnGQ44RY+8vdP2 -4eO8pG28oCbAltAy3X1TJk31eC/U6XCqghl/W7RHbn4R0Np95EHGYV0I/+AZBMuO -UzS80ziaPYYGspBWiQysnoE5QEnhuw4C3mD1gMuuJwvCtFcaaEnQ1u4DOCFjJf3v -Dn/mqEvV85Dq44tRFBOO7hZ8FdowgzsCAwEAAaNyMHAwCQYDVR0TBAIwADAOBgNV -HQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwHQYDVR0OBBYEFFJgMPBP -TED5gT8O6JWa8JbdRjhwMB8GA1UdIwQYMBaAFHRVRMEgUDLNXDopNT+qKuU7bw7M -MA0GCSqGSIb3DQEBCwUAA4IBAQAlopK0tg+8oB2fltAyyWn9Z4Iw4/V86pqDfd7r -IBPvB908DvWeTBIHrj1ntfvrt2jaziCdH4PfSul2Ffe6YbyrFTRVTkXKRBoQjFkH -U9hBaOhtp3vp1luHkyu9UJWOZss6vRiNSPQI1hL2B9bC34W5kHd1GxlxIwKRGs7t -5pIz7n9Og4QDWP9eo9sxFSQDBtv0hlybfAaZGcgWAmB+pUNHbnK3MGI0RH+zxCFu -mlqvuahp3p0DxjBjr0JygwNIuPKsq866HVbaXof3Y/ErcYKc32l75wJvjYX8d1yA -w9nsm2LFZC7Q41keQy3o+/53U5uODjW715KHntp2s42sVrXL +AQoCggEBAKdlse0/JX2i+XUTXse8nEY27+jYxHX2/Pm1VN8NSzOjUjc46Swx4CyX +xBEYEVvX3dtF9EFEpyK5v51IM2Qhu8Jg/1jXzhHRlINixOk1mFR04z8KbX+M3bfe +eWdckRXEk3mQdTJx3oGznbR+mC9rn62fNx9Pr+ow45jC6+vyJ8ljf/uUjqUNxhax +yt++QY1lTDbcqIyz1eb2GjWSy5RF+5c3JD1dAcXtYFYI23JXdzh7C4TWcHEgbObJ +0LAk6ivjEEqoudLFtqB/VL/rL6Rw06wbQd55KwOmOEbXWLNuOeqK+yTTW+owvdiI +QfYWs+7QTuENLP/IhA7ePAqVcbFJRX0CAwEAAaNyMHAwCQYDVR0TBAIwADAOBgNV +HQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwHQYDVR0OBBYEFPiCr0bG +KX1rCw8yMzU/r7gq4BGsMB8GA1UdIwQYMBaAFMxGNObNcHntFNEmLkqnpJi1I3sS +MA0GCSqGSIb3DQEBCwUAA4IBAQBqHFz4wnQJRQhtDsKWJguHlkr3ufZGBALOmn8z +ZkoRN83JlIMS93gQAvbC8Ra8toWrKFpSAy8cphBRbzvvQV/yJJ8Ht1+FJOjmShf8 +96i4sihIwizki/k5PfhKRneKRlzZQvcD/RqH5ANJ8F6kosfKeNBe1LZ81II35Cv9 +dvSMn4H9am1dPRuLszaSpHx0nWdRfynjUE39n/HpdRxblP28PgnaZID6fIIgIlFE +ctx0/IdubmK3MUnfM9Eoz4FVUy+3draVBKNFOw/GwuWZpZwhDZsD76GVE4mmt3u2 +ktrGo5pUYPF2t/eFo9mKVDBWQZVUuTGK87cfNvrCeFTxCfPS -----END CERTIFICATE----- diff --git a/obp-api/src/test/resources/cert/tpp-client.key b/obp-api/src/test/resources/cert/tpp-client.key index 3a01e432ed..fcb7a547ff 100644 --- a/obp-api/src/test/resources/cert/tpp-client.key +++ b/obp-api/src/test/resources/cert/tpp-client.key @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCcQhlxR+Jr22bJ -Rv3uUK9zeRTxOI6jvFUX5m5KUSydTFZYaQfRqPTE7NOWBbyeX5Hvs4W8wZ2JZXRs -+NNUSL4vJ85p2MXdAJ/advhd4OE8t8Gggwph9IA2CT3uG/vsQsvYvHByOOUd2Q9Q -JNlTGwDHIyVD/VnIUuzamSCaM9mnZxkOOEWPvL3T9uHjvKRtvKAmwJbQMt19UyZN -9Xgv1OlwqoIZf1u0R25+EdDafeRBxmFdCP/gGQTLjlM0vNM4mj2GBrKQVokMrJ6B -OUBJ4bsOAt5g9YDLricLwrRXGmhJ0NbuAzghYyX97w5/5qhL1fOQ6uOLURQTju4W -fBXaMIM7AgMBAAECggEAAf2pm1LiJKOn/JqF87pdcegyBxzEXHuyzdomv5WnLt3h -H8E+00IG1Fd8HqY2EAKBtn8gDhadbjm3sQe+kY1XtvwX2itj4fv3DW6EnZWW6RIi -SmxA/pyB4a5edqEupYT4WeuUty/YY3f3hPrjNfbbHK5q9CHPOgkscWCQFMbiWAqo -IjhcG+9jMD2b5Bcj3z+hpp8ldkY4n64WJgc2tBIwMMgngVa0Q3WgA1GRy1K8bwii -S5lMcUNu+pKHDUUeb7G7B4I8Ti/QtggUR8Xbn6sp4gq676DnW6SKeuD99/fTC/QR -yHc3VVrIyNgJfTiMLYxuxNAs24juMcri8n/iFkDsUQKBgQDQfKtV/SHmnPkLhtMd -vqUNYJeNqOGC3zkY9At6Frt2so+T4PiU9GxaREnWUd5mXf5TYfUBU7heSdo1ZTqe -S1jV/15289IuMh0W/uxN34VWA6vB3ClRVFuKytIotOpzO6p/pWg174sGrv1aT9yA -kk2/Els09pnFuLrBYPxg65KJ+QKBgQC/3lo/gF0tyUuriHAhFaTw0ZDxat0wVsGA -ST9OViS2M2wWigOt3aD9P7v8tnLN+sE1dYffGqv/G8LypNc4qcFV+ii/7Ws/AFXL -ndna9BVnbhl+hDvJgwEFSMQjGYNYJqQfUhwkqf17tHqlZHRrJPTyMj0BJEN/zNqx -V7w7LNnj0wKBgBsSdTUfP42wiG5EvZk5LjmEd2l/qnjR/5oL5omd9g29tgMfzWea -LY+zGltoGhb00ZeGOshHVMN5l4ojcO8pHYn8NQoBMyOogjqAM7MQ+UFoDJ+JtRvI -dnKZT5zVkTQRZVxZ4CRQzJ67jk5x3FIdrVbITWudMe6IxEdkDqGPNWIRAoGAfpjN -31xpHs7U2Od7ZLkNTcBY7JHgRAbaQjUSSjPYoUeop+6EovG7SZ4GAaa4dWRgm09j -STgmCHte1A8j2sVXRc17mbejrbwg3+rVVfz7KnWg6LODZ2DDCaOLlsU8vqswW2Io -I6DuGfNpgTuY/SEzZaL0UY6F4wtduOn0zu63DfUCgYBrW/EuvLlB9Q7pLMohdNV9 -9lhYHqz9W9lI9ZS15I/xlFuVwLFNZsLtRvCdppZLJvU6w/0gr7cz5VyOrOxsqqAV -2Za3q7eZzoMtbI4YHxOqFKct3Zo5jIHY+TOtN/FEGZFrhMySvsDNZt++SF8JqT9D -GTZ0pzt9zZvPYURKJSKhzQ== +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCnZbHtPyV9ovl1 +E17HvJxGNu/o2MR19vz5tVTfDUszo1I3OOksMeAsl8QRGBFb193bRfRBRKciub+d +SDNkIbvCYP9Y184R0ZSDYsTpNZhUdOM/Cm1/jN233nlnXJEVxJN5kHUycd6Bs520 +fpgva5+tnzcfT6/qMOOYwuvr8ifJY3/7lI6lDcYWscrfvkGNZUw23KiMs9Xm9ho1 +ksuURfuXNyQ9XQHF7WBWCNtyV3c4ewuE1nBxIGzmydCwJOor4xBKqLnSxbagf1S/ +6y+kcNOsG0HeeSsDpjhG11izbjnqivsk01vqML3YiEH2FrPu0E7hDSz/yIQO3jwK +lXGxSUV9AgMBAAECggEAJTMGceovNub1aBS8oobRtJHHSReIuw0+xPAMsv+lh01j +OxXYzUMtJcO5JLJUQzRqnyyBvdJXkPI1uGo/z1/mtWiwPepBJzkLd5IGn6uTFSuf +h1YWJLrlV5OnJ3bM4Ak9240kWigvUePcJyzdvWX9h0wDKCBhf5nLLhqc6mM2/7vR +tUBdXIbimiXzSTLAUuTgXWTNdXrteGMwGAJ9A4i0p0AJ/fxXYXpsEmcsK5gX0Bdg +7INtzkSK6N6ZQSeO/iy9AtKjnuF0P6FRvvDehaeZtmGCabFiWLuTTGJSQy7T7CDe +wsKACiW7pT7eml+xhn774dNMqhv8QCugIjFUtpD7KQKBgQC1LsrdqtM4jNTEwu9Q +b+fKW7Hyp6Yy7kKDnvWnUuSmIuUoXnhPoNshY7J3Gf6zM5MopYEjWvTaFfOPNJFc ++vi2Ih3fLK4l+XN8sG8EJASe+QubMK/su26xsGK/4Gz1vl9gl0I7AroH6LGDvOl+ +VLO5oVC+UiLnv8WGF10IBDAxRQKBgQDshZrbgTI7XH5xW2Xvf/eFBjCvaY2Rwdew +bKLqYTo8vrp9LFcOOtGO8TnLvOscsAXwk4W4/CqYRVax/E1z5HOyulbuFK+5zQbI +or3mEY1ojxfGlEQ+bxC+dJJskCMWtUXRlktMq6ZwNKxwrJi7EC2DPUffn0vcPu1S +uwCjylya2QKBgB7XfEkHjzUNJBrqY/p5UiesPDpmN3BsBn6JTJ/TCIEPZ5rbmfdU +Fnk0k2ia0DSJDv/YTIdMMGn/WpqCBEyjnDrEy/j1jh+Auxv6nKtDxlWZZ1RfkVLn +BQzSTb0D4whiA7JZjTimWiyGe9FFEyoiGQX8Y9ZB/RceGe2dhISc0edRAoGAC9zb +mnzNXyrRcLAL2cpKSNK5qGKb5h6ZydB5D1ZXDGrogZSyvRT4I7o3kt9jz2mIsrtD +bra/ECoSIhUscU6Y+vLbn09MTP5ag7+d1Kc2t3LN13jiP1tktJi2K1nDWMU4vj1S +g4Lscx5BM9mEQ4WZMsblDvM3Y9GIJ6kXbB+fK2kCgYBhNLamSb2yXPROqSW9RiD7 +RkpkIApv9VLXCm5xIHif0qwO9ivF/BK/zwVqQIKZtEvdLWtwA7CWBQyDgJcUIjRy +Br3Uhoh64TEmxLpfSc7DiaRrxnde6sP53U2/7iDA8wNizUKJPK/KVpUkENHE+lPq +kcKt6ROGKY9WlSe0hSrn5Q== -----END PRIVATE KEY----- diff --git a/obp-api/src/test/resources/cert/tpp-client.p12 b/obp-api/src/test/resources/cert/tpp-client.p12 index f6e638f107437506da05dc797ad2a0de7cd80d8d..1985fdca8c3f324b402e8923e792c293467e16c6 100644 GIT binary patch delta 3396 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

8pMjVT>wzvWJf?Yp> zZy822`(|#!JCq+9n(&^>6%OSONZNxB3?!kR&d0;FSFe4vd6$1lx>ai?B) zt0Iie$XcTwTDNNDYTlANsh*GKa!P&d_Xa&K-gdyj>f*0k)oUhh8lE zFEyryO3-n>Q}xl|SXB&Nxo~w5YI*3V)kR?n%%H^9WsK+%sadgZy<4~RU3#~X*5x4- ztwA^;JBEi5{;&8TgbN*r)&gxw2a=7zysb&;gA4*=$yl@kAiJT<66LpRi!qRgA<%Bc zs275mu;#Vep;noQff!VI{I;|iY zqmt_^l?roJ_NFys?y-uQ5(Eqr2!!EEr{YBfd96{k&tJN6@tWO6 zM2__Xw2bH1pcs7VlrhH4DnzKa+e$l;qPTHodJI(#`Rxl!1${A zAbOb}n2dO0$|Yb-p%At(d7xG8s{5ATp+ginInyBFSm2x_o*#Q;_V@QcG(z1A^=T&i zM|%GweMs&>Z3spU&58@jTJ&z0>qWKXVoeq!8Utte5G@`tuIxt_c>qa1!8{_w26D^!xFwwq~yda+z39JI#pCQ#)SkXQ*zPvS34so8C8(T&G( z!}u>N)~6pZcY(Mq-Ow{s`_JjEau{9Y2_z-r0t7~jGm8fLmTL2J7LnCrv?TZ#5#fdTF~js9?ggoXOkpk<=AC)9a|f*AUmB0dj} zYK^6Mg!3d?b&*&z-i;@WSMM0FzHYpZI&Uf(kLL9Nkiku~4xaP-#77bTdiW&zD9huRpZstilUz07kEadNTbd76jX-zJ@n-<09DnIWay&ZZ13KmMypY96^tKu z!1!?ojBdb39xyhbMQY8i{6ho>rEs1yP%AynzZjivlf7LA#}ReHQc;u(Da#h!02~C; zd(0~3s&L=36|a>e=D0$Z>D@9cq^RK-Z0~~o62t;DGFZ3_Lu2qxqXq>$KwRg_dQDkE zYwMWRlpWNU$>679p6vGr_R(X8}$NuuHQJTwCGR z{ne(t{4Uyg2as6Cs;nfbNLXUIi8t5$o3%l{h=yxSftGMA5turXw;>)$9Iogv z35-P}1@>MhQ5ji(-X>v@I9oPXu%o_(8VC##U$RK}39F$I23Q@N%dr*OXNWdx6~st& zPn)l5P}p3GR;hflaS}55bqXFJUO*Is06`T@M(#f}UMt!$NtJ%Nlz;9GdF|$^hveCZ z&vrUAxk90kpNy0)#=yJ6IQ^R#+X_iP%jYS%s*oaEt+PGCEJDSIH&kcXVw!pxhID8gq zhqFgzIi|}SV~_OQBR$Wg3YlR8^j-rx1KyOo4fJElb;Ef$8}_tD)WL&CUcB@EqqY?&liET&n~?3Q7`kcG7Q zcX^eDvS_F^G)xktt>FI(nU*;VNrIRrs8pG?F`_|8lFzavu^>rcnzAI>6~v(iyNOC+ z5RiN^p{(;TAuUJ}qiUlhhLU4c0!d<2x1t2btsF&(&TqvfrZP~8JCtE6tgU2XSPY4j zDQ4hhF_I|^;s@p<$S#<;1VNdzvmqz}MtVa-8;i48dt*U)khQlI5*cI1pF zFVUx2f)%=|Puk*%eB5$nJ)-;AFfGFJEmq*YRf+`Y9|6ZB?L|^b;Z{kfPDz#4v-%cb z8h;;VGZv&5G|A_927s{!75-rfBE7_@%*(Hi_~L#E-|}HGFB#~aM6cvxDo6#1Ac+=-{5WcKKkmnt^xKxa5*X@RXv7?g zY1qc*ypHlTJpyH59Q}+IB$g8aun1L>0Pf5&xfyd-DmTeQBZ)?Mor=88X1N}2yn5yO z=#9}UW9MIP?IUqP7;n@CVk`MBMf|38?uO~rh+G)sZ47mXHFT)YD_18rP355p8VO@? zz@)Pt3md_zuzA3t9MOZ^v!Lx*;E`I60~cgUb?a#(uJ?lEvw9Y$6!Jv;Gc>nufbfgc z)gq0x24@LWlLBX)k>CroI(CNwH^c!^;KpEETm^2Rwgq;L3@i%VtbqD?pN;BX@CAIB z6`c$%Sl_t3jB4nhf%=lzBK_Ab$fwM zB&z;}h3F#+!j1}9do5_Ag20W6iwIy=0F=SNzCZ*B;^cpU!<{DCfM5d;UG7p?RW@*T zgA)}SKYaV;ITsbH0b7Jr5i0j=A`XwOCFv@^q(kq+pn`075wc(~M?oshfj6nkm1(vK z{%0vQq0EFa_D!1UH~VvMyx9){*55oy@@=AkB-(`*w008|4B+X^Pvmc~q{}B+h_UU# z(vC%)Bxi&j8u@8_g^36Mj94}iY!>>wvV#9@gK6<8hH)>k%VdU9|cYWI4_Wf zaX}md9%QXVaWb8B66atnw{o|15vLsKERlH6idrOFEr_r3sm3S(zYv}7dF~vA42fD7 zm{wS4gCM{Q95!V$5(fljkUfxYdka;c&RKAfP?K|BVXWCTG3DhZ`spv6=>eDlZWlA4~YqpcrPg|*f1DVe4B%4lcXIsfb$jH6I6xKVMk5DAy z(GXutP*MnC>eYSNT&mF^))E1NIb2R+@ElY~65)yz?6z|vO|3|&h$M-^s5itC{GTq- z4Y9UCWYB`)Qk+}#Q1~IAZ9YHNS|nnEIL36}qEjV0j>TaR1^6akvxkT6*C`n*S0?Rw zh=G^UGWXhm{W^ST7L)1oFM2i1IIy0ET2HlL4q$i30 zE5;pIF^q_ZIx`lcd`96&sK2!yJLxktUo!U3y$TW_oT)Q2E@HEFd2Wh0fe>ii+od)4ed1=f+CS27Lgv{7ZXYGD zQ$psjvIt{r->LAcrT8!gDKO_Lu^1z(!COC@%-T^uOHgW6g~pyP474 zu;$9e6Zd};X5j6LrtxKM8sQsZypt5>dkElQ064YS(b(9UHDK9{Jnl?8!wA#g)qn`& zff? zEc3Rzpj-ir=Mj!Y-h>`QXv34gOf!p1*p#kN6!A+akvtWPK+e-N(kL?!MN8}PkCKVz z{-0nfVR{vLVO(J?Z_tsThl9bHN^J_n`U^CA7ml-wbLs+47*_|j^4Dk<>nL>d6NjzR z1c_oZeDs0fp4FF8$ip{? z@*qxc4;}a-%txfG!;e$%IQ&7&Pf!kC-Hr(pz976oKN-Z)4#AQj0k7@UoxBfEkFJWL zGzlLdjgV|&#egWylUG1o9&7m1gQtPDB%}+j=~QQ$r`T)$=_6asGt`kzbr1bt;$pO= zxMh&SF}o=G;zNAKqe`^4iSI3#K9Cbc7dYP{0VQJZneeMA51&rpv=wKk zF+aYdzpp(oMEX6ruv`oCh>W9l>mKB@t^9IAd7-zG^;3SF#Ne%zJS#Rrob*Bf>bKH# zKIC2Ty98bO)BNt`4RqmPi73nxn-^nv;xPLXc#?b#5J6}*&r&E!2iAyoU%&Z-ub}6< z+D>h;%g+Q|=-VB1d!P4v#5NXX+aH*I_7<)TzDHCy6?7v4v0*Xe_XZo#wg>MF-dTTB z(6ijPLUg(j-`VK97jSkmoLvvhC0*B$-yLjN-t2Fo-4J{HtwGNceeHdK2nN^NL8e{5 z5B1qK^7fNh^JPJk7rYeX48k+3}80d|L@t{*YE5OlF@vIcs>|Q%?CS$ zU3;E(Z9Ax92li+?zIWpAW+wu)kV?09-Z#9x!FJ5&>|^$!lH{=rkKU+{GFKDgTd zzH#R>82LgF>sa3lLA)b3!sDctZNA!+g)G>+m_wh7pnyxvB{{T4bW_&=?QP&p?ZG#L z1XlmeAW5m%3`6cHwAYJd{*!MT_KQw8$}xu6gEa zbBOPs<@=+!@>LrYWv_&5DY)&Ghvk5vBSPn)HS1RD%|vwXl7pGeXAs;$M5pB2bki1* z>FCr+-4^64xwtFlOwsk+5v7xcZT5U%2(d!K?YTsxt>i$ON{pI>hrRe+08-GhgM(Pd6b{-3abAPaQ}g0g zZc5j-0BqVHQg)BJ;HX0AxVaTgX&5K&xK2aY-Dq*gjvR^X{qpjcbWMH<Sd3=o&3Tx!#ppROmbbz-6Q}XguqtHKWHw`=2AgkZDfApKncJpAH%5q+wPEYw_-&Di5$FAI< zyqd|dx>-rFDs^QdgRf>1qu@7ahx$qRE|r)VR*vH9V_Qs?&~ZE;ZbQQKl30ZM6Tr!d zoPA*6p+3fY`cUDWX#n9BGOg5Jw{ibN?C*z!HsjtF?c=M6_OD|dw65V#oR$0o8sfX{ zasH6L{-~X85Bql*R{k-{K?a+sn!+UPiZ$qX02}?IZ~TT0f0=)8$hT40%&J@mdfFu| zv@~oqq|RSs@#Nvo19-750C4t=3z5w%qx#sD^Tpko#<|mB za<1&DPUg9zN17?R!Vifv?KivlQw`ii<+0|@^o^R23!$sS5ed@_KrcH~T??G+5q+s& zbK_N4s{83UGexxA8wPpVp>ga)ejl#Y${9Y62{Y%Tn{ij^bVi1WBL9;`G2D60xjVA@ zuL>_g@?L65_ORO#f=)B}P8|nc!c8L=FP(qo;`>HB4Q*c=ZZHVvjpCaa6)#z%UM120Iz1NQM__vpKNC0a#Wi9PN-xm`*+) z8cv-(k}$56N!Z;I#T5VHnTI_-p{LkJPncB&t2ha}X&hYa<9}%hGp(G$a+s~VaO!Dm zaOscmgd5u^iD;+Tx{qzmV?W5LMT*(ue`CnM4O0Xq`s7k}r&Ou>^-8GE;ua4SvByFz8@Sr!h^)zu%IDdKn|YuLKYUvXT_A`fv3{%pkeI`jR=cQaJ9U^o;DqXT;S z>jjiJ`br*~EEc|?rf#;d7tdi;Iul>@=M{T=k;*6-$Xv-B zC<#+E6N+wOCYJkFx$^*|Z4!jZGx)!wM#Vh`v})cNB-quRw1afw+pYH^#+o#fJ<0Cg nbZ<}ZfgQ&)n={)pJ2T1NExrA{o3nkHRHiT4m#k-TG3LJkr|Y}K literal 0 HcmV?d00001 diff --git a/scripts/__pycache__/restore_resource_doc_bodies.cpython-310.pyc b/scripts/__pycache__/restore_resource_doc_bodies.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b22f7f773ba37c35fa62c855d618e01fe8941090 GIT binary patch literal 13596 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 literal 0 HcmV?d00001 diff --git a/scripts/generate_dev_certs.sh b/scripts/generate_dev_certs.sh index 88265f6156..aadb38194e 100755 --- a/scripts/generate_dev_certs.sh +++ b/scripts/generate_dev_certs.sh @@ -136,10 +136,17 @@ rm -f dev-truststore.p12 keytool -importcert -noprompt -alias dev-ca -file dev-ca.crt \ -keystore dev-truststore.p12 -storetype PKCS12 -storepass "$PASSWORD" 2>/dev/null -# The CA key is deliberately NOT kept: everything is reproducible from this -# script, and a signing key is the one thing worth not committing even in a set -# that is public by design. -rm -f dev-ca.key dev-ca.srl +# dev-ca.key IS kept, unlike the first version of this script. OBP-API owns the +# truststore, so it owns the trust root, and the counterpart application +# (OBP-Hola) has to be able to sign a client certificate that OBP-API will then +# accept -- which mirrors the real arrangement, where the TPP holds its own key +# and the bank's CA signs it. Regenerating the whole set from a shared script is +# not a workable substitute across two repositories. +# +# The key is safe to commit only because it can never matter: it signs nothing +# outside development, it is named "OBP Dev CA" wherever it appears, and +# Http4sMtls refuses to boot a Production server on these stores at all. +rm -f dev-ca.srl # The .crt/.key PEM pairs are kept alongside the .p12 bundles on purpose: Java wants PKCS12, while # curl and nginx want PEM, and making the reader convert first is friction in both directions. The @@ -147,7 +154,7 @@ rm -f dev-ca.key dev-ca.srl echo echo ">>> Done. Password for every store: $PASSWORD" -for f in dev-ca.crt dev-truststore.p12 obp-server.p12 tpp-client.p12 tpp-client.crt \ +for f in dev-ca.crt dev-ca.key dev-truststore.p12 obp-server.p12 tpp-client.p12 tpp-client.crt \ tpp-client.key proxy-client.p12 expired-tpp.p12; do [ -f "$f" ] && echo " $f" done From 09b8014a692300d6fd42c8095742a047051c803e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 23 Jul 2026 17:09:21 +0200 Subject: [PATCH 15/15] fix(mtls): stop the run scripts calling mTLS dev-only The Scala boot log dropped '(dev-only)' when the run-mode gate was replaced by the dev-keystore digest check, but the three shell messages around it did not, so starting a server printed the old claim twice on the way to printing the new one. --- flushall_build_and_run.sh | 2 +- flushall_fast_build_and_run.sh | 2 +- scripts/mtls_env.sh | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flushall_build_and_run.sh b/flushall_build_and_run.sh index ae6485bdca..ea2b015a7a 100755 --- a/flushall_build_and_run.sh +++ b/flushall_build_and_run.sh @@ -36,7 +36,7 @@ for arg in "$@"; do ;; --mtls) USE_MTLS=true - echo ">>> mTLS mode requested (dev-only in-process TLS termination)" + echo ">>> mTLS mode requested (in-process TLS termination)" ;; esac done diff --git a/flushall_fast_build_and_run.sh b/flushall_fast_build_and_run.sh index 7483e2ce7b..84b326920d 100755 --- a/flushall_fast_build_and_run.sh +++ b/flushall_fast_build_and_run.sh @@ -51,7 +51,7 @@ for arg in "$@"; do ;; --mtls) USE_MTLS=true - echo ">>> mTLS mode requested (dev-only in-process TLS termination)" + echo ">>> mTLS mode requested (in-process TLS termination)" ;; esac done diff --git a/scripts/mtls_env.sh b/scripts/mtls_env.sh index aff7ed8ef2..e268240027 100755 --- a/scripts/mtls_env.sh +++ b/scripts/mtls_env.sh @@ -1,6 +1,6 @@ #!/bin/bash ################################################################################ -# OBP-API dev-only mTLS environment +# OBP-API mTLS environment # # Exports the OBP_* overrides that switch the http4s server into in-process mTLS # termination (see obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala and @@ -77,7 +77,7 @@ if [ -n "$mtls_props_hostname" ]; then export OBP_HOSTNAME fi -echo ">>> mTLS enabled (dev-only in-process TLS termination)" +echo ">>> mTLS enabled (in-process TLS termination)" echo " keystore : $OBP_MTLS_KEYSTORE_PATH" echo " truststore : $OBP_MTLS_TRUSTSTORE_PATH" echo " client_auth: $OBP_MTLS_CLIENT_AUTH"