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 8febd8147a..100d57e298 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,11 +34,61 @@ 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 +### 0. The 30-second path — no certificates to generate + +`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`: + +| File | Subject | Role | +|---|---|---| +| `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`) | +| `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 | + +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 +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 --cacert $CERT/dev-ca.crt https://localhost:8080/obp/v5.1.0/root +``` + +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 instead when you need a specific subject that a Consumer or regulated-entity +lookup matches on. + ### 1. Generate certificates You need: a server keypair (JKS), a truststore containing the client certificate (JKS), and a @@ -64,15 +117,30 @@ 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 -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,18 +159,29 @@ hostname=https://localhost:8080 consumer_validation_method_for_consent=CONSUMER_CERTIFICATE ``` -`run.mode` must be `development` (it is with the standard local run scripts). +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`. + +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 ```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: ``` -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 @@ -138,8 +217,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 @@ -170,21 +250,44 @@ 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, 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. + +> **`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 `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`. | | `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';` | @@ -195,20 +298,35 @@ 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. +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 | |---|---| | `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. | +| [`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/docs/MTLS_TOPOLOGIES.md b/docs/MTLS_TOPOLOGIES.md new file mode 100644 index 0000000000..7b10118263 --- /dev/null +++ b/docs/MTLS_TOPOLOGIES.md @@ -0,0 +1,473 @@ +# mTLS topologies: peer vs caller + +**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 + +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 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? + +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 `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 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` 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 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 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 + +### 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. + +**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 + +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. **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 + +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`. +- **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 + +| 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 | + +## 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 — **implemented** + +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 +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 — **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: + +| 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. + +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 + +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 — **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 +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 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. diff --git a/flushall_build_and_run.sh b/flushall_build_and_run.sh index 13a8bfa6c5..ea2b015a7a 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 (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) ################################################################################ @@ -109,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 @@ -132,6 +145,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..84b326920d 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 (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" @@ -165,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 \ @@ -175,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 @@ -237,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 \ @@ -248,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 @@ -300,6 +314,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..3a8a949e71 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -166,7 +166,18 @@ 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 +## +## 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 6060652d63..5cb380dac8 100644 --- a/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala +++ b/obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala @@ -1,24 +1,18 @@ package bootstrap.http4s -import java.io.FileInputStream -import java.nio.charset.StandardCharsets +import java.io.{File, FileInputStream} 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.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 @@ -32,9 +26,21 @@ import org.typelevel.ci.CIString * mtls.truststore.password=... * mtls.client_auth=need (need = reject certless handshakes; want = optional) * - * The prop is honoured ONLY in Development run mode. In production OBP must sit behind a reverse - * proxy that terminates mTLS and forwards the client certificate as the PSD2-CERT header — this - * feature exists so local development does not need that proxy. + * 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). + * + * 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 { @@ -46,40 +52,123 @@ 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). + 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 + 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.") + 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 + } + (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 @@ -91,32 +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: 64-column base64 with \n separators, the format developers paste when - // registering a Consumer, so the verbatim clientCertificate DB lookup can match. The - // normalizePemX509Certificate fallback and the consent removeBreakLines compare cover the rest. - private val pemEncoder = java.util.Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.US_ASCII)) - - def toPem(certificate: X509Certificate): String = - s"${CertificateConstants.BEGIN_CERT}\n${pemEncoder.encodeToString(certificate.getEncoded)}\n${CertificateConstants.END_CERT}" - - /** - * Replaces the Jetty customizer of the old RunMTLSWebApp: takes the client certificate that - * Ember verified during the TLS handshake (exposed via ServerRequestKeys.SecureSession) and - * injects it as the PSD2-CERT header. Any client-supplied PSD2-CERT header is always removed - * first — when OBP terminates TLS itself, that header can only be a spoofing attempt. - */ - def injectClientCertificate(httpApp: HttpApp[IO]): HttpApp[IO] = Kleisli { req => - val stripped = req.removeHeader(psd2CertHeaderName) - val leafCertificate: Option[X509Certificate] = req.attributes - .lookup(ServerRequestKeys.SecureSession) - .flatten - .flatMap(_.X509Certificate.headOption) - val requestForApp = leafCertificate match { - case Some(certificate) => stripped.putHeaders(Header.Raw(psd2CertHeaderName, toPem(certificate))) - case None => stripped - } - httpApp(requestForApp) - } + // 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 presented in a handshake and the same certificate arriving through a proxy are + // byte-identical. + def toPem(certificate: X509Certificate): String = CertificateUtil.toPem(certificate) } diff --git a/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala b/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala index bc45aaf2d1..d12da825ac 100644 --- a/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala +++ b/obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala @@ -27,14 +27,17 @@ 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://")) 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/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index cd283759a1..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 @@ -3849,32 +3848,79 @@ 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("") - 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) + 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 certificate: $failure") + Future(failure) + case Empty => + logger.debug("passesPsd2ServiceProvider: no TPP 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/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/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..47ecbd70fb 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,66 +307,72 @@ 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) - - 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) - } + } 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)) + + 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 + ) - 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) + 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) + } } - } } } 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/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 72ca127695..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 @@ -162,7 +162,13 @@ object Http4sApp extends MdcLoggable { def httpApp: HttpApp[IO] = { val app = baseServices.orNotFound - Kleisli { req: Request[IO] => + Kleisli { rawReq: Request[IO] => + // 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/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/resources/cert/dev-ca.crt b/obp-api/src/test/resources/cert/dev-ca.crt new file mode 100644 index 0000000000..c01125c687 --- /dev/null +++ b/obp-api/src/test/resources/cert/dev-ca.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDYTCCAkmgAwIBAgIUB0Dy5O9Schyz7//JRohKuKVTpfMwDQYJKoZIhvcNAQEL +BQAwODELMAkGA1UEBhMCREUxFDASBgNVBAoMC1RFU09CRSBHbWJIMRMwEQYDVQQD +DApPQlAgRGV2IENBMB4XDTI2MDcyMzE0NTcyNloXDTM2MDcyMDE0NTcyNlowODEL +MAkGA1UEBhMCREUxFDASBgNVBAoMC1RFU09CRSBHbWJIMRMwEQYDVQQDDApPQlAg +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 new file mode 100644 index 0000000000..4475cf3ead Binary files /dev/null and b/obp-api/src/test/resources/cert/dev-truststore.p12 differ 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..803bab9636 --- /dev/null +++ b/obp-api/src/test/resources/cert/expired-tpp.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDgzCCAmugAwIBAgIUYS1s0xABdqgs4F5BbuhKi5guOkIwDQYJKoZIhvcNAQEL +BQAwODELMAkGA1UEBhMCREUxFDASBgNVBAoMC1RFU09CRSBHbWJIMRMwEQYDVQQD +DApPQlAgRGV2IENBMB4XDTIwMDEwMTAwMDAwMFoXDTIxMDEwMTAwMDAwMFowSzEL +MAkGA1UEBhMCREUxGDAWBgNVBAoMD0V4YW1wbGUgVFBQIEx0ZDEMMAoGA1UECwwD +VFBQMRQwEgYDVQQDDAtleHBpcmVkLXRwcDCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBAKsizOpVGsYiMf3Z9MOy+dUvK81/6V03+wnjjdB9uGgFi74pLiQk +ax4LvIO8YNSCUyb0IcKtHSjZayP6NgxqFkjOnjB/IgWzkQhTaPVQnq0EJmdQY5Or +XIvqqm1hl1AXZt1OrGIkZO5m8jvD4jnuTfhYTU6jpD3fyTi22yQXU6B0nnBX+s6k +bZ1SkYGkatWamXxgco29gP0nYQ2H/Wt2QQC46RvAvOtRpS3RvS+ozHoA0gnsceQ2 +FwzbxMQ6ZwYmi18PK+OW81LzFqdlFf5Vlpu2oQZXu/zEjVrZk4HvdpVusef7zK7e +dWU4Bu196n2Nnv2CGJnpJSzAQVMjjmHQ0pECAwEAAaNyMHAwCQYDVR0TBAIwADAO +BgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwHQYDVR0OBBYEFKJ7 +m5udYlgsYt8I+OgkMqZKg3HsMB8GA1UdIwQYMBaAFMxGNObNcHntFNEmLkqnpJi1 +I3sSMA0GCSqGSIb3DQEBCwUAA4IBAQB5Q2vOMWmQzDljfjl6xso+aJOMW94oA2mg +QY91O+vVyX65HGXyYBwfhh8yaLaT/KHRdhP7daqOD9PpWL65jgLgs8Uwg90CvSrm +ol8zfQ0gF8uM1RFgiCCyO6bSRyWl7i/d1Zh5zQB79X2ZzQ/UjunSpovQsXTCnsUL +ProvrfmrPc3jnVJmngob0uqXQJjYOC7+Rww0c/7NVmmAxxrpe9VbwKysOlNgdkaD +btgZc+yM6OjQ5feXujiPs+/EQdS6X8zDpcqFPwoFAJEmyMFVeYXqBgFuNIPLj2NW +FNoolFjt3M7i2l1ZkN9nr8dFDV4SBGBBxArUwkNApJINvKw8Kol+ +-----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..fc8070f2c1 --- /dev/null +++ b/obp-api/src/test/resources/cert/expired-tpp.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +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 new file mode 100644 index 0000000000..d034371b30 Binary files /dev/null and b/obp-api/src/test/resources/cert/expired-tpp.p12 differ 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..ba4e73d857 --- /dev/null +++ b/obp-api/src/test/resources/cert/obp-server.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDojCCAoqgAwIBAgIUENMfo1/7Y0nCBbOHWTx+Esgbx1UwDQYJKoZIhvcNAQEL +BQAwODELMAkGA1UEBhMCREUxFDASBgNVBAoMC1RFU09CRSBHbWJIMRMwEQYDVQQD +DApPQlAgRGV2IENBMB4XDTI2MDcyMzE0NTcyNloXDTM2MDcyMDE0NTcyNlowTDEL +MAkGA1UEBhMCREUxFDASBgNVBAoMC1RFU09CRSBHbWJIMRMwEQYDVQQLDApPQlAg +U2VydmVyMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQDXR8/uGAPJ2viTHlzy+5YtGzhhkbF6h91OY6aEoQ/K5GljSGlY +SBlfmRu+NKjJxPg9TQw4yPLTrub4FQ8+lCUM5Gp+SZy8Lox5nzZgRGbIOkIT+/tQ +UBpucimBY5ZUZg806uwuFRRHLU7eeCnMqWtrpaskdhSMm2kRTOIba7yXg1Ab1FXL +y4l2g7zmDBqF+nin8J36ZRm/b6UXhNWcbACU/sCVy1aap3TwQSY9i3JIQbmgOxgj +TJzXe/vlpTvF51cBhHrJa5IVArfboPjPzD1CA9w7a0xdR0WMC6Z56ub9BlymS7Tk +0guxxBnZFPNP0VBjR20cfkVqsefF4chD5VdJAgMBAAGjgY8wgYwwCQYDVR0TBAIw +ADAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwGgYDVR0RBBMw +EYIJbG9jYWxob3N0hwR/AAABMB0GA1UdDgQWBBR7FrEbEtCWtwrg4aSPyN4/9q+n +vDAfBgNVHSMEGDAWgBTMRjTmzXB57RTRJi5Kp6SYtSN7EjANBgkqhkiG9w0BAQsF +AAOCAQEAe2XVAlhJwb6Z5rUkV8s13SzlEye1+HlFREj8ogdTC3s/UM8PSEVrvF6R +KDej0H7ZguzDReVE3hbuiVqrNXd0ffkpVRHxuL0Mr+ETafNqixTWJLKaT4uo96P8 +0NMJG0dzIxNnhkvO7guhkVAld90ifQo35XPaT2b4jchHl/8DLzctfTMTRyQNPa7N ++cKYKCxyY971U77TYOxkgd6IQx9YedujCw5Yg6AvTNu/I4OmFZDW6uXBx0lZycAo +PZCWVM9m3d9WA3wJFUpEgWIqoX9Q+11ifyrabvS1S/Ged9qlXcO9VZHvCAOAXyrI +HkQDJZsR5g2bg5W4KpELx5lEvsnXYg== +-----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..420b6c451d --- /dev/null +++ b/obp-api/src/test/resources/cert/obp-server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDXR8/uGAPJ2viT +Hlzy+5YtGzhhkbF6h91OY6aEoQ/K5GljSGlYSBlfmRu+NKjJxPg9TQw4yPLTrub4 +FQ8+lCUM5Gp+SZy8Lox5nzZgRGbIOkIT+/tQUBpucimBY5ZUZg806uwuFRRHLU7e +eCnMqWtrpaskdhSMm2kRTOIba7yXg1Ab1FXLy4l2g7zmDBqF+nin8J36ZRm/b6UX +hNWcbACU/sCVy1aap3TwQSY9i3JIQbmgOxgjTJzXe/vlpTvF51cBhHrJa5IVArfb +oPjPzD1CA9w7a0xdR0WMC6Z56ub9BlymS7Tk0guxxBnZFPNP0VBjR20cfkVqsefF +4chD5VdJAgMBAAECggEAI2kwjp1sohten/RynZU8kpbTo1jvtJP8lxRVI6PKkTkZ +DLewfFD/u3XX1mWbDfVUT+EIjZ6gMqmmXFA6fbSok4JO8g7xtRKDEM5bh2I49d5/ +WAIuyHsknd4xbZMP+zn+blnmF35oI30UaAuj9II5pS9PA2RY5Pf7RFk9J/2Eu/cU +RAuM+XkfS3WiBXRvErWE1xFoWd58SDeXvsexkUj+bX034NOsbBVsS1pP2jn/7vbX +tMX057iW6RKvFWdj62AEw5Au1YruFM7314Lk0x8Eq1kWAjGkSdQMUJ9B+AB24wKp +6uUSkWjtyjtsF3w+hYzTehI+0JRJrZuLQmYQgcgwMQKBgQDjBt0OIrpzBBIW6oUP +WVdTnYLK/LQZyExxLlEnhvnSUo5T7VRYdk28j3Ibt/KJE6vRTesY5iQlXJ/2Jf7x +CQRWHyQHTK1/UkhYjpVTxfuuL6G7Eu6TOORqummXikneSGPT53TjbEXplpC9HwIq +mhOuaanyIscFDL0MPTuqAeK5fwKBgQDywTBACW669Qi+UmK+odd+ywRNpCSNb/f7 +nr4SwC2y/2JVVjanhCb8d9RhI/9zG+rEC1CfQig22MR2BoyEndOERDHZRr7v57/d +xQx92rP4vYPRqwNEIEsms1dViCBZFIno1sz/3MZJW3sGZ/5QOBVhN7K8vKh5aVOK +734O330DNwKBgEtcjObTWmcxs7uNsAvPUXC9OZyeVD62wcFUabYgVS+fHgX4I2aW +JunwCCXxYv5j3EZHgkIqq1cHr0XriPyETL816STPLEy2iLN06Vb8wrYos5xBBZcz +bIeNR935FtpF13WRQLj5yR9oTrWo5HQ131CnXLW3G6+ucdbtQLAHnjaLAoGBAOFY +btbzKqRItL4gMmPuTuH3yd3dIsVdDGG6wY0ccw+3vOuk5YVpytsQzckDJq4PW0Zz +jnrYagZIAU/i0myFKgWdXHzrDHIduuE+e1MVQ34DRyvKSXTjWX/qeYb/n6+xvjez +todJxZPxZOnUfaKv/UDK+JP1uZtDJ/dQTL+3vQ/PAoGAO7mou779d/T+vaFHv6I8 +ica+noG1xHxATAA9S9yXmN8MK/LhpwwfrF3Hj/1k/ZGayYvRVbaAftMaH7B59Q2O +fUQUUvkBsNiTwwFwxXeO4L5FYh8rK6T9XmmuWMuB/WDfXs/xZFXLFlXKdALQTWMt +oYPwMDLn77bKyqs1K9hFG/k= +-----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 0000000000..33dc1abd62 Binary files /dev/null and b/obp-api/src/test/resources/cert/obp-server.p12 differ 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..b6f29fb50b --- /dev/null +++ b/obp-api/src/test/resources/cert/proxy-client.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDhjCCAm6gAwIBAgIUFWpFSazj+6Vx+ZlJFeyidQPg8ZcwDQYJKoZIhvcNAQEL +BQAwODELMAkGA1UEBhMCREUxFDASBgNVBAoMC1RFU09CRSBHbWJIMRMwEQYDVQQD +DApPQlAgRGV2IENBMB4XDTI2MDcyMzE0NTcyN1oXDTM2MDcyMDE0NTcyN1owTjEL +MAkGA1UEBhMCREUxFDASBgNVBAoMC1RFU09CRSBHbWJIMRMwEQYDVQQLDApFZGdl +IFByb3h5MRQwEgYDVQQDDAtuZ2lueC1kZXYtMTCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBAOSRyFCniPld8aZJ+td4xrzjDvIe1wvi2cyDUb3xPn6cCVS6 +4rU7SVekQxopwaa+ShiGtBXjZr6QlZF+f3eh1Kg2BY4RTanohWCGG3kW4YkYIhFb +hLJmnAW05fx70ERfZWZ8rCws0ia31zewR4pD2f0DTQrMy0fH0/+FqTjmgRMvuhym +LMJ/yM2sW5YAxADpFKc0b+X/sFesTBDDfNA5iT3gfmc93e51pNGtWZi8t9K/15Nv +2qNW6H1WL44OSCrf6oi1mxi8o/Enz+2Ro8rFMyjfnwL6/iv9z/IZitEdtJTyxBJl +BbbEJ3TTF/to4WJQB88YhtjNdxSV82PphBGpTrUCAwEAAaNyMHAwCQYDVR0TBAIw +ADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwHQYDVR0OBBYE +FCSJf6CNkAroVUQ4wkvqJa1uDbHYMB8GA1UdIwQYMBaAFMxGNObNcHntFNEmLkqn +pJi1I3sSMA0GCSqGSIb3DQEBCwUAA4IBAQBJPPtzti0/gpqh34oCqJa5Wy7NnaSl +BlEi5MZ205k1elNi0Hp9cdiB2iArE1naVyP3kIl0TjttrlT1nsaBPJOJx4HrWF/E +ZLrTCEvc3Cjvt7+KSSvENZB2ljtQBXOpzSPQwNSEfKYKfuhLDy0jWd6aWkKVRDCE +jz2KdAkypnUr+UJjLL3NrEPaEdDuCSLe7YljqVY+zAnm4lrVQCwoGUkQkma0mXqN +dMHrNfB1YjnNL5RYwHk0afDGAlUEFvIgF1K5UzksnTR2bZl1w+ffURxQvZayJP5G +Xfq/sBgwqLrTU5udUvfKw1rh06keT6YbmU0A3BarOtXqZRGhFORa/GV+ +-----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..147e0f71e8 --- /dev/null +++ b/obp-api/src/test/resources/cert/proxy-client.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDkkchQp4j5XfGm +SfrXeMa84w7yHtcL4tnMg1G98T5+nAlUuuK1O0lXpEMaKcGmvkoYhrQV42a+kJWR +fn93odSoNgWOEU2p6IVghht5FuGJGCIRW4SyZpwFtOX8e9BEX2VmfKwsLNImt9c3 +sEeKQ9n9A00KzMtHx9P/hak45oETL7ocpizCf8jNrFuWAMQA6RSnNG/l/7BXrEwQ +w3zQOYk94H5nPd3udaTRrVmYvLfSv9eTb9qjVuh9Vi+ODkgq3+qItZsYvKPxJ8/t +kaPKxTMo358C+v4r/c/yGYrRHbSU8sQSZQW2xCd00xf7aOFiUAfPGIbYzXcUlfNj +6YQRqU61AgMBAAECggEANebHJXYM0Dm+RefGVs/tlhe5q5FoRy5NDHSfbzqX8UWc +0bVsxy5fAmMDTAUy3L6dPKFvkIXa8e+oxfHd33lza+OpdbJQ7VOfOH8HtYuWzjy5 +s/wwE94sxw+8+Mi9+ZHBmOSnuZcsS+MoxsI0bL/JhWgk3/ohqiDOtsBvgrKhF8PE +TlADH21uVN3zRBD9JZG8CB8dQOgm6FQ9uXVKINGc/9EOpx+tSrVfEo8XEcNzq9lp +wPX2l0AdjAq7xSFdWtpA7iMlsr82uQid0cnyT5lhwRvmynGs4CBioTDpj5K2ickj +iIQFsMOjKjuJZemEM180vTNptUNdLBAAJLo+XAFRIQKBgQD/e6MwwvtBJeLqcrs5 +6F/Ay3Q72Udb98Zbjb46LHN353Stx541MlVbi/Gr5LU9xwSVUNbNtWg8+EsGIjWd +C8SUeQVoe1LZLjz/w3civx9LYdzpobHd5kN+lXXaej9oNJwPxqM6QC7Lse945L7W +vAhHJLZuefPy3mNF1FLjOUYvrQKBgQDlCDOTmmaJPZsG3InHzgySjm012talKrl/ +LzSTFyGWijduXJD1s9EDdJL0CY7mCh+UDE4WmXFuaWStjmnlPeO700s5lxZRMUdt +FlArpd20Uh9iAa+SCej7tx7qBkMn8X8Mp7qaPObBlWzg8MAJea7DfcXyIQwhJo74 +a0ou2J/cKQKBgQD9Ht7hOd07f7DhfciXp+3ukuTXQv+bU9J39OhKtK22V2BXqJXL +uNGvAOjw2Ijk6yBUW6JmbtwWxB14tz4NGZKrU3gTO1QrDs+qy1tm1prH0e7Qnr4d +zryCVsxMKKBXuv//9VrVJK/4apOLYH7fO66r5ejFbhhPQRx0G9f/fkhWhQKBgAaD +nTPtiE0O+4HOc/zC14izlFebyc8Yz/3WEeC9H69wbvMsntLeMmuuvR9DxlS0pQFI ++E+cPaWuSbbF/i0O/ZMyB0m0CmZ0yFtJ7y3OoeenDk1zTtMQhRfjtXViiDZyn/J0 +MtKAOO/4mAgt0Mh0NYxJ339rgTTQK/DU3F9IugNBAoGAPp5AMV/7xU4ElPncj8ep +oiia/mvzXwG1GR1YfPRVS4EDS9IBQkthlfh23iyxHAxw0BCpec6EOsVhk4Kidwj0 +wmHKRQQ1mD7T/Ojhy0Y9vuFY/x06RZt3tDTOcG6CWCGCRt23XgsYUBoXlk7IR37x +l+ex3sNzPrQwhCCMLmA7KiQ= +-----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 0000000000..6e6d9d85a8 Binary files /dev/null and b/obp-api/src/test/resources/cert/proxy-client.p12 differ diff --git a/obp-api/src/test/resources/cert/tpp-client.crt b/obp-api/src/test/resources/cert/tpp-client.crt new file mode 100644 index 0000000000..e099a6ded8 --- /dev/null +++ b/obp-api/src/test/resources/cert/tpp-client.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDgDCCAmigAwIBAgIUBAMDQWbn5qzvAW0MlGIJygIDSOgwDQYJKoZIhvcNAQEL +BQAwODELMAkGA1UEBhMCREUxFDASBgNVBAoMC1RFU09CRSBHbWJIMRMwEQYDVQQD +DApPQlAgRGV2IENBMB4XDTI2MDcyMzE0NTcyN1oXDTM2MDcyMDE0NTcyN1owSDEL +MAkGA1UEBhMCREUxGDAWBgNVBAoMD0V4YW1wbGUgVFBQIEx0ZDEMMAoGA1UECwwD +VFBQMREwDwYDVQQDDAh0ZXN0LXRwcDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +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 new file mode 100644 index 0000000000..fcb7a547ff --- /dev/null +++ b/obp-api/src/test/resources/cert/tpp-client.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +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 new file mode 100644 index 0000000000..1985fdca8c Binary files /dev/null and b/obp-api/src/test/resources/cert/tpp-client.p12 differ 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/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 781346e2de..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" - } + // 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. - 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) + // 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 { @@ -96,4 +73,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/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 + } +} 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 + } +} diff --git a/scripts/__pycache__/check_lift_http4s_resource_doc_parity.cpython-310.pyc b/scripts/__pycache__/check_lift_http4s_resource_doc_parity.cpython-310.pyc new file mode 100644 index 0000000000..1ba60bc20a Binary files /dev/null and b/scripts/__pycache__/check_lift_http4s_resource_doc_parity.cpython-310.pyc differ 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 0000000000..b22f7f773b Binary files /dev/null and b/scripts/__pycache__/restore_resource_doc_bodies.cpython-310.pyc differ diff --git a/scripts/generate_dev_certs.sh b/scripts/generate_dev_certs.sh new file mode 100755 index 0000000000..aadb38194e --- /dev/null +++ b/scripts/generate_dev_certs.sh @@ -0,0 +1,160 @@ +#!/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 + +# 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 +# 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-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 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..e268240027 --- /dev/null +++ b/scripts/mtls_env.sh @@ -0,0 +1,89 @@ +#!/bin/bash +################################################################################ +# 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 +# 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 (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