Skip to content

ldap2: SNI-aware connections and a shared JNDI socket-factory classloader - #6396

Draft
beanuwave wants to merge 3 commits into
opensearch-project:mainfrom
sternadsoftware:fips-split/3-ldap-sni
Draft

ldap2: SNI-aware connections and a shared JNDI socket-factory classloader#6396
beanuwave wants to merge 3 commits into
opensearch-project:mainfrom
sternadsoftware:fips-split/3-ldap-sni

Conversation

@beanuwave

Copy link
Copy Markdown
Contributor

Category: Bug fix, Enhancement

Two LDAPS defects plus the TLS plumbing they need. Independent of FIPS, though
FIPS is what surfaced them.

Key changes

  • SNI for LDAPS. Two sequential (not duplicate) concerns:
    SNISettingTLSSocketFactory sets the ClientHello SNI before the handshake so a
    multi-cert server serves the right certificate; HostnameVerifyingTrustManager
    checks the returned certificate after. For IP targets the SNI factory
    early-returns (no SNI) and the trust manager is the only hostname check; for DNS
    it sets SNI plus endpoint identification, making the trust-manager check
    redundant. HostnameAwareConnectionFactory threads the target hostname through
    so SNI works on both the pooled and unpooled paths.
    This works around the JNDI LDAP provider resolving hostnames to IPs before
    socket creation (bcgit/bc-java#460).
  • ldap2 LDAPS reconnect ClassNotFoundException (bug fix). The Java9CL
    classloader that lets the JNDI provider resolve ldaptive's socket factory was
    private to LDAPAuthorizationBackend, so a reconnect from the ldap2 backend
    failed. Extracted as SocketFactoryClassLoader and set on ldap2's JNDI provider
    config, so PrivilegedProvider's thread-context swap resolves the factory.
  • Bypassing ldaptive's internal PKCS#12 key copy (both authentication and
    authorization paths). ldaptive's create*CredentialConfig with key aliases
    routes through KeyStoreSSLContextInitializer.getKeyManagers(), which copies the
    private key into a fresh in-memory PKCS#12 store protected by SunJCE's
    PBEWithHmacSHA256AndAES_256 — unavailable in FIPS. Fix: build keystores from
    PEM via PemKeyReader.toTruststore/toKeystore and pass null key aliases, so
    kmf.init(keystore, password) is called directly and the PKCS#12 branch is
    bypassed. (LDAPAuthorizationBackend previously used
    createX509CredentialConfig, which hit that path unconditionally.)
  • Both LDAP backends now reuse SSLConfigConstants.ALLOWED_SSL_PROTOCOLS for their
    default enabled_ssl_protocols.

Reviewer call-outs

  1. Dropped the automatic System.setProperty(disableEndpointIdentification, true)
    from the default ldap backend
    — now warn-only. Two reasons: (a) it aligns
    both backends on the same logic (ldap2 never set it); (b) mutating a global
    JVM property from application code is process-wide and load-order-dependent, so
    one auth domain's verify_hostnames: false silently reconfigured hostname
    checking for the entire JVM. Operators who need it must now set the -D
    deliberately. This is a behaviour change for existing configs.
  2. Why are verify_hostnames and trust_all coupled to the same verifier?
    verifyHostnames = !trustAll && <setting>, so trust_all: true forces
    AllowAnyHostnameVerifier on top of AllowAnyTrustManager. Chain validation
    and hostname matching are orthogonal; collapsing them means you cannot relax one
    without the other, and it hides which layer a config change actually touches.
    Worth untangling (not done here).
  3. ldaptive 2.x's native (Netty) transport opens sockets with the real hostname,
    which would let this entire SNI stack be deleted. Out of scope here.

Testing

The suite runs in non-FIPS mode by default. To exercise the FIPS code paths, set the environment variable before invoking Gradle:

OPENSEARCH_FIPS_MODE=true ./gradlew test integrationTest

When set, the build swaps in the FIPS java.security policy (BCFIPS-only providers), enables -Dorg.bouncycastle.fips.approved_only=true, and points the JVM at the BCFKS truststore. FIPS-incompatible tests (BCrypt, Argon2, SAML, SSLv3, JKS/PKCS12, weak/short passwords) are auto-skipped via JUnit assumptions. Static bcrypt fixtures and their short demo passwords are rewritten to PBKDF2 and padded past the 14-char floor by FipsHashAdapter (a no-op outside FIPS), and a few timing-sensitive integ tests scale down under FIPS, where PBKDF2 logins and BCTLS handshakes are markedly slower.

For a running cluster, select the FIPS-approved password hasher in opensearch.yml (BCrypt/Argon2 are not available in approved-only mode):

plugins.security.password.hashing.algorithm: pbkdf2

The demo hashes in config/opensearch-security/internal_users.yml are BCrypt, which won't verify under PBKDF2 - regenerate the hash for each test account (e.g. with tools/hash.sh) and replace it before applying the security config.

Test LDAP authentication over LDAPS (SNI, hostname verification, mTLS)

Self-contained manual tests for the LDAP TLS changes: SNI / hostname verification, mutual TLS, and the TLS protocol floor. Authentication only - these changes don't touch authz code, so role resolution is out of scope (verify that against a real directory).

Run them against any LDAPS directory that supports mTLS (a client cert is required). The walkthrough uses a local UnboundID in-memory stand-in only because it's repeatable and trivial to set up - a convenience, not a requirement; substitute your own server anywhere it appears. Its setup lives in LDAP_UNBOUNDID_STANDIN_GUIDE.md.

TLS material is the OpenSearch install's own demo certs in $OPENSEARCH_HOME/config/ - esnode (server, SAN includes localhost), root-ca.pem (trust anchor), kirk (client). Run the node with OPENSEARCH_FIPS_MODE=true (omit for non-FIPS; the only observable difference is the TLS protocol floor).

FIPS agent-build gotchas (both are Core / distribution follow-ups): (1) core's base security.policy grants bc-fips/bcpkix-fips but not bctls-fips, so every LDAPS bind is denied under the agent; (2) the JUL->log4j bridge isn't active for BC FIPS JSSE at handshake time, so each bind's INFO traces leak to stderr as [WARN][stderr] spam (noise, not a failure).

cd $OPENSEARCH_HOME
export LDAP_USER="testuser"; export LDAP_PASS="testpassword"   # cn=Test User,ou=people,o=TEST

# Optional trace logging for the "what to look for" lines (config/log4j2.properties):
#   logger.ldap.name=org.opensearch.security.auth.ldap
#   logger.ldap.level=trace
#   logger.ldap2.name=org.opensearch.security.auth.ldap2
#   logger.ldap2.level=trace
#   logger.ldaptive.name=org.ldaptive
#   logger.ldaptive.level=debug

# === Baseline config (authc.ldap.authentication_backend.config; o=TEST, no authz block) ======
# Edit this first, then apply below. Bare *_filepath resolve against config/; kirk + root-ca are the
# shipped demo certs, so as shipped this IS scenario 1a / 2a -> 200. Run every scenario once per
# backend (flip the type: line).
#   type: ldap                  # DEFAULT | LDAP2: org.opensearch.security.auth.ldap2.LDAPAuthenticationBackend2
#   config:
#     enable_ssl: true
#     enable_ssl_client_auth: true          # mTLS - client cert required
#     verify_hostnames: true
#     hosts: [localhost:8636]
#     pemtrustedcas_filepath: root-ca.pem
#     pemcert_filepath: kirk.pem
#     pemkey_filepath: kirk-key.pem
#     bind_dn: "cn=opensearch-bind,ou=people,o=TEST"
#     password: "bindpassword"
#     userbase: "ou=people,o=TEST"
#     usersearch: '(uid={0})'
#     username_attribute: uid

# === Apply / authenticate (verify loop - re-run after each config.yml edit) ==
# apply: push the authc.ldap block (-t config, live; TLS-material/host edits need a node restart).
sh ./plugins/opensearch-security/tools/securityadmin.sh \
  -f ./config/opensearch-security/config.yml \
  -t config \
  -icl \
  -nhnv \
  -cacert config/root-ca.pem \
  -cert config/kirk.pem \
  -key config/kirk-key.pem \
  -h localhost \
  -p 9200

# authenticate: 200 + user_name=testuser (backend_roles empty - no authz) = LDAPS + mTLS bind OK.
curl -sk -u "$LDAP_USER:$LDAP_PASS" https://localhost:9200/_plugins/_security/authinfo?pretty

# What to look for (baseline 200):
#   Configuring SNI for hostname: localhost ...            # SNI server_name set (the fix)
#   checkServerTrusted ... succeeded                       # esnode chains to root-ca
#   verifyDNS found hostname match: localhost              # hostname layer 1 pass
#   Opened a connection, total count is now 1              # DEFAULT | LDAP2: Authenticated username testuser

Test matrix. Run every scenario in all four cells - flip the backend on type:; for FIPS set OPENSEARCH_FIPS_MODE=true (launcher loads fips_java.security -> BCJSSE), for non-FIPS set OPENSEARCH_JAVA_OPTS="-Djava.security.properties=$OPENSEARCH_HOME/config/java.security" (BCFIPS stays declared - the FIPS installer converts the node stores to BCFKS - but TLS runs on SunJSSE). Launch-time provider swap, no code path (see Keystores / TLS). Outcomes are identical; only the protocol floor ([TLSv1.3, TLSv1.2] FIPS vs + TLSv1.1 non-FIPS) and the provider differ, so the Prov*/TlsFatalAlert class names in the excerpts are BCJSSE-only.

DEFAULT ldap LDAP2 (...ldap2.LDAPAuthenticationBackend2)
FIPS yes yes
non-FIPS yes yes

Scenario 1 - hostname verification. Same trusted esnode cert throughout; 1b-1d dial a name not in its SAN (echo "127.0.0.1 ldap-wrong.example.com" | sudo tee -a /etc/hosts, then set hosts: [ldap-wrong.example.com:8636]), so the only thing that can object is one of the two hostname guards: (1) ldaptive's verifier (verify_hostnames), (2) JNDI endpoint-id (-Dcom.sun.jndi.ldap.object.disableEndpointIdentification=true in config/jvm.options; the plugin no longer sets it, only warns). Chain trust is valid throughout, so this isolates hostname checking - the untrusted-cert case is 2c. Apply + restart per row.

verify_hostnames JNDI endpoint-id Result / what it proves
1a true on correct name -> 200 (baseline; SNI fix works)
1b true on rejected, layer 1 - ldaptive DefaultHostnameVerifier
1c false on rejected, layer 2 - JNDI/BC endpoint-id (verify_hostnames: false alone isn't enough)
1d false off accepted (200) - hostname unenforced only when both guards are off
1b  HostnameVerifyingTrustManager ... hostnames=[ldap-wrong.example.com] failed         # layer 1 (ldaptive)
    CertificateException: Hostname '[ldap-wrong.example.com]' does not match 'CN=node-0.example.com...'
    (for ldap2 the reject fires inside SniAwareConnection.open() = no fail-open)
1c  AllowAnyHostnameVerifier ... succeeded                                              # layer 1 off
    CertificateException: No subject alternative name found matching domain name ldap-wrong.example.com  # layer 2
        at org.bouncycastle.jsse.provider.ProvX509TrustManager.checkEndpointID(...)
1d  (no hostname reject) -> Opened a connection / Authenticated username testuser

Cleanup: remove the /etc/hosts line + the jvm.options flag, restore hosts: + verify_hostnames: true. Never set disableEndpointIdentification=true in production (it's process-wide).

Scenario 2 - mTLS client authentication. Vary only the client cert / trust anchor; apply + restart per row, and restore pemtrustedcas_filepath: root-ca.pem after 2c. First generate the two "bad" credentials once - both self-signed, so neither chains to the demo root-ca - into config/:

cd $OPENSEARCH_HOME/config
# 2b: untrusted client cert + key (self-signed; does NOT chain to root-ca)
openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj "/CN=untrusted-client" \
  -keyout untrusted-client.key -out untrusted-client.pem
# 2c: untrusted CA - a trust anchor that did NOT sign esnode
openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj "/CN=untrusted-ca" \
  -keyout untrusted-ca.key -out untrusted-ca.pem
change (in config.yml) Result / what it proves
2a pemcert/pemkey_filepath: kirk.* (baseline) accepted - PEM client key loads + signs the handshake under BC
2b pemcert/pemkey_filepath: untrusted-client.* rejected - BC withholds the client cert -> server aborts mandatory mTLS
2c pemtrustedcas_filepath: untrusted-ca.pem fails - server cert no longer chains to the trust anchor
2b  checkServerTrusted ... succeeded                        # server side fine
    received fatal(2) certificate_required(116) alert       # client cert withheld
    must NOT log: ClassNotFoundException ...SNISettingTLSSocketFactory  (ldap2 reconnect bug, fixed here)
2c  checkServerTrusted ... failed
    CertPathBuilderException: No issuer certificate for certificate in certification path found.
    TlsFatalAlert: certificate_unknown(46)  ->  Authentication finally failed

Check List

  • New functionality includes testing
  • New functionality has been documented
  • New Roles/Permissions have a corresponding security dashboards plugin PR
  • API changes companion pull request created
  • Commits are signed per the DCO using --signoff

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

iigonin and others added 3 commits August 13, 2026 17:03
Introduces gradle/fips.gradle as the single place where FIPS mode is decided
and applied to the build's test surface: which test classes exist in each mode
and the JVM arguments test workers need to actually be in FIPS mode. Mode is
driven by the OPENSEARCH_FIPS_MODE environment variable, surfaced to production
code through the new FipsMode enum.

BC FIPS artifacts move to compileOnly in both modes (they are provided by
OpenSearch core), bctls-fips is added, and the securityadmin standalone bundles
now ship the BC FIPS jars in deps/.

Because java_test.security registers BouncyCastleFipsProvider in every test JVM
-- including non-FIPS runs -- any suite that touches JCA now leaves a
"BC FIPS Entropy Daemon" thread behind, which RandomizedRunner reports as a
leak. BCFipsEntropyDaemonFilter covers it; the framework's BouncyCastleThreadFilter
does not. It is applied to the suites that trip over it here, and reused by
later FIPS work.

No FIPS variant test classes exist yet, so this otherwise lands inert: the
default build is unchanged and fips.gradle currently selects nothing.

Signed-off-by: Iwan Igonin <iigonin@sternad.de>
Co-authored-by: Benny Goerzig <benny.goerzig@sap.com>
Co-authored-by: Karsten Schnitter <k.schnitter@sap.com>
Co-authored-by: Kai Sternad <k.sternad@sternad.de>
Replaces the isPkcs11()-style branching in the SSL configuration layer with
sealed pem/jdk/pkcs11 records for both key stores and trust stores, and moves
PKCS#11 dispatch into those records. Store passwords are wrapped in a
StorePassword type so they are redacted in toString() rather than leaking into
logs.

A PKCS#11 store lives on the token rather than on disk, so the path becomes
optional throughout: KeyStoreUtils loads such stores with a null stream, and
error messages name the token instead of a file. PemKeyReader learns the
PKCS11 store type and validates that a PKCS#11 provider is actually registered.
Trust store settings that a PKCS#11 configuration ignores now produce a warning
instead of being silently dropped.

Signed-off-by: Iwan Igonin <iigonin@sternad.de>
Co-authored-by: Benny Goerzig <benny.goerzig@sap.com>
Co-authored-by: Karsten Schnitter <k.schnitter@sap.com>
Co-authored-by: Kai Sternad <k.sternad@sternad.de>
…ader

JNDI's LDAP provider never passes the target hostname to the SSLSocketFactory
it instantiates (bcgit/bc-java#460), so an ldaps connection could not present
an SNI extension and servers doing name-based virtual hosting returned the
wrong certificate. SNISettingTLSSocketFactory carries the hostname through a
ThreadLocal for the duration of the connect and sets it on the socket's SSL
parameters; SniAwareConnection and HostnameAwareConnectionFactory drive it for
the pooled and unpooled paths.

The Java9CL classloader that worked around the provider's inability to see
ldaptive's socket factory was private to LDAPAuthorizationBackend, so a
reconnect from the ldap2 backend raised ClassNotFoundException. It is extracted
as SocketFactoryClassLoader and shared by both backends.

LDAPAuthorizationBackend also builds its PEM credentials through a keystore
rather than createX509CredentialConfig, and stops setting the global
com.sun.jndi.ldap.object.disableEndpointIdentification system property, which
disabled hostname verification process-wide as a side effect of one connection.

Signed-off-by: Iwan Igonin <iigonin@sternad.de>
Co-authored-by: Benny Goerzig <benny.goerzig@sap.com>
Co-authored-by: Karsten Schnitter <k.schnitter@sap.com>
Co-authored-by: Kai Sternad <k.sternad@sternad.de>
@github-actions

Copy link
Copy Markdown
Contributor

PR Code Analyzer ❗

AI-powered 'Code-Diff-Analyzer' found issues on commit 26ce531.

Hard block: Issues at High severity or above will block this PR from merging.

PathLineSeverityDescription
build.gradle601highNew dependency 'org.bouncycastle:bctls-fips' added in multiple scopes (integrationTestImplementation, compileOnly, testImplementation, detachedConfiguration). Per mandatory rule, all dependency additions must be flagged regardless of apparent legitimacy — maintainers must verify the artifact resolves to the expected BouncyCastle TLS FIPS JAR from the configured registry.
src/test/resources/fips-jvm-truststore.bcfks1highBinary BCFKS trust store file added to test resources and referenced as the JVM-wide trust store via '-Djavax.net.ssl.trustStore' in gradle/fips.gradle. The contents cannot be audited in the diff. Any certificate in this store would be trusted by all FIPS-mode test workers for TLS verification, including LDAPS connections.
src/main/java/org/opensearch/security/support/FipsMode.java21medium'envSupplier' is declared public static and non-final, meaning any code with access to this class can override FIPS mode detection at runtime — effectively disabling FIPS enforcement without touching JVM security properties. This is production code (src/main/java), not test code, making the exposure broader than the test-injection use case implies.
gradle/fips.gradle79lowHardcoded trust store password 'changeit' is passed as a JVM system property ('-Djavax.net.ssl.trustStorePassword=changeit') to every FIPS test worker. While this is test infrastructure, the password appears in process listings and is set globally on the JVM, making it readable by any code running in the same test worker.

The table above displays the top 10 most important findings.

Total: 4 | Critical: 0 | High: 2 | Medium: 1 | Low: 1


Pull Requests Author(s): Please update your Pull Request according to the report above.

Repository Maintainer(s): You can bypass diff analyzer by adding label skip-diff-analyzer after reviewing the changes carefully, then re-run failed actions. To re-enable the analyzer, remove the label, then re-run all actions.


⚠️ Note: The Code-Diff-Analyzer helps protect against potentially harmful code patterns. Please ensure you have thoroughly reviewed the changes beforehand.

Thanks.

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.

2 participants