From 64a563cc8931c3eec5d4980650f9b0dfd6a4f24e Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Mon, 28 Sep 2026 12:48:49 +0000
Subject: [PATCH] [#1095] Load a file based key store or trust store again when the file changes (#1101)

---
 opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedKeyManagerProvider.java           |  293 +++++++++-
 opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedTrustManagerProvider.java         |  284 ++++++++++
 opendj-server-legacy/src/test/java/org/opends/server/extensions/FileBasedTrustManagerProviderTestCase.java |  419 ++++++++++++++++
 opendj-server-legacy/src/test/java/org/opends/server/extensions/FileBasedKeyManagerProviderTestCase.java   |  370 ++++++++++++++
 opendj-server-legacy/src/test/java/org/opends/server/extensions/FileStampTestCase.java                     |   69 ++
 opendj-server-legacy/src/main/java/org/opends/server/extensions/FileStamp.java                             |  100 +++
 opendj-server-legacy/src/messages/org/opends/messages/extension.properties                                 |   13 
 7 files changed, 1,503 insertions(+), 45 deletions(-)

diff --git a/opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedKeyManagerProvider.java b/opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedKeyManagerProvider.java
index 0f62462..5149cab 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedKeyManagerProvider.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedKeyManagerProvider.java
@@ -13,6 +13,7 @@
  *
  * Copyright 2006-2008 Sun Microsystems, Inc.
  * Portions Copyright 2011-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.opends.server.extensions;
 
@@ -24,13 +25,23 @@
 import java.io.FileInputStream;
 import java.io.FileReader;
 import java.io.IOException;
+import java.net.Socket;
 import java.security.KeyStore;
 import java.security.KeyStoreException;
+import java.security.Principal;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.Enumeration;
 import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
 
 import javax.net.ssl.KeyManager;
 import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.X509ExtendedKeyManager;
 
 import com.forgerock.opendj.util.FipsStaticUtils;
 import org.forgerock.i18n.LocalizableMessage;
@@ -61,12 +72,176 @@
   /** The configuration for this key manager provider. */
   private FileBasedKeyManagerProviderCfg currentConfig;
 
-  /** The PIN needed to access the keystore. */
-  private char[] keyStorePIN;
   /** The path to the key store backing file. */
   private String keyStoreFile;
   /** The key store type to use. */
   private String keyStoreType;
+  /**
+   * The number of configuration changes applied. It is counted after the fields above are set,
+   * and read before them.
+   */
+  private volatile int configurationChanges;
+
+  /**
+   * A key manager loaded from the key store file, with the configuration change and the stamps
+   * of the files it was loaded under, and the aliases the file held private keys under.
+   */
+  private static final class LoadedKeyManager
+  {
+    private final int configurationChanges;
+    private final List<FileStamp> stamps;
+    private final Set<String> keyAliases;
+    private final X509ExtendedKeyManager keyManager;
+
+    private LoadedKeyManager(int configurationChanges, List<FileStamp> stamps, Set<String> keyAliases,
+        X509ExtendedKeyManager keyManager)
+    {
+      this.configurationChanges = configurationChanges;
+      this.stamps = stamps;
+      this.keyAliases = keyAliases;
+      this.keyManager = keyManager;
+    }
+
+    private boolean isLoadedFrom(int configurationChanges, List<FileStamp> stamps)
+    {
+      return this.configurationChanges == configurationChanges && this.stamps.equals(stamps);
+    }
+  }
+
+  /**
+   * The key manager handed out by {@link #getKeyManagers()}. The key store file is looked at
+   * again when a handshake chooses its alias, and only then: the certificate chain and the
+   * private key of the alias chosen come from the key manager that alias was chosen from,
+   * unless the file is loaded again, by another handshake, in between.
+   * <p>
+   * Each key manager handed out loads the file on its own, so asking the provider again, as a
+   * connection handler does to check a configuration change, leaves those in use alone.
+   */
+  private final class ReloadingKeyManager extends X509ExtendedKeyManager
+  {
+    /** The key manager last loaded, when this one was handed out or by a handshake since. */
+    private volatile LoadedKeyManager loaded;
+
+    private ReloadingKeyManager(LoadedKeyManager loaded)
+    {
+      this.loaded = loaded;
+    }
+
+    /**
+     * Returns the key manager to use for a new handshake, first loading the key store file again
+     * when it has changed since it was last loaded, or the configuration has. A file that cannot
+     * be loaded - caught half written, or not matching its PIN - leaves the key manager last
+     * loaded in use, and is not tried again until it changes again. So does a file with no private
+     * key, or one that shares no alias with the file last loaded. A connection handler presents
+     * the key named by its ssl-cert-nickname, which the provider does not see, and the aliases of
+     * the file last loaded stand in for it: a file that keeps one of them, but not the one a
+     * handler names, is still taken.
+     */
+    private X509ExtendedKeyManager currentKeyManager()
+    {
+      LoadedKeyManager current = loaded;
+      if (current.isLoadedFrom(configurationChanges, stampFiles()))
+      {
+        return current.keyManager;
+      }
+      // the provider's lock, which applyConfigurationChange takes too: a load reads the
+      // configuration as it was before a change or after it, never a mix of both
+      synchronized (FileBasedKeyManagerProvider.this)
+      {
+        // stamped again under the lock: stamps taken before it may be those of a write another
+        // thread has loaded past meanwhile
+        final int changes = configurationChanges;
+        final List<FileStamp> stamps = stampFiles();
+        current = loaded;
+        if (current.isLoadedFrom(changes, stamps))
+        {
+          return current.keyManager;
+        }
+        // the key store a changed configuration names may hold its keys under any aliases
+        final Set<String> knownAliases =
+            current.configurationChanges == changes ? current.keyAliases : Collections.<String> emptySet();
+        try
+        {
+          final char[] pin = currentPIN();
+          final KeyStore keyStore = getKeystore(pin);
+          final Set<String> keyAliases = keyAliases(keyStore);
+          if (keyAliases.isEmpty())
+          {
+            throw new DirectoryException(DirectoryServer.getCoreConfigManager().getServerErrorResultCode(),
+                ERR_NO_KEY_ENTRY_IN_KEYSTORE.get(keyStoreFile));
+          }
+          if (!knownAliases.isEmpty() && Collections.disjoint(knownAliases, keyAliases))
+          {
+            throw new DirectoryException(DirectoryServer.getCoreConfigManager().getServerErrorResultCode(),
+                ERR_FILE_KEYMANAGER_NO_KNOWN_KEY_ALIAS.get(keyStoreFile, knownAliases));
+          }
+          final KeyManager[] keyManagers = loadKeyManagers(keyStore, pin);
+          if (keyManagers.length != 1 || !(keyManagers[0] instanceof X509ExtendedKeyManager))
+          {
+            throw new DirectoryException(DirectoryServer.getCoreConfigManager().getServerErrorResultCode(),
+                ERR_FILE_KEYMANAGER_CANNOT_CREATE_FACTORY.get(keyStoreFile, Arrays.toString(keyManagers)));
+          }
+          loaded = new LoadedKeyManager(changes, stamps, keyAliases, (X509ExtendedKeyManager) keyManagers[0]);
+          logger.info(NOTE_FILE_KEYMANAGER_RELOADED, keyStoreFile, currentConfig.dn());
+        }
+        catch (DirectoryException e)
+        {
+          logger.traceException(e);
+          loaded = new LoadedKeyManager(changes, stamps, knownAliases, current.keyManager);
+          logger.error(ERR_FILE_KEYMANAGER_CANNOT_RELOAD, keyStoreFile, currentConfig.dn(), e.getMessageObject());
+        }
+        return loaded.keyManager;
+      }
+    }
+
+    @Override
+    public String[] getClientAliases(String keyType, Principal[] issuers)
+    {
+      return currentKeyManager().getClientAliases(keyType, issuers);
+    }
+
+    @Override
+    public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket)
+    {
+      return currentKeyManager().chooseClientAlias(keyType, issuers, socket);
+    }
+
+    @Override
+    public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine)
+    {
+      return currentKeyManager().chooseEngineClientAlias(keyType, issuers, engine);
+    }
+
+    @Override
+    public String[] getServerAliases(String keyType, Principal[] issuers)
+    {
+      return currentKeyManager().getServerAliases(keyType, issuers);
+    }
+
+    @Override
+    public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket)
+    {
+      return currentKeyManager().chooseServerAlias(keyType, issuers, socket);
+    }
+
+    @Override
+    public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine)
+    {
+      return currentKeyManager().chooseEngineServerAlias(keyType, issuers, engine);
+    }
+
+    @Override
+    public X509Certificate[] getCertificateChain(String alias)
+    {
+      return loaded.keyManager.getCertificateChain(alias);
+    }
+
+    @Override
+    public PrivateKey getPrivateKey(String alias)
+    {
+      return loaded.keyManager.getPrivateKey(alias);
+    }
+  }
 
   /**
    * Creates a new instance of this file-based key manager provider.  The
@@ -87,7 +262,7 @@
     currentConfig = cfg;
     keyStoreFile = getKeyStoreFile(cfg, ccr);
     keyStoreType = getKeyStoreType(cfg, ccr);
-    keyStorePIN = getKeyStorePIN(cfg, ccr);
+    getKeyStorePIN(cfg, ccr);
     if (!ccr.getMessages().isEmpty())
     {
       throw new InitializationException(ccr.getMessages().get(0));
@@ -107,18 +282,9 @@
   {
     try
     {
-      KeyStore keyStore = getKeystore();
-      Enumeration<String> aliases = keyStore.aliases();
-      while (aliases.hasMoreElements())
-      {
-        String theAlias = aliases.nextElement();
-        if (alias.equals(theAlias) && keyStore.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class))
-        {
-          return true;
-        }
-      }
+      return keyAliases(getKeystore(currentPIN())).contains(alias);
     }
-    catch (DirectoryException | KeyStoreException e)
+    catch (DirectoryException e)
     {
       // Ignore.
       logger.traceException(e);
@@ -126,7 +292,22 @@
     return false;
   }
 
-  private KeyStore getKeystore() throws DirectoryException
+  /**
+   * Returns the PIN the configuration names now, rather than the one it named when the provider
+   * was configured: a PIN file may have been renewed since, together with the key store.
+   */
+  private char[] currentPIN() throws DirectoryException
+  {
+    final ConfigChangeResult ccr = new ConfigChangeResult();
+    final char[] pin = getKeyStorePIN(currentConfig, ccr);
+    if (ccr.getResultCode() != ResultCode.SUCCESS)
+    {
+      throw new DirectoryException(ccr.getResultCode(), ccr.getMessages().get(0));
+    }
+    return pin;
+  }
+
+  private KeyStore getKeystore(char[] keyStorePIN) throws DirectoryException
   {
     try
     {
@@ -145,19 +326,45 @@
     }
   }
 
