mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Valery Kharseko
yesterday 1414f8993a616a8dc2d5430e3a646e3075b87b57
[#905] Warn when a replication handshake fails and document CA-signed certificates (#906)
9 files modified
1 files added
512 ■■■■■ changed files
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-change-certs.adoc 158 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-troubleshooting.adoc 13 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/backends/TrustStoreBackend.java 40 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/crypto/CryptoManagerImpl.java 85 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/replication/protocol/ReplSessionSecurity.java 91 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/util/SelectableCertificateKeyManager.java 9 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/messages/org/opends/messages/core.properties 7 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/messages/org/opends/messages/replication.properties 13 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/crypto/CryptoManagerTestCase.java 45 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/replication/protocol/ReplSessionSecurityTest.java 51 ●●●●● patch | view | raw | blame | history
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-change-certs.adoc
@@ -12,7 +12,7 @@
  information: "Portions copyright [year] [name of copyright owner]".
 
  Copyright 2017 ForgeRock AS.
  Portions Copyright 2024 3A Systems LLC.
  Portions Copyright 2024-2026 3A Systems LLC.
////
:figure-caption!:
@@ -29,6 +29,8 @@
* Replace a key pair used for replication
* Use a CA-signed certificate for replication
OpenDJ uses keystores (for private keys) and truststores (for public, signed certificates). Up to three sets of keystores are used, as shown in the following illustration.
[#figure-keystores]
@@ -54,7 +56,7 @@
This Java Keystore holds public key certificates of all servers replicating with the current server. It also includes the `ads-certificate` key pair of the current server. The password is stored in `ads-truststore.pin`.
+
Do not change this keystore directly.
Do not change this keystore directly, except to install a CA-signed certificate for replication as described in xref:#replace-ads-cert-ca["To Use a CA-Signed Certificate for Replication"].
`keystore`::
This Java Keystore holds the private key and server certificate, `server-cert`, used to protect TLS/SSL communications with client applications. The password, stored in `keystore.pin`, is also the key password for `server-cert`.
@@ -422,3 +424,155 @@
====
[#replace-ads-cert-ca]
.To Use a CA-Signed Certificate for Replication
====
Replication connections use neither the `keystore` nor the `admin-keystore`. Both the key pair presented on the replication port and the certificates trusted on that port are read from the `ads-truststore`, and the alias to present is the `ssl-cert-nickname` property of the crypto manager, `ads-certificate` by default. To secure replication with certificates signed by your own Certificate Authority (CA), install the CA-signed key pair in the `ads-truststore` of every server.
Servers authenticate each other on the replication port, so import the CA certificate everywhere first, in a separate pass. A server whose certificate changes before the other servers trust your CA can no longer connect to the topology.
Each `keytool` command applies to a single server, and is run while that server is stopped. A running server writes the whole `ads-truststore` back to disk when it adds the certificate of another server to it, which would discard the changes made meanwhile by `keytool`.
. Take each server in turn and add your CA certificate and your CA-signed key pair to its `ads-truststore`. Replication continues to work during this pass, as the certificates presented by the servers do not change yet.
.. Stop the server:
+
[source, console]
----
$ /path/to/opendj/bin/stop-ds
----
.. Import the CA certificate, and any intermediate certificates, as trusted certificates:
+
[source, console]
----
$ cd /path/to/opendj/config
$ keytool \
 -importcert \
 -noprompt \
 -trustcacerts \
 -alias ca-cert \
 -file ca-cert.pem \
 -keystore ads-truststore \
 -storepass `cat ads-truststore.pin`
Certificate was added to keystore
----
+
Without `-noprompt`, `keytool` asks whether to trust the certificate. When the answer cannot be read, as in a script, it leaves the keystore untouched, prints `Certificate was not added to keystore`, and still exits with status 0.
.. Import the CA-signed key pair, with its full certificate chain, under a new alias.
+
Leave the `ads-certificate` key pair in place. It is also the instance key that the crypto manager uses to unwrap the symmetric keys shared with the topology, and its fingerprint is published under `cn=instance keys,cn=admin data`.
+
The key password must be identical to the keystore password, as the server unlocks the private key with the PIN of the `ads-truststore`:
+
[source, console]
----
$ keytool \
 -importkeystore \
 -noprompt \
 -srckeystore server-cert.p12 \
 -srcstoretype PKCS12 \
 -srcalias server-cert \
 -srcstorepass password \
 -destkeystore ads-truststore \
 -destalias repl-cert \
 -deststorepass `cat ads-truststore.pin` \
 -destkeypass `cat ads-truststore.pin`
----
.. Check that the alias holds a private key entry and that the whole chain was imported.
+
Use `-v`: without it, `keytool` prints the same two lines whether the alias holds the full chain or the server certificate alone. A chain which stops at the server certificate is only rejected by the peers, once this server presents it:
+
[source, console]
----
$ keytool \
 -list \
 -v \
 -alias repl-cert \
 -keystore ads-truststore \
 -storepass `cat ads-truststore.pin`
Alias name: repl-cert
Creation date: Sep 1, 2026
Entry type: PrivateKeyEntry
Certificate chain length: 2
Certificate[1]:
Owner: CN=opendj.example.com
Issuer: CN=Example CA
...
Certificate[2]:
Owner: CN=Example CA
Issuer: CN=Example CA
...
----
+
The chain must end with the CA certificate imported in the previous step, `Certificate chain length` counting the server certificate and every certificate up to your root CA.
.. Start the server:
+
[source, console]
----
$ /path/to/opendj/bin/start-ds
----
. When every server holds the CA certificate and its own CA-signed key pair, take each server in turn and switch it to the new alias.
.. Configure the crypto manager to present the new certificate.
+
`ssl-cert-nickname` is multi-valued, and `--set` replaces every value it holds. The `ads-certificate` key pair stays in the `ads-truststore`, but its alias no longer has to be listed here, as the crypto manager reads its instance key from the `ads-certificate` alias directly. Where several nicknames are configured, to run certificates with different public key algorithms in parallel, list them all in a single `--set`:
+
[source, console]
----
$ dsconfig \
 set-crypto-manager-prop \
 --port 4444 \
 --hostname opendj.example.com \
 --bindDN "cn=Directory Manager" \
 --bindPassword password \
 --set ssl-cert-nickname:repl-cert \
 --no-prompt
----
.. Restart the server for the change to take effect.
+
The crypto manager reads `ssl-cert-nickname` when it starts, and replication keeps the value it read, so the property only takes effect on restart:
+
[source, console]
----
$ /path/to/opendj/bin/stop-ds --restart
----
.. Check that replication still works, and that `logs/errors` shows no SSL handshake failure on the replication port:
+
[source, console]
----
$ dsreplication \
 status \
 --port 4444 \
 --hostname opendj.example.com \
 --adminUID admin \
 --adminPassword password \
 --no-prompt
----
+
Move on to the next server only once this one has rejoined the topology. Should it fail to, set `ssl-cert-nickname` back to the alias it used to present, `ads-certificate` by default, and restart the server again. The administration connector does not use the `ads-truststore`, so it stays reachable for `dsconfig` while the replication port does not work.
+
[NOTE]
======
The server does not check the host name presented in the replication certificate, so a certificate whose subject does not match the host name of the server is accepted. Expiration dates are enforced, however. Unlike the self-signed `ads-certificate`, which is generated with a 20 year validity, CA-signed certificates usually have to be renewed: repeat this procedure before they expire.
Trusting your CA on the replication port means trusting every certificate it issues, as the certificate is the only credential a peer presents and its subject is not checked. Use a CA dedicated to the replication topology, or one whose issuance you constrain, rather than a corporate CA which also signs certificates for unrelated hosts.
======
====
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-troubleshooting.adoc
@@ -773,11 +773,16 @@
[source]
----
[27/Jun/2011:14:37:48 +0200] category=SYNC severity=INFORMATION msgID=14680169
[27/Jun/2011:14:37:48 +0200] category=SYNC severity=WARNING msgID=105
 msg=Replication server accepted a connection from 10.10.0.10/10.10.0.10:52859
 to local address 0.0.0.0/0.0.0.0:8989 but the SSL handshake failed. This is
 probably benign, but may indicate a transient network outage or a
 misconfigured client application connecting to this replication server.
 to local address 0.0.0.0/0.0.0.0:8989 but the SSL handshake failed. This may
 be benign, for example a network probe or a client application connecting to
 the replication port, but it also occurs when the replication certificates
 are misconfigured: check that the certificate nickname configured in the
 crypto manager exists in the ads-truststore, and that every server of the
 topology trusts the certificates of the other servers. At most one such
 failure is logged as a warning every 5 minutes; the others go to the debug
 log, which is disabled by default (0 since the previous warning).
 The error was: Remote host closed connection during handshake
----
OpenDJ maintains historical information about changes in order to bring replicas up to date, and to resolve replication conflicts. To prevent historical information from growing without limit, OpenDJ purges historical information after a configurable delay (`replication-purge-delay`, default: 3 days). A replica can become irrevocably out of sync if you restore it from a backup archive older than the purge delay, or if you stop it for longer than the purge delay. If this happens to you, disable the replica, and then reinitialize it from a recent backup or from a server that is up to date.
opendj-server-legacy/src/main/java/org/opends/server/backends/TrustStoreBackend.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2007-2010 Sun Microsystems, Inc.
 * Portions Copyright 2011-2016 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC.
 */
package org.opends.server.backends;
@@ -822,6 +823,45 @@
    }
  }
  /**
   * Retrieves the path to the file which holds this trust store.
   *
   * @return  The path to the file which holds this trust store.
   */
  public String getTrustStoreFile()
  {
    return trustStoreFile;
  }
  /**
   * Indicates whether this trust store holds a key entry, that is a private key
   * and its certificate, under the provided alias. The alias is matched the way
   * the key store itself matches it, without regard to case for the JKS and
   * PKCS12 store types.
   *
   * @param  alias  The alias to look for.
   *
   * @return  {@code true} if this trust store holds a key entry under the
   *          provided alias, {@code false} otherwise.
   *
   * @throws  DirectoryException  If the trust store cannot be read.
   */
  public boolean containsKeyWithAlias(String alias) throws DirectoryException
  {
    final KeyStore keyStore = loadKeyStore();
    try
    {
      return keyStore.isKeyEntry(alias);
    }
    catch (KeyStoreException e)
    {
      logger.traceException(e);
      LocalizableMessage message = ERR_TRUSTSTORE_CANNOT_LOAD.get(trustStoreFile, getExceptionMessage(e));
      throw new DirectoryException(DirectoryServer.getCoreConfigManager().getServerErrorResultCode(), message, e);
    }
  }
  private KeyStore loadKeyStore() throws DirectoryException
  {
    try (FileInputStream inputStream = new FileInputStream(getFileForPath(trustStoreFile)))
opendj-server-legacy/src/main/java/org/opends/server/crypto/CryptoManagerImpl.java
@@ -43,6 +43,8 @@
import java.util.SortedSet;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@@ -188,6 +190,17 @@
   */
  private static final int CIPHERTEXT_PROLOGUE_VERSION = 1 ;
  /**
   * Minimum interval between two errors about the same certificate nickname missing from
   * the trust store. A new SSL context is built for every connection attempt and a server
   * which cannot present its certificate reconnects every 500 ms, so the error cannot be
   * logged on every attempt; it cannot be logged once and never again either, as the error
   * log is rotated while the misconfiguration outlives it.
   * <p>
   * Package private for testing.
   */
  static final long CERT_NICKNAME_CHECK_INTERVAL_NANOS = TimeUnit.MINUTES.toNanos(5);
  private final CipherKeyManager cipherCryptoManager = new CipherKeyManager();
  private final MacKeyManager macCryptoManager = new MacKeyManager();
@@ -217,6 +230,11 @@
  /** The names of the local certificates to use for SSL. */
  private final SortedSet<String> sslCertNicknames;
  /**
   * Value of {@link System#nanoTime()} at which each certificate nickname was last looked
   * up in the trust store, keyed by "component:nickname".
   */
  private final ConcurrentMap<String, Long> certNicknameChecks = new ConcurrentHashMap<>();
  /** Whether replication sessions use SSL encryption. */
  private final boolean sslEncryption;
  /** The set of SSL protocols enabled or null for the default set. */
@@ -2679,12 +2697,15 @@
      TrustManager[] trustManagers = trustStoreBackend.getTrustManagers();
      SSLContext sslContext = SSLContext.getInstance("TLS");
      if (sslCertNicknames == null)
      if (sslCertNicknames == null || sslCertNicknames.isEmpty())
      {
        // No nickname is configured: let the key manager choose, as wrapping it with an
        // empty set of aliases would present no certificate at all.
        sslContext.init(keyManagers, trustManagers, null);
      }
      else
      {
        logMissingCertNicknames(componentName, sslCertNicknames, trustStoreBackend);
        KeyManager[] extendedKeyManagers =
            SelectableCertificateKeyManager.wrap(keyManagers, sslCertNicknames, componentName);
        sslContext.init(extendedKeyManagers, trustManagers, null);
@@ -2702,6 +2723,68 @@
    }
  }
  /**
   * Logs an error for each configured certificate nickname which the trust store
   * does not hold, so that a misconfigured nickname is reported for what it is
   * instead of only showing up as a failed handshake. A new SSL context is built
   * for every connection attempt, so each nickname is looked up at most once per
   * {@link #CERT_NICKNAME_CHECK_INTERVAL_NANOS} interval and per component: a
   * reconnection loop neither floods the error log nor reads the trust store an
   * extra time on every attempt, while a nickname which stays missing is reported
   * again for as long as it is missing.
   *
   * @param componentName
   *          The name of the component the SSL context is built for.
   * @param sslCertNicknames
   *          The configured certificate nicknames.
   * @param trustStoreBackend
   *          The trust store backend holding the key pairs.
   * @throws DirectoryException
   *           If the trust store cannot be read.
   */
  private void logMissingCertNicknames(String componentName, SortedSet<String> sslCertNicknames,
      TrustStoreBackend trustStoreBackend) throws DirectoryException
  {
    final long nowNanos = System.nanoTime();
    for (String nickname : sslCertNicknames)
    {
      if (isCertNicknameCheckDue(componentName + ":" + nickname, nowNanos)
          && !trustStoreBackend.containsKeyWithAlias(nickname))
      {
        logger.error(ERR_CRYPTOMGR_SSL_CERT_NICKNAME_NOT_FOUND,
            nickname, trustStoreBackend.getTrustStoreFile(), componentName);
      }
    }
  }
  /**
   * Indicates whether the provided certificate nickname is to be looked up in the trust
   * store now, and records the look up if it is. Only one connection attempt at a time is
   * given the look up and the next one comes a whole interval later, so that the trust
   * store is read, and the error logged, once per interval however often a peer which
   * cannot present its certificate reconnects. A look up which then fails to read the
   * trust store only delays the next one by an interval, and cannot go unnoticed: the SSL
   * context is not built at all when the trust store cannot be read.
   * <p>
   * Package private for testing.
   *
   * @param checked
   *          The "component:nickname" pair to look up.
   * @param nowNanos
   *          The value of {@link System#nanoTime()} at which the look up would happen.
   * @return {@code true} if the nickname is to be looked up now, {@code false} otherwise.
   */
  boolean isCertNicknameCheckDue(String checked, long nowNanos)
  {
    final Long lastCheck = certNicknameChecks.get(checked);
    if (lastCheck == null)
    {
      return certNicknameChecks.putIfAbsent(checked, nowNanos) == null;
    }
    return nowNanos - lastCheck >= CERT_NICKNAME_CHECK_INTERVAL_NANOS
        && certNicknameChecks.replace(checked, lastCheck, nowNanos);
  }
  @Override
  public SortedSet<String> getSslCertNicknames()
  {
opendj-server-legacy/src/main/java/org/opends/server/replication/protocol/ReplSessionSecurity.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2008 Sun Microsystems, Inc.
 * Portions Copyright 2011-2016 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC.
 */
package org.opends.server.replication.protocol;
@@ -22,6 +23,8 @@
import java.io.IOException;
import java.net.Socket;
import java.util.SortedSet;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLException;
@@ -49,6 +52,29 @@
  private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();
  /**
   * Minimum interval, in minutes, between two warnings about a failed SSL handshake
   * on the replication port. Every connection which is not a replication peer fails
   * the handshake, network probes included, so only the first failure of an interval
   * is logged as a warning and the following ones are logged at debug level.
   */
  private static final long HANDSHAKE_FAILURE_WARN_INTERVAL_MINUTES = 5;
  /** Package private for testing. */
  static final long HANDSHAKE_FAILURE_WARN_INTERVAL_NANOS =
      TimeUnit.MINUTES.toNanos(HANDSHAKE_FAILURE_WARN_INTERVAL_MINUTES);
  /**
   * Value of {@link System#nanoTime()} at which the last handshake failure was
   * logged as a warning. It starts one interval in the past so that the first
   * failure is warned about.
   */
  private final AtomicLong lastHandshakeFailureWarnNanos =
      new AtomicLong(System.nanoTime() - HANDSHAKE_FAILURE_WARN_INTERVAL_NANOS);
  /** Number of handshake failures logged at debug level since the last warning. */
  private final AtomicLong suppressedHandshakeFailures = new AtomicLong();
  /**
   * Whether replication sessions use SSL encryption.
   */
  private final boolean sslEncryption;
@@ -253,10 +279,10 @@
    }
    catch (final SSLException e)
    {
      // This is probably a connection attempt from an unexpected client
      // log that to warn the administrator.
      logger.debug(INFO_SSL_SERVER_CON_ATTEMPT_ERROR, socket.getRemoteSocketAddress(),
          socket.getLocalSocketAddress(), e.getLocalizedMessage());
      // This may be a connection attempt from an unexpected client, but it is
      // also how a certificate misconfiguration shows up, so warn the
      // administrator instead of failing silently.
      logHandshakeFailure(socket, e);
      return null;
    }
    finally
@@ -272,6 +298,63 @@
  /**
   * Logs a failed SSL handshake on the replication port, as a warning for the
   * first failure of each {@link #HANDSHAKE_FAILURE_WARN_INTERVAL_MINUTES}
   * interval and at debug level for the following ones. The warning reports how
   * many failures were logged at debug level before it, so that a single line
   * cannot be mistaken for a single failed connection. That count looks backwards
   * only: the failures which follow the last warning of a burst are counted but
   * never reported, as nothing flushes the count when the failures stop.
   *
   * @param socket
   *          The socket the handshake failed on.
   * @param e
   *          The handshake failure.
   */
  private void logHandshakeFailure(final Socket socket, final SSLException e)
  {
    final long recorded = recordHandshakeFailure(System.nanoTime());
    if (recorded >= 0)
    {
      logger.warn(WARN_SSL_SERVER_CON_ATTEMPT_ERROR, socket.getRemoteSocketAddress(),
          socket.getLocalSocketAddress(), HANDSHAKE_FAILURE_WARN_INTERVAL_MINUTES,
          recorded, e.getLocalizedMessage());
    }
    else
    {
      logger.debug(WARN_SSL_SERVER_CON_ATTEMPT_ERROR, socket.getRemoteSocketAddress(),
          socket.getLocalSocketAddress(), HANDSHAKE_FAILURE_WARN_INTERVAL_MINUTES,
          -recorded - 1, e.getLocalizedMessage());
    }
  }
  /**
   * Records a handshake failure which happened at the provided time and tells how it
   * must be logged, together with the number of failures logged at debug level since
   * the previous warning.
   * <p>
   * Package private for testing.
   *
   * @param nowNanos
   *          The value of {@link System#nanoTime()} at which the handshake failed.
   * @return A number greater than or equal to zero if this failure is to be logged as a
   *         warning, which is then the number of failures logged at debug level since
   *         the previous warning, or {@code -count - 1} if this failure is itself to be
   *         logged at debug level, where {@code count} is the number of failures logged
   *         at debug level since the previous warning, this one included.
   */
  long recordHandshakeFailure(final long nowNanos)
  {
    final long lastWarn = lastHandshakeFailureWarnNanos.get();
    if (nowNanos - lastWarn >= HANDSHAKE_FAILURE_WARN_INTERVAL_NANOS
        && lastHandshakeFailureWarnNanos.compareAndSet(lastWarn, nowNanos))
    {
      return suppressedHandshakeFailures.getAndSet(0);
    }
    return -suppressedHandshakeFailures.incrementAndGet() - 1;
  }
  /**
   * Determine whether sessions to a given replication server should be
   * encrypted.
   *
opendj-server-legacy/src/main/java/org/opends/server/util/SelectableCertificateKeyManager.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2008-2010 Sun Microsystems, Inc.
 * Portions Copyright 2015 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC.
 */
package org.opends.server.util;
@@ -105,6 +106,11 @@
        return clientAlias;
      }
    }
    // Every key type requested by the peer has been tried, so no client certificate is
    // sent at all. The peer may well accept the connection, as client authentication is
    // optional for most of them, so keep this at debug level. The components which build
    // their SSL context from a configured nickname, the crypto manager and the connection
    // handlers, report a nickname missing from their key store when they build it.
    logger.debug(INFO_MISSING_KEY_TYPE_IN_ALIASES, componentName, aliases.toString(), Arrays.toString(keyType));
    return null;
  }
