Skip to content

http4s mtls dev toggle#2876

Open
constantine2nd wants to merge 4 commits into
OpenBankProject:developfrom
constantine2nd:feature/http4s-mtls-dev-toggle
Open

http4s mtls dev toggle#2876
constantine2nd wants to merge 4 commits into
OpenBankProject:developfrom
constantine2nd:feature/http4s-mtls-dev-toggle

Conversation

@constantine2nd

Copy link
Copy Markdown
Contributor

No description provided.

Turning on the dev-only in-process mTLS termination previously meant editing
five props. Reduce it to a single switch, and stop the switch from silently
breaking local logins.

- Http4sMtls: only mtls.enabled is required now. The four store props fall back
  to the checked-in dev pair (server.jks / server.trust.jks), each fallback
  logged at WARN naming the prop and its OBP_* environment equivalent, so a
  default is never silent. A missing store now fails with the resolved absolute
  path and the working directory in the message -- the defaults are
  repo-relative, so "launched from the wrong directory" is the realistic
  failure and a bare FileNotFoundException did not say so.

- Http4sMtls.keyStoreTypeOf: load .p12/.pfx as PKCS12 and everything else as
  JKS, for keystore and truststore alike. The type cannot be sniffed cheaply
  and guessing wrong fails with an opaque parse error. This makes
  localhost_san_dns_ip.pfx usable -- the only local cert carrying
  DNS:localhost + IP:127.0.0.1, where the server.jks default has no SAN at all
  and relies on clients falling back to CN.

- scripts/mtls_env.sh: exports the OBP_MTLS_* overrides. No new machinery was
  needed -- APIUtil.getPropsValue already reads OBP_<NAME> from the environment
  ahead of the props file, so every prop is env-overridable. Pre-exported
  values win.

  It also pins local_identity_provider. Enabling TLS means hostname must become
  https://, but hostname is the default for local_identity_provider
  (constant.scala), which is the provider column every AuthUser / ResourceUser
  row is keyed on -- moving the scheme alone gives existing local users a
  different provider string and orphans them. The pin reads the hostname via
  Lift's own resolution order: Lift maps Development to an EMPTY modeName, so
  development.default.props is never loaded and default.props is. Reading the
  wrong file there reintroduces exactly the orphaning this pin prevents.

- scripts/java_env.sh: select a JDK >= 17 before any Maven work. The build
  compiles with -release 17 and a default JDK 11 fails with "'17' is not a
  valid choice for '-release'", which reads like a source error. An adequate
  JAVA_HOME is honoured; an inadequate one is replaced with an announcement.

- Both build_and_run scripts: --mtls flag following the existing
  --clean/--online/--no-flush/--background idiom. mtls_env.sh is sourced after
  the build so it can never affect compilation.

- Tests: extension-to-store-type mapping, and buildSslContext loading the .pfx.

Verified live: plain HTTP rejected (curl 52), certless handshake rejected under
client_auth=need (curl 56), 200 with a real client certificate, and a spoofed
PSD2-CERT header replaced by the handshake certificate (1252-char exact match).
Both scripts build and boot from a JDK 11 shell.
…ipts

Both build_and_run scripts run under `set -e`, and both call mvn without
guarding it. A failing build therefore aborted the script at the mvn line, so
everything written to handle that failure never ran:

- flushall_fast_build_and_run.sh captured BUILD_EXIT_CODE=$? and had a whole
  "detect incremental compilation cache issue, retry with a clean build" block
  keyed off it. None of it was reachable. The script exited silently with no
  diagnosis and no retry.
- flushall_build_and_run.sh could not reach its BUILD_EXIT check, so it never
  printed the log tail it promises on failure.

Bracket each mvn invocation with set +e / set -e so the exit code is captured
and the existing handling runs.

The symptom is easy to misread: the scripts pipe their output, so the exit code
a caller sees is the pipeline's, not the script's -- a failed build could look
like a successful run that simply produced no server.

Verified by putting a failing mvn on PATH and running the real script: it now
reports the failure, detects the cache-issue signature, retries with a clean
build, tails the log and exits 1.
…sable

With requirePsd2Certificates=ONLINE, any request reaching passesPsd2Aisp
without a TPP-Signature-Certificate header produced

    500 OBP-50000: Unknown Error.: Illegal base64 character 2d

getCertificateFromTppSignatureCertificate read the header via getHeaderValue,
which substitutes SecureRandomUtil.csprng.nextLong().toString when a header is
absent. That is a serviceable "never matches" sentinel for the string
comparisons it was written for (Digest, Signature), but the value here goes
straight into Base64.getDecoder: a negative Long renders with a leading '-',
which is 0x2d, and the decoder throws.

