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 0f62462fe8..5149cab56f 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 @@ public class FileBasedKeyManagerProvider /** 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 stamps; + private final Set keyAliases; + private final X509ExtendedKeyManager keyManager; + + private LoadedKeyManager(int configurationChanges, List stamps, Set keyAliases, + X509ExtendedKeyManager keyManager) + { + this.configurationChanges = configurationChanges; + this.stamps = stamps; + this.keyAliases = keyAliases; + this.keyManager = keyManager; + } + + private boolean isLoadedFrom(int configurationChanges, List 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. + *

+ * 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 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 knownAliases = + current.configurationChanges == changes ? current.keyAliases : Collections. emptySet(); + try + { + final char[] pin = currentPIN(); + final KeyStore keyStore = getKeystore(pin); + final Set 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 @@ public void initializeKeyManagerProvider(FileBasedKeyManagerProviderCfg cfg) 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 @@ public boolean containsKeyWithAlias(String alias) { try { - KeyStore keyStore = getKeystore(); - Enumeration 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 @@ public boolean containsKeyWithAlias(String alias) 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 @@ private KeyStore getKeystore() throws DirectoryException } } + /** + * {@inheritDoc} + *

+ * 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 stamps = stampFiles(); + final char[] pin = currentPIN(); + final KeyStore keyStore = getKeystore(pin); + final Set 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 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 @@ public boolean containsAtLeastOneKey() { try { - return findOneKeyEntry(getKeystore()); + return !keyAliases(getKeystore(currentPIN())).isEmpty(); } catch (Exception e) { logger.traceException(e); @@ -185,18 +392,28 @@ public boolean containsAtLeastOneKey() } } - private boolean findOneKeyEntry(KeyStore keyStore) throws KeyStoreException + /** Returns the aliases the key store holds private keys under. */ + private Set keyAliases(KeyStore keyStore) throws DirectoryException { - Enumeration aliases = keyStore.aliases(); - while (aliases.hasMoreElements()) + try { - String alias = aliases.nextElement(); - if (keyStore.entryInstanceOf(alias, KeyStore.PrivateKeyEntry.class)) + final Set keyAliases = new TreeSet<>(); + final Enumeration 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; + } + catch (KeyStoreException e) + { + LocalizableMessage message = ERR_FILE_KEYMANAGER_CANNOT_LOAD.get(keyStoreFile, getExceptionMessage(e)); + throw new DirectoryException(DirectoryServer.getCoreConfigManager().getServerErrorResultCode(), message, e); } - return false; } @Override @@ -227,14 +444,20 @@ public ConfigChangeResult applyConfigurationChange(FileBasedKeyManagerProviderCf 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 32b4ad7295..5a6f4f8225 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 @@ public class FileBasedTrustManagerProvider { 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; @@ -68,6 +72,200 @@ public class FileBasedTrustManagerProvider /** The trust store type to use. */ 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 stamps; + private final X509TrustManager trustManager; + + private LoadedTrustManager(int configurationChanges, List stamps, X509TrustManager trustManager) + { + this.configurationChanges = configurationChanges; + this.stamps = stamps; + this.trustManager = trustManager; + } + + private boolean isLoadedFrom(int configurationChanges, List 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 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 * initializeTrustManagerProvider method must be called on the @@ -87,7 +285,7 @@ public void initializeTrustManagerProvider(FileBasedTrustManagerProviderCfg cfg) 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,8 +300,58 @@ public void finalizeTrustManagerProvider() currentConfig.removeFileBasedChangeListener(this); } + /** + * {@inheritDoc} + *

+ * 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 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 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 @@ public TrustManager[] getTrustManagers() throws DirectoryException 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 @@ public TrustManager[] getTrustManagers() throws DirectoryException } } + /** + * 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 unacceptableReasons) { @@ -173,14 +432,19 @@ public ConfigChangeResult applyConfigurationChange(FileBasedTrustManagerProvider 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 0000000000..1269ceb9e1 --- /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 of(File... files) + { + final List 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 84aa00ce09..29ea8ff69f 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 @@ ERR_LDAP_TRUSTMANAGER_PIN_FILE_EMPTY_651=File %s specified in \ 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 7702dce195..da0b42581a 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 @@ public void testInvalidConfigs(Entry e) 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> 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 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 4ea4ece122..f94205243b 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 @@ public void testInvalidConfigs(Entry e) } } + /** + * 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 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 0000000000..adb7ddcd02 --- /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 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); + } + } +}