From 3f8dea4ae17845f11b4aff6d484be29a0c02e620 Mon Sep 17 00:00:00 2001 From: Max Lambrecht Date: Sat, 9 May 2026 13:56:27 -0500 Subject: [PATCH] fix(helper): preserve multiple X509 bundle authorities Signed-off-by: Max Lambrecht --- .../io/spiffe/helper/keystore/KeyStore.java | 20 +++ .../helper/keystore/KeyStoreHelper.java | 35 ++++- .../helper/keystore/KeyStoreHelperTest.java | 147 +++++++++++++++++- 3 files changed, 198 insertions(+), 4 deletions(-) diff --git a/java-spiffe-helper/src/main/java/io/spiffe/helper/keystore/KeyStore.java b/java-spiffe-helper/src/main/java/io/spiffe/helper/keystore/KeyStore.java index 07c9e7c9..514ca84a 100644 --- a/java-spiffe-helper/src/main/java/io/spiffe/helper/keystore/KeyStore.java +++ b/java-spiffe-helper/src/main/java/io/spiffe/helper/keystore/KeyStore.java @@ -10,6 +10,9 @@ import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; import java.util.Objects; /** @@ -123,6 +126,23 @@ void storeAuthorityEntry(final AuthorityEntry authorityEntry) throws KeyStoreExc this.flush(); } + void deleteEntriesByAliasPrefix(final String aliasPrefix) throws KeyStoreException { + List aliasesToDelete = new ArrayList<>(); + Enumeration aliases = javaKeyStore.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (alias.startsWith(aliasPrefix)) { + aliasesToDelete.add(alias); + } + } + + for (String alias : aliasesToDelete) { + javaKeyStore.deleteEntry(alias); + } + + this.flush(); + } + // Flush KeyStore to disk, to the configured keyStoreFilePath private void flush() throws KeyStoreException { try (OutputStream outputStream = Files.newOutputStream(keyStoreFilePath)) { diff --git a/java-spiffe-helper/src/main/java/io/spiffe/helper/keystore/KeyStoreHelper.java b/java-spiffe-helper/src/main/java/io/spiffe/helper/keystore/KeyStoreHelper.java index 37741421..65d283d8 100644 --- a/java-spiffe-helper/src/main/java/io/spiffe/helper/keystore/KeyStoreHelper.java +++ b/java-spiffe-helper/src/main/java/io/spiffe/helper/keystore/KeyStoreHelper.java @@ -15,7 +15,12 @@ import java.io.IOException; import java.nio.file.Path; import java.security.KeyStoreException; +import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.CountDownLatch; @@ -206,18 +211,44 @@ private void storeX509ContextUpdate(final X509Context update) throws KeyStoreExc } private void storeBundle(TrustDomain trustDomain, X509Bundle bundle) throws KeyStoreException { + List authorities = sortedAuthorities(bundle); + trustStore.deleteEntriesByAliasPrefix(generateAliasPrefix(trustDomain)); + int index = 0; - for (X509Certificate certificate : bundle.getX509Authorities()) { + for (X509Certificate certificate : authorities) { final AuthorityEntry authorityEntry = AuthorityEntry.builder() .alias(generateAlias(trustDomain, index)) .certificate(certificate) .build(); trustStore.storeAuthorityEntry(authorityEntry); + index++; + } + } + + private List sortedAuthorities(X509Bundle bundle) throws KeyStoreException { + List authorities = new ArrayList<>(bundle.getX509Authorities()); + try { + authorities.sort(Comparator.comparing(this::certificateSortKey)); + } catch (IllegalArgumentException e) { + throw new KeyStoreException("X.509 authority cannot be encoded", e); } + return authorities; + } + + private String certificateSortKey(final X509Certificate certificate) { + try { + return Base64.getEncoder().encodeToString(certificate.getEncoded()); + } catch (CertificateEncodingException e) { + throw new IllegalArgumentException(e); + } + } + + private String generateAliasPrefix(final TrustDomain trustDomain) { + return trustDomain.getName().concat("."); } private String generateAlias(final TrustDomain trustDomain, int index) { - return trustDomain.getName().concat(".").concat(String.valueOf(index)); + return generateAliasPrefix(trustDomain).concat(String.valueOf(index)); } private boolean isClosed() { diff --git a/java-spiffe-helper/src/test/java/io/spiffe/helper/keystore/KeyStoreHelperTest.java b/java-spiffe-helper/src/test/java/io/spiffe/helper/keystore/KeyStoreHelperTest.java index 2b9f37cb..b97dc8fa 100644 --- a/java-spiffe-helper/src/test/java/io/spiffe/helper/keystore/KeyStoreHelperTest.java +++ b/java-spiffe-helper/src/test/java/io/spiffe/helper/keystore/KeyStoreHelperTest.java @@ -1,17 +1,24 @@ package io.spiffe.helper.keystore; +import io.spiffe.bundle.x509bundle.X509Bundle; +import io.spiffe.bundle.x509bundle.X509BundleSet; import io.spiffe.exception.SocketEndpointAddressException; import io.spiffe.helper.exception.KeyStoreHelperException; import io.spiffe.internal.CertificateUtils; import io.spiffe.spiffeid.SpiffeId; +import io.spiffe.spiffeid.TrustDomain; +import io.spiffe.utils.X509CertificateTestUtils; import io.spiffe.workloadapi.Address; +import io.spiffe.workloadapi.Watcher; import io.spiffe.workloadapi.WorkloadApiClient; +import io.spiffe.workloadapi.X509Context; import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import uk.org.webcompere.systemstubs.environment.EnvironmentVariables; +import java.io.InputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -24,9 +31,13 @@ import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.fail; class KeyStoreHelperTest { @@ -121,6 +132,134 @@ void testNewHelper_use_default_type_and_alias() throws KeyStoreException, Socket checkBundleEntries(trustStoreFilePath, trustStorePass, KeyStoreType.getDefaultType(), authority1Alias); } + @Test + void testNewHelper_storesMultipleAuthoritiesForSameTrustDomain() throws Exception { + String keyStorefileName = RandomStringUtils.randomAlphabetic(10); + keyStoreFilePath = Paths.get(keyStorefileName); + + String trustStoreFileName = RandomStringUtils.randomAlphabetic(10); + trustStoreFilePath = Paths.get(trustStoreFileName); + + String trustStorePass = "truststore123"; + String keyStorePass = "keystore123"; + String keyPass = "keypass123"; + KeyStoreType keyStoreType = KeyStoreType.JKS; + + Set authorities = new HashSet<>(); + authorities.add(X509CertificateTestUtils.createRootCA("CN=Root CA 1", "spiffe://example.org").getCertificate()); + authorities.add(X509CertificateTestUtils.createRootCA("CN=Root CA 2", "spiffe://example.org").getCertificate()); + + WorkloadApiClient client = new WorkloadApiClientStub() { + @Override + public void watchX509Context(Watcher watcher) { + X509Context context = fetchX509Context(); + X509Bundle bundle = new X509Bundle(TrustDomain.parse("example.org"), authorities); + X509BundleSet bundleSet = X509BundleSet.of(Collections.singleton(bundle)); + watcher.onUpdate(X509Context.of(context.getX509Svids(), bundleSet)); + } + }; + + final KeyStoreHelper.KeyStoreOptions options = KeyStoreHelper.KeyStoreOptions + .builder() + .keyStoreType(keyStoreType) + .keyStorePath(keyStoreFilePath) + .keyStorePass(keyStorePass) + .trustStorePath(trustStoreFilePath) + .trustStorePass(trustStorePass) + .keyPass(keyPass) + .workloadApiClient(client) + .build(); + + try (KeyStoreHelper keystoreHelper = KeyStoreHelper.create(options)) { + keystoreHelper.run(false); + } catch (KeyStoreHelperException e) { + fail(e); + } + + KeyStore trustStore = java.security.KeyStore.getInstance(keyStoreType.value()); + try (InputStream trustStoreInputStream = Files.newInputStream(trustStoreFilePath)) { + trustStore.load(trustStoreInputStream, trustStorePass.toCharArray()); + } + + Certificate authority1 = trustStore.getCertificate("example.org.0"); + Certificate authority2 = trustStore.getCertificate("example.org.1"); + assertNotNull(authority1); + assertNotNull(authority2); + assertEquals(2, trustStore.size()); + Set storedAuthorities = new HashSet<>(); + storedAuthorities.add((X509Certificate) authority1); + storedAuthorities.add((X509Certificate) authority2); + assertEquals(authorities, storedAuthorities); + } + + @Test + void testNewHelper_removesStaleAuthoritiesForSameTrustDomain() throws Exception { + String keyStorefileName = RandomStringUtils.randomAlphabetic(10); + keyStoreFilePath = Paths.get(keyStorefileName); + + String trustStoreFileName = RandomStringUtils.randomAlphabetic(10); + trustStoreFilePath = Paths.get(trustStoreFileName); + + String trustStorePass = "truststore123"; + String keyStorePass = "keystore123"; + String keyPass = "keypass123"; + KeyStoreType keyStoreType = KeyStoreType.JKS; + + X509Certificate authority1 = X509CertificateTestUtils.createRootCA("CN=Root CA 1", "spiffe://example.org").getCertificate(); + X509Certificate authority2 = X509CertificateTestUtils.createRootCA("CN=Root CA 2", "spiffe://example.org").getCertificate(); + + Set initialAuthorities = new HashSet<>(); + initialAuthorities.add(authority1); + initialAuthorities.add(authority2); + + Set rotatedAuthorities = new HashSet<>(); + rotatedAuthorities.add(authority1); + + WorkloadApiClient client = new WorkloadApiClientStub() { + @Override + public void watchX509Context(Watcher watcher) { + X509Context context = fetchX509Context(); + X509Bundle initialBundle = new X509Bundle(TrustDomain.parse("example.org"), initialAuthorities); + watcher.onUpdate(X509Context.of( + context.getX509Svids(), + X509BundleSet.of(Collections.singleton(initialBundle)) + )); + + X509Bundle rotatedBundle = new X509Bundle(TrustDomain.parse("example.org"), rotatedAuthorities); + watcher.onUpdate(X509Context.of( + context.getX509Svids(), + X509BundleSet.of(Collections.singleton(rotatedBundle)) + )); + } + }; + + final KeyStoreHelper.KeyStoreOptions options = KeyStoreHelper.KeyStoreOptions + .builder() + .keyStoreType(keyStoreType) + .keyStorePath(keyStoreFilePath) + .keyStorePass(keyStorePass) + .trustStorePath(trustStoreFilePath) + .trustStorePass(trustStorePass) + .keyPass(keyPass) + .workloadApiClient(client) + .build(); + + try (KeyStoreHelper keystoreHelper = KeyStoreHelper.create(options)) { + keystoreHelper.run(false); + } catch (KeyStoreHelperException e) { + fail(e); + } + + KeyStore trustStore = java.security.KeyStore.getInstance(keyStoreType.value()); + try (InputStream trustStoreInputStream = Files.newInputStream(trustStoreFilePath)) { + trustStore.load(trustStoreInputStream, trustStorePass.toCharArray()); + } + + assertEquals(authority1, trustStore.getCertificate("example.org.0")); + assertNull(trustStore.getCertificate("example.org.1")); + assertEquals(1, trustStore.size()); + } + @Test void testCreateHelper_keyStore_trustStore_same_file_throwsException() throws SocketEndpointAddressException { @@ -337,7 +476,9 @@ private void checkPrivateKeyEntry(Path keyStoreFilePath, KeyStore keyStore = java.security.KeyStore.getInstance(keyStoreType.value()); - keyStore.load(Files.newInputStream(keyStoreFilePath), keyStorePassword.toCharArray()); + try (InputStream keyStoreInputStream = Files.newInputStream(keyStoreFilePath)) { + keyStore.load(keyStoreInputStream, keyStorePassword.toCharArray()); + } Certificate[] chain = keyStore.getCertificateChain(alias); SpiffeId spiffeId = CertificateUtils.getSpiffeId((X509Certificate) chain[0]); PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, privateKeyPassword.toCharArray()); @@ -354,7 +495,9 @@ private void checkBundleEntries(Path keyStoreFilePath, throws IOException, KeyStoreException, CertificateException, NoSuchAlgorithmException { KeyStore keyStore = java.security.KeyStore.getInstance(keyStoreType.value()); - keyStore.load(Files.newInputStream(keyStoreFilePath), keyStorePassword.toCharArray()); + try (InputStream keyStoreInputStream = Files.newInputStream(keyStoreFilePath)) { + keyStore.load(keyStoreInputStream, keyStorePassword.toCharArray()); + } Certificate certificate = keyStore.getCertificate(alias); assertNotNull(certificate);