+  /**
+   * {@inheritDoc}
+   * <p>
+   * The key manager returned reads the key store file again when a handshake starts after the
+   * file, or the PIN file, has changed, so that a renewed certificate is presented without
+   * restarting the server or the component using it.
+   */
   @Override
   public KeyManager[] getKeyManagers() throws DirectoryException
   {
-    KeyStore keyStore = getKeystore();
+    final int changes = configurationChanges;
+    final List<FileStamp> stamps = stampFiles();
+    final char[] pin = currentPIN();
+    final KeyStore keyStore = getKeystore(pin);
+    final Set<String> keyAliases = keyAliases(keyStore);
+    if (keyAliases.isEmpty())
+    {
+      // Troubleshooting message to let now of possible config error
+      logger.error(ERR_NO_KEY_ENTRY_IN_KEYSTORE, keyStoreFile);
+    }
+    final KeyManager[] keyManagers = loadKeyManagers(keyStore, pin);
+    if (keyManagers.length != 1 || !(keyManagers[0] instanceof X509ExtendedKeyManager))
+    {
+      return keyManagers;
+    }
+    return new KeyManager[] { new ReloadingKeyManager(
+        new LoadedKeyManager(changes, stamps, keyAliases, (X509ExtendedKeyManager) keyManagers[0])) };
+  }
 
+  private List<FileStamp> stampFiles()
+  {
+    final String pinFile = currentConfig.getKeyStorePinFile();
+    return FileStamp.of(getFileForPath(keyStoreFile), pinFile != null ? getFileForPath(pinFile) : null);
+  }
+
+  private KeyManager[] loadKeyManagers(KeyStore keyStore, char[] keyStorePIN) throws DirectoryException
+  {
     try
     {
-      if (! findOneKeyEntry(keyStore))
-      {
-        // Troubleshooting message to let now of possible config error
-        logger.error(ERR_NO_KEY_ENTRY_IN_KEYSTORE, keyStoreFile);
-      }
-
       String keyManagerAlgorithm = KeyManagerFactory.getDefaultAlgorithm();
       KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(keyManagerAlgorithm);
       keyManagerFactory.init(keyStore, keyStorePIN);
@@ -177,7 +384,7 @@
   {
     try
     {
-      return findOneKeyEntry(getKeystore());
+      return !keyAliases(getKeystore(currentPIN())).isEmpty();
     }
     catch (Exception e) {
       logger.traceException(e);
@@ -185,18 +392,28 @@
     }
   }
 
-  private boolean findOneKeyEntry(KeyStore keyStore) throws KeyStoreException
+  /** Returns the aliases the key store holds private keys under. */
+  private Set<String> keyAliases(KeyStore keyStore) throws DirectoryException
   {
-    Enumeration<String> aliases = keyStore.aliases();
-    while (aliases.hasMoreElements())
+    try
     {
-      String alias = aliases.nextElement();
-      if (keyStore.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class))
+      final Set<String> keyAliases = new TreeSet<>();
+      final Enumeration<String> aliases = keyStore.aliases();
+      while (aliases.hasMoreElements())
       {
-        return true;
+        final String alias = aliases.nextElement();
+        if (keyStore.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class))
+        {
+          keyAliases.add(alias);
+        }
       }
+      return keyAliases;
     }
-    return false;
+    catch (KeyStoreException e)
+    {
+      LocalizableMessage message = ERR_FILE_KEYMANAGER_CANNOT_LOAD.get(keyStoreFile, getExceptionMessage(e));
+      throw new DirectoryException(DirectoryServer.getCoreConfigManager().getServerErrorResultCode(), message, e);
+    }
   }
 
   @Override
@@ -227,14 +444,20 @@
     final ConfigChangeResult ccr = new ConfigChangeResult();
     String newKeyStoreFile = getKeyStoreFile(cfg, ccr);
     String newKeyStoreType = getKeyStoreType(cfg, ccr);
-    char[] newPIN = getKeyStorePIN(cfg, ccr);
+    getKeyStorePIN(cfg, ccr);
 
     if (ccr.getResultCode() == ResultCode.SUCCESS)
     {
-      currentConfig = cfg;
-      keyStorePIN   = newPIN;
-      keyStoreFile  = newKeyStoreFile;
-      keyStoreType  = newKeyStoreType;
+      synchronized (this)
+      {
+        currentConfig = cfg;
+        keyStoreFile  = newKeyStoreFile;
+        keyStoreType  = newKeyStoreType;
+        // the key managers already handed out load the key store the new configuration names
+        // on their next handshake, even where its files are those they were loaded from, and
+        // whatever aliases it holds its keys under
+        configurationChanges++;
+      }
     }
 
     return ccr;
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedTrustManagerProvider.java b/opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedTrustManagerProvider.java
index 32b4ad7..5a6f4f8 100644
--- a/opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedTrustManagerProvider.java
+++ b/opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedTrustManagerProvider.java
@@ -13,6 +13,7 @@
  *
  * Copyright 2006-2010 Sun Microsystems, Inc.
  * Portions Copyright 2014-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.opends.server.extensions;
 
@@ -20,11 +21,17 @@
 import org.forgerock.i18n.LocalizableMessage;
 import java.io.File;
 import java.io.FileInputStream;
+import java.net.Socket;
 import java.security.KeyStore;
 import java.security.KeyStoreException;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
 import java.util.List;
+import javax.net.ssl.SSLEngine;
 import javax.net.ssl.TrustManager;
 import javax.net.ssl.TrustManagerFactory;
+import javax.net.ssl.X509ExtendedTrustManager;
 import javax.net.ssl.X509TrustManager;
 
 import org.forgerock.opendj.config.server.ConfigurationChangeListener;
@@ -56,9 +63,6 @@
 {
   private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();
 
-  /** The PIN needed to access the trust store. */
-  private char[] trustStorePIN;
-
   /** The handle to the configuration for this trust manager. */
   private FileBasedTrustManagerProviderCfg currentConfig;
 
@@ -69,6 +73,200 @@
   private String trustStoreType;
 
   /**
+   * The number of configuration changes applied. It is counted after the fields above are set,
+   * and read before them.
+   */
+  private volatile int configurationChanges;
+
+  /**
+   * A trust manager loaded from the trust store file, with the configuration change and the
+   * stamps of the files it was loaded under.
+   */
+  private static final class LoadedTrustManager
+  {
+    private final int configurationChanges;
+    private final List<FileStamp> stamps;
+    private final X509TrustManager trustManager;
+
+    private LoadedTrustManager(int configurationChanges, List<FileStamp> stamps, X509TrustManager trustManager)
+    {
+      this.configurationChanges = configurationChanges;
+      this.stamps = stamps;
+      this.trustManager = trustManager;
+    }
+
+    private boolean isLoadedFrom(int configurationChanges, List<FileStamp> stamps)
+    {
+      return this.configurationChanges == configurationChanges && this.stamps.equals(stamps);
+    }
+  }
+
+  /**
+   * What a trust manager handed out by {@link #getTrustManagers()} delegates to. Each trust
+   * manager handed out loads the file on its own, so asking the provider again, as a component
+   * does to check a configuration change, leaves those in use alone.
+   */
+  private final class TrustStoreFollower
+  {
+    /**
+     * Whether the server ran in FIPS mode when the trust manager was handed out. The trust store
+     * is loaded again as it was loaded then, so that what is loaded stays of the kind handed
+     * out, even where the server has turned to FIPS mode since, or away from it.
+     */
+    private final boolean fipsMode;
+    /** Whether the trust manager handed out is an extended one, which needs an extended one to delegate to. */
+    private final boolean extended;
+    /** The trust manager last loaded, when this one was handed out or by a check since. */
+    private volatile LoadedTrustManager loaded;
+
+    private TrustStoreFollower(boolean fipsMode, LoadedTrustManager loaded)
+    {
+      this.fipsMode = fipsMode;
+      this.extended = loaded.trustManager instanceof X509ExtendedTrustManager;
+      this.loaded = loaded;
+    }
+
+    /**
+     * Returns the trust manager to check a certificate with, first loading the trust store file
+     * again when it has changed since it was last loaded, or the configuration has. A file that
+     * cannot be loaded leaves the trust manager last loaded in use, and is not tried again until
+     * it changes again.
+     */
+    private X509TrustManager currentTrustManager()
+    {
+      LoadedTrustManager current = loaded;
+      if (current.isLoadedFrom(configurationChanges, stampFiles()))
+      {
+        return current.trustManager;
+      }
+      // the provider's lock, which applyConfigurationChange takes too: a load reads the
+      // configuration as it was before a change or after it, never a mix of both
+      synchronized (FileBasedTrustManagerProvider.this)
+      {
+        // stamped again under the lock: stamps taken before it may be those of a write another
+        // thread has loaded past meanwhile
+        final int changes = configurationChanges;
+        final List<FileStamp> stamps = stampFiles();
+        current = loaded;
+        if (current.isLoadedFrom(changes, stamps))
+        {
+          return current.trustManager;
+        }
+        try
+        {
+          final TrustManager[] trustManagers = loadTrustManagers(currentPIN(), fipsMode);
+          if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)
+              || extended != trustManagers[0] instanceof X509ExtendedTrustManager)
+          {
+            throw new DirectoryException(DirectoryServer.getCoreConfigManager().getServerErrorResultCode(),
+                ERR_FILE_TRUSTMANAGER_CANNOT_CREATE_FACTORY.get(trustStoreFile, Arrays.toString(trustManagers)));
+          }
+          loaded = new LoadedTrustManager(changes, stamps, (X509TrustManager) trustManagers[0]);
+          logger.info(NOTE_FILE_TRUSTMANAGER_RELOADED, trustStoreFile, currentConfig.dn());
+        }
+        catch (DirectoryException e)
+        {
+          logger.traceException(e);
+          loaded = new LoadedTrustManager(changes, stamps, current.trustManager);
+          logger.error(ERR_FILE_TRUSTMANAGER_CANNOT_RELOAD, trustStoreFile, currentConfig.dn(), e.getMessageObject());
+        }
+        return loaded.trustManager;
+      }
+    }
+  }
+
+  /** The trust manager handed out by {@link #getTrustManagers()} over a plain trust manager. */
+  private static final class ReloadingTrustManager implements X509TrustManager
+  {
+    private final TrustStoreFollower follower;
+
+    private ReloadingTrustManager(TrustStoreFollower follower)
+    {
+      this.follower = follower;
+    }
+
+    @Override
+    public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException
+    {
+      follower.currentTrustManager().checkClientTrusted(chain, authType);
+    }
+
+    @Override
+    public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException
+    {
+      follower.currentTrustManager().checkServerTrusted(chain, authType);
+    }
+
+    @Override
+    public X509Certificate[] getAcceptedIssuers()
+    {
+      return follower.currentTrustManager().getAcceptedIssuers();
+    }
+  }
+
+  /** The trust manager handed out by {@link #getTrustManagers()} over an extended trust manager. */
+  private static final class ReloadingExtendedTrustManager extends X509ExtendedTrustManager
+  {
+    private final TrustStoreFollower follower;
+
+    private ReloadingExtendedTrustManager(TrustStoreFollower follower)
+    {
+      this.follower = follower;
+    }
+
+    private X509ExtendedTrustManager current()
+    {
+      return (X509ExtendedTrustManager) follower.currentTrustManager();
+    }
+
+    @Override
+    public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException
+    {
+      current().checkClientTrusted(chain, authType);
+    }
+
+    @Override
+    public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket)
+        throws CertificateException
+    {
+      current().checkClientTrusted(chain, authType, socket);
+    }
+
+    @Override
+    public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine)
+        throws CertificateException
+    {
+      current().checkClientTrusted(chain, authType, engine);
+    }
+
+    @Override
+    public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException
+    {
+      current().checkServerTrusted(chain, authType);
+    }
+
+    @Override
+    public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket)
+        throws CertificateException
+    {
+      current().checkServerTrusted(chain, authType, socket);
+    }
+
+    @Override
+    public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine)
+        throws CertificateException
+    {
+      current().checkServerTrusted(chain, authType, engine);
+    }
+
+    @Override
+    public X509Certificate[] getAcceptedIssuers()
+    {
+      return current().getAcceptedIssuers();
+    }
+  }
+
+  /**
    * Creates a new instance of this file-based trust manager provider.  The
    * <CODE>initializeTrustManagerProvider</CODE> method must be called on the
    * resulting object before it may be used.
@@ -87,7 +285,7 @@
     currentConfig = cfg;
     trustStoreFile = getTrustStoreFile(cfg, ccr);
     trustStoreType = getTrustStoreType(cfg, ccr);
-    trustStorePIN = getTrustStorePIN(cfg, ccr);
+    getTrustStorePIN(cfg, ccr);
     if (!ccr.getMessages().isEmpty())
     {
       throw new InitializationException(ccr.getMessages().get(0));
@@ -102,9 +300,59 @@
     currentConfig.removeFileBasedChangeListener(this);
   }
 
+  /**
+   * {@inheritDoc}
+   * <p>
+   * The trust manager returned reads the trust store file again when a certificate is checked
+   * after the file, or the PIN file, has changed, so that a renewed trust store is used without
+   * restarting the server or the component using it.
+   */
   @Override
   public TrustManager[] getTrustManagers() throws DirectoryException
   {
+    final int changes = configurationChanges;
+    final List<FileStamp> stamps = stampFiles();
+    final boolean fipsMode = isFipsMode();
+    final TrustManager[] trustManagers = loadTrustManagers(currentPIN(), fipsMode);
+    if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager))
+    {
+      return trustManagers;
+    }
+    final TrustStoreFollower follower = new TrustStoreFollower(
+        fipsMode, new LoadedTrustManager(changes, stamps, (X509TrustManager) trustManagers[0]));
+    // an extended trust manager stays one, and a plain one stays plain, for JSSE adds checks
+    // of its own around a plain one
+    return new TrustManager[] { follower.extended
+        ? new ReloadingExtendedTrustManager(follower) : new ReloadingTrustManager(follower) };
+  }
+
+  /**
+   * Returns the PIN the configuration names now, rather than the one it named when the provider
+   * was configured: a PIN file may have been renewed since, together with the trust store.
+   */
+  private char[] currentPIN() throws DirectoryException
+  {
+    final ConfigChangeResult ccr = new ConfigChangeResult();
+    final char[] pin = getTrustStorePIN(currentConfig, ccr);
+    if (ccr.getResultCode() != ResultCode.SUCCESS)
+    {
+      throw new DirectoryException(ccr.getResultCode(), ccr.getMessages().get(0));
+    }
+    return pin;
+  }
+
+  private List<FileStamp> stampFiles()
+  {
+    final String pinFile = currentConfig.getTrustStorePinFile();
+    return FileStamp.of(getFileForPath(trustStoreFile), pinFile != null ? getFileForPath(pinFile) : null);
+  }
+
+  /**
+   * Loads the trust managers of the trust store file: in FIPS mode as they are, and otherwise each
+   * within an expiration check.
+   */
+  private TrustManager[] loadTrustManagers(char[] trustStorePIN, boolean fipsMode) throws DirectoryException
+  {
     KeyStore trustStore;
     try (FileInputStream inputStream = new FileInputStream(getFileForPath(trustStoreFile)))
     {
@@ -125,7 +373,7 @@
       trustManagerFactory.init(trustStore);
       TrustManager[] trustManagers = trustManagerFactory.getTrustManagers();
       TrustManager[] newTrustManagers = new TrustManager[trustManagers.length];
-      if (isFips()) {
+      if (fipsMode) {
     	  newTrustManagers = trustManagers;
       } else {
 	      for (int i=0; i < trustManagers.length; i++)
@@ -145,6 +393,17 @@
     }
   }
 
+  /**
+   * Tells whether the server runs in FIPS mode, where the trust managers loaded are handed out as
+   * they are, without an expiration check around them.
+   *
+   * @return {@code true} if the server runs in FIPS mode
+   */
+  boolean isFipsMode()
+  {
+    return isFips();
+  }
+
   @Override
   public boolean isConfigurationAcceptable(TrustManagerProviderCfg cfg, List<LocalizableMessage> unacceptableReasons)
   {
@@ -173,14 +432,19 @@
     final ConfigChangeResult ccr = new ConfigChangeResult();
     String newTrustStoreFile = getTrustStoreFile(cfg, ccr);
     String newTrustStoreType = getTrustStoreType(cfg, ccr);
-    char[] newPIN = getTrustStorePIN(cfg, ccr);
+    getTrustStorePIN(cfg, ccr);
 
     if (ccr.getResultCode() == ResultCode.SUCCESS)
     {
-      currentConfig = cfg;
-      trustStorePIN   = newPIN;
-      trustStoreFile  = newTrustStoreFile;
-      trustStoreType  = newTrustStoreType;
+      synchronized (this)
+      {
+        currentConfig = cfg;
+        trustStoreFile  = newTrustStoreFile;
+        trustStoreType  = newTrustStoreType;
+        // the trust managers already handed out load the trust store the new configuration
+        // names on their next check, even where its files are those they were loaded from
+        configurationChanges++;
+      }
     }
 
     return ccr;
diff --git a/opendj-server-legacy/src/main/java/org/opends/server/extensions/FileStamp.java b/opendj-server-legacy/src/main/java/org/opends/server/extensions/FileStamp.java
new file mode 100644
index 0000000..1269ceb
--- /dev/null
+++ b/opendj-server-legacy/src/main/java/org/opends/server/extensions/FileStamp.java
@@ -0,0 +1,100 @@
+/*
+ * 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.extensions;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * What a file looked like when it was read: enough to tell, without reading it again, that it
+ * has been rewritten or replaced since. A file replaced by a rename, the way certificate
+ * renewal agents and Kubernetes update a mounted Secret, is a different file even when its
+ * size and modification time happen to match, which is why the file key is kept as well.
+ * Symbolic links are followed, so the stamp of a link describes the file it points to.
+ */
+final class FileStamp
+{
+  private final long lastModified;
+  private final long size;
+  private final Object fileKey;
+
+  private FileStamp(long lastModified, long size, Object fileKey)
+  {
+    this.lastModified = lastModified;
+    this.size = size;
+    this.fileKey = fileKey;
+  }
+
+  /**
+   * Returns the stamps of the provided files, in the same order. Every file that is missing or
+   * cannot be looked at gets the same stamp.
+   *
+   * @param files
+   *          The files to stamp; {@code null} elements are skipped.
+   * @return The stamps of the files.
+   */
+  static List<FileStamp> of(File... files)
+  {
+    final List<FileStamp> stamps = new ArrayList<>(files.length);
+    for (File file : files)
+    {
+      if (file != null)
+      {
+        stamps.add(of(file));
+      }
+    }
+    return stamps;
+  }
+
+  private static FileStamp of(File file)
+  {
+    try
+    {
+      final BasicFileAttributes attributes = Files.readAttributes(file.toPath(), BasicFileAttributes.class);
+      return new FileStamp(attributes.lastModifiedTime().toMillis(), attributes.size(), attributes.fileKey());
+    }
+    catch (IOException | SecurityException e)
+    {
+      return new FileStamp(-1, -1, null);
+    }
+  }
+
+  @Override
+  public boolean equals(Object o)
+  {
+    if (this == o)
+    {
+      return true;
+    }
+    if (!(o instanceof FileStamp))
+    {
+      return false;
+    }
+    final FileStamp other = (FileStamp) o;
+    return lastModified == other.lastModified && size == other.size && Objects.equals(fileKey, other.fileKey);
+  }
+
+  @Override
+  public int hashCode()
+  {
+    return Objects.hash(lastModified, size, fileKey);
+  }
+}
diff --git a/opendj-server-legacy/src/messages/org/opends/messages/extension.properties b/opendj-server-legacy/src/messages/org/opends/messages/extension.properties
index 84aa00c..29ea8ff 100644
--- a/opendj-server-legacy/src/messages/org/opends/messages/extension.properties
+++ b/opendj-server-legacy/src/messages/org/opends/messages/extension.properties
@@ -12,6 +12,7 @@
 #
 # Copyright 2006-2010 Sun Microsystems, Inc.
 # Portions Copyright 2011-2016 ForgeRock AS.
+# Portions Copyright 2026 3A Systems, LLC.
 
 
 
@@ -1003,3 +1004,15 @@
  attribute ds-cfg-trust-store-pin-file of configuration entry %s should \
  contain the PIN needed to access the LDAP trust manager, but this file \
  is empty
+NOTE_FILE_KEYMANAGER_RELOADED_652=The keystore file %s used by key manager \
+ provider %s has changed and was loaded again
+ERR_FILE_KEYMANAGER_CANNOT_RELOAD_653=The keystore file %s used by key \
+ manager provider %s has changed but could not be loaded again: %s. The \
+ provider keeps using what it last loaded from it
+NOTE_FILE_TRUSTMANAGER_RELOADED_654=The trust store file %s used by trust \
+ manager provider %s has changed and was loaded again
+ERR_FILE_TRUSTMANAGER_CANNOT_RELOAD_655=The trust store file %s used by \
+ trust manager provider %s has changed but could not be loaded again: %s. \
+ The provider keeps using what it last loaded from it
+ERR_FILE_KEYMANAGER_NO_KNOWN_KEY_ALIAS_656=The keystore file %s holds no \
+ private key under any of the aliases %s it held private keys under before
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/extensions/FileBasedKeyManagerProviderTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/extensions/FileBasedKeyManagerProviderTestCase.java
index 7702dce..da0b425 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/extensions/FileBasedKeyManagerProviderTestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/extensions/FileBasedKeyManagerProviderTestCase.java
@@ -13,12 +13,29 @@
  *
  * Copyright 2006-2008 Sun Microsystems, Inc.
  * Portions Copyright 2013-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.opends.server.extensions;
 
+import java.io.ByteArrayOutputStream;
 import java.io.File;
+import java.io.FileInputStream;
 import java.io.FileWriter;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.security.Key;
+import java.security.KeyStore;
+import java.security.cert.Certificate;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
+import java.util.function.Function;
+
+import javax.net.ssl.X509ExtendedKeyManager;
+
 import org.testng.annotations.BeforeClass;
 import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
@@ -30,6 +47,7 @@
 import org.opends.server.types.Entry;
 import org.opends.server.types.InitializationException;
 
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.opends.server.util.ServerConstants.*;
 
 /**
@@ -289,6 +307,358 @@
     initializeKeyManagerProvider(e);
   }
 
+  /**
+   * A key manager handed out by the provider presents what the key store file holds when a
+   * handshake starts, not what it held when the key manager was handed out: a file replaced
+   * with another certificate, or with the same certificate under another PIN, is loaded again.
+   * A file that cannot be loaded, that holds no private key, or that holds none under the
+   * aliases the file held keys under before leaves the last certificate loaded in use.
+   */
+  @Test
+  public void testKeyStoreLoadedAgainWhenChanged() throws Exception
+  {
+    final File configDir = new File(DirectoryServer.getInstanceRoot(), "config");
+    final File keyStore = new File(configDir, "reload-test.keystore");
+    final File pinFile = new File(configDir, "reload-test.keystore.pin");
+    replace(keyStore, Files.readAllBytes(new File(configDir, "server.keystore").toPath()));
+    replace(pinFile, ("password" + EOL).getBytes(StandardCharsets.UTF_8));
+
+    FileBasedKeyManagerProvider provider = initializeKeyManagerProvider(TestCaseUtils.makeEntry(
+        "dn: cn=Reloaded Key Manager Provider,cn=SSL,cn=config",
+        "objectClass: top",
+        "objectClass: ds-cfg-key-manager-provider",
+        "objectClass: ds-cfg-file-based-key-manager-provider",
+        "cn: Reloaded Key Manager Provider",
+        "ds-cfg-java-class: org.opends.server.extensions.FileBasedKeyManagerProvider",
+        "ds-cfg-enabled: true",
+        "ds-cfg-key-store-file: config/reload-test.keystore",
+        "ds-cfg-key-store-pin-file: config/reload-test.keystore.pin"));
+    try
+    {
+      final X509ExtendedKeyManager keyManager = (X509ExtendedKeyManager) provider.getKeyManagers()[0];
+      final String serverCertificate = serverCertificateOf(keyManager);
+
+      // renewed under the alias the handler is configured with
+      replace(keyStore, rewritten(new File(configDir, "client.keystore"), "password", "password", "server-cert"));
+      final String clientCertificate = serverCertificateByAliasesOf(keyManager);
+      assertThat(clientCertificate).isNotEqualTo(serverCertificate);
+
+      // a handler presenting the key under its ssl-cert-nickname would find none in these
+      replace(keyStore, rewritten(new File(configDir, "server.keystore"), "password", "password", "other-cert"));
+      assertThat(serverCertificateOf(keyManager)).isEqualTo(clientCertificate);
+      replace(keyStore, Files.readAllBytes(new File(configDir, "server.truststore").toPath()));
+      assertThat(serverCertificateOf(keyManager)).isEqualTo(clientCertificate);
+
+      // the PIN changes with the key store, but the new PIN is not there yet
+      replace(keyStore, withPassword(new File(configDir, "server.keystore"), "password", "changed"));
+      assertThat(serverCertificateOf(keyManager)).isEqualTo(clientCertificate);
+      replace(pinFile, ("changed" + EOL).getBytes(StandardCharsets.UTF_8));
+      assertThat(serverCertificateOf(keyManager)).isEqualTo(serverCertificate);
+
+      replace(keyStore, "not a key store".getBytes(StandardCharsets.UTF_8));
+      assertThat(serverCertificateOf(keyManager)).isEqualTo(serverCertificate);
+
+      // the failed load above kept the aliases the last key store loaded holds its key under
+      replace(keyStore, rewritten(new File(configDir, "client.keystore"), "password", "changed", "other-cert"));
+      assertThat(serverCertificateOf(keyManager)).isEqualTo(serverCertificate);
+    }
+    finally
+    {
+      provider.finalizeKeyManagerProvider();
+      Files.deleteIfExists(keyStore.toPath());
+      Files.deleteIfExists(pinFile.toPath());
+    }
+  }
+
+  /**
+   * Each key manager handed out loads the key store on its own. Asking the provider again, as a
+   * connection handler does to check a configuration change, leaves the key managers handed out
+   * before alone, and the new key manager presents what the file holds: the key under the alias
+   * a handler is being configured with, which the key managers in use refused.
+   */
+  @Test
+  public void testKeyManagerHandedOutAgainLeavesEarlierOnesAlone() throws Exception
+  {
+    final File configDir = new File(DirectoryServer.getInstanceRoot(), "config");
+    final File keyStore = new File(configDir, "handed-out-test.keystore");
+    replace(keyStore, Files.readAllBytes(new File(configDir, "server.keystore").toPath()));
+    FileBasedKeyManagerProvider provider = initializeKeyManagerProvider(TestCaseUtils.makeEntry(
+        "dn: cn=Handed Out Key Manager Provider,cn=SSL,cn=config",
+        "objectClass: top",
+        "objectClass: ds-cfg-key-manager-provider",
+        "objectClass: ds-cfg-file-based-key-manager-provider",
+        "cn: Handed Out Key Manager Provider",
+        "ds-cfg-java-class: org.opends.server.extensions.FileBasedKeyManagerProvider",
+        "ds-cfg-enabled: true",
+        "ds-cfg-key-store-file: config/handed-out-test.keystore",
+        "ds-cfg-key-store-pin: password"));
+    try
+    {
+      final X509ExtendedKeyManager keyManager = (X509ExtendedKeyManager) provider.getKeyManagers()[0];
+      final String serverCertificate = serverCertificateOf(keyManager);
+
+      // renewed under an alias the key manager in use does not know: refused
+      replace(keyStore, rewritten(new File(configDir, "client.keystore"), "password", "password", "other-cert"));
+      assertThat(serverCertificateOf(keyManager)).isEqualTo(serverCertificate);
+
+      final X509ExtendedKeyManager renamed = (X509ExtendedKeyManager) provider.getKeyManagers()[0];
+      assertThat(renamed.getServerAliases("RSA", null)).containsExactly("other-cert");
+      assertThat(serverCertificateByAliasesOf(renamed)).isNotEqualTo(serverCertificate);
+      assertThat(serverCertificateOf(keyManager)).isEqualTo(serverCertificate);
+    }
+    finally
+    {
+      provider.finalizeKeyManagerProvider();
+      Files.deleteIfExists(keyStore.toPath());
+    }
+  }
+
+  /**
+   * Every way a key manager handed out chooses an alias looks at the key store file first: the
+   * client side, which the OAuth2 client uses, and the server side over a socket, which JMX uses.
+   */
+  @Test
+  public void testEveryAliasChoiceLoadsAgain() throws Exception
+  {
+    final File configDir = new File(DirectoryServer.getInstanceRoot(), "config");
+    final File keyStore = new File(configDir, "alias-choice-test.keystore");
+    replace(keyStore, Files.readAllBytes(new File(configDir, "server.keystore").toPath()));
+    FileBasedKeyManagerProvider provider = initializeKeyManagerProvider(TestCaseUtils.makeEntry(
+        "dn: cn=Alias Choice Key Manager Provider,cn=SSL,cn=config",
+        "objectClass: top",
+        "objectClass: ds-cfg-key-manager-provider",
+        "objectClass: ds-cfg-file-based-key-manager-provider",
+        "cn: Alias Choice Key Manager Provider",
+        "ds-cfg-java-class: org.opends.server.extensions.FileBasedKeyManagerProvider",
+        "ds-cfg-enabled: true",
+        "ds-cfg-key-store-file: config/alias-choice-test.keystore",
+        "ds-cfg-key-store-pin: password"));
+    try
+    {
+      final X509ExtendedKeyManager keyManager = (X509ExtendedKeyManager) provider.getKeyManagers()[0];
+      final String serverCertificate = serverCertificateOf(keyManager);
+      final byte[] serverKeyStore = Files.readAllBytes(keyStore.toPath());
+      final byte[] clientKeyStore =
+          rewritten(new File(configDir, "client.keystore"), "password", "password", "server-cert");
+      final String[] rsa = { "RSA" };
+      final List<Function<X509ExtendedKeyManager, String>> choices = Arrays.asList(
+          km -> km.getClientAliases("RSA", null)[0],
+          km -> km.chooseClientAlias(rsa, null, null),
+          km -> km.chooseEngineClientAlias(rsa, null, null),
+          km -> km.chooseServerAlias("RSA", null, null));
+
+      boolean client = false;
+      String previous = serverCertificate;
+      for (Function<X509ExtendedKeyManager, String> choice : choices)
+      {
+        client = !client;
+        replace(keyStore, client ? clientKeyStore : serverKeyStore);
+        // server.keystore holds ads-certificate too, which a client side choice may take
+        final String alias = choice.apply(keyManager);
+        assertThat(alias).isNotNull();
+        final String certificate = keyManager.getCertificateChain(alias)[0].getSubjectX500Principal().getName();
+        assertThat(certificate).isNotEqualTo(previous);
+        previous = certificate;
+      }
+    }
+    finally
+    {
+      provider.finalizeKeyManagerProvider();
+      Files.deleteIfExists(keyStore.toPath());
+    }
+  }
+
+  /**
+   * A changed configuration makes the key managers already handed out load the key store again
+   * on their next handshake, even where the file has not changed since they last looked at it.
+   */
+  @Test
+  public void testKeyStoreLoadedAgainWhenPinChangesInConfiguration() throws Exception
+  {
+    final File configDir = new File(DirectoryServer.getInstanceRoot(), "config");
+    final File keyStore = new File(configDir, "reconfigured-test.keystore");
+    replace(keyStore, Files.readAllBytes(new File(configDir, "server.keystore").toPath()));
+    FileBasedKeyManagerProvider provider = initializeKeyManagerProvider(reconfiguredProviderEntry("password"));
+    try
+    {
+      final X509ExtendedKeyManager keyManager = (X509ExtendedKeyManager) provider.getKeyManagers()[0];
+      final String serverCertificate = serverCertificateOf(keyManager);
+
+      // a key store under a PIN the configuration does not have yet: the failed load records its stamps
+      replace(keyStore, withPassword(new File(configDir, "client.keystore"), "password", "changed"));
+      assertThat(serverCertificateOf(keyManager)).isEqualTo(serverCertificate);
+
+      // and whatever aliases the key store the new configuration names holds its keys under
+      provider.applyConfigurationChange(InitializationUtils.getConfiguration(
+          FileBasedKeyManagerProviderCfgDefn.getInstance(), reconfiguredProviderEntry("changed")));
+      final String clientCertificate = serverCertificateOf(keyManager);
+      assertThat(clientCertificate).isNotEqualTo(serverCertificate);
+
+      // with no aliases to hold a store to, a store with no private key is still not taken
+      provider.applyConfigurationChange(InitializationUtils.getConfiguration(
+          FileBasedKeyManagerProviderCfgDefn.getInstance(), reconfiguredProviderEntry("changed")));
+      replace(keyStore, withPassword(new File(configDir, "server.truststore"), "password", "changed"));
+      assertThat(serverCertificateOf(keyManager)).isEqualTo(clientCertificate);
+    }
+    finally
+    {
+      provider.finalizeKeyManagerProvider();
+      Files.deleteIfExists(keyStore.toPath());
+    }
+  }
+
+  private static Entry reconfiguredProviderEntry(String pin) throws Exception
+  {
+    return TestCaseUtils.makeEntry(
+        "dn: cn=Reconfigured Key Manager Provider,cn=SSL,cn=config",
+        "objectClass: top",
+        "objectClass: ds-cfg-key-manager-provider",
+        "objectClass: ds-cfg-file-based-key-manager-provider",
+        "cn: Reconfigured Key Manager Provider",
+        "ds-cfg-java-class: org.opends.server.extensions.FileBasedKeyManagerProvider",
+        "ds-cfg-enabled: true",
+        "ds-cfg-key-store-file: config/reconfigured-test.keystore",
+        "ds-cfg-key-store-pin: " + pin);
+  }
+
+  /**
+   * A key store file that cannot be loaded is not tried again until it changes again, even where
+   * it would load by then: the PIN below comes from a system property, which is not stamped.
+   */
+  @Test
+  public void testKeyStoreNotLoadedAgainUntilChanged() throws Exception
+  {
+    final File configDir = new File(DirectoryServer.getInstanceRoot(), "config");
+    final File keyStore = new File(configDir, "retry-test.keystore");
+    replace(keyStore, Files.readAllBytes(new File(configDir, "server.keystore").toPath()));
+    System.setProperty("retry.test.key.store.pin", "password");
+    FileBasedKeyManagerProvider provider = initializeKeyManagerProvider(TestCaseUtils.makeEntry(
+        "dn: cn=Retried Key Manager Provider,cn=SSL,cn=config",
+        "objectClass: top",
+        "objectClass: ds-cfg-key-manager-provider",
+        "objectClass: ds-cfg-file-based-key-manager-provider",
+        "cn: Retried Key Manager Provider",
+        "ds-cfg-java-class: org.opends.server.extensions.FileBasedKeyManagerProvider",
+        "ds-cfg-enabled: true",
+        "ds-cfg-key-store-file: config/retry-test.keystore",
+        "ds-cfg-key-store-pin-property: retry.test.key.store.pin"));
+    try
+    {
+      final X509ExtendedKeyManager keyManager = (X509ExtendedKeyManager) provider.getKeyManagers()[0];
+      final String serverCertificate = serverCertificateOf(keyManager);
+
+      replace(keyStore, rewritten(new File(configDir, "client.keystore"), "password", "changed", "server-cert"));
+      assertThat(serverCertificateOf(keyManager)).isEqualTo(serverCertificate);
+      System.setProperty("retry.test.key.store.pin", "changed");
+      assertThat(serverCertificateOf(keyManager)).isEqualTo(serverCertificate);
+    }
+    finally
+    {
+      provider.finalizeKeyManagerProvider();
+      System.clearProperty("retry.test.key.store.pin");
+      Files.deleteIfExists(keyStore.toPath());
+    }
+  }
+
+  /**
+   * A key store renewed together with its PIN file is loaded with the new PIN by the provider
+   * itself, as a connection handler rebuilding its SSL context asks it to, with no handshake in
+   * between to read the PIN again.
+   */
+  @Test
+  public void testKeyStoreAndPinRenewedTogetherLoadedWithoutHandshake() throws Exception
+  {
+    final File configDir = new File(DirectoryServer.getInstanceRoot(), "config");
+    final File keyStore = new File(configDir, "renewed-test.keystore");
+    final File pinFile = new File(configDir, "renewed-test.keystore.pin");
+    replace(keyStore, Files.readAllBytes(new File(configDir, "server.keystore").toPath()));
+    replace(pinFile, ("password" + EOL).getBytes(StandardCharsets.UTF_8));
+    FileBasedKeyManagerProvider provider = initializeKeyManagerProvider(TestCaseUtils.makeEntry(
+        "dn: cn=Renewed Key Manager Provider,cn=SSL,cn=config",
+        "objectClass: top",
+        "objectClass: ds-cfg-key-manager-provider",
+        "objectClass: ds-cfg-file-based-key-manager-provider",
+        "cn: Renewed Key Manager Provider",
+        "ds-cfg-java-class: org.opends.server.extensions.FileBasedKeyManagerProvider",
+        "ds-cfg-enabled: true",
+        "ds-cfg-key-store-file: config/renewed-test.keystore",
+        "ds-cfg-key-store-pin-file: config/renewed-test.keystore.pin"));
+    try
+    {
+      final String serverCertificate = serverCertificateOf((X509ExtendedKeyManager) provider.getKeyManagers()[0]);
+
+      replace(keyStore, rewritten(new File(configDir, "client.keystore"), "password", "changed", "server-cert"));
+      replace(pinFile, ("changed" + EOL).getBytes(StandardCharsets.UTF_8));
+      assertThat(provider.containsAtLeastOneKey()).isTrue();
+      assertThat(provider.containsKeyWithAlias("server-cert")).isTrue();
+      final X509ExtendedKeyManager keyManager = (X509ExtendedKeyManager) provider.getKeyManagers()[0];
+      assertThat(serverCertificateOf(keyManager)).isNotEqualTo(serverCertificate);
+    }
+    finally
+    {
+      provider.finalizeKeyManagerProvider();
+      Files.deleteIfExists(keyStore.toPath());
+      Files.deleteIfExists(pinFile.toPath());
+    }
+  }
+
+  /** The road LDAPS takes with ssl-cert-nickname set: SelectableCertificateKeyManager asks getServerAliases. */
+  private static String serverCertificateByAliasesOf(X509ExtendedKeyManager keyManager)
+  {
+    final String[] aliases = keyManager.getServerAliases("RSA", null);
+    assertThat(aliases).isNotEmpty();
+    return keyManager.getCertificateChain(aliases[0])[0].getSubjectX500Principal().getName();
+  }
+
+  private static String serverCertificateOf(X509ExtendedKeyManager keyManager)
+  {
+    final String alias = keyManager.chooseEngineServerAlias("RSA", null, null);
+    assertThat(alias).isNotNull();
+    return keyManager.getCertificateChain(alias)[0].getSubjectX500Principal().getName();
+  }
+
+  /** Returns the key store in the provided file, with its entries protected by another password. */
+  static byte[] withPassword(File file, String oldPassword, String newPassword) throws Exception
+  {
+    return rewritten(file, oldPassword, newPassword, null);
+  }
+
+  /**
+   * Returns the key store in the provided file, with its entries protected by another password
+   * and, unless the alias provided is {@code null}, its private keys under that alias.
+   */
+  private static byte[] rewritten(File file, String oldPassword, String newPassword, String keyAlias)
+      throws Exception
+  {
+    final KeyStore keyStore = KeyStore.getInstance("JKS");
+    try (InputStream in = new FileInputStream(file))
+    {
+      keyStore.load(in, oldPassword.toCharArray());
+    }
+    for (String alias : Collections.list(keyStore.aliases()))
+    {
+      if (!keyStore.isKeyEntry(alias))
+      {
+        continue;
+      }
+      final Key key = keyStore.getKey(alias, oldPassword.toCharArray());
+      final Certificate[] chain = keyStore.getCertificateChain(alias);
+      keyStore.deleteEntry(alias);
+      keyStore.setKeyEntry(keyAlias != null ? keyAlias : alias, key, newPassword.toCharArray(), chain);
+    }
+    final ByteArrayOutputStream out = new ByteArrayOutputStream();
+    keyStore.store(out, newPassword.toCharArray());
+    return out.toByteArray();
+  }
+
+  /** Replaces the file the way a renewal agent does: the new content is renamed over it. */
+  static void replace(File file, byte[] content) throws Exception
+  {
+    final Path tmp = Files.createTempFile(file.getParentFile().toPath(), file.getName(), ".tmp");
+    Files.write(tmp, content);
+    Files.move(tmp, file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
+  }
+
   private FileBasedKeyManagerProvider initializeKeyManagerProvider(Entry e) throws Exception {
     return InitializationUtils.initializeKeyManagerProvider(
         new FileBasedKeyManagerProvider(), e, FileBasedKeyManagerProviderCfgDefn.getInstance());
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/extensions/FileBasedTrustManagerProviderTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/extensions/FileBasedTrustManagerProviderTestCase.java
index 4ea4ece..f942052 100644
--- a/opendj-server-legacy/src/test/java/org/opends/server/extensions/FileBasedTrustManagerProviderTestCase.java
+++ b/opendj-server-legacy/src/test/java/org/opends/server/extensions/FileBasedTrustManagerProviderTestCase.java
@@ -13,13 +13,31 @@
  *
  * Copyright 2006-2008 Sun Microsystems, Inc.
  * Portions Copyright 2014-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.opends.server.extensions;
 
 import java.io.File;
+import java.io.FileInputStream;
 import java.io.FileWriter;
+import java.io.InputStream;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.security.KeyStore;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
 import java.util.List;
 
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.SSLSocketFactory;
+import javax.net.ssl.X509ExtendedTrustManager;
+import javax.net.ssl.X509TrustManager;
+
 import org.forgerock.opendj.config.server.ConfigException;
 import org.forgerock.opendj.server.config.meta.FileBasedTrustManagerProviderCfgDefn;
 import org.opends.server.TestCaseUtils;
@@ -30,6 +48,11 @@
 import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
+import static com.forgerock.opendj.util.StaticUtils.isFips;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.fail;
+import static org.opends.server.extensions.FileBasedKeyManagerProviderTestCase.replace;
+import static org.opends.server.extensions.FileBasedKeyManagerProviderTestCase.withPassword;
 import static org.opends.server.util.ServerConstants.*;
 
 /**
@@ -281,6 +304,402 @@
     }
   }
 
+  /**
+   * A trust manager handed out by the provider checks certificates against what the trust
+   * store file holds at the time of the check: a file replaced with other certificates, or with
+   * the same certificates under another PIN, is loaded again, and a file that cannot be loaded
+   * leaves the last certificates loaded in use.
+   */
+  @Test
+  public void testTrustStoreLoadedAgainWhenChanged() throws Exception
+  {
+    final File configDir = new File(DirectoryServer.getInstanceRoot(), "config");
+    final File trustStore = new File(configDir, "reload-test.truststore");
+    final File pinFile = new File(configDir, "reload-test.truststore.pin");
+    replace(trustStore, Files.readAllBytes(new File(configDir, "server.truststore").toPath()));
+    replace(pinFile, ("password" + EOL).getBytes(StandardCharsets.UTF_8));
+
+    FileBasedTrustManagerProvider provider = initializeTrustManagerProvider(TestCaseUtils.makeEntry(
+        "dn: cn=Reloaded Trust Manager Provider,cn=SSL,cn=config",
+        "objectClass: top",
+        "objectClass: ds-cfg-trust-manager-provider",
+        "objectClass: ds-cfg-file-based-trust-manager-provider",
+        "cn: Reloaded Trust Manager Provider",
+        "ds-cfg-java-class: org.opends.server.extensions.FileBasedTrustManagerProvider",
+        "ds-cfg-enabled: true",
+        "ds-cfg-trust-store-file: config/reload-test.truststore",
+        "ds-cfg-trust-store-pin-file: config/reload-test.truststore.pin"));
+    try
+    {
+      final X509TrustManager trustManager = (X509TrustManager) provider.getTrustManagers()[0];
+      // a plain trust manager stays plain, for JSSE adds checks of its own around it; outside
+      // FIPS mode the provider wraps what it loads in a plain ExpirationCheckTrustManager
+      assertThat(trustManager instanceof X509ExtendedTrustManager).isEqualTo(isFips());
+      assertThat(trustManager.getAcceptedIssuers()).hasSize(3);
+
+      // the PIN changes with the trust store, but the new PIN is not there yet
+      replace(trustStore, withPassword(new File(configDir, "client.truststore"), "password", "changed"));
+      assertThat(trustManager.getAcceptedIssuers()).hasSize(3);
+      replace(pinFile, ("changed" + EOL).getBytes(StandardCharsets.UTF_8));
+      assertThat(trustManager.getAcceptedIssuers()).hasSize(2);
+
+      replace(trustStore, "not a trust store".getBytes(StandardCharsets.UTF_8));
+      assertThat(trustManager.getAcceptedIssuers()).hasSize(2);
+    }
+    finally
+    {
+      provider.finalizeTrustManagerProvider();
+      Files.deleteIfExists(trustStore.toPath());
+      Files.deleteIfExists(pinFile.toPath());
+    }
+  }
+
+  /**
+   * A trust store file that cannot be loaded is not tried again until it changes again, even
+   * where it would load by then: the PIN below comes from a system property, which is not stamped.
+   */
+  @Test
+  public void testTrustStoreNotLoadedAgainUntilChanged() throws Exception
+  {
+    final File configDir = new File(DirectoryServer.getInstanceRoot(), "config");
+    final File trustStore = new File(configDir, "retry-test.truststore");
+    replace(trustStore, Files.readAllBytes(new File(configDir, "server.truststore").toPath()));
+    System.setProperty("retry.test.trust.store.pin", "password");
+    FileBasedTrustManagerProvider provider = initializeTrustManagerProvider(TestCaseUtils.makeEntry(
+        "dn: cn=Retried Trust Manager Provider,cn=SSL,cn=config",
+        "objectClass: top",
+        "objectClass: ds-cfg-trust-manager-provider",
+        "objectClass: ds-cfg-file-based-trust-manager-provider",
+        "cn: Retried Trust Manager Provider",
+        "ds-cfg-java-class: org.opends.server.extensions.FileBasedTrustManagerProvider",
+        "ds-cfg-enabled: true",
+        "ds-cfg-trust-store-file: config/retry-test.truststore",
+        "ds-cfg-trust-store-pin-property: retry.test.trust.store.pin"));
+    try
+    {
+      final X509TrustManager trustManager = (X509TrustManager) provider.getTrustManagers()[0];
+      assertThat(trustManager.getAcceptedIssuers()).hasSize(3);
+
+      replace(trustStore, withPassword(new File(configDir, "client.truststore"), "password", "changed"));
+      assertThat(trustManager.getAcceptedIssuers()).hasSize(3);
+      System.setProperty("retry.test.trust.store.pin", "changed");
+      assertThat(trustManager.getAcceptedIssuers()).hasSize(3);
+    }
+    finally
+    {
+      provider.finalizeTrustManagerProvider();
+      System.clearProperty("retry.test.trust.store.pin");
+      Files.deleteIfExists(trustStore.toPath());
+    }
+  }
+
+  /**
+   * A trust store renewed together with its PIN file is loaded with the new PIN by the provider
+   * itself, as a component rebuilding its SSL context asks it to, with no certificate checked in
+   * between to read the PIN again.
+   */
+  @Test
+  public void testTrustStoreAndPinRenewedTogetherLoadedWithoutCheck() throws Exception
+  {
+    final File configDir = new File(DirectoryServer.getInstanceRoot(), "config");
+    final File trustStore = new File(configDir, "renewed-test.truststore");
+    final File pinFile = new File(configDir, "renewed-test.truststore.pin");
+    replace(trustStore, Files.readAllBytes(new File(configDir, "server.truststore").toPath()));
+    replace(pinFile, ("password" + EOL).getBytes(StandardCharsets.UTF_8));
+    FileBasedTrustManagerProvider provider = initializeTrustManagerProvider(TestCaseUtils.makeEntry(
+        "dn: cn=Renewed Trust Manager Provider,cn=SSL,cn=config",
+        "objectClass: top",
+        "objectClass: ds-cfg-trust-manager-provider",
+        "objectClass: ds-cfg-file-based-trust-manager-provider",
+        "cn: Renewed Trust Manager Provider",
+        "ds-cfg-java-class: org.opends.server.extensions.FileBasedTrustManagerProvider",
+        "ds-cfg-enabled: true",
+        "ds-cfg-trust-store-file: config/renewed-test.truststore",
+        "ds-cfg-trust-store-pin-file: config/renewed-test.truststore.pin"));
+    try
+    {
+      assertThat(((X509TrustManager) provider.getTrustManagers()[0]).getAcceptedIssuers()).hasSize(3);
+
+      replace(trustStore, withPassword(new File(configDir, "client.truststore"), "password", "changed"));
+      replace(pinFile, ("changed" + EOL).getBytes(StandardCharsets.UTF_8));
+      assertThat(((X509TrustManager) provider.getTrustManagers()[0]).getAcceptedIssuers()).hasSize(2);
+    }
+    finally
+    {
+      provider.finalizeTrustManagerProvider();
+      Files.deleteIfExists(trustStore.toPath());
+      Files.deleteIfExists(pinFile.toPath());
+    }
+  }
+
+  /**
+   * A changed configuration makes the trust managers already handed out load the trust store
+   * again on their next check, even where the file has not changed since they last looked at it.
+   */
+  @Test
+  public void testTrustStoreLoadedAgainWhenPinChangesInConfiguration() throws Exception
+  {
+    final File configDir = new File(DirectoryServer.getInstanceRoot(), "config");
+    final File trustStore = new File(configDir, "reconfigured-test.truststore");
+    replace(trustStore, Files.readAllBytes(new File(configDir, "server.truststore").toPath()));
+    FileBasedTrustManagerProvider provider = initializeTrustManagerProvider(
+        providerEntry("Reconfigured", "reconfigured-test.truststore", "password"));
+    try
+    {
+      final X509TrustManager trustManager = (X509TrustManager) provider.getTrustManagers()[0];
+      assertThat(trustManager.getAcceptedIssuers()).hasSize(3);
+
+      // a trust store under a PIN the configuration does not have yet: the failed load records its stamps
+      replace(trustStore, withPassword(new File(configDir, "client.truststore"), "password", "changed"));
+      assertThat(trustManager.getAcceptedIssuers()).hasSize(3);
+
+      provider.applyConfigurationChange(InitializationUtils.getConfiguration(
+          FileBasedTrustManagerProviderCfgDefn.getInstance(),
+          providerEntry("Reconfigured", "reconfigured-test.truststore", "changed")));
+      assertThat(trustManager.getAcceptedIssuers()).hasSize(2);
+    }
+    finally
+    {
+      provider.finalizeTrustManagerProvider();
+      Files.deleteIfExists(trustStore.toPath());
+    }
+  }
+
+  /**
+   * Each trust manager handed out loads the trust store on its own. Asking the provider again, as
+   * a component does to check a configuration change, leaves the trust managers handed out before
+   * alone: the one below keeps the trust store it last loaded, for its file has not changed since
+   * it failed to load it.
+   */
+  @Test
+  public void testTrustManagerHandedOutAgainLeavesEarlierOnesAlone() throws Exception
+  {
+    final File configDir = new File(DirectoryServer.getInstanceRoot(), "config");
+    final File trustStore = new File(configDir, "handed-out-test.truststore");
+    replace(trustStore, Files.readAllBytes(new File(configDir, "server.truststore").toPath()));
+    System.setProperty("handed.out.test.trust.store.pin", "password");
+    FileBasedTrustManagerProvider provider = initializeTrustManagerProvider(TestCaseUtils.makeEntry(
+        "dn: cn=Handed Out Trust Manager Provider,cn=SSL,cn=config",
+        "objectClass: top",
+        "objectClass: ds-cfg-trust-manager-provider",
+        "objectClass: ds-cfg-file-based-trust-manager-provider",
+        "cn: Handed Out Trust Manager Provider",
+        "ds-cfg-java-class: org.opends.server.extensions.FileBasedTrustManagerProvider",
+        "ds-cfg-enabled: true",
+        "ds-cfg-trust-store-file: config/handed-out-test.truststore",
+        "ds-cfg-trust-store-pin-property: handed.out.test.trust.store.pin"));
+    try
+    {
+      final X509TrustManager trustManager = (X509TrustManager) provider.getTrustManagers()[0];
+      assertThat(trustManager.getAcceptedIssuers()).hasSize(3);
+
+      replace(trustStore, withPassword(new File(configDir, "client.truststore"), "password", "changed"));
+      assertThat(trustManager.getAcceptedIssuers()).hasSize(3);
+      System.setProperty("handed.out.test.trust.store.pin", "changed");
+
+      assertThat(((X509TrustManager) provider.getTrustManagers()[0]).getAcceptedIssuers()).hasSize(2);
+      assertThat(trustManager.getAcceptedIssuers()).hasSize(3);
+    }
+    finally
+    {
+      provider.finalizeTrustManagerProvider();
+      System.clearProperty("handed.out.test.trust.store.pin");
+      Files.deleteIfExists(trustStore.toPath());
+    }
+  }
+
+  /**
+   * A plain trust manager handed out outside FIPS mode loads the trust store as it was loaded
+   * then, within an expiration check, after the server has turned to FIPS mode, and after it has
+   * turned away from it again: a renewal is taken either way.
+   */
+  @Test
+  public void testPlainTrustManagerLoadedAgainAsHandedOutWhenFipsModeChanges() throws Exception
+  {
+    final File configDir = new File(DirectoryServer.getInstanceRoot(), "config");
+    final File trustStore = new File(configDir, "fips-on-test.truststore");
+    replace(trustStore, Files.readAllBytes(new File(configDir, "server.truststore").toPath()));
+    final FipsSwitchedTrustManagerProvider provider = InitializationUtils.initializeTrustManagerProvider(
+        new FipsSwitchedTrustManagerProvider(), providerEntry("FIPS On", "fips-on-test.truststore", "password"),
+        FileBasedTrustManagerProviderCfgDefn.getInstance());
+    try
+    {
+      final X509TrustManager trustManager = (X509TrustManager) provider.getTrustManagers()[0];
+      assertThat(trustManager).isNotInstanceOf(X509ExtendedTrustManager.class);
+      assertThat(trustManager.getAcceptedIssuers()).hasSize(3);
+
+      provider.fips = true;
+      replace(trustStore, Files.readAllBytes(new File(configDir, "client.truststore").toPath()));
+      assertThat(trustManager.getAcceptedIssuers()).hasSize(2);
+
+      provider.fips = false;
+      replace(trustStore, Files.readAllBytes(new File(configDir, "server.truststore").toPath()));
+      assertThat(trustManager.getAcceptedIssuers()).hasSize(3);
+    }
+    finally
+    {
+      provider.finalizeTrustManagerProvider();
+      Files.deleteIfExists(trustStore.toPath());
+    }
+  }
+
+  /**
+   * In FIPS mode the provider hands out an extended trust manager, over the extended trust
+   * manager it loads, and loads the trust store again the same way, even after the server has
+   * turned away from FIPS mode, as it does once it has generated a certificate outside FIPS mode.
+   */
+  @Test
+  public void testTrustManagerHandedOutInFipsModeIsExtended() throws Exception
+  {
+    final File configDir = new File(DirectoryServer.getInstanceRoot(), "config");
+    final File trustStore = new File(configDir, "fips-test.truststore");
+    replace(trustStore, Files.readAllBytes(new File(configDir, "server.truststore").toPath()));
+    final FipsSwitchedTrustManagerProvider provider = new FipsSwitchedTrustManagerProvider();
+    provider.fips = true;
+    InitializationUtils.initializeTrustManagerProvider(provider,
+        providerEntry("FIPS", "fips-test.truststore", "password"), FileBasedTrustManagerProviderCfgDefn.getInstance());
+    try
+    {
+      final X509TrustManager trustManager = (X509TrustManager) provider.getTrustManagers()[0];
+      assertThat(trustManager).isInstanceOf(X509ExtendedTrustManager.class);
+      assertThat(trustManager.getAcceptedIssuers()).hasSize(3);
+
+      replace(trustStore, Files.readAllBytes(new File(configDir, "client.truststore").toPath()));
+      assertThat(trustManager.getAcceptedIssuers()).hasSize(2);
+
+      provider.fips = false;
+      replace(trustStore, Files.readAllBytes(new File(configDir, "server.truststore").toPath()));
+      assertThat(trustManager.getAcceptedIssuers()).hasSize(3);
+    }
+    finally
+    {
+      provider.finalizeTrustManagerProvider();
+      Files.deleteIfExists(trustStore.toPath());
+    }
+  }
+
+  /**
+   * Each certificate check of a trust manager handed out, of either kind, loads a renewed trust
+   * store first: the certificate a renewal drops is refused by the first check after it, and
+   * taken again by the first check after the next renewal brings it back. A check given a socket
+   * or an engine hands it on, and the one it delegates to refuses a check outside a handshake.
+   */
+  @Test
+  public void testEveryCertificateCheckLoadsAgain() throws Exception
+  {
+    final File configDir = new File(DirectoryServer.getInstanceRoot(), "config");
+    final File trustStore = new File(configDir, "check-test.truststore");
+    final byte[] serverTrustStore = Files.readAllBytes(new File(configDir, "server.truststore").toPath());
+    final byte[] clientTrustStore = Files.readAllBytes(new File(configDir, "client.truststore").toPath());
+    // client.truststore does not hold this self-signed certificate
+    final X509Certificate[] dropped =
+        { trustedCertificate(new File(configDir, "server.truststore"), "client-emailaddress-cert") };
+    replace(trustStore, serverTrustStore);
+    final FipsSwitchedTrustManagerProvider plainProvider = InitializationUtils.initializeTrustManagerProvider(
+        new FipsSwitchedTrustManagerProvider(), providerEntry("Plain Check", "check-test.truststore", "password"),
+        FileBasedTrustManagerProviderCfgDefn.getInstance());
+    final FipsSwitchedTrustManagerProvider extendedProvider = new FipsSwitchedTrustManagerProvider();
+    extendedProvider.fips = true;
+    InitializationUtils.initializeTrustManagerProvider(extendedProvider,
+        providerEntry("Extended Check", "check-test.truststore", "password"),
+        FileBasedTrustManagerProviderCfgDefn.getInstance());
+    try (ServerSocket serverSocket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress());
+        Socket socket = SSLSocketFactory.getDefault().createSocket(
+            InetAddress.getLoopbackAddress(), serverSocket.getLocalPort()))
+    {
+      final X509TrustManager plain = (X509TrustManager) plainProvider.getTrustManagers()[0];
+      assertThat(plain).isNotInstanceOf(X509ExtendedTrustManager.class);
+      final X509ExtendedTrustManager extended = (X509ExtendedTrustManager) extendedProvider.getTrustManagers()[0];
+      final List<CertificateCheck> checks = Arrays.asList(
+          chain -> plain.checkClientTrusted(chain, "RSA"),
+          chain -> plain.checkServerTrusted(chain, "RSA"),
+          chain -> extended.checkClientTrusted(chain, "RSA"),
+          chain -> extended.checkServerTrusted(chain, "RSA"),
+          chain -> extended.checkClientTrusted(chain, "RSA", (Socket) null),
+          chain -> extended.checkServerTrusted(chain, "RSA", (Socket) null),
+          chain -> extended.checkClientTrusted(chain, "RSA", (SSLEngine) null),
+          chain -> extended.checkServerTrusted(chain, "RSA", (SSLEngine) null));
+      for (int i = 0; i < checks.size(); i++)
+      {
+        replace(trustStore, clientTrustStore);
+        assertRefused(checks.get(i), dropped, "check " + i + " after the renewal that drops the certificate");
+        replace(trustStore, serverTrustStore);
+        checks.get(i).check(dropped);
+      }
+
+      // a socket connected, or an engine, with no handshake under way
+      final SSLEngine engine = SSLContext.getDefault().createSSLEngine();
+      assertRefused(chain -> extended.checkClientTrusted(chain, "RSA", socket), dropped, "client check, socket");
+      assertRefused(chain -> extended.checkServerTrusted(chain, "RSA", socket), dropped, "server check, socket");
+      assertRefused(chain -> extended.checkClientTrusted(chain, "RSA", engine), dropped, "client check, engine");
+      assertRefused(chain -> extended.checkServerTrusted(chain, "RSA", engine), dropped, "server check, engine");
+    }
+    finally
+    {
+      plainProvider.finalizeTrustManagerProvider();
+      extendedProvider.finalizeTrustManagerProvider();
+      Files.deleteIfExists(trustStore.toPath());
+    }
+  }
+
+  /** A certificate check of a trust manager. */
+  private interface CertificateCheck
+  {
+    void check(X509Certificate[] chain) throws CertificateException;
+  }
+
+  private static void assertRefused(CertificateCheck check, X509Certificate[] chain, String description)
+  {
+    try
+    {
+      check.check(chain);
+      fail(description + ": the certificate is taken");
+    }
+    catch (CertificateException expected)
+    {
+      // refused, as expected
+    }
+  }
+
+  private static X509Certificate trustedCertificate(File trustStore, String alias) throws Exception
+  {
+    final KeyStore keyStore = KeyStore.getInstance("JKS");
+    try (InputStream in = new FileInputStream(trustStore))
+    {
+      keyStore.load(in, "password".toCharArray());
+    }
+    return (X509Certificate) keyStore.getCertificate(alias);
+  }
+
+  /**
+   * A provider whose FIPS mode the test turns on, instead of inserting a FIPS security provider
+   * into the JVM, where it would stay for every test class run after this one.
+   */
+  private static final class FipsSwitchedTrustManagerProvider extends FileBasedTrustManagerProvider
+  {
+    private volatile boolean fips;
+
+    @Override
+    boolean isFipsMode()
+    {
+      return fips;
+    }
+  }
+
+  private static Entry providerEntry(String name, String trustStoreFile, String pin) throws Exception
+  {
+    return TestCaseUtils.makeEntry(
+        "dn: cn=" + name + " Trust Manager Provider,cn=SSL,cn=config",
+        "objectClass: top",
+        "objectClass: ds-cfg-trust-manager-provider",
+        "objectClass: ds-cfg-file-based-trust-manager-provider",
+        "cn: " + name + " Trust Manager Provider",
+        "ds-cfg-java-class: org.opends.server.extensions.FileBasedTrustManagerProvider",
+        "ds-cfg-enabled: true",
+        "ds-cfg-trust-store-file: config/" + trustStoreFile,
+        "ds-cfg-trust-store-pin: " + pin);
+  }
+
   private FileBasedTrustManagerProvider initializeTrustManagerProvider(Entry e) throws Exception {
     return InitializationUtils.initializeTrustManagerProvider(
         new FileBasedTrustManagerProvider(), e, FileBasedTrustManagerProviderCfgDefn.getInstance());
diff --git a/opendj-server-legacy/src/test/java/org/opends/server/extensions/FileStampTestCase.java b/opendj-server-legacy/src/test/java/org/opends/server/extensions/FileStampTestCase.java
new file mode 100644
index 0000000..adb7ddc
--- /dev/null
+++ b/opendj-server-legacy/src/test/java/org/opends/server/extensions/FileStampTestCase.java
@@ -0,0 +1,69 @@
+/*
+ * 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.extensions;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileTime;
+import java.util.List;
+
+import org.testng.SkipException;
+import org.testng.annotations.Test;
+
+/** Tests the stamps a file based key or trust manager provider tells a changed file by. */
+@Test(sequential = true)
+public class FileStampTestCase extends ExtensionsTestCase
+{
+  /**
+   * A file renamed over another one, the way a renewal agent or a Kubernetes Secret volume
+   * replaces it, changes the stamp even where its size and modification time are the same.
+   */
+  @Test
+  public void testRenameWithSameSizeAndTimeChangesStamp() throws Exception
+  {
+    final Path dir = Files.createTempDirectory("stamp");
+    try
+    {
+      final Path file = dir.resolve("stamped.bin");
+      Files.write(file, new byte[] { 1 });
+      if (Files.readAttributes(file, BasicFileAttributes.class).fileKey() == null)
+      {
+        throw new SkipException("no file key on this file system");
+      }
+      final FileTime time = Files.getLastModifiedTime(file);
+      final List<FileStamp> before = FileStamp.of(file.toFile());
+
+      final Path tmp = dir.resolve("stamped.tmp");
+      Files.write(tmp, new byte[] { 2 });
+      Files.setLastModifiedTime(tmp, time);
+      Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
+
+      assertThat(Files.size(file)).isEqualTo(1);
+      assertThat(Files.getLastModifiedTime(file)).isEqualTo(time);
+      assertThat(FileStamp.of(file.toFile())).isNotEqualTo(before);
+    }
+    finally
+    {
+      Files.deleteIfExists(dir.resolve("stamped.bin"));
+      Files.deleteIfExists(dir.resolve("stamped.tmp"));
+      Files.delete(dir);
+    }
+  }
+}

--
Gitblit v1.10.0