@@ -181,6 +187,9 @@
        return serverAlias;
      }
    }
    // The peer is asked for one key type at a time, so returning no alias here is part
    // of a normal negotiation, for instance an EC key type against an RSA only key
    // store. Keep this at debug level to avoid warning about healthy handshakes.
    logger.debug(INFO_MISSING_KEY_TYPE_IN_ALIASES, componentName, aliases.toString(), Arrays.toString(keyType));
    return null;
  }
opendj-server-legacy/src/messages/org/opends/messages/core.properties
@@ -1338,3 +1338,10 @@
ERR_CANNOT_HASH_DATA_754=Cannot properly use SHA-1 using the java provider. Verify java.security is properly configured
ERR_MISSING_ADMIN_BACKENDS_755=Cannot complete initialization of server's backends because the root and \
 administrative backends have not been initialized yet.
ERR_CRYPTOMGR_SSL_CERT_NICKNAME_NOT_FOUND_759=The certificate nickname "%s" \
 configured in the ssl-cert-nickname property of the crypto manager was not found \
 in the trust store %s used for server to server communication, so the %s component \
 cannot present that certificate to its peers. Where it is the only nickname \
 configured, no certificate is presented at all and peers requiring client \
 authentication, replication servers included, reject the connection. Import the \
 key pair under that nickname into that trust store, or configure a nickname it holds