The bug was also flaky by construction. Only about half of calls got a negative
Long; the rest base64-decoded to garbage and failed later in getCertificatePem
with a different error. Same missing header, two symptoms.

Make getCertificateFromTppSignatureCertificate return Box[X509Certificate]
instead of throwing, and look the header up directly rather than through
getHeaderValue. Each failure mode is now a Failure: header absent, not base64,
base64 of something that is not a PEM, or a PEM that is not a certificate.

Call sites:
- APIUtil.passesPsd2ServiceProviderCommon: fail closed. passesPsd2ServiceProvider
  already maps a Failure to 401 -- this is the path that produced the 500.
- BerlinGroupSigning.verifySignedRequest: checkRequestIsSigned only proves the
  header is present, not usable, so an unusable one is a 401.
- BerlinGroupSigning.getOrCreateConsumer: leave the caller's result untouched.
  Consumer creation is a side effect of a signed request, not the thing being
  authorised; verifySignedRequest is what rejects a bad certificate.
- BerlinGroupCheck: without a certificate there is no serial number to compare
  keyId.SN against, so it is an invalid signature header (400).

Found by running a real TPP (OBP-Hola) through the UK Open Banking consent
journey. Verified: the identical POST to
/open-banking/v3.1/account-access-consents now returns 401 with no "Illegal
base64" in the log; RegulatedEntityTest passes.

Note this changes only the failure mode, not the gate: a caller with no
TPP-Signature-Certificate is still rejected, just with the correct status.
…lly uses

BEHAVIOUR CHANGE for UK Open Banking endpoints when requirePsd2Certificates=ONLINE.
Read the note at the end before upgrading.

The PSD2 gate (passesPsd2Aisp / passesPsd2Pisp) identified the TPP from the
TPP-Signature-Certificate header regardless of which API standard was being
served. That header is Berlin Group NextGenPSD2. UK Open Banking does not use it
at all: OBIE identifies the TPP by the mTLS transport certificate, which reaches
OBP as PSD2-CERT -- set by the reverse proxy that terminates mTLS, or by
bootstrap.http4s.Http4sMtls in development. A correctly behaving OBIE client was
therefore rejected for omitting a header its specification never mentions.

BerlinGroupCheck.validate already scopes every OTHER piece of Berlin Group
machinery to Berlin Group URLs -- the mandatory headers, the request-signature
verification, the on-the-fly consumer creation. The PSD2 gate was the single
place that crossed that boundary. tppCertificateForStandard brings it in line:
UK Open Banking URLs read PSD2-CERT, everything else keeps the previous code
path byte for byte.

One call site changes, which covers all 10 UK enforcement sites (5 in v3.1.0,
5 in v4.0.1) and any added later. Berlin Group (37 sites) and the OBP-native
endpoints (4) are untouched.

Only the certificate SOURCE changes, not what is done with it: both branches feed
the same regulated-entity lookup, which matches on issuer CN + serial number and
works with any X509 certificate. No eIDAS QCStatement is required -- that is only
needed by the CERTIFICATE mode, which is unaffected.

Verified end to end against a real TPP (OBP-Hola) over mutual TLS:
- before registering the entity: 401 OBP-34102 (regulated entity not found),
  proving the mTLS certificate is now the one being looked up, where previously
  the same call returned OBP-20306 (no certificate in the request at all);
- after registering it: the UK v4.0.1 consent is created, Status
  AWAITINGAUTHORISATION, with the gate logging the matched entity and PSP_AI;
- /berlin-group/v1.3/consents still rejects on missing tpp-signature-certificate,
  unchanged.
RegulatedEntityTest and the mTLS suites pass (13 tests). No test in this repo
sends TPP-Signature-Certificate to a UK endpoint -- only the Berlin Group suites
reference that header.

UPGRADE NOTE. Under eIDAS a TPP holds two different certificates: the QWAC, used
for TLS and therefore surfacing as PSD2-CERT, and the QSEAL, used to sign and
therefore carried in TPP-Signature-Certificate. They have different serial
numbers and often different issuing CAs. Since regulated entities are matched on
issuer CN + serial, a deployment that runs UK endpoints with
requirePsd2Certificates=ONLINE and onboarded its TPPs by QSEAL will now match a
different entity, or none:
- no PSD2-CERT present (no mTLS proxy) -> previously working TPPs get 401;
- QWAC registered as a different entity -> that entity's services decide the call.
This cannot authorise an unregistered party: both paths require a CA + serial
match and fail closed. Deployments on requirePsd2Certificates=NONE are unaffected
because the gate does not run. Register the QWAC alongside the QSEAL to migrate.
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
B Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

💡 Need a hand with PR review? Try Gitar by Sonar!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant