diff --git a/README.md b/README.md index 8c0bb44..e6d67e5 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,33 @@ The `java-buildpack-security-provider` is a utility that watches for changes to container identity and trust stores and dynamically updates the KeyManager and TrustManager of an application. +## Certificate rotation +The KeyManager is reloaded when the instance identity certificate (`CF_INSTANCE_CERT`) changes. The Diego executor [writes the new private key (`CF_INSTANCE_KEY`) before the new certificate](https://github.com/cloudfoundry/executor/blob/208a08c51850167d93e1040cfd8538fc1925c882/depot/containerstore/instance_identity_handler.go#L94-L102), so only the certificate file is watched. Since 1.22.0, each load logs the certificate serial, subject and when the certificate chain expires (earliest expiry in the chain, in UTC). + +Since 1.22.0, the private key is checked against the certificate on every load. Only RSA keys, the type the Diego executor issues, are checked; other key types are accepted with an informational log message. On a reload with a mismatch or an empty certificate file, the current KeyManager is kept and an error is logged (JUL level `SEVERE`, shown as `ERROR` through SLF4J or Spring Boot): + +``` +Private key ... does not match certificate ..., keeping current KeyManager (certificate chain expires at 2026-10-15T09:30:00Z). The private key may not have been replaced before the certificate +``` + +On the first load there is no current KeyManager to keep: a mismatched pair or an empty certificate file is not installed and an error is logged. No client certificate is presented until a certificate change brings a matching pair; other TLS connections keep working. + +``` +Private key ... does not match certificate ..., no KeyManager installed: no client certificate is presented until a matching pair is loaded +``` + +A kept KeyManager is not replaced until the next certificate change. With Diego defaults (24 hour validity, rotation 30 minutes before expiry) the kept certificate expires about 30 minutes later, after which TLS handshakes using it fail. A loaded certificate chain that has already expired is installed as before, with an error: + +``` +Certificate chain ... expired at 2026-10-15T09:30:00Z, TLS handshakes using it will fail +``` + +Alert on these errors and restart the affected instance to get a new key pair: + +```shell +$ cf restart-app-instance APP_NAME INDEX +``` + ## Development The project depends on Java 8. To build from source, run the following: diff --git a/pom.xml b/pom.xml index 5f13e6f..1142118 100644 --- a/pom.xml +++ b/pom.xml @@ -53,17 +53,25 @@ - 1.21.0-SNAPSHOT + 1.22.0-SNAPSHOT 1.86 4.13.2 4.11.0 1.7.36 1.2.13 3.27.7 + 2.50.0 UTF-8 + + + com.google.errorprone + error_prone_annotations + ${errorprone.version} + provided + org.assertj assertj-core diff --git a/src/main/java/org/cloudfoundry/security/FileWatchingX509ExtendedKeyManager.java b/src/main/java/org/cloudfoundry/security/FileWatchingX509ExtendedKeyManager.java index 9fba576..b27305b 100644 --- a/src/main/java/org/cloudfoundry/security/FileWatchingX509ExtendedKeyManager.java +++ b/src/main/java/org/cloudfoundry/security/FileWatchingX509ExtendedKeyManager.java @@ -16,6 +16,8 @@ package org.cloudfoundry.security; +import com.google.errorprone.annotations.concurrent.GuardedBy; + import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLEngine; @@ -29,10 +31,13 @@ import java.security.NoSuchAlgorithmException; import java.security.Principal; import java.security.PrivateKey; +import java.security.PublicKey; import java.security.UnrecoverableKeyException; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.security.interfaces.RSAKey; +import java.time.Instant; import java.util.List; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Logger; @@ -45,8 +50,14 @@ final class FileWatchingX509ExtendedKeyManager extends X509ExtendedKeyManager { private final AtomicReference keyManager = new AtomicReference<>(); + @GuardedBy("updateLock") private final KeyManagerFactory keyManagerFactory; + private final Object updateLock = new Object(); + + @GuardedBy("updateLock") + private Instant notAfter; + private final Path privateKey; FileWatchingX509ExtendedKeyManager(Path certificates, Path privateKey, KeyManagerFactory keyManagerFactory) { @@ -54,53 +65,75 @@ final class FileWatchingX509ExtendedKeyManager extends X509ExtendedKeyManager { this.privateKey = privateKey; this.keyManagerFactory = keyManagerFactory; - new FileWatcher(this.certificates, new FileWatcherCallback()).watch(); - // disable watching the key file to prevent race condition bug - a certificate file change covers the key change - // new FileWatcher(this.privateKey, new FileWatcherCallback()).watch(); - - if (this.keyManager.compareAndSet(null, getKeyManager(getKeyStore()))) { - this.logger.info(String.format("Initialized KeyManager for %s and %s", this.privateKey, this.certificates)); - } + // Only the certificate file is watched: the Diego executor renames the new private key into place + // before the new certificate, so a certificate change means both files are new. See + // https://github.com/cloudfoundry/executor/blob/208a08c51850167d93e1040cfd8538fc1925c882/depot/containerstore/instance_identity_handler.go#L94-L102 + // If that order ever changes, the update reads the old key with the new certificate and the mismatch check + // below keeps the current pair. Watching the key file as well caused + // https://github.com/cloudfoundry/java-buildpack-security-provider/issues/8 + new FileWatcher(this.certificates, this::update).watch(); + update(); } @Override public String chooseClientAlias(String[] strings, Principal[] principals, Socket socket) { - return this.keyManager.get().chooseClientAlias(strings, principals, socket); + X509ExtendedKeyManager keyManager = this.keyManager.get(); + return keyManager != null ? keyManager.chooseClientAlias(strings, principals, socket) : null; } @Override public String chooseEngineClientAlias(String[] strings, Principal[] principals, SSLEngine sslEngine) { - return this.keyManager.get().chooseEngineClientAlias(strings, principals, sslEngine); + X509ExtendedKeyManager keyManager = this.keyManager.get(); + return keyManager != null ? keyManager.chooseEngineClientAlias(strings, principals, sslEngine) : null; } @Override public String chooseEngineServerAlias(String s, Principal[] principals, SSLEngine sslEngine) { - return this.keyManager.get().chooseEngineServerAlias(s, principals, sslEngine); + X509ExtendedKeyManager keyManager = this.keyManager.get(); + return keyManager != null ? keyManager.chooseEngineServerAlias(s, principals, sslEngine) : null; } @Override public String chooseServerAlias(String s, Principal[] principals, Socket socket) { - return this.keyManager.get().chooseServerAlias(s, principals, socket); + X509ExtendedKeyManager keyManager = this.keyManager.get(); + return keyManager != null ? keyManager.chooseServerAlias(s, principals, socket) : null; } @Override public X509Certificate[] getCertificateChain(String s) { - return this.keyManager.get().getCertificateChain(s); + X509ExtendedKeyManager keyManager = this.keyManager.get(); + X509Certificate[] chain = keyManager != null ? keyManager.getCertificateChain(s) : null; + logUnknownAlias(keyManager, s, chain, "certificate chain"); + return chain; } @Override public String[] getClientAliases(String s, Principal[] principals) { - return this.keyManager.get().getClientAliases(s, principals); + X509ExtendedKeyManager keyManager = this.keyManager.get(); + return keyManager != null ? keyManager.getClientAliases(s, principals) : null; } @Override public PrivateKey getPrivateKey(String s) { - return this.keyManager.get().getPrivateKey(s); + X509ExtendedKeyManager keyManager = this.keyManager.get(); + PrivateKey privateKey = keyManager != null ? keyManager.getPrivateKey(s) : null; + logUnknownAlias(keyManager, s, privateKey, "private key"); + return privateKey; + } + + // An alias is chosen and looked up in separate calls, and aliases change on every load. A reload in between would make the + // lookup fail. This is not expected to happen in practice; if it is ever logged, the lookup needs to be made reload-safe. + // The delegating key manager asks the system key manager first, so only aliases unknown to it reach this check. + private void logUnknownAlias(X509ExtendedKeyManager keyManager, String alias, Object found, String what) { + if (keyManager != null && alias != null && found == null) { + this.logger.severe(() -> String.format("Should not happen: no %s for alias %s, the KeyManager was probably reloaded between choosing and looking up the alias", what, alias)); + } } @Override public String[] getServerAliases(String s, Principal[] principals) { - return this.keyManager.get().getServerAliases(s, principals); + X509ExtendedKeyManager keyManager = this.keyManager.get(); + return keyManager != null ? keyManager.getServerAliases(s, principals) : null; } private X509ExtendedKeyManager getKeyManager(KeyStore keyStore) { @@ -119,33 +152,93 @@ private X509ExtendedKeyManager getKeyManager(KeyStore keyStore) { } } - private KeyStore getKeyStore() { + private KeyStore getKeyStore(PrivateKey privateKey, List certificates) { try { KeyStore keyStore = KeyStoreEntryCollector.identity(); - PrivateKey privateKey = PrivateKeyFactory.generate(this.privateKey); - List certificates = X509CertificateFactory.generate(this.certificates); - KeyStoreEntryCollector.accumulate(keyStore, privateKey, new char[0], certificates.toArray(new Certificate[certificates.size()])); - return keyStore; } catch (CertificateException | IOException | KeyStoreException | NoSuchAlgorithmException e) { throw new UndeclaredThrowableException(e); } } - private final class FileWatcherCallback implements Runnable { + // Only RSA keys, the type the Diego executor issues, are compared; other key types are accepted unchecked. + private boolean matches(PrivateKey privateKey, List certificates) { + if (certificates.isEmpty()) { + return false; + } + + PublicKey publicKey = certificates.get(0).getPublicKey(); + if (!(privateKey instanceof RSAKey) || !(publicKey instanceof RSAKey)) { + this.logger.info(() -> String.format("Private key %s is not an RSA key, not checked against certificate %s. This is unexpected: " + + "check the key generator, the security provider probably needs an update", this.privateKey, this.certificates)); + return true; + } - @Override - public void run() { - if (FileWatchingX509ExtendedKeyManager.this.keyManager.getAndSet(getKeyManager(getKeyStore())) == null) { - FileWatchingX509ExtendedKeyManager.this.logger.info(String.format("Initialized KeyManager for %s and %s", FileWatchingX509ExtendedKeyManager.this.privateKey, - FileWatchingX509ExtendedKeyManager.this.certificates)); - } else { - FileWatchingX509ExtendedKeyManager.this.logger.info(String.format("Updated KeyManager for %s and %s", FileWatchingX509ExtendedKeyManager.this.privateKey, - FileWatchingX509ExtendedKeyManager.this.certificates)); + return ((RSAKey) privateKey).getModulus().equals(((RSAKey) publicKey).getModulus()); + } + + void update() { + // The whole update is locked: with the files read outside the lock, an update that read the files earlier + // could finish later and replace a newer pair with the older one. Also, the shared KeyManagerFactory is not + // thread-safe. No contention cost: updates come from the constructor and the single watcher thread, and + // handshakes never take the lock. + synchronized (this.updateLock) { + PrivateKey privateKey; + List certificates; + try { + privateKey = PrivateKeyFactory.generate(this.privateKey); + certificates = X509CertificateFactory.generate(this.certificates); + } catch (CertificateException | IOException e) { + throw new UndeclaredThrowableException(e); + } + + // No retry: the Diego executor never writes a mismatched pair, and the next certificate change + // triggers a new update. Until then the current pair, still valid, beats one that cannot authenticate. + // A single delayed retry would also cover a certificate renamed before its key, but Diego renames the + // key first; add one only if real-world cases show the need. + // On the first load there is no current pair: none is installed, so no client certificate is presented + // and other TLS connections keep working, until a certificate change brings a matching pair. + if (!matches(privateKey, certificates)) { + if (this.keyManager.get() != null) { + Instant currentNotAfter = this.notAfter; + this.logger.severe(() -> String.format("Private key %s does not match certificate %s, keeping current KeyManager (certificate chain expires at %s). " + + "The private key may not have been replaced before the certificate", + this.privateKey, this.certificates, currentNotAfter)); + } else { + this.logger.severe(() -> String.format("Private key %s does not match certificate %s, no KeyManager installed: " + + "no client certificate is presented until a matching pair is loaded", this.privateKey, this.certificates)); + } + return; + } + + X509ExtendedKeyManager keyManager = getKeyManager(getKeyStore(privateKey, certificates)); + Instant notAfter = notAfter(certificates); + boolean initialized = this.keyManager.getAndSet(keyManager) == null; + this.notAfter = notAfter; + + X509Certificate leaf = certificates.get(0); + this.logger.info(() -> String.format("%s KeyManager for %s and %s: serial %s, subject %s, certificate chain expires at %s", + initialized ? "Initialized" : "Updated", this.privateKey, this.certificates, + leaf.getSerialNumber().toString(16), leaf.getSubjectX500Principal().getName(), notAfter)); + + if (notAfter.isBefore(Instant.now())) { + this.logger.severe(() -> String.format("Certificate chain %s expired at %s, TLS handshakes using it will fail", this.certificates, notAfter)); } } + } + // Instant prints as ISO-8601 in UTC, independent of the default time zone. Never called with an empty list: + // matches() rejects it first. + static Instant notAfter(List certificates) { + Instant notAfter = Instant.MAX; + for (X509Certificate certificate : certificates) { + Instant certificateNotAfter = certificate.getNotAfter().toInstant(); + if (certificateNotAfter.isBefore(notAfter)) { + notAfter = certificateNotAfter; + } + } + return notAfter; } } diff --git a/src/test/java/org/cloudfoundry/security/FileWatchingX509ExtendedKeyManagerTest.java b/src/test/java/org/cloudfoundry/security/FileWatchingX509ExtendedKeyManagerTest.java index 44c1e68..b6385e4 100644 --- a/src/test/java/org/cloudfoundry/security/FileWatchingX509ExtendedKeyManagerTest.java +++ b/src/test/java/org/cloudfoundry/security/FileWatchingX509ExtendedKeyManagerTest.java @@ -25,10 +25,21 @@ import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Level; +import java.util.logging.LogRecord; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; +// The client-certificates-*.pem fixtures are Diego-issued and expired in 2017 (CA in 2018). That is fine: loading and +// matching never check validity dates, only a TLS handshake would. The expired-chain warning is logged but not asserted. public final class FileWatchingX509ExtendedKeyManagerTest extends AbstractLoggingTest { @Test @@ -57,10 +68,12 @@ public void watchesWatchedFile() throws IOException, InterruptedException, NoSuc String alias = keyManager.getClientAliases("RSA", null)[0]; Thread.sleep(5_000); - Files.copy(Paths.get("src/test/resources/client-certificates-2.pem"), watchedCertificates, StandardCopyOption.REPLACE_EXISTING); - Files.copy(Paths.get("src/test/resources/client-private-key-2.pem"), watchedPrivateKey, StandardCopyOption.REPLACE_EXISTING); + // Same as Diego: each file is replaced atomically, as a rename, the private key before the certificate, + // so no update sees a half-written file. + replace(Paths.get("src/test/resources/client-private-key-2.pem"), watchedPrivateKey); + replace(Paths.get("src/test/resources/client-certificates-2.pem"), watchedCertificates); - long timeout = System.currentTimeMillis() + 300_000; + long timeout = System.currentTimeMillis() + 30_000; for (; ; ) { if (System.currentTimeMillis() > timeout) { fail("Failed to update within timeout"); @@ -74,6 +87,177 @@ public void watchesWatchedFile() throws IOException, InterruptedException, NoSuc } } + @Test + public void notInitializedWhenPrivateKeyDoesNotMatchCertificate() throws Exception { + Path watchedCertificates = getWatchedCertificatesFile(); + Files.copy(Paths.get("src/test/resources/client-certificates-2.pem"), watchedCertificates); + + Path watchedPrivateKey = getWatchedPrivateKeyFile(); + Files.copy(Paths.get("src/test/resources/client-private-key-1.pem"), watchedPrivateKey); + + AtomicReference keyManager = new AtomicReference<>(); + List records = LogRecorder.record(FileWatchingX509ExtendedKeyManager.class, () -> + keyManager.set(new FileWatchingX509ExtendedKeyManager(watchedCertificates, watchedPrivateKey, KeyManagerFactory.getInstance("SunX509")))); + + assertNotInitialized(keyManager.get()); + assertThat(severe(records, "Private key " + watchedPrivateKey + " does not match certificate")).hasSize(1); + } + + @Test + public void notInitializedWhenCertificatesAreEmpty() throws Exception { + Path watchedCertificates = getWatchedCertificatesFile(); + Files.write(watchedCertificates, new byte[0]); + + Path watchedPrivateKey = getWatchedPrivateKeyFile(); + Files.copy(Paths.get("src/test/resources/client-private-key-1.pem"), watchedPrivateKey); + + AtomicReference keyManager = new AtomicReference<>(); + List records = LogRecorder.record(FileWatchingX509ExtendedKeyManager.class, () -> + keyManager.set(new FileWatchingX509ExtendedKeyManager(watchedCertificates, watchedPrivateKey, KeyManagerFactory.getInstance("SunX509")))); + + assertNotInitialized(keyManager.get()); + assertThat(severe(records, "Private key " + watchedPrivateKey + " does not match certificate")).hasSize(1); + } + + @Test + public void initializedByUpdateAfterInvalidFirstLoad() throws IOException, CertificateException, NoSuchAlgorithmException { + Path watchedCertificates = getWatchedCertificatesFile(); + Files.copy(Paths.get("src/test/resources/client-certificates-2.pem"), watchedCertificates); + + Path watchedPrivateKey = getWatchedPrivateKeyFile(); + Files.copy(Paths.get("src/test/resources/client-private-key-1.pem"), watchedPrivateKey); + + FileWatchingX509ExtendedKeyManager keyManager = new FileWatchingX509ExtendedKeyManager(watchedCertificates, watchedPrivateKey, KeyManagerFactory.getInstance("SunX509")); + + Files.copy(Paths.get("src/test/resources/client-private-key-2.pem"), watchedPrivateKey, StandardCopyOption.REPLACE_EXISTING); + keyManager.update(); + + assertThat(getLeafCertificate(keyManager)).isEqualTo(X509CertificateFactory.generate(Paths.get("src/test/resources/client-certificates-2.pem")).get(0)); + } + + @Test + public void updatesWhenPrivateKeyMatchesCertificate() throws IOException, CertificateException, NoSuchAlgorithmException { + Path watchedCertificates = getWatchedCertificatesFile(); + Files.copy(Paths.get("src/test/resources/client-certificates-1.pem"), watchedCertificates); + + Path watchedPrivateKey = getWatchedPrivateKeyFile(); + Files.copy(Paths.get("src/test/resources/client-private-key-1.pem"), watchedPrivateKey); + + FileWatchingX509ExtendedKeyManager keyManager = new FileWatchingX509ExtendedKeyManager(watchedCertificates, watchedPrivateKey, KeyManagerFactory.getInstance("SunX509")); + + Files.copy(Paths.get("src/test/resources/client-private-key-2.pem"), watchedPrivateKey, StandardCopyOption.REPLACE_EXISTING); + Files.copy(Paths.get("src/test/resources/client-certificates-2.pem"), watchedCertificates, StandardCopyOption.REPLACE_EXISTING); + keyManager.update(); + + assertThat(getLeafCertificate(keyManager)).isEqualTo(X509CertificateFactory.generate(Paths.get("src/test/resources/client-certificates-2.pem")).get(0)); + } + + // A reload between choosing an alias and looking it up is not expected to cause trouble, but would make the lookup return null. + @Test + public void logsWhenAliasFromBeforeReloadIsNoLongerKnown() throws Exception { + Path watchedCertificates = getWatchedCertificatesFile(); + Files.copy(Paths.get("src/test/resources/client-certificates-1.pem"), watchedCertificates); + + Path watchedPrivateKey = getWatchedPrivateKeyFile(); + Files.copy(Paths.get("src/test/resources/client-private-key-1.pem"), watchedPrivateKey); + + FileWatchingX509ExtendedKeyManager keyManager = new FileWatchingX509ExtendedKeyManager(watchedCertificates, watchedPrivateKey, KeyManagerFactory.getInstance("SunX509")); + String staleAlias = keyManager.getClientAliases("RSA", null)[0]; + + // Every load generates new aliases; reloading unchanged files avoids a racing reload by the file watcher. + keyManager.update(); + + String currentAlias = keyManager.getClientAliases("RSA", null)[0]; + List records = LogRecorder.record(FileWatchingX509ExtendedKeyManager.class, () -> { + assertThat(keyManager.getCertificateChain(currentAlias)).isNotNull(); + assertThat(keyManager.getPrivateKey(currentAlias)).isNotNull(); + assertThat(keyManager.getCertificateChain(staleAlias)).isNull(); + assertThat(keyManager.getPrivateKey(staleAlias)).isNull(); + }); + + assertThat(severe(records, "Should not happen:")).extracting(LogRecord::getMessage).hasSize(2) + .allMatch(m -> m.contains(staleAlias)); + } + + @Test + public void keepsCurrentWhenPrivateKeyDoesNotMatchCertificate() throws Exception { + Path watchedCertificates = getWatchedCertificatesFile(); + Files.copy(Paths.get("src/test/resources/client-certificates-1.pem"), watchedCertificates); + + Path watchedPrivateKey = getWatchedPrivateKeyFile(); + Files.copy(Paths.get("src/test/resources/client-private-key-1.pem"), watchedPrivateKey); + + FileWatchingX509ExtendedKeyManager keyManager = new FileWatchingX509ExtendedKeyManager(watchedCertificates, watchedPrivateKey, KeyManagerFactory.getInstance("SunX509")); + + Files.copy(Paths.get("src/test/resources/client-certificates-2.pem"), watchedCertificates, StandardCopyOption.REPLACE_EXISTING); + List records = LogRecorder.record(FileWatchingX509ExtendedKeyManager.class, keyManager::update); + + assertThat(getLeafCertificate(keyManager)).isEqualTo(X509CertificateFactory.generate(Paths.get("src/test/resources/client-certificates-1.pem")).get(0)); + // The watcher thread may also react to the change, so the error can be logged more than once + assertThat(severe(records, "Private key " + watchedPrivateKey + " does not match certificate")).isNotEmpty(); + } + + @Test + public void keepsCurrentWhenCertificatesAreEmpty() throws IOException, CertificateException, NoSuchAlgorithmException { + Path watchedCertificates = getWatchedCertificatesFile(); + Files.copy(Paths.get("src/test/resources/client-certificates-1.pem"), watchedCertificates); + + Path watchedPrivateKey = getWatchedPrivateKeyFile(); + Files.copy(Paths.get("src/test/resources/client-private-key-1.pem"), watchedPrivateKey); + + FileWatchingX509ExtendedKeyManager keyManager = new FileWatchingX509ExtendedKeyManager(watchedCertificates, watchedPrivateKey, KeyManagerFactory.getInstance("SunX509")); + + Files.write(watchedCertificates, new byte[0]); + keyManager.update(); + + assertThat(getLeafCertificate(keyManager)).isEqualTo(X509CertificateFactory.generate(Paths.get("src/test/resources/client-certificates-1.pem")).get(0)); + } + + @Test + public void notAfterIsEarliestInChain() throws IOException, CertificateException { + List certificates = X509CertificateFactory.generate(Paths.get("src/test/resources/client-certificates-1.pem")); + Collections.reverse(certificates); + + assertThat(FileWatchingX509ExtendedKeyManager.notAfter(certificates)).isEqualTo(Instant.parse("2017-05-19T17:58:20Z")); + } + + private static void assertNotInitialized(FileWatchingX509ExtendedKeyManager keyManager) { + assertThat(keyManager.getClientAliases("RSA", null)).isNull(); + assertThat(keyManager.chooseClientAlias(new String[]{"RSA"}, null, null)).isNull(); + assertThat(keyManager.getCertificateChain("any")).isNull(); + assertThat(keyManager.getPrivateKey("any")).isNull(); + } + + private static List severe(List records, String messagePrefix) { + List severe = new ArrayList<>(); + for (LogRecord record : records) { + if (record.getLevel() == Level.SEVERE && record.getMessage().startsWith(messagePrefix)) { + severe.add(record); + } + } + return severe; + } + + // The file watcher can install a new KeyManager between the two calls; aliases change on every load, so retry. + private X509Certificate getLeafCertificate(FileWatchingX509ExtendedKeyManager keyManager) { + for (int i = 0; i < 3; i++) { + String[] aliases = keyManager.getClientAliases("RSA", null); + if (aliases != null && aliases.length > 0) { + X509Certificate[] chain = keyManager.getCertificateChain(aliases[0]); + if (chain != null) { + return chain[0]; + } + } + } + throw new IllegalStateException("No client alias with a certificate chain after 3 attempts"); + } + + private void replace(Path source, Path target) throws IOException { + Path replacement = Files.createTempFile(target.getParent(), "replacement-", ".tmp"); + Files.copy(source, replacement, StandardCopyOption.REPLACE_EXISTING); + Files.move(replacement, target, StandardCopyOption.ATOMIC_MOVE); + } + private Path getWatchedCertificatesFile() throws IOException { Path workDirectory = Files.createTempDirectory("file-watching-key-manager-test-"); return workDirectory.resolve("certificates.pem"); diff --git a/src/test/java/org/cloudfoundry/security/LogRecorder.java b/src/test/java/org/cloudfoundry/security/LogRecorder.java index af13fae..14bfd70 100644 --- a/src/test/java/org/cloudfoundry/security/LogRecorder.java +++ b/src/test/java/org/cloudfoundry/security/LogRecorder.java @@ -16,8 +16,8 @@ package org.cloudfoundry.security; -import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.LogRecord; @@ -31,7 +31,7 @@ private LogRecorder() { // Records everything logged to the logger named after the given class while the action runs, at all levels. static List record(Class loggerClass, Action action) throws Exception { Logger logger = Logger.getLogger(loggerClass.getName()); - List records = new ArrayList<>(); + List records = new CopyOnWriteArrayList<>(); Handler handler = new Handler() { @Override