Skip to content

Commit bbe26e1

Browse files
authored
Merge pull request #2879 from simonredfern/develop
OC related + Certificate Trust in Metrics
2 parents bc2b2ae + 4be70a4 commit bbe26e1

47 files changed

Lines changed: 3465 additions & 176 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

OPEN_CORRIDOR_INTERFACE_C_PUBLISH_PLAN.md

Lines changed: 520 additions & 0 deletions
Large diffs are not rendered by default.

OPEN_CORRIDOR_SIMPLE_NETTING.md

Lines changed: 293 additions & 0 deletions
Large diffs are not rendered by default.

docs/MTLS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@ Consumer — presenting a different client certificate is rejected.
347347
| `mtls.client_auth` | `need` | `need` rejects certless handshakes; `want` makes the client certificate optional. |
348348
| `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. |
349349
| `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. |
350-
| `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. |
350+
| `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. **Ignored (treated as `false`) when `mtls.enabled=true` and no trusted proxies are configured** — OBP is then the TLS edge, so a header from a certless peer (possible under `client_auth=want`) can only be a spoofing attempt and is stripped. |
351351

352352
DNs are compared in canonical form, so case and spacing do not matter — but **RDN order does**.
353353
Print the exact values to paste with

docs/MTLS_TOPOLOGIES.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,14 @@ setting. Mitigate it by logging a warning at boot whenever it resolves to `true`
208208
noisy rather than silent, and remove the default entirely once §11.5 has rolled through every
209209
environment.
210210

211+
One deployment shape is carved out of the default (added 2026-07-28): when `mtls.enabled=true`
212+
and the forwarder allowlist is empty, OBP is provably the edge — every legitimate caller identity
213+
arrives in the handshake — so the prop is ignored and the header stripped. Honouring the
214+
permissive default there would have re-opened a hole the pre-generalisation middleware had
215+
closed: under `mtls.client_auth=want` a certless peer could spoof `PSD2-CERT`, where
216+
`injectClientCertificate` used to strip it unconditionally. `PeerTrust.effectiveTrustWithoutTls`
217+
implements the carve-out; the prop keeps its meaning for the plain-hop and behind-proxy shapes.
218+
211219
### 5.5 Observability
212220

213221
Record, per request, which branch of §3 resolved (direct caller vs forwarded) and the peer subject,

obp-api/src/main/resources/props/sample.props.template

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ jwt.use.ssl=false
177177
# mtls.trusted_proxy.1.subject=CN=nginx-prod-1,O=Your Org,C=DE
178178
## Whether a forwarded PSD2-CERT is trusted when the sender presented no client certificate.
179179
## True is the long-standing behaviour of a plain proxy hop; set false once the proxy uses mTLS.
180+
## Ignored (treated as false) when mtls.enabled=true and no trusted proxies are configured:
181+
## OBP is then the TLS edge, so a header from a certless peer can only be a spoofing attempt.
180182
# mtls.trust_forwarded_header_without_tls=true
181183
# mtls.enabled=true
182184
# mtls.keystore.path=obp-api/src/test/resources/cert/server.jks

obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -63,17 +63,29 @@ object Http4sMtls extends MdcLoggable {
6363
private val DevStorePassword = "123456"
6464

6565
/**
66-
* SHA-256 of the checked-in dev stores, so they can be recognised wherever they are copied to.
66+
* SHA-256 of EVERY store checked into `obp-api/src/test/resources/cert`, keyed by filename, so
67+
* they can be recognised wherever they are copied to.
6768
*
68-
* These are public: the private key is in the repository and the password is in this file. A
69-
* production server using either would accept forged client certificates and present a
70-
* certificate anyone can impersonate, so [[assertNotADevStoreInProduction]] refuses to boot.
69+
* All of them are public: the private keys are in the repository and the password is in the
70+
* source. A production server using any of them would accept forged client certificates and
71+
* present a certificate anyone can impersonate, so [[assertNotADevStoreInProduction]] refuses to
72+
* boot. The list must cover both the legacy JKS pair (the prop defaults) and the role-named
73+
* PKCS12 set that generate_dev_certs.sh writes — docs/MTLS.md steers readers to the latter, so
74+
* guarding only the pair the props default to would leave the recommended set unguarded.
7175
*
72-
* Http4sMtlsTest asserts these digests still match the files, so regenerating the dev pair fails
73-
* the build here rather than silently disarming the check.
76+
* Http4sMtlsTest asserts these digests still match the files, so regenerating any dev store
77+
* fails the build here rather than silently disarming the check.
7478
*/
75-
private[http4s] val DevKeystoreSha256 = "c51d3c1694b3d3a5cb9fd7d41011b75ec22c2a35ef48f9713f9b0e00d54d78eb"
76-
private[http4s] val DevTruststoreSha256 = "f47613f7ec4de4e291668c070047c256bf4578cfa9b1dabe5a61d056461473d0"
79+
private[http4s] val DevStoreDigests: Map[String, String] = Map(
80+
"server.jks" -> "c51d3c1694b3d3a5cb9fd7d41011b75ec22c2a35ef48f9713f9b0e00d54d78eb",
81+
"server.trust.jks" -> "f47613f7ec4de4e291668c070047c256bf4578cfa9b1dabe5a61d056461473d0",
82+
"localhost_san_dns_ip.pfx" -> "e78c09858fe0659bc67d14570e67e57d750280d10db43980992e54ba60a7427d",
83+
"obp-server.p12" -> "e56ec88d23fb1691d494090f6abec98332b3410e75251814f70ac0ed981bf918",
84+
"dev-truststore.p12" -> "9c606b3012ffc01e85d26c196b80d1ad5f731fa6a7b5c6ed94736d79d800e3ec",
85+
"tpp-client.p12" -> "717bdb83aa3d85f6245cf99762a080cfdf45d9e87de699d7339e600a90754e4a",
86+
"proxy-client.p12" -> "e7f1e212a25642daaabd587184836afcf05506cdf19eb5ad570bdbe3352d82a8",
87+
"expired-tpp.p12" -> "42c9564f96b21354f3b0f9a91925c2e81a22bad6850070d1618966a2cafe0e09"
88+
)
7789

7890
private[http4s] def sha256Of(file: File): String = {
7991
val digest = java.security.MessageDigest.getInstance("SHA-256")
@@ -101,9 +113,9 @@ object Http4sMtls extends MdcLoggable {
101113
private[http4s] def assertNotADevStoreInProduction(propName: String, file: File): Unit =
102114
if (Props.mode == Props.RunModes.Production) {
103115
val digest = sha256Of(file)
104-
if (digest == DevKeystoreSha256 || digest == DevTruststoreSha256) {
116+
DevStoreDigests.find(_._2 == digest).foreach { case (knownAs, _) =>
105117
throw new RuntimeException(
106-
s"'$propName' points at ${file.getAbsolutePath}, which is one of the development stores " +
118+
s"'$propName' points at ${file.getAbsolutePath}, which is the development store '$knownAs' " +
107119
"checked into the OBP-API repository. Its private key is public and its password is " +
108120
"'123456', so it cannot be used with run.mode=production. Supply your own certificates.")
109121
}
@@ -118,7 +130,8 @@ object Http4sMtls extends MdcLoggable {
118130

119131
// Returns the absolute store path plus its password, defaulting both to the dev pair.
120132
def store(pathProp: String, devPath: String, passwordProp: String): (String, String) = {
121-
val path = prop(pathProp).getOrElse {
133+
val configuredPath = prop(pathProp)
134+
val path = configuredPath.getOrElse {
122135
logger.warn(s"'$pathProp' is not set — falling back to the checked-in dev store '$devPath'. " +
123136
s"Set '$pathProp' (or ${envVarOf(pathProp)}) to use your own certificates.")
124137
devPath
@@ -129,9 +142,16 @@ object Http4sMtls extends MdcLoggable {
129142
s"(resolved to ${file.getAbsolutePath} from working directory ${new File(".").getAbsolutePath}). " +
130143
s"Launch from the repo root or set '$pathProp' to an absolute path.")
131144
assertNotADevStoreInProduction(pathProp, file)
145+
// The password only follows the dev default when the store itself does. An operator-supplied
146+
// store with a missing password prop must fail here, by name — falling back to '123456'
147+
// would surface later as an opaque keystore integrity error instead.
132148
val password = prop(passwordProp).getOrElse {
133-
logger.warn(s"'$passwordProp' is not set — falling back to the checked-in dev store password.")
134-
DevStorePassword
149+
if (configuredPath.isEmpty) {
150+
logger.warn(s"'$passwordProp' is not set — using the checked-in dev store password to match the dev store.")
151+
DevStorePassword
152+
} else throw new RuntimeException(
153+
s"'$pathProp' is set but '$passwordProp' is not. Set '$passwordProp' (or ${envVarOf(passwordProp)}) " +
154+
"to the password of that store.")
135155
}
136156
(file.getAbsolutePath, password)
137157
}

obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ object Http4sServer extends IOApp with MdcLoggable {
2222
val httpApp = Http4sApp.httpApp
2323

2424
override def run(args: List[String]): IO[ExitCode] = {
25+
// Force the peer-trust configuration at boot. It is a lazy val first needed when a request
26+
// carries certificate material, so without this an unparseable mtls.trusted_proxy.N DN (logged
27+
// at ERROR, proxy silently untrusted) would surface mid-traffic instead of in the boot log.
28+
code.api.util.PeerTrust.config
29+
2530
val builder = EmberServerBuilder
2631
.default[IO]
2732
.withHost(Host.fromString(host).get)

obp-api/src/main/scala/bootstrap/liftweb/Boot.scala

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ import code.transactionRequestAttribute.TransactionRequestAttribute
135135
import code.transactionStatusScheduler.TransactionRequestStatusScheduler
136136
import code.transaction_types.MappedTransactionType
137137
import code.transactionattribute.MappedTransactionAttribute
138+
import code.bankconnectors.opencorridor.{OpenCorridorBankBroker, OpenCorridorOutbox, OpenCorridorOutboxRelay}
138139
import code.transactionrequests.{MappedTransactionRequest, MappedTransactionRequestTypeCharge, TransactionRequestReasons}
139140
import code.usercustomerlinks.MappedUserCustomerLink
140141
import code.customerlinks.CustomerLink
@@ -560,6 +561,11 @@ class Boot extends MdcLoggable {
560561
val delay = APIUtil.getPropsAsLongValue("transaction_request_status_scheduler_delay").openOrThrowException("Incorrect value for transaction_request_status_scheduler_delay, please provide number of seconds.")
561562
TransactionRequestStatusScheduler.start(delay)
562563
}
564+
// Open Corridor: the transactional-outbox relay publishing Interface C messages
565+
// (credit notifications + settlement instructions) to the banks' own vhosts.
566+
if (APIUtil.getPropsAsBoolValue("open_corridor_enabled", false)) {
567+
OpenCorridorOutboxRelay.start(APIUtil.getPropsAsLongValue("open_corridor.outbox_relay_interval", 10L))
568+
}
563569
APIUtil.getPropsAsLongValue("database_messages_scheduler_interval") match {
564570
case Full(i) => DatabaseDriverScheduler.start(i)
565571
case _ => // Do not start it
@@ -988,6 +994,8 @@ object ToSchemify extends MdcLoggable {
988994
MappedCounterpartyWhereTag,
989995
MappedTransactionRequest,
990996
TransactionRequestAttribute,
997+
OpenCorridorBankBroker,
998+
OpenCorridorOutbox,
991999
MappedMetric,
9921000
MetricArchive,
9931001
MetricsArchiveRun,

obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3195,7 +3195,9 @@ object SwaggerDefinitionsJSON {
31953195
status_code = 401,
31963196
operation_id = "OBPv4.0.0-getBanks",
31973197
api_instance_id = "obp_node_a",
3198-
consent_reference_id = Some(ExampleValue.consentReferenceIdExample.value)
3198+
consent_reference_id = Some(ExampleValue.consentReferenceIdExample.value),
3199+
certificate_trust = Some("forwarded"),
3200+
certificate_trust_detail = Some("cn=nginx-prod-1,ou=edge,o=tesobe gmbh,c=de")
31993201
)
32003202
lazy val metricsJsonV600 = MetricsJsonV600(
32013203
metrics = List(metricJsonV600)

obp-api/src/main/scala/code/api/util/APIUtil.scala

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -162,14 +162,25 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
162162
private val DateWithMsRollbackFormatTL = ThreadLocal.withInitial(() => new SimpleDateFormat(DateWithMsAndTimeZoneOffset))
163163
private val rfc7231DateTL = ThreadLocal.withInitial(() => new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH))
164164

165-
def DateWithYearFormat: SimpleDateFormat = DateWithYearFormatTL.get()
166-
def DateWithMonthFormat: SimpleDateFormat = DateWithMonthFormatTL.get()
167-
def DateWithDayFormat: SimpleDateFormat = DateWithDayFormatTL.get()
168-
def DateWithSecondsFormat: SimpleDateFormat = DateWithSecondsFormatTL.get()
165+
// SimpleDateFormat captures the JVM default time zone at CONSTRUCTION, and these instances are
166+
// cached per thread. Boot.scala sets the default zone to UTC during startup, so an instance
167+
// constructed on a thread that ran earlier (a test class's field initializers, an early boot
168+
// thread on a non-UTC machine) would keep the machine's zone forever — while Calendar.getInstance
169+
// elsewhere follows the new default, skewing parsed dates by the zone offset. Re-pin on every
170+
// access so the cached instance always follows the current default; setTimeZone is a field write.
171+
private def withCurrentZone(format: SimpleDateFormat): SimpleDateFormat = {
172+
format.setTimeZone(java.util.TimeZone.getDefault)
173+
format
174+
}
175+
176+
def DateWithYearFormat: SimpleDateFormat = withCurrentZone(DateWithYearFormatTL.get())
177+
def DateWithMonthFormat: SimpleDateFormat = withCurrentZone(DateWithMonthFormatTL.get())
178+
def DateWithDayFormat: SimpleDateFormat = withCurrentZone(DateWithDayFormatTL.get())
179+
def DateWithSecondsFormat: SimpleDateFormat = withCurrentZone(DateWithSecondsFormatTL.get())
169180
// If you need UTC Z format, please continue to use DateWithMsFormat. eg: 2025-01-01T01:01:01.000Z
170-
def DateWithMsFormat: SimpleDateFormat = DateWithMsFormatTL.get()
181+
def DateWithMsFormat: SimpleDateFormat = withCurrentZone(DateWithMsFormatTL.get())
171182
// If you need a format with timezone offset (+0000), please use DateWithMsRollbackFormat, eg: 2025-01-01T01:01:01.000+0000
172-
def DateWithMsRollbackFormat: SimpleDateFormat = DateWithMsRollbackFormatTL.get()
183+
def DateWithMsRollbackFormat: SimpleDateFormat = withCurrentZone(DateWithMsRollbackFormatTL.get())
173184

174185
def rfc7231Date: SimpleDateFormat = rfc7231DateTL.get()
175186

@@ -1199,6 +1210,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
11991210
case "iss" => Full(OBPIss(values.head))
12001211
case "consent_id" => Full(OBPConsentId(values.head))
12011212
case "consent_reference_id" => Full(OBPConsentReferenceId(values.head))
1213+
case "certificate_trust" => Full(OBPCertificateTrust(values.head))
12021214
case "user_id" => Full(OBPUserId(values.head))
12031215
case "provider_provider_id" => Full(ProviderProviderId(values.head))
12041216
case "bank_id" => Full(OBPBankId(values.head))
@@ -1334,6 +1346,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
13341346
val azp = getHttpRequestUrlParam(httpRequestUrl,"azp")
13351347
val consentId = getHttpRequestUrlParam(httpRequestUrl,"consent_id")
13361348
val consentReferenceId = getHttpRequestUrlParam(httpRequestUrl,"consent_reference_id")
1349+
val certificateTrust = getHttpRequestUrlParam(httpRequestUrl,"certificate_trust")
13371350
val userId = getHttpRequestUrlParam(httpRequestUrl, "user_id")
13381351
val providerProviderId = getHttpRequestUrlParam(httpRequestUrl, "provider_provider_id")
13391352
val bankId = getHttpRequestUrlParam(httpRequestUrl, "bank_id")
@@ -1369,7 +1382,7 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{
13691382

13701383
Full(List(
13711384
HTTPParam("sort_by",sortBy), HTTPParam("sort_direction",sortDirection), HTTPParam("from_date",fromDate), HTTPParam("to_date", toDate), HTTPParam("limit",limit), HTTPParam("offset",offset),
1372-
HTTPParam("anon", anon), HTTPParam("status", status), HTTPParam("consumer_id", consumerId), HTTPParam("azp", azp), HTTPParam("iss", iss), HTTPParam("consent_id", consentId), HTTPParam("consent_reference_id", consentReferenceId), HTTPParam("user_id", userId), HTTPParam("provider_provider_id", providerProviderId), HTTPParam("url", url), HTTPParam("app_name", appName),
1385+
HTTPParam("anon", anon), HTTPParam("status", status), HTTPParam("consumer_id", consumerId), HTTPParam("azp", azp), HTTPParam("iss", iss), HTTPParam("consent_id", consentId), HTTPParam("consent_reference_id", consentReferenceId), HTTPParam("certificate_trust", certificateTrust), HTTPParam("user_id", userId), HTTPParam("provider_provider_id", providerProviderId), HTTPParam("url", url), HTTPParam("app_name", appName),
13731386
HTTPParam("implemented_by_partial_function",implementedByPartialFunction), HTTPParam("implemented_in_version",implementedInVersion), HTTPParam("verb", verb),
13741387
HTTPParam("correlation_id", correlationId), HTTPParam("duration", duration), HTTPParam("exclude_app_names", excludeAppNames),
13751388
HTTPParam("exclude_url_patterns", excludeUrlPattern),HTTPParam("exclude_implemented_by_partial_functions", excludeImplementedByPartialfunctions),

0 commit comments

Comments
 (0)