Skip to content

[#1095] Load a file based key store or trust store again when the file changes - #1101

Open
vharseko wants to merge 4 commits into
OpenIdentityPlatform:masterfrom
vharseko:issue-1095-reload-key-store
Open

vharseko wants to merge 4 commits into
OpenIdentityPlatform:masterfrom
vharseko:issue-1095-reload-key-store

Conversation

@vharseko

@vharseko vharseko commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Problem

A connection handler builds its SSLContext once, when it is initialized or when its own configuration changes (LDAPConnectionHandler#configureSSL). The key managers in that context hold the key store as it was read at that moment. So a renewed key store file was not used until the server restarted, or until some property of the handler was changed. Measured on openidentityplatform/opendj:latest: after cp of a new config/keystore, LDAPS keeps serving the old certificate, and a dsconfig set-connection-handler-prop on the handler makes it serve the new one. Certificates renewed by an agent (cert-manager, certbot, Vault agent) need the new file used without a restart. The Docker side is #1087 / #1100.

Change

FileBasedKeyManagerProvider and FileBasedTrustManagerProvider hand out a key manager / trust manager that delegates to the one it last loaded from the file. Each manager handed out keeps its own state and loads the file on its own. So a later getKeyManagers() / getTrustManagers() call, which a connection handler makes when it checks or applies a configuration change, does not change the managers already in use:

  • Before a handshake chooses its alias, the manager compares the stamps of the store file and of the PIN file (if one is configured) with the stamps taken when they were loaded. For a trust manager the check happens before each certificate check and before getAcceptedIssuers. A stamp is the modification time, size and file key (inode) from BasicFileAttributes, with symbolic links followed (new package-private FileStamp). The file key catches a file replaced by rename, the way renewal agents and Kubernetes Secret volumes replace it.

  • When a stamp has changed, the manager loads the store again under its own lock and logs NOTE_FILE_{KEY,TRUST}MANAGER_RELOADED. The stamps are taken again inside the lock, and those are the ones recorded. Each manager handed out logs its own reload, so a renewal is logged once per manager that sees it.

  • The PIN is not cached. Every load, including getKeyManagers() / getTrustManagers() and containsAtLeastOneKey() / containsKeyWithAlias(), reads the PIN that the configuration names at that moment. So a failed load leaves no stale PIN behind, and a renewed PIN file is followed everywhere. This closes A key store and PIN renewed together fail to load in getKeyManagers() / getTrustManagers() until a handshake reloads the PIN #1105: a store renewed together with its PIN file loads through getKeyManagers() / getTrustManagers() without a handshake in between.

  • The last good manager stays in use, and ERR_FILE_{KEY,TRUST}MANAGER_CANNOT_RELOAD is logged once per manager, when the new file:

    • cannot be loaded (caught half written, or not matching its PIN);
    • is a key store with no private key;
    • is a key store with no private key under any alias the last good store held keys under (ERR_FILE_KEYMANAGER_NO_KNOWN_KEY_ALIAS).

    The provider does not see a handler's ssl-cert-nickname. A renewal that moves the key to another alias would leave the handler nothing to present, so the aliases of the last good store stand in for the nickname. The guard catches only a renewal that shares no alias with the last good store: a store with keys {A, B} renewed to {B} is taken, even if a handler names A. The stamps of the failed attempt are recorded, so the file is not tried again until it changes again. This covers a store and its PIN renewed as two separate writes. After such a refusal, a handler reconfigured to the new alias gets a new key manager that loads the file as it is.

  • getCertificateChain / getPrivateKey use the manager the alias was chosen from and do not look at the file. The one remaining window is noted in a comment: another handshake loading the file again between those calls.

  • A plain trust manager (ExpirationCheckTrustManager, the non-FIPS path) is handed out as a plain X509TrustManager, and an extended one (FIPS) as an extended one, so JSSE keeps adding the same checks around it as before. A plain one handed out accepts a reloaded manager of either kind, since isFips() can turn true while the server runs. An extended one requires an extended one. The provider asks a package-private isFipsMode(), which returns isFips() and which a test overrides.

  • applyConfigurationChange counts the configuration changes applied. A manager handed out that sees a new count loads the file again on its next use, and the alias guard is off for that load. So a changed key-store-file / PIN reaches the existing SSL contexts too.

  • Messages 652-656 are added to extension.properties. No open PR uses these numbers.

getKeyManagers() / getTrustManagers() still load the file on the call and throw as before when it cannot be loaded. A key store without a private key is still only logged there, so configuration validation is unchanged.

Tests

  • FileBasedKeyManagerProviderTestCase#testKeyStoreLoadedAgainWhenChanged: a key manager handed out once follows the file through these steps:
    1. server.keystore;
    2. the client.keystore key under server-cert, read through getServerAliases (the path LDAPS takes with ssl-cert-nickname);
    3. the server key under other-cert, and then server.truststore (no private key): the client certificate stays;
    4. server.keystore under a new password, with the new PIN file written after the key store: the certificate stays the previous one until the PIN file arrives;
    5. a file that is not a key store: the last certificate stays;
    6. the client key under other-cert: still refused, because the failed load in step 5 kept the aliases.
  • #testKeyManagerHandedOutAgainLeavesEarlierOnesAlone and FileBasedTrustManagerProviderTestCase#testTrustManagerHandedOutAgainLeavesEarlierOnesAlone: after a manager in use refuses the file, or fails to load it, the provider is asked again. The new manager loads the file as it is (on the key side, the key under other-cert, the handler-reconfigured-to-the-new-alias case), and the manager handed out before keeps what it had.
  • #testEveryAliasChoiceLoadsAgain: getClientAliases, chooseClientAlias, chooseEngineClientAlias and chooseServerAlias(Socket) (the OAuth2 client and JMX paths) each see a renewal first.
  • #testKeyStoreLoadedAgainWhenPinChangesInConfiguration and FileBasedTrustManagerProviderTestCase#testTrustStoreLoadedAgainWhenPinChangesInConfiguration: a PIN changed only in the configuration makes the manager load the unchanged file again. On the key side, after a further configuration change, a store with no private key is still not taken.
  • #testKeyStoreNotLoadedAgainUntilChanged and FileBasedTrustManagerProviderTestCase#testTrustStoreNotLoadedAgainUntilChanged: with the PIN in a system property, which is not stamped, a file that failed to load is not tried again when only the PIN changes.
  • FileBasedTrustManagerProviderTestCase#testTrustStoreLoadedAgainWhenChanged: with a PIN file, the test goes server.truststore (3 issuers) → client.truststore under a new password (still 3 until the PIN file changes, then 2) → not a trust store (still 2).
  • FileBasedTrustManagerProviderTestCase#testPlainTrustManagerTakesExtendedOneWhenFipsModeTurnsOn and #testTrustManagerHandedOutInFipsModeIsExtended: with isFipsMode() overridden by a test subclass, rather than a FIPS security provider inserted into the shared test JVM. A plain manager handed out outside FIPS mode follows a renewal loaded after FIPS mode turns on. In FIPS mode, the manager handed out is an X509ExtendedTrustManager and follows a renewal.
  • #testKeyStoreAndPinRenewedTogetherLoadedWithoutHandshake and FileBasedTrustManagerProviderTestCase#testTrustStoreAndPinRenewedTogetherLoadedWithoutCheck (A key store and PIN renewed together fail to load in getKeyManagers() / getTrustManagers() until a handshake reloads the PIN #1105): the store and its PIN file are replaced together, and the provider itself is asked with no handshake or certificate check in between, as a connection handler rebuilding its SSL context does. On the key side containsAtLeastOneKey() / containsKeyWithAlias() see the new store too.
  • FileStampTestCase: a file renamed over another with the same size and modification time changes the stamp. The test is skipped where the file system has no file key.
  • The three classes are green: 19/19, 19/19 and 1/1. Each of these mutants turns at least one of the new tests red:
    • getKeyManagers() / getTrustManagers() loading with the PIN read when the provider was initialized (the state before this PR's second commit);
    • one manager state shared by the provider, whether every getKeyManagers() / getTrustManagers() call replaces it or only the first one sets it;
    • no guard for a store without a private key, or without a known alias;
    • a failed key load recording no aliases;
    • getServerAliases, getClientAliases, chooseClientAlias, chooseEngineClientAlias or chooseServerAlias asking the manager last loaded;
    • no reset on a configuration change (key and trust side);
    • a failed load recording no stamps (key and trust side);
    • no PIN file in the trust stamps;
    • a trust kind check that requires the same class as the manager last loaded;
    • an always plain trust wrapper (the FIPS test), and an always extended one;
    • FileStamp without the file key.

Fixes #1095
Fixes #1105

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: The reload sits exactly where the stale certificate came from: the managers handed out, not the handler's SSL context.

  • FileStamp compares mtime, size and the file key with symbolic links followed, so a renewal agent's rename is caught (FileStamp.java:67-92).
  • A failed reload records its stamps and keeps the last good manager, while getKeyManagers() / getTrustManagers() still throw on an unloadable file, so configuration validation is unchanged (FileBasedKeyManagerProvider.java:294-298).
  • A plain trust manager stays plain, so JSSE keeps its own checks around ExpirationCheckTrustManager (FileBasedTrustManagerProvider.java:223-226).

issue (non-blocking): A reloaded key store that loads but has no usable key or alias replaces the last good key manager.

opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedKeyManagerProvider.java:284-313

The last good manager is kept only when loadKeyManagers() throws. A store with no private-key entry is only logged (:313), and the ssl-cert-nickname alias is checked only when a handler builds its SSL context (LDAPConnectionHandler.java:1335). Suppose a renewal is written under another alias than the configured one (config.ldif default server-cert). It is loaded at :291, SelectableCertificateKeyManager.findServerAlias (:184) finds no alias, and every LDAPS/HTTPS handshake fails until the file changes again. Only NOTE_FILE_KEYMANAGER_RELOADED is logged. Before this change the old certificate kept serving. The guard keeps the old manager for one unusable file (wrong PIN) but drops it for another (wrong alias).

        final KeyManager[] keyManagers = loadKeyManagers();
        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)));
        }
        if (!containsAtLeastOneKey())
        {
          throw new DirectoryException(DirectoryServer.getCoreConfigManager().getServerErrorResultCode(),
              ERR_NO_KEY_ENTRY_IN_KEYSTORE.get(keyStoreFile));
        }