opendj-server-legacy/src/messages/org/opends/messages/replication.properties
@@ -440,10 +440,15 @@
WARN_EXCEPTION_STARTING_SESSION_PHASE_119=Directory server DS(%d) \
 encountered an unexpected error while connecting to replication server \
 %s for domain "%s": %s
INFO_SSL_SERVER_CON_ATTEMPT_ERROR_105=Replication server accepted a connection \
 from %s to local address %s but the SSL handshake failed. This is probably \
 benign, but may indicate a transient network outage or a misconfigured client \
 application connecting to this replication server. The error was: %s
WARN_SSL_SERVER_CON_ATTEMPT_ERROR_105=Replication server accepted a connection \
 from %s to local address %s but the SSL handshake failed. This may be benign, \
 for example a network probe or a client application connecting to the \
 replication port, but it also occurs when the replication certificates are \
 misconfigured: check that the certificate nickname configured in the crypto \
 manager exists in the ads-truststore, and that every server of the topology \
 trusts the certificates of the other servers. At most one such failure is \
 logged as a warning every %d minutes; the others go to the debug log, which is \
 disabled by default (%d since the previous warning). The error was: %s
WARN_MISSING_REMOTE_MONITOR_DATA_106=Timed out waiting for monitor data \
 for the domain "%s" from replication server RS(%d)
