Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
520 changes: 520 additions & 0 deletions OPEN_CORRIDOR_INTERFACE_C_PUBLISH_PLAN.md

Large diffs are not rendered by default.

293 changes: 293 additions & 0 deletions OPEN_CORRIDOR_SIMPLE_NETTING.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/MTLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ Consumer — presenting a different client certificate is rejected.
| `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. |
| `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. |

DNs are compared in canonical form, so case and spacing do not matter — but **RDN order does**.
Print the exact values to paste with
Expand Down
8 changes: 8 additions & 0 deletions docs/MTLS_TOPOLOGIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,14 @@ setting. Mitigate it by logging a warning at boot whenever it resolves to `true`
noisy rather than silent, and remove the default entirely once §11.5 has rolled through every
environment.

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

### 5.5 Observability

Record, per request, which branch of §3 resolved (direct caller vs forwarded) and the peer subject,
Expand Down
2 changes: 2 additions & 0 deletions obp-api/src/main/resources/props/sample.props.template
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ jwt.use.ssl=false
# 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.
## 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 can only be a spoofing attempt.
# mtls.trust_forwarded_header_without_tls=true
# mtls.enabled=true
# mtls.keystore.path=obp-api/src/test/resources/cert/server.jks
Expand Down
46 changes: 33 additions & 13 deletions obp-api/src/main/scala/bootstrap/http4s/Http4sMtls.scala
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,29 @@ object Http4sMtls extends MdcLoggable {
private val DevStorePassword = "123456"

/**
* SHA-256 of the checked-in dev stores, so they can be recognised wherever they are copied to.
* SHA-256 of EVERY store checked into `obp-api/src/test/resources/cert`, keyed by filename, 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.
* All of them are public: the private keys are in the repository and the password is in the
* source. A production server using any of them would accept forged client certificates and
* present a certificate anyone can impersonate, so [[assertNotADevStoreInProduction]] refuses to
* boot. The list must cover both the legacy JKS pair (the prop defaults) and the role-named
* PKCS12 set that generate_dev_certs.sh writes — docs/MTLS.md steers readers to the latter, so
* guarding only the pair the props default to would leave the recommended set unguarded.
*
* Http4sMtlsTest asserts these digests still match the files, so regenerating the dev pair fails
* the build here rather than silently disarming the check.
* Http4sMtlsTest asserts these digests still match the files, so regenerating any dev store
* fails the build here rather than silently disarming the check.
*/
private[http4s] val DevKeystoreSha256 = "c51d3c1694b3d3a5cb9fd7d41011b75ec22c2a35ef48f9713f9b0e00d54d78eb"
private[http4s] val DevTruststoreSha256 = "f47613f7ec4de4e291668c070047c256bf4578cfa9b1dabe5a61d056461473d0"
private[http4s] val DevStoreDigests: Map[String, String] = Map(
"server.jks" -> "c51d3c1694b3d3a5cb9fd7d41011b75ec22c2a35ef48f9713f9b0e00d54d78eb",
"server.trust.jks" -> "f47613f7ec4de4e291668c070047c256bf4578cfa9b1dabe5a61d056461473d0",
"localhost_san_dns_ip.pfx" -> "e78c09858fe0659bc67d14570e67e57d750280d10db43980992e54ba60a7427d",
"obp-server.p12" -> "e56ec88d23fb1691d494090f6abec98332b3410e75251814f70ac0ed981bf918",
"dev-truststore.p12" -> "9c606b3012ffc01e85d26c196b80d1ad5f731fa6a7b5c6ed94736d79d800e3ec",
"tpp-client.p12" -> "717bdb83aa3d85f6245cf99762a080cfdf45d9e87de699d7339e600a90754e4a",
"proxy-client.p12" -> "e7f1e212a25642daaabd587184836afcf05506cdf19eb5ad570bdbe3352d82a8",
"expired-tpp.p12" -> "42c9564f96b21354f3b0f9a91925c2e81a22bad6850070d1618966a2cafe0e09"
)

private[http4s] def sha256Of(file: File): String = {
val digest = java.security.MessageDigest.getInstance("SHA-256")
Expand Down Expand Up @@ -101,9 +113,9 @@ object Http4sMtls extends MdcLoggable {
private[http4s] def assertNotADevStoreInProduction(propName: String, file: File): Unit =
if (Props.mode == Props.RunModes.Production) {
val digest = sha256Of(file)
if (digest == DevKeystoreSha256 || digest == DevTruststoreSha256) {
DevStoreDigests.find(_._2 == digest).foreach { case (knownAs, _) =>
throw new RuntimeException(
s"'$propName' points at ${file.getAbsolutePath}, which is one of the development stores " +
s"'$propName' points at ${file.getAbsolutePath}, which is the development store '$knownAs' " +
"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.")
}
Expand All @@ -118,7 +130,8 @@ object Http4sMtls extends MdcLoggable {

// 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 {
val configuredPath = prop(pathProp)
val path = configuredPath.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
Expand All @@ -129,9 +142,16 @@ object Http4sMtls extends MdcLoggable {
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)
// The password only follows the dev default when the store itself does. An operator-supplied
// store with a missing password prop must fail here, by name — falling back to '123456'
// would surface later as an opaque keystore integrity error instead.
val password = prop(passwordProp).getOrElse {
logger.warn(s"'$passwordProp' is not set — falling back to the checked-in dev store password.")
DevStorePassword
if (configuredPath.isEmpty) {
logger.warn(s"'$passwordProp' is not set — using the checked-in dev store password to match the dev store.")
DevStorePassword
} else throw new RuntimeException(
s"'$pathProp' is set but '$passwordProp' is not. Set '$passwordProp' (or ${envVarOf(passwordProp)}) " +
"to the password of that store.")
}
(file.getAbsolutePath, password)
}
Expand Down
5 changes: 5 additions & 0 deletions obp-api/src/main/scala/bootstrap/http4s/Http4sServer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ object Http4sServer extends IOApp with MdcLoggable {
val httpApp = Http4sApp.httpApp

override def run(args: List[String]): IO[ExitCode] = {
// Force the peer-trust configuration at boot. It is a lazy val first needed when a request
// carries certificate material, so without this an unparseable mtls.trusted_proxy.N DN (logged
// at ERROR, proxy silently untrusted) would surface mid-traffic instead of in the boot log.
code.api.util.PeerTrust.config

val builder = EmberServerBuilder
.default[IO]
.withHost(Host.fromString(host).get)
Expand Down
8 changes: 8 additions & 0 deletions obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ import code.transactionRequestAttribute.TransactionRequestAttribute
import code.transactionStatusScheduler.TransactionRequestStatusScheduler
import code.transaction_types.MappedTransactionType
import code.transactionattribute.MappedTransactionAttribute
import code.bankconnectors.opencorridor.{OpenCorridorBankBroker, OpenCorridorOutbox, OpenCorridorOutboxRelay}
import code.transactionrequests.{MappedTransactionRequest, MappedTransactionRequestTypeCharge, TransactionRequestReasons}
import code.usercustomerlinks.MappedUserCustomerLink
import code.customerlinks.CustomerLink
Expand Down Expand Up @@ -560,6 +561,11 @@ class Boot extends MdcLoggable {
val delay = APIUtil.getPropsAsLongValue("transaction_request_status_scheduler_delay").openOrThrowException("Incorrect value for transaction_request_status_scheduler_delay, please provide number of seconds.")
TransactionRequestStatusScheduler.start(delay)
}
// Open Corridor: the transactional-outbox relay publishing Interface C messages
// (credit notifications + settlement instructions) to the banks' own vhosts.
if (APIUtil.getPropsAsBoolValue("open_corridor_enabled", false)) {
OpenCorridorOutboxRelay.start(APIUtil.getPropsAsLongValue("open_corridor.outbox_relay_interval", 10L))
}
APIUtil.getPropsAsLongValue("database_messages_scheduler_interval") match {
case Full(i) => DatabaseDriverScheduler.start(i)
case _ => // Do not start it
Expand Down Expand Up @@ -988,6 +994,8 @@ object ToSchemify extends MdcLoggable {
MappedCounterpartyWhereTag,
MappedTransactionRequest,
TransactionRequestAttribute,
OpenCorridorBankBroker,
OpenCorridorOutbox,
MappedMetric,
MetricArchive,
MetricsArchiveRun,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3195,7 +3195,9 @@ object SwaggerDefinitionsJSON {
status_code = 401,
operation_id = "OBPv4.0.0-getBanks",
api_instance_id = "obp_node_a",
consent_reference_id = Some(ExampleValue.consentReferenceIdExample.value)
consent_reference_id = Some(ExampleValue.consentReferenceIdExample.value),
certificate_trust = Some("forwarded"),
certificate_trust_detail = Some("cn=nginx-prod-1,ou=edge,o=tesobe gmbh,c=de")
)
lazy val metricsJsonV600 = MetricsJsonV600(
metrics = List(metricJsonV600)
Expand Down
27 changes: 20 additions & 7 deletions obp-api/src/main/scala/code/api/util/APIUtil.scala
Original file line number Diff line number Diff line change
Expand Up @@ -162,14 +162,25 @@
private val DateWithMsRollbackFormatTL = ThreadLocal.withInitial(() => new SimpleDateFormat(DateWithMsAndTimeZoneOffset))
private val rfc7231DateTL = ThreadLocal.withInitial(() => new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH))

def DateWithYearFormat: SimpleDateFormat = DateWithYearFormatTL.get()
def DateWithMonthFormat: SimpleDateFormat = DateWithMonthFormatTL.get()
def DateWithDayFormat: SimpleDateFormat = DateWithDayFormatTL.get()
def DateWithSecondsFormat: SimpleDateFormat = DateWithSecondsFormatTL.get()
// SimpleDateFormat captures the JVM default time zone at CONSTRUCTION, and these instances are
// cached per thread. Boot.scala sets the default zone to UTC during startup, so an instance
// constructed on a thread that ran earlier (a test class's field initializers, an early boot
// thread on a non-UTC machine) would keep the machine's zone forever — while Calendar.getInstance
// elsewhere follows the new default, skewing parsed dates by the zone offset. Re-pin on every
// access so the cached instance always follows the current default; setTimeZone is a field write.
private def withCurrentZone(format: SimpleDateFormat): SimpleDateFormat = {
format.setTimeZone(java.util.TimeZone.getDefault)
format
}

def DateWithYearFormat: SimpleDateFormat = withCurrentZone(DateWithYearFormatTL.get())

Check warning on line 176 in obp-api/src/main/scala/code/api/util/APIUtil.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "DateWithYearFormat" to match the regular expression ^([a-z][a-zA-Z0-9]*+(_[^a-zA-Z0-9]++)?+|[^a-zA-Z0-9]++)$

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AZ-pDFAAC20taTj0mCDv&open=AZ-pDFAAC20taTj0mCDv&pullRequest=2879
def DateWithMonthFormat: SimpleDateFormat = withCurrentZone(DateWithMonthFormatTL.get())
def DateWithDayFormat: SimpleDateFormat = withCurrentZone(DateWithDayFormatTL.get())

Check warning on line 178 in obp-api/src/main/scala/code/api/util/APIUtil.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "DateWithDayFormat" to match the regular expression ^([a-z][a-zA-Z0-9]*+(_[^a-zA-Z0-9]++)?+|[^a-zA-Z0-9]++)$

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AZ-pDFAAC20taTj0mCDx&open=AZ-pDFAAC20taTj0mCDx&pullRequest=2879
def DateWithSecondsFormat: SimpleDateFormat = withCurrentZone(DateWithSecondsFormatTL.get())

Check warning on line 179 in obp-api/src/main/scala/code/api/util/APIUtil.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "DateWithSecondsFormat" to match the regular expression ^([a-z][a-zA-Z0-9]*+(_[^a-zA-Z0-9]++)?+|[^a-zA-Z0-9]++)$

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AZ-pDFAAC20taTj0mCDy&open=AZ-pDFAAC20taTj0mCDy&pullRequest=2879
// If you need UTC Z format, please continue to use DateWithMsFormat. eg: 2025-01-01T01:01:01.000Z
def DateWithMsFormat: SimpleDateFormat = DateWithMsFormatTL.get()
def DateWithMsFormat: SimpleDateFormat = withCurrentZone(DateWithMsFormatTL.get())
// If you need a format with timezone offset (+0000), please use DateWithMsRollbackFormat, eg: 2025-01-01T01:01:01.000+0000
def DateWithMsRollbackFormat: SimpleDateFormat = DateWithMsRollbackFormatTL.get()
def DateWithMsRollbackFormat: SimpleDateFormat = withCurrentZone(DateWithMsRollbackFormatTL.get())

def rfc7231Date: SimpleDateFormat = rfc7231DateTL.get()

Expand Down Expand Up @@ -1199,6 +1210,7 @@
case "iss" => Full(OBPIss(values.head))
case "consent_id" => Full(OBPConsentId(values.head))
case "consent_reference_id" => Full(OBPConsentReferenceId(values.head))
case "certificate_trust" => Full(OBPCertificateTrust(values.head))
case "user_id" => Full(OBPUserId(values.head))
case "provider_provider_id" => Full(ProviderProviderId(values.head))
case "bank_id" => Full(OBPBankId(values.head))
Expand Down Expand Up @@ -1334,6 +1346,7 @@
val azp = getHttpRequestUrlParam(httpRequestUrl,"azp")
val consentId = getHttpRequestUrlParam(httpRequestUrl,"consent_id")
val consentReferenceId = getHttpRequestUrlParam(httpRequestUrl,"consent_reference_id")
val certificateTrust = getHttpRequestUrlParam(httpRequestUrl,"certificate_trust")
val userId = getHttpRequestUrlParam(httpRequestUrl, "user_id")
val providerProviderId = getHttpRequestUrlParam(httpRequestUrl, "provider_provider_id")
val bankId = getHttpRequestUrlParam(httpRequestUrl, "bank_id")
Expand Down Expand Up @@ -1369,7 +1382,7 @@

Full(List(
HTTPParam("sort_by",sortBy), HTTPParam("sort_direction",sortDirection), HTTPParam("from_date",fromDate), HTTPParam("to_date", toDate), HTTPParam("limit",limit), HTTPParam("offset",offset),
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),
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),
HTTPParam("implemented_by_partial_function",implementedByPartialFunction), HTTPParam("implemented_in_version",implementedInVersion), HTTPParam("verb", verb),
HTTPParam("correlation_id", correlationId), HTTPParam("duration", duration), HTTPParam("exclude_app_names", excludeAppNames),
HTTPParam("exclude_url_patterns", excludeUrlPattern),HTTPParam("exclude_implemented_by_partial_functions", excludeImplementedByPartialfunctions),
Expand Down
15 changes: 15 additions & 0 deletions obp-api/src/main/scala/code/api/util/ApiRole.scala
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,21 @@ object ApiRole extends MdcLoggable{
case class CanCreateAnyTransactionRequest(requiresBankId: Boolean = true) extends ApiRole
lazy val canCreateAnyTransactionRequest = CanCreateAnyTransactionRequest()

// Open Corridor: the bank's own Bank Node (M2M service user) reports Cardano promise
// evidence back to the PENDING OPEN_CORRIDOR_PROMISE Transaction Request.
case class CanAttachOpenCorridorPromise(requiresBankId: Boolean = true) extends ApiRole
lazy val canAttachOpenCorridorPromise = CanAttachOpenCorridorPromise()

// Open Corridor: operator role for registering each onboarded bank's RabbitMQ broker
// coordinates (host/port/vhost/credentials) in the per-bank publish registry.
case class CanConfigureOpenCorridorBroker(requiresBankId: Boolean = false) extends ApiRole
lazy val canConfigureOpenCorridorBroker = CanConfigureOpenCorridorBroker()

// Open Corridor: operator role for the settle-pair trigger — nets a bank pair's
// PENDING promises, posts the net Transaction and enqueues the Interface C messages.
case class CanSettleOpenCorridor(requiresBankId: Boolean = false) extends ApiRole
lazy val canSettleOpenCorridor = CanSettleOpenCorridor()

case class CanAddSocialMediaHandle(requiresBankId: Boolean = true) extends ApiRole
lazy val canAddSocialMediaHandle = CanAddSocialMediaHandle()

Expand Down
Loading
Loading