Or: also keep the last good manager when the new store has none of the key aliases the last good store had.


issue (non-blocking): The only test of the key store reload does not exercise getServerAliases, the method LDAPS calls when ssl-cert-nickname is set.

opendj-server-legacy/src/test/java/org/opends/server/extensions/FileBasedKeyManagerProviderTestCase.java:358-363

serverCertificateOf chooses the alias with chooseEngineServerAlias. With a nickname set, LDAPConnectionHandler.java:1346 wraps the managers in SelectableCertificateKeyManager, which asks ReloadingKeyManager.getServerAliases instead. The mutant return loaded.keyManager.getServerAliases(keyType, issuers); at FileBasedKeyManagerProvider.java:124 passes testKeyStoreLoadedAgainWhenChanged, yet with it LDAPS keeps serving the old certificate. This test is the only one on #1095.

  /** 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();
  }

Pin: use it for the first read after replace(keyStore, client.keystore): final String clientCertificate = serverCertificateByAliasesOf(keyManager);. The :124 mutant then fails isNotEqualTo(serverCertificate).


issue (non-blocking): The trust reload rejects a new manager whose class differs from the last one, and isFips() can change that class while the server runs.

opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedTrustManagerProvider.java:265, :222, :305-311

loadTrustManagers wraps in ExpirationCheckTrustManager only when !isFips(), and isFips() matches any provider whose name contains "fips". Creating a BCFKS key or trust provider at runtime calls FipsStaticUtils.registerBcProvider(true), which inserts BCFIPS (FipsStaticUtils.java:53). From then on every renewal of a JKS trust store is rejected with ERR_FILE_TRUSTMANAGER_CANNOT_RELOAD, until the next getTrustManagers() stores the raw manager at :222. After that, the plain wrappers handed out earlier run without ExpirationCheckTrustManager. The cause argument of that message is Arrays.toString(trustManagers), and the key side does the same at :289.

        if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)
            || current.trustManager instanceof X509ExtendedTrustManager
                && !(trustManagers[0] instanceof X509ExtendedTrustManager))

issue (non-blocking): A failed reload leaves the new PIN in keyStorePIN / trustStorePIN.

opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedKeyManagerProvider.java:284, :294-298; FileBasedTrustManagerProvider.java:263

The PIN field is assigned before the load, and the catch block does not restore it. Suppose the PIN file is renewed before the store, or alone. One handshake stores the new PIN and fails on the old store. Until the store is renewed, containsAtLeastOneKey() / containsKeyWithAlias() return false and getKeyManagers() throws. Enabling or reconfiguring an SSL handler on this provider is then rejected (LDAPConnectionHandler.java:1318, :1335, HTTPConnectionHandler.java:893, :911). Without such a handshake in the window, the same change is accepted.

      final char[] previousPIN = keyStorePIN;
      try
      {
        // ... unchanged, including keyStorePIN = pin;
      }
      catch (DirectoryException e)
      {
        keyStorePIN = previousPIN;
        logger.traceException(e);
        loaded = new LoadedKeyManager(stamps, current.keyManager);
        logger.error(ERR_FILE_KEYMANAGER_CANNOT_RELOAD, keyStoreFile, currentConfig.dn(), e.getMessageObject());
      }

Or: the next suggestion, which removes the cached PIN from the road altogether.


suggestion (non-blocking): getKeyManagers() / getTrustManagers() could read the PIN the configuration names now, as the reload does.

opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedTrustManagerProvider.java:214-217; FileBasedKeyManagerProvider.java:237-240

Only currentKeyManager / currentTrustManager re-read the PIN (:279 / :258). After a store and its PIN are renewed, a fresh getTrustManagers() still loads with the cached PIN and throws ERR_FILE_TRUSTMANAGER_CANNOT_LOAD. That call comes from pass-through authentication (LDAPPassThroughAuthenticationPolicyFactory.java:1159) or from a reconfigured handler. It keeps throwing until a handshake on a manager handed out earlier refreshes the field, and never if no such manager shares the provider. This already happened before the change, so it is a gap in the renewal story, not a regression.

  /** Reads the PIN the configuration names now, as currentTrustManager does. */
  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;
  }

Then trustStorePIN = currentPIN(); before loadTrustManagers() in getTrustManagers() and in currentTrustManager(), and the same for the key store.


suggestion (non-blocking): Take the stamps again inside the lock.

opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedTrustManagerProvider.java:242-251; FileBasedKeyManagerProvider.java:263-272

The double-check under the lock compares loaded.stamps with the caller's pre-lock stamps and records those. Suppose a thread stamped an intermediate state of a non-atomic write. It reloads what another thread has just loaded, and the next caller reloads once more. The NOTE/ERR message is then logged two or three times and the file re-read, although the description says the error is logged once. The manager chosen stays correct.

    List<FileStamp> stamps = stampFiles();
    LoadedTrustManager current = loaded;
    if (current.stamps.equals(stamps))
    {
      return current.trustManager;
    }
    synchronized (this)
    {
      stamps = stampFiles();
      current = loaded;
      // ... unchanged

suggestion (non-blocking): No test covers the reset in applyConfigurationChange that makes the managers already handed out load again.

opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedKeyManagerProvider.java:397-400; FileBasedTrustManagerProvider.java:365-368

Neither test class calls applyConfigurationChange. With the block deleted, every test stays green. The block only matters when the PIN or type changes and the files do not, since a new file path changes the stamp anyway.

  @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(providerEntry("password"));
    try
    {
      final X509ExtendedKeyManager keyManager = (X509ExtendedKeyManager) provider.getKeyManagers()[0];
      final String serverCertificate = serverCertificateOf(keyManager);

      // a store under a PIN the configuration does not have yet: the failed reload records its stamps
      replace(keyStore, withPassword(new File(configDir, "client.keystore"), "password", "changed"));
      assertThat(serverCertificateOf(keyManager)).isEqualTo(serverCertificate);

      provider.applyConfigurationChange(InitializationUtils.getConfiguration(
          FileBasedKeyManagerProviderCfgDefn.getInstance(), providerEntry("changed")));
      assertThat(serverCertificateOf(keyManager)).isNotEqualTo(serverCertificate);
    }
    finally
    {
      provider.finalizeKeyManagerProvider();
      Files.deleteIfExists(keyStore.toPath());
    }
  }

  private static Entry providerEntry(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);
  }

Pin: the last assertion fails with the block at :397-400 deleted.


suggestion (non-blocking): No test pins "not retried until the file changes again".

opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedKeyManagerProvider.java:297; FileBasedTrustManagerProvider.java:276

The mutant that leaves loaded unchanged in the catch block passes both tests. Retrying an unchanged bad file fails again and returns the same manager, and neither test checks logs or load counts. A PIN source that is not stamped makes a retry visible:

      // provider entry with ds-cfg-key-store-pin-property: reload.test.pin instead of the PIN file
      System.setProperty("reload.test.pin", "password");
      // ... provider, keyManager, serverCertificate as in testKeyStoreLoadedAgainWhenChanged
      replace(keyStore, withPassword(new File(configDir, "client.keystore"), "password", "changed"));
      assertThat(serverCertificateOf(keyManager)).isEqualTo(serverCertificate);
      System.setProperty("reload.test.pin", "changed");
      assertThat(serverCertificateOf(keyManager)).isEqualTo(serverCertificate);

Pin: the mutant retries on the last call and returns the client certificate. The same shape with ds-cfg-trust-store-pin-property covers :276.


suggestion (non-blocking): The trust test does not cover the PIN-file stamp, the PIN re-read or the choice between the plain and extended wrapper.

opendj-server-legacy/src/test/java/org/opends/server/extensions/FileBasedTrustManagerProviderTestCase.java:315

The test uses a literal ds-cfg-trust-store-pin: password and no PIN file, and it only calls getAcceptedIssuers(). These mutants all pass: dropping the PIN file from stampFiles() (:231), dropping trustStorePIN = pin (:263), and always handing out ReloadingTrustManager (:225).

    // provider entry with ds-cfg-trust-store-pin-file: config/reload-test.truststore.pin, holding "password"
    final X509TrustManager trustManager = (X509TrustManager) provider.getTrustManagers()[0];
    assertThat(trustManager).isNotInstanceOf(X509ExtendedTrustManager.class);
    assertThat(trustManager.getAcceptedIssuers()).hasSize(3);
    replace(trustStore, FileBasedKeyManagerProviderTestCase.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);

Pin: withPassword becomes package-private. Dropping the PIN-file stamp or the PIN re-read makes the last assertion fail. The isNotInstanceOf fails if an extended wrapper is always handed out.


suggestion (non-blocking): No test pins that the file key catches a rename that keeps size and mtime.

opendj-server-legacy/src/main/java/org/opends/server/extensions/FileStamp.java:92

Every replace() in both tests also changes the size, so the mutant that drops Objects.equals(fileKey, other.fileKey) passes. That is the Kubernetes Secret case the description names.

public class FileStampTestCase extends ExtensionsTestCase
{
  @Test
  public void testRenameWithSameSizeAndTimeChangesStamp() throws Exception
  {
    final Path file = Files.createTempFile("stamp", ".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 = Files.createTempFile(file.getParent(), "stamp", ".tmp");
    Files.write(tmp, new byte[] { 2 });
    Files.setLastModifiedTime(tmp, time);
    Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
    assertThat(FileStamp.of(file.toFile())).isNotEqualTo(before);
  }
}

Pin: the assertion fails with the file-key term dropped. The skip covers Windows, where fileKey() is null and build-maven also runs

@vharseko

vharseko commented Sep 25, 2026 •

Copy link
Copy Markdown
Member Author

A note carried over from the review of #1100, recorded here so it is not lost. It is not a change request.

In #1100, a keystore that comes with a new password is copied into config/ while the server runs. A handshake after that goes through currentKeyManager(), which reads the PIN again from the configuration (FileBasedKeyManagerProvider.java:279-284) before it loads the store, so the new password is picked up there.

getKeyManagers() (:237-246) does not do this: loadKeyManagers() → getKeystore() loads the file with the keyStorePIN in memory. When the store and its PIN have both changed and no handshake has come through since, a call to getKeyManagers() (a dsconfig change of the LDAPS, StartTLS or HTTPS handler, which rebuilds the SSL context) fails with ERR_FILE_KEYMANAGER_CANNOT_LOAD, and the change is rejected. In the Docker image the window is at most one health-check interval (30 s), because the probe's LDAPS handshake reloads the PIN.

The fix would be for getKeyManagers() to read the PIN again before it loads the store, the way currentKeyManager() does. FileBasedTrustManagerProvider#getTrustManagers() has the same shape and needs the same check. This is based on reading the code at a1e78af; I have not reproduced it.

Tracked as #1105.

…e again when the file changes

A connection handler builds its SSL context once, so a renewed key store file was not
used until the server restarted or the handler's configuration changed. The key and
trust managers handed out by the file based providers now check the file, and its PIN
file, when a handshake starts (a certificate is checked, for a trust store) and load
it again when it has changed. A file that cannot be loaded leaves the last good
content in use and is logged once, until it changes again.

Fixes OpenIdentityPlatform#1095
…al holds no key a handler can present, and read the PIN on every load

Review round 2 of OpenIdentityPlatform#1101:
- a reloaded key store with no private key, or with none under the aliases the last
  good one held keys under, leaves the last good key manager in use and logs why
  (ERR_FILE_KEYMANAGER_NO_KNOWN_KEY_ALIAS_656); a configuration change clears the
  aliases remembered;
- the PIN is no longer cached: every load reads the PIN the configuration names at
  that moment, so a failed reload leaves nothing behind, and getKeyManagers() /
  getTrustManagers() follow a renewed PIN file;
- a plain trust manager handed out takes a new trust manager of either kind, so a
  server that has turned to FIPS mode keeps loading a renewed trust store;
- the stamps are taken again under the lock, and those are recorded;
- tests for getServerAliases, the reset on a configuration change, no retry of an
  unchanged bad file, the trust store PIN file and wrapper kind, and the file key
  of FileStamp.
@vharseko
vharseko force-pushed the issue-1095-reload-key-store branch from a1e78af to 735b9aa Compare September 25, 2026 10:44
@vharseko

Copy link
Copy Markdown
Member Author

Thanks for the review. All ten points are taken, in 735b9aa. I rebased the branch onto the current master before starting the round; the rebase was clean.

1. A reloaded store with no usable key replaced the last good one. Taken, both halves. The reload now keeps the last good key manager in two cases: when the new store holds no private key (ERR_NO_KEY_ENTRY_IN_KEYSTORE), and when it holds none under any alias the last good store held private keys under (new ERR_FILE_KEYMANAGER_NO_KNOWN_KEY_ALIAS_656). The provider cannot see the handler's ssl-cert-nickname, so the alias guard compares against the aliases of the last good store. Two differences from the snippet:

  • The check runs on the KeyStore already loaded, not through containsAtLeastOneKey(). That method reads the file a second time, possibly seeing a newer write, and with the PIN of that moment.
  • getKeyManagers() still only logs a missing key, as before, so configuration validation is unchanged.

applyConfigurationChange clears the remembered aliases along with the stamps. A store that the new configuration names under other aliases is therefore still accepted. The trade-off: a renewal that moves the key to another alias now needs a restart or a configuration change, and the ERR message names the aliases it expected. With the default ssl-cert-nickname, such a renewal used to make every handshake fail.

Before this round, the test's server.keystore → client.keystore step also changed the alias (server-cert → client-cert). It now puts the client key under server-cert. Two steps are added, and in both the previous certificate stays:

  • the server key under other-cert;
  • server.truststore, which holds no private key.

The second step is also caught by the alias guard, since an empty alias set shares no alias with the last store. So the no-key guard is pinned where no aliases are remembered: after a configuration change, in testKeyStoreLoadedAgainWhenPinChangesInConfiguration (see 7).

2. getServerAliases was not exercised. Taken as suggested: serverCertificateByAliasesOf reads the first certificate after the renewal.

3. The class comparison rejected every trust reload once FIPS came up. Taken as suggested. A plain trust manager that was handed out accepts a new manager of either kind; an extended one requires an extended one. isFips() only goes from false to true (providers are inserted, never removed), so an extended wrapper never meets a plain ExpirationCheckTrustManager.

4 + 5. The cached PIN. Taken, by removing it. keyStorePIN / trustStorePIN are gone. Every load reads the PIN that the configuration names at that moment and passes it down: getKeyManagers(), getTrustManagers(), containsAtLeastOneKey(), containsKeyWithAlias() and the reload. A failed reload has nothing left to leave behind. initializeKeyManagerProvider / applyConfigurationChange still validate the PIN source as before. One visible difference: if a PIN file is removed while the server runs, the next getKeyManagers() now fails with the PIN error, instead of succeeding with the PIN read at start.

This also removes the gap recorded above from the review of #1100 and tracked as #1105: getKeyManagers() / getTrustManagers() now load a store renewed together with its PIN file without waiting for a handshake. The test that #1105 proposes, where the store and its PIN file are replaced and getKeyManagers() / getTrustManagers() is then called with no handshake in between, is not part of this round. So #1105 is left open, and the PR body does not claim to fix it.

6. Stamps taken before the lock. Taken: the stamps are taken again under the lock, and those are the ones recorded. No test pins this; it would take a controlled interleaving of two handshakes.

7. The applyConfigurationChange reset was untested. Taken: testKeyStoreLoadedAgainWhenPinChangesInConfiguration, close to your snippet. It gained one more step: after a second configuration change, a store with no private key is still not accepted.

8. "Not retried until the file changes" was untested. Taken on both sides: testKeyStoreNotLoadedAgainUntilChanged / testTrustStoreNotLoadedAgainUntilChanged, with the PIN in a system property. On the key side, the renewed store keeps the alias server-cert; otherwise the alias guard from 1 would reject it for a reason other than the PIN.

9. The trust test lacked the PIN file, the PIN re-read and the plain/extended check. Taken. The test now uses ds-cfg-trust-store-pin-file, renews the store under a new password first and the PIN file after it, and withPassword is now package-private. One change from the snippet: the wrapper assertion is assertThat(trustManager instanceof X509ExtendedTrustManager).isEqualTo(isFips()) instead of isNotInstanceOf. The reason is that bc-fips is on the test classpath and AdsTrustStoreInstallTestCase creates a BCFKS store in the same JVM.

10. A rename with the same size and mtime was untested. Taken: new FileStampTestCase, with the SkipException for file systems without a file key (the Windows legs).

I also added the missing Portions Copyright 2026 3A Systems, LLC. line to extension.properties.

Verification (-Pprecommit, reactor build): FileBasedKeyManagerProviderTestCase 16/16, FileBasedTrustManagerProviderTestCase 14/14, FileStampTestCase 1/1. Each mutant below turns the named test red:

Mutant Red test
getServerAliases asks loaded.keyManager (your :124) testKeyStoreLoadedAgainWhenChanged
no alias guard testKeyStoreLoadedAgainWhenChanged
no no-key guard testKeyStoreLoadedAgainWhenPinChangesInConfiguration
no reset in applyConfigurationChange testKeyStoreLoadedAgainWhenPinChangesInConfiguration
key catch block leaves loaded unchanged testKeyStoreNotLoadedAgainUntilChanged
trust catch block leaves loaded unchanged testTrustStoreNotLoadedAgainUntilChanged
PIN file dropped from the trust stamps testTrustStoreLoadedAgainWhenChanged
always the extended trust wrapper testTrustStoreLoadedAgainWhenChanged
FileStamp.equals without the file key testRenameWithSameSizeAndTimeChangesStamp

…le loads without a handshake in between

The PIN is read on every load since the previous commit, so getKeyManagers() /
getTrustManagers() open a key store or trust store renewed together with its PIN
file with the new PIN, where they used the PIN held since the provider was
configured. Pin it on both sides: replace the store and its PIN file, then ask the
provider itself, as a connection handler rebuilding its SSL context does.

Fixes OpenIdentityPlatform#1105
@vharseko

Copy link
Copy Markdown
Member Author

A follow-up to the round above: 6705997 adds the test that #1105 proposes, so this PR now also fixes #1105 (Fixes #1105 is added to the body). It is a test-only commit on top of 735b9aa, and the branch base is unchanged.

  • FileBasedKeyManagerProviderTestCase#testKeyStoreAndPinRenewedTogetherLoadedWithoutHandshake: the key store and its PIN file are replaced together (the client key under server-cert, under a new password). Then the provider itself is asked, with no handshake in between: containsAtLeastOneKey() and containsKeyWithAlias("server-cert") are true, and getKeyManagers() presents the new certificate.
  • FileBasedTrustManagerProviderTestCase#testTrustStoreAndPinRenewedTogetherLoadedWithoutCheck: the same on the trust side. getTrustManagers() returns the renewed store (2 issuers instead of 3), with no certificate checked in between.

Verification: the two classes are green (17/17, 15/15). A mutant that restores the round-1 behaviour turns exactly these two tests red, with the failure #1105 describes: ERR_FILE_{KEY,TRUST}MANAGER_CANNOT_LOAD … Keystore was tampered with, or password was incorrect. In that mutant, the PIN is read once when the provider is initialized and then used by getKeyManagers() / getTrustManagers().

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Round 2 closes all ten points of round 1 where the bug is, and the reload path is now careful about what it records.

  • FileStamp adds the file key to mtime and size, so a file renamed over another with the same size and mtime is caught, as renewal agents and Kubernetes Secret volumes replace it (FileStampTestCase).
  • The stamps are taken again under the lock, and those are the ones recorded (FileBasedKeyManagerProvider.java:293, FileBasedTrustManagerProvider.java:263).
  • The PIN is no longer cached: every load reads currentPIN() (FileBasedKeyManagerProvider.java:210), so a failed reload leaves no stale PIN behind.

issue (blocking): getKeyManagers() replaces the key manager of every running handler and skips the reload guards.

opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedKeyManagerProvider.java:248-265, :304-313

getKeyManagers() writes the provider-wide loaded, which every ReloadingKeyManager handed out earlier reads (:111-153, :284). It only logs a store with no private key (:254-258) and never runs the alias check. A handler's configuration dry run calls it before the change can be refused: LDAPConnectionHandler2.isConfigurationChangeAcceptable :629 → :584 createSSLContext → getKeyManagers() :965/:978/:987 (LDAPConnectionHandler :776 → :720 → :1328/:1346). Take a renewal that moves the key to alias 1 (cert-manager's alias), which the handshake path refused at :309. Any later dsconfig set-connection-handler-prop on a handler that uses the same provider, refused or not, then installs the refused store with current stamps. With ssl-cert-nickname: server-cert, every LDAPS handshake fails until the file changes again. At BASE the dry run's key managers were private and thrown away. 6705997 changes only tests, so this is still there at the current head.

    final KeyManager[] keyManagers = loadKeyManagers(keyStore, pin);
    if (keyManagers.length != 1 || !(keyManagers[0] instanceof X509ExtendedKeyManager))
    {
      return keyManagers;
    }
    synchronized (this)
    {
      // a store already handed out is replaced only by the guarded reload in currentKeyManager()
      if (loaded == null)
      {
        loaded = new LoadedKeyManager(stamps, keyAliases, (X509ExtendedKeyManager) keyManagers[0]);
      }
    }
    return new KeyManager[] { new ReloadingKeyManager() };

Pin: in testKeyStoreLoadedAgainWhenChanged, after the other-cert step is refused, call provider.getKeyManagers() once more and assert that the manager handed out first still serves the client certificate. This goes red at 735b9aa.


suggestion (non-blocking): No test pins that a failed reload keeps the remembered aliases.

opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedKeyManagerProvider.java:326

The catch keeps current.keyAliases. A mutant that records Collections.emptySet() there stays green. In testKeyStoreLoadedAgainWhenChanged, every store after the first failed load is either refused by the no-key check (:304, which runs before the alias check) or holds server-cert. With the mutant, one half-written file turns the alias guard off for the next renewal. I traced this by reading and did not run it.

Pin: after the "not a key store" step, write the server key under other-cert again and assert that the certificate stays the same. That kills the mutant.


suggestion (non-blocking): No test pins the relaxed trust kind check, where a plain manager accepts an extended reload.

opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedTrustManagerProvider.java:276

Both trust tests reload ExpirationCheckTrustManager → ExpirationCheckTrustManager with isFips() false. So round 1's trustManagers[0].getClass() != current.trustManager.getClass(), restored as a mutant, stays green. The fix for a server that turns FIPS mode on while running could be reverted and no test would notice.

Pin: load the store once, make isFips() true in this class's JVM (FipsStaticUtils.registerBcProvider(true) inserts BCFIPS at runtime), renew the file, and assert that getAcceptedIssuers() follows the new store.


suggestion (non-blocking): No test calls four of ReloadingKeyManager's six alias methods.

opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedKeyManagerProvider.java:109-136, opendj-server-legacy/src/test/java/org/opends/server/extensions/FileBasedKeyManagerProviderTestCase.java:459-469

The tests reach the reload only through chooseEngineServerAlias and getServerAliases. If getClientAliases, chooseClientAlias, chooseEngineClientAlias or chooseServerAlias(Socket) delegated to loaded.keyManager instead of currentKeyManager(), every test would stay green. Production uses two of those roads. JMX without a nickname hands the unwrapped manager to a server SSLSocket (RmiConnector.java:346), and OAuth2 client TLS (HttpOAuth2AuthorizationMechanism.java:123) calls the client methods. A mutant on either road keeps presenting the stale certificate after a renewal.

Pin: after one renewal, read keyManager.getCertificateChain(keyManager.chooseClientAlias(new String[] { "RSA" }, null, null))[0] before any other call. After a second renewal, read keyManager.getCertificateChain(keyManager.chooseServerAlias("RSA", null, null))[0] first. Assert the renewed certificate each time.


suggestion (non-blocking): No test pins the stamp reset in the trust provider's applyConfigurationChange.

opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedTrustManagerProvider.java:375-379

No test calls the trust provider's applyConfigurationChange, so deleting the reset stays green. Without the reset, a trust store renewed before its trust-store-pin change keeps the old CA set in live contexts until the file changes again. This is the trust half of round-1 point 7.

Pin: add the counterpart of testKeyStoreLoadedAgainWhenPinChangesInConfiguration. Write client.truststore under a new password while the configuration still names the old PIN; getAcceptedIssuers() stays at 3. Then call applyConfigurationChange with the new PIN and assert 2.


question (non-blocking): Which trust-wrapper mutant did you measure red: "an always plain trust wrapper" (the PR body) or "always the extended trust wrapper" (your round-1 reply)?

opendj-server-legacy/src/test/java/org/opends/server/extensions/FileBasedTrustManagerProviderTestCase.java:324

The test JVM is not in FIPS mode, and the loaded manager is ExpirationCheckTrustManager, a plain X509TrustManager. Under an always-plain getTrustManagers(), assertThat(trustManager instanceof X509ExtendedTrustManager).isEqualTo(isFips()) is false == false, so only "always extended" fails. If the body means the extended (FIPS) branch of getTrustManagers() is pinned, it is not: no CI cell runs failsafe in FIPS mode, and that makes this a Major test gap. If it is a slip, correct the body.

Pin: make isFips() true in this class's JVM (FipsStaticUtils.registerBcProvider(true); I did not run this) and assert that getTrustManagers()[0] is an X509ExtendedTrustManager.


nitpick (non-blocking): The Javadoc of currentKeyManager() says the alias guard protects the key named by a handler's ssl-cert-nickname, but the rule it implements does not.

opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedKeyManagerProvider.java:274-281, :309

Take a last good store with {A, B}, a handler with nickname A, and a renewal with only {B}. :309 passes because B is shared, the renewal is taken, and the handler has no key to present. The any-alias rule is your stated trade-off. The Javadoc and the commit subject ("holds no key a handler can present") should say that the guard catches only a renewal that shares no alias with the last good store.

…nded out load the file on its own

getKeyManagers() stored what it loaded in a field shared by every key
manager handed out before, without the checks a reload runs. A handler's
configuration check calls it, so it could install a key store that a
reload had refused, for every running handler. Each key manager and
trust manager handed out now keeps what it last loaded. A configuration
change reaches them through a counter of changes applied.

Tests pin the managers handed out before a new getKeyManagers() /
getTrustManagers() call, the aliases kept by a failed load, the four
other alias choices, the reset on the trust side, and both FIPS paths,
through a package-private isFipsMode() that a test overrides.
@vharseko

Copy link
Copy Markdown
Member Author

Thanks for the review. All seven points are taken, in 6fd0db1. The blocking one is fixed differently from the snippet; the reason is below. The branch base is unchanged: it is still the current master (d30ff78).

1 (blocking). getKeyManagers() replaced the key manager of every running handler. Confirmed: the dry run wrote the shared loaded without the reload guards. The fix returns to what was there before this PR: key managers handed out are private. Each ReloadingKeyManager now keeps what it last loaded (the stamps, the aliases and the manager) and reloads under its own lock. getKeyManagers() builds a new one and changes nothing shared.

I did not take the loaded == null snippet, because it breaks the obvious remedy for the case you describe. A renewal moves the key to alias 1, the guard refuses it and logs ERR_FILE_KEYMANAGER_NO_KNOWN_KEY_ALIAS, and the admin sets ssl-cert-nickname: 1 on the handler. The dry run passes, because containsKeyWithAlias("1") reads the file. But the handler's new SSLContext gets a key manager that delegates to the shared, older store, which has no alias 1. Its stamps match the file, since the failed attempt recorded them, so nothing reloads, and the handler fails every handshake until a restart. With private managers, the reconfigured handler loads the file as it is, and the other handlers keep their last good store.

applyConfigurationChange can no longer reset a single field, so the provider counts the configuration changes it applies (configurationChanges, FileBasedKeyManagerProvider.java:457). A manager that sees a new count loads the file again, and the alias guard is off for that load (:159), as the reset did before. The trust side is changed the same way (TrustStoreFollower, FileBasedTrustManagerProvider.java:109). There the dry run could not install a store that a reload would refuse, but it still changed live state. One visible difference: after a renewal, NOTE/ERR_FILE_{KEY,TRUST}MANAGER_* is logged once per manager handed out that sees it, not once per provider. The PR body now says so.

Pin: testKeyManagerHandedOutAgainLeavesEarlierOnesAlone. The client key is written under other-cert and refused by the manager in use. Then getKeyManagers() is called again. The new manager's getServerAliases is exactly other-cert and presents the new certificate, and the first manager still presents the old one. At 6705997 the last assertion fails. With the snippet, the second assertion fails: the new manager returns ["ads-certificate", "server-cert"], the older store. The trust counterpart is testTrustManagerHandedOutAgainLeavesEarlierOnesAlone, with the PIN in a system property: after a failed load, a new getTrustManagers() loads the store, and the manager handed out before keeps the old one. It fails at 6705997.

2. A failed reload keeping the aliases was unpinned. Taken, with one change to the pin. After the "not a key store" step, the manager presents the server certificate, so writing the server key under other-cert gives the same subject whether the file is taken or refused, and serverCertificateOf compares only the subject. The added step writes the client key under other-cert (with the current PIN, changed). The mutant that records Collections.emptySet() in the catch then takes it, and testKeyStoreLoadedAgainWhenChanged fails.

3. The relaxed trust kind check was unpinned. Taken, but not through registerBcProvider(true). The failsafe run is one JVM with parallel=none and alphabetical order, so BCFIPS would stay provider #1 for every class after this one and would also take over loading JKS. AdsTrustStoreInstallTestCase also sorts before org.opends.server.extensions, so isFips() already depends on the run. Instead, the provider decides through a package-private isFipsMode() (:390) that returns isFips(), and a test subclass overrides it. testPlainTrustManagerTakesExtendedOneWhenFipsModeTurnsOn hands out a plain manager, switches FIPS mode on, renews the file, and expects getAcceptedIssuers() to follow it (3 → 2). Round 1's getClass() comparison, restored as a mutant, fails it. The kind check now reads a flag fixed when the manager is handed out, not the kind of the manager last loaded (:152).

4. Four of the six alias methods were not called. Taken: testEveryAliasChoiceLoadsAgain renews the file four times, alternating the client and the server key under server-cert. After each renewal, the first call is one of getClientAliases, chooseClientAlias, chooseEngineClientAlias and chooseServerAlias(Socket), and the certificate of the alias chosen must be the renewed one. server.keystore also holds ads-certificate, which a client-side choice may pick, so the test compares certificates, not alias names. Each of the four mutants that delegates to loaded.keyManager fails it.

5. The reset in the trust applyConfigurationChange was untested. Taken: testTrustStoreLoadedAgainWhenPinChangesInConfiguration, the counterpart of the key test. It sees 3 issuers, still 3 after client.truststore is written under a PIN the configuration does not have yet, and 2 after applyConfigurationChange with that PIN.

6. Question: which trust wrapper mutant was measured? "Always the extended trust wrapper", as in my round-1 reply. "Always plain" in the PR body was a slip, and you are right that it could not have failed outside FIPS mode. The FIPS branch is now pinned: testTrustManagerHandedOutInFipsModeIsExtended runs with FIPS mode on, expects an X509ExtendedTrustManager, and reloads extended → extended. The mutant that always hands out the plain wrapper fails it. The PR body is corrected.

7. Nitpick: the Javadoc overclaimed. Taken. The Javadoc of currentKeyManager() now says that the aliases of the file last loaded stand in for the handler's nickname, and that a file keeping one of them, but not the one a handler names, is still taken (your {A, B} → {B} case). The PR body says the same. I left the subject of 735b9aa alone rather than rewrite pushed history; the squash merge takes the PR title.

Verification: FileBasedKeyManagerProviderTestCase 19/19, FileBasedTrustManagerProviderTestCase 19/19, FileStampTestCase 1/1. The packages extensions, protocols.ldap, protocols.http and crypto ran as well: 3491 tests. Ten classes failed in startServer / setUp with unable to bind to 0.0.0.0:65534 … Address already in use, a local port collision with other test servers on the machine. All ten are green when run again, one JVM per class. Each mutant below fails the tests named, and no others:

Mutant Red test
your loaded == null snippet on 6705997 testKeyManagerHandedOutAgainLeavesEarlierOnesAlone
6705997 as is (shared loaded) testKeyManagerHandedOutAgainLeavesEarlierOnesAlone, testTrustManagerHandedOutAgainLeavesEarlierOnesAlone
a failed key load records no aliases testKeyStoreLoadedAgainWhenChanged
no configuration count on the key side testKeyStoreLoadedAgainWhenPinChangesInConfiguration
no configuration count on the trust side testTrustStoreLoadedAgainWhenPinChangesInConfiguration
getClientAliases / chooseClientAlias / chooseEngineClientAlias / chooseServerAlias ask loaded.keyManager (four mutants) testEveryAliasChoiceLoadsAgain
round 1's getClass() trust kind check testPlainTrustManagerTakesExtendedOneWhenFipsModeTurnsOn
always the plain trust wrapper testTrustManagerHandedOutInFipsModeIsExtended
always the extended trust wrapper six trust tests (a ClassCastException over the plain ExpirationCheckTrustManager)

}

/** The trust manager handed out by {@link #getTrustManagers()} over a plain trust manager. */
private final class ReloadingTrustManager implements X509TrustManager
}

/** The trust manager handed out by {@link #getTrustManagers()} over an extended trust manager. */
private final class ReloadingExtendedTrustManager extends X509ExtendedTrustManager

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: 6fd0db1 fixes the round-2 blocker at its root: each manager handed out now keeps its own state, so nothing the provider is asked later can reach it.

  • getKeyManagers() and getTrustManagers() wrap a fresh LoadedKeyManager / TrustStoreFollower on each call and write no shared state (FileBasedKeyManagerProvider.java:335-354). A handler's configuration dry run can no longer swap a live handler's key. testKeyManagerHandedOutAgainLeavesEarlierOnesAlone and testTrustManagerHandedOutAgainLeavesEarlierOnesAlone pin it.
  • The ReloadingKeyManager Javadoc now states the trade-off: a renewal that keeps any remembered alias is taken, even if it drops the alias a handler names (FileBasedKeyManagerProvider.java:130-139).
  • #1105 is closed with a test on each side (testKeyStoreAndPinRenewedTogetherLoadedWithoutHandshake, testTrustStoreAndPinRenewedTogetherLoadedWithoutCheck). Both provider classes ran 19/19 green on the Linux JDK 17 cell.

issue (non-blocking): A handshake's reload no longer excludes applyConfigurationChange, so it can load a mix of the old and the new configuration.

opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedKeyManagerProvider.java:140-190, :449-457; FileBasedTrustManagerProvider.java:135, :427

currentKeyManager() is now a method of the inner ReloadingKeyManager, so synchronized (this) at :147 takes the manager's monitor. applyConfigurationChange still sets currentConfig, keyStoreFile and keyStoreType under the provider's monitor (:449-457). A reload that reads count N while a new key-store-file or PIN is being applied can pair the new file with the old PIN, or check the new aliases against N's remembered ones. That load fails: ERR_FILE_KEYMANAGER_CANNOT_RELOAD is logged for a store that is fine, and the handshake keeps the old key. The attempt is recorded under N, so the next handshake loads again and nothing stays wrong. The volatile count covers only a reader that sees the new count. The trust side has the same window. At 735b9aa both methods shared the provider's monitor.

      synchronized (this)
      {
        // applyConfigurationChange sets the configuration a load reads under the provider's
        // monitor: take it too, so a load sees the configuration before a change or after it
        synchronized (FileBasedKeyManagerProvider.this)
        {
          final int changes = configurationChanges;
          final List<FileStamp> stamps = stampFiles();
          // ... the rest of the current block, unchanged
        }
      }

The lock order is always manager → provider. applyConfigurationChange takes only the provider's monitor and getKeyManagers() takes none, so there is no cycle. The same change applies in TrustStoreFollower.currentTrustManager() with FileBasedTrustManagerProvider.this.


suggestion (non-blocking): No test pins the extended half of the trust kind check, in either direction.

opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedTrustManagerProvider.java:118, :152, :212

The two FIPS tests reload only plain → extended (:503-521) and extended → extended (:533-550). Two mutants survive the class (traced, not run):

  • Delete || extended && !(trustManagers[0] instanceof X509ExtendedTrustManager). An extended manager handed out then takes a plain ExpirationCheckTrustManager, and current() (:212) throws ClassCastException on every certificate check.
  • Restore 735b9aa's per-load current.trustManager instanceof X509ExtendedTrustManager. Then plain handed out → extended load → plain load is refused.

This state is reachable outside tests. On a non-FIPS server, Platform.generateSelfSignedCertificate (Platform.java:251-296) registers BCFIPS and removes it in a finally, and isFips() is not cached, so FIPS mode can turn off again at runtime.

      // testPlainTrustManagerTakesExtendedOneWhenFipsModeTurnsOn, after the last assert
      provider.fips = false;
      replace(trustStore, Files.readAllBytes(new File(configDir, "server.truststore").toPath()));
      assertThat(trustManager.getAcceptedIssuers()).hasSize(3);

      // testTrustManagerHandedOutInFipsModeIsExtended, after the last assert
      provider.fips = false;
      replace(trustStore, Files.readAllBytes(new File(configDir, "server.truststore").toPath()));
      assertThat(trustManager.getAcceptedIssuers()).hasSize(2);

Pin: the first block turns the per-load mutant red (it stays at 2). The second turns the deleted-clause mutant red (ClassCastException instead of the kept two issuers).


suggestion (non-blocking): No test calls a check method of a handed-out file-based trust manager; every reload test reads only getAcceptedIssuers().

opendj-server-legacy/src/main/java/org/opends/server/extensions/FileBasedTrustManagerProvider.java:182-191, :216-253

A search for checkClientTrusted|checkServerTrusted under */src/test/* finds only TrustManagersTestCase:212 (opendj-core) and BlindTrustManagerProviderTestCase:70-71. Three mutants survive the class. One check method skips the reload and delegates to follower.loaded.trustManager. One has an empty body and trusts every chain. One SSLEngine/Socket overload falls back to the 2-arg one. The description says the stamp check "happens before each certificate check", and this is the path that decides trust. The reload itself is pinned, because getAcceptedIssuers() goes through the same currentTrustManager().

      final java.security.cert.X509Certificate[] before = trustManager.getAcceptedIssuers();
      replace(trustStore, Files.readAllBytes(new File(configDir, "client.truststore").toPath()));
      // checked before any getAcceptedIssuers() call loads the renewal for it
      final List<java.security.cert.X509Certificate> refused = new java.util.ArrayList<>();
      for (java.security.cert.X509Certificate cert : before)
      {
        try
        {
          trustManager.checkClientTrusted(new java.security.cert.X509Certificate[] { cert }, "RSA");
        }
        catch (java.security.cert.CertificateException e)
        {
          refused.add(cert);
        }
      }
      final List<java.security.cert.X509Certificate> kept = java.util.Arrays.asList(trustManager.getAcceptedIssuers());
      for (java.security.cert.X509Certificate cert : before)
      {
        if (!kept.contains(cert))
        {
          assertThat(refused).contains(cert);
        }
      }

Pin: in a copy of testTrustManagerHandedOutAgainLeavesEarlierOnesAlone's setup, a certificate the renewal drops is refused by the first check after the renewal. Unmeasured. It assumes client.truststore does not keep an issuer of the certificate it drops.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug enhancement java Changes to Java sources security Security fixes / CodeQL code-scanning alerts tests Test suites: fixing, enabling, un-disabling

Projects

None yet

3 participants