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

Valery Kharseko
16 hours ago 64a563cc8931c3eec5d4980650f9b0dfd6a4f24e
[#1095] Load a file based key store or trust store again when the file changes (#1101)
5 files modified
2 files added
1528 ■■■■■ changed files
opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedKeyManagerProvider.java 279 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedTrustManagerProvider.java 278 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/main/java/org/opends/server/extensions/FileStamp.java 100 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/messages/org/opends/messages/extension.properties 13 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/extensions/FileBasedKeyManagerProviderTestCase.java 370 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/extensions/FileBasedTrustManagerProviderTestCase.java 419 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/src/test/java/org/opends/server/extensions/FileStampTestCase.java 69 ●●●●● patch | view | raw | blame | history
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();
    try
    {
      if (! findOneKeyEntry(keyStore))
    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
    {
      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();
    try
    {
      final Set<String> keyAliases = new TreeSet<>();
      final Enumeration<String> aliases = keyStore.aliases();
    while (aliases.hasMoreElements())
    {
      String alias = aliases.nextElement();
        final String alias = aliases.nextElement();
      if (keyStore.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class))
      {
        return true;
          keyAliases.add(alias);
      }
    }
    return false;
      return keyAliases;
    }
    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)
    {
      synchronized (this)
      {
      currentConfig = cfg;
      keyStorePIN   = newPIN;
      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;
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)
    {
      synchronized (this)
      {
      currentConfig = cfg;
      trustStorePIN   = newPIN;
      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;
opendj-server-legacy/src/main/java/org/opends/server/extensions/FileStamp.java
New file
@@ -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);
  }
}
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
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());
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());
opendj-server-legacy/src/test/java/org/opends/server/extensions/FileStampTestCase.java
New file
@@ -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);
    }
  }
}