NOTE_LOAD_BALANCE_REPLICATION_SERVER_213=Directory Server DS(%d) is disconnecting \
opendj-server-legacy/src/test/java/org/opends/server/crypto/CryptoManagerTestCase.java
@@ -13,6 +13,7 @@
 *
 * Copyright 2008 Sun Microsystems, Inc.
 * Portions Copyright 2013-2016 ForgeRock AS.
 * Portions Copyright 2026 3A Systems, LLC.
 */
package org.opends.server.crypto;
@@ -22,6 +23,7 @@
import static org.forgerock.opendj.ldap.SearchScope.*;
import static org.opends.server.TestCaseUtils.*;
import static org.opends.server.config.ConfigConstants.*;
import static org.opends.server.crypto.CryptoManagerImpl.CERT_NICKNAME_CHECK_INTERVAL_NANOS;
import static org.opends.server.protocols.internal.InternalClientConnection.*;
import static org.opends.server.protocols.internal.Requests.*;
import static org.opends.server.types.Attributes.*;
@@ -52,6 +54,7 @@
import org.opends.admin.ads.ADSContext;
import org.opends.admin.ads.util.BlindTrustManager;
import org.opends.server.TestCaseUtils;
import org.opends.server.backends.TrustStoreBackend;
import org.opends.server.core.DirectoryServer;
import org.opends.server.protocols.internal.InternalClientConnection;
import org.opends.server.protocols.internal.InternalSearchOperation;
@@ -86,6 +89,48 @@
    TestCaseUtils.restartServer();
  }
  /**
   The nickname check must answer for the trust store the SSL context is built from: the
   instance key pair the server generated for itself is held under its alias, a nickname
   which was never imported is not.
   */
  @Test
  public void testTrustStoreKnowsWhichCertNicknamesItHolds() throws Exception
  {
    // Generates the ads-certificate key pair if the trust store does not hold it yet.
    assertNotNull(CryptoManagerImpl.getInstanceKeyCertificateFromLocalTruststore());
    final TrustStoreBackend trustStore = (TrustStoreBackend) getServerContext()
        .getBackendConfigManager().getLocalBackendById(ID_ADS_TRUST_STORE_BACKEND);
    assertThat(trustStore.containsKeyWithAlias(ADS_CERTIFICATE_ALIAS)).isTrue();
    assertThat(trustStore.containsKeyWithAlias("no-such-nickname")).isFalse();
  }
  /**
   A server which cannot present its certificate reconnects every 500 ms and a new SSL
   context is built for every attempt, so a certificate nickname missing from the trust
   store is looked up, and reported, at most once per interval and per component -- but
   again on the next interval, for as long as it is missing.
   */
  @Test
  public void testMissingCertNicknameIsReportedOncePerInterval()
  {
    final CryptoManagerImpl cm = DirectoryServer.getCryptoManager();
    final String checked = "Replication Server:" + UUID.randomUUID();
    final long start = System.nanoTime();
    assertThat(cm.isCertNicknameCheckDue(checked, start))
        .as("the nickname has never been looked up").isTrue();
    assertThat(cm.isCertNicknameCheckDue(checked, start + 1))
        .as("the next connection attempt does not look it up again").isFalse();
    assertThat(cm.isCertNicknameCheckDue(checked, start + CERT_NICKNAME_CHECK_INTERVAL_NANOS - 1))
        .as("nor does the last attempt of the interval").isFalse();
    assertThat(cm.isCertNicknameCheckDue(checked, start + CERT_NICKNAME_CHECK_INTERVAL_NANOS))
        .as("a whole interval later it is looked up again").isTrue();
    assertThat(cm.isCertNicknameCheckDue(checked, start + CERT_NICKNAME_CHECK_INTERVAL_NANOS + 1))
        .as("and the next interval starts from that look up").isFalse();
  }
  @Test
  public void testImportKeysUsesLatestKey()
      throws Exception {
opendj-server-legacy/src/test/java/org/opends/server/replication/protocol/ReplSessionSecurityTest.java
New file
@@ -0,0 +1,51 @@
/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions copyright [year] [name of copyright owner]".
 *
 * Copyright 2026 3A Systems, LLC.
 */
package org.opends.server.replication.protocol;
import static org.assertj.core.api.Assertions.assertThat;
import static org.opends.server.replication.protocol.ReplSessionSecurity.HANDSHAKE_FAILURE_WARN_INTERVAL_NANOS;
import org.opends.server.DirectoryServerTestCase;
import org.testng.annotations.Test;
/** Tests for {@link ReplSessionSecurity}. */
@SuppressWarnings("javadoc")
public class ReplSessionSecurityTest extends DirectoryServerTestCase
{
  /**
   * Any connection which is not a replication peer fails the handshake on the replication
   * port, and a data server which cannot present its certificate reconnects every 500 ms,
   * so only the first failure of an interval may be logged as a warning.
   */
  @Test
  public void handshakeFailuresAreWarnedAboutOncePerInterval() throws Exception
  {
    final ReplSessionSecurity security = new ReplSessionSecurity(null, null, null, true);
    final long start = System.nanoTime();
    assertThat(security.recordHandshakeFailure(start))
        .as("the first failure is warned about, and stands for itself alone").isEqualTo(0);
    assertThat(security.recordHandshakeFailure(start + 1))
        .as("the next failure is the first one logged at debug level").isEqualTo(-2);
    assertThat(security.recordHandshakeFailure(start + HANDSHAKE_FAILURE_WARN_INTERVAL_NANOS - 1))
        .as("the following one is the second").isEqualTo(-3);
    assertThat(security.recordHandshakeFailure(start + HANDSHAKE_FAILURE_WARN_INTERVAL_NANOS))
        .as("the next warning counts the failures logged at debug level before it").isEqualTo(2);
    assertThat(security.recordHandshakeFailure(start + 2 * HANDSHAKE_FAILURE_WARN_INTERVAL_NANOS))
        .as("the count starts again from the previous warning").isEqualTo(0);
  }
}