issuerCandidates = subjectToCerts.get(issuerPrincipal);
+ X509Certificate issuer = null;
+
+ if (issuerCandidates != null) {
+ // Find the first candidate that can actually verify the current certificate's signature
+ // Use a final reference for use in lambda expressions
+ final X509Certificate currentCert = current;
+ issuer = issuerCandidates.stream()
+ .filter(candidate -> candidate != currentCert)
+ .filter(candidate -> isValidIssuer(candidate, currentCert))
+ .findFirst()
+ .orElse(null);
+ }
+
+ if (issuer == null) {
+ // No valid issuer found in chain
+ break;
+ }
+
+ current = issuer;
+ }
+
+ // Append any certificates that were not placed in the ordered chain
+ // (e.g. cross-signed roots or unrelated intermediates) so nothing is silently dropped.
+ for (X509Certificate cert : x509Certs) {
+ if (!orderedChain.contains(cert)) {
+ orderedChain.add(cert);
+ }
+ }
+
+ // Convert back to Certificate array
+ Certificate[] result = orderedChain.toArray(new Certificate[0]);
+
+ // Log the ordered chain for debugging
+ if (LOGGER.isLoggable(java.util.logging.Level.FINE)) {
+ logCertificateChain("Certificate chain after ordering", result);
+ }
+
+ return result;
+
+ } catch (Exception e) {
+ // If any error occurs during ordering, log it and return original order
+ // This prevents silently hiding certificate chain issues in production
+ LOGGER.log(FINE,
+ "Failed to order certificate chain. Returning original order. This may cause jarsigner PKIX issues.",
+ e);
+ return certificates;
+ }
+ }
+
+ /**
+ * Completes an incomplete certificate chain by downloading missing intermediate CA certificates
+ * using the AIA (Authority Information Access) extension embedded in each certificate.
+ *
+ * This is needed when Azure Key Vault's secrets endpoint returns only the leaf certificate
+ * (e.g. when the caller merged only the leaf cert during CSR completion for a non-exportable key).
+ * Without the intermediate CA certificates, jarsigner cannot build a valid PKIX path to a trusted
+ * root CA, producing "PKIX path building failed" warnings on verify.
+ *
+ *
The method walks up the contiguous issuer path (leaf → intermediate → root) starting from
+ * the first certificate, downloading missing intermediates via AIA. Downloaded issuers are inserted
+ * immediately after the current end of the valid chain (before any unplaced/extra certificates).
+ * This process repeats until the chain reaches a self-signed root CA, no more AIA URLs are found, or
+ * the safety download limit is reached.
+ *
+ *
Security Note: AIA downloading can trigger outbound HTTP(S) requests to URLs
+ * embedded in certificates. Set the system property {@code azure.keyvault.jca.disable-aia-download=true}
+ * to disable AIA chain completion in locked-down environments or when loading untrusted certificates.
+ *
+ * @param orderedCertificates certificate array with contiguous issuer path + any unplaced certs appended
+ * @return the (potentially extended) certificate array with missing intermediates inserted in the valid chain
+ */
+ static Certificate[] completeChainViaAia(Certificate[] orderedCertificates) {
+ if (orderedCertificates == null || orderedCertificates.length == 0) {
+ return orderedCertificates;
+ }
+
+ // Check if AIA downloading is disabled by system property
+ String disableAiaDownload = System.getProperty(DISABLE_AIA_DOWNLOAD_PROPERTY);
+ if ("true".equalsIgnoreCase(disableAiaDownload)) {
+ LOGGER.log(FINE, "AIA chain completion is disabled by system property [{0}]",
+ DISABLE_AIA_DOWNLOAD_PROPERTY);
+ return orderedCertificates;
+ }
+
+ List chain = new ArrayList<>(Arrays.asList(orderedCertificates));
+ int maxDownloads = 10; // Safety limit to prevent infinite loops
+
+ while (true) {
+ // Find the end of the valid chain (continuous issuer path leaf → issuer → ...).
+ // This excludes any extra/unplaced certificates appended at the end.
+ int validChainEnd = findValidChainEnd(chain);
+ if (validChainEnd < 0) {
+ // Empty chain, stop
+ break;
+ }
+
+ Certificate topOfValidChain = chain.get(validChainEnd);
+ if (!(topOfValidChain instanceof X509Certificate)) {
+ break;
+ }
+ X509Certificate x509Top = (X509Certificate) topOfValidChain;
+
+ // Chain is complete once the top cert is actually self-signed (verified by signature)
+ if (isSelfSignedCertificate(x509Top)) {
+ LOGGER.log(FINE, "Certificate chain is complete. Root CA: {0}",
+ x509Top.getSubjectX500Principal().getName());
+ break;
+ }
+
+ // Check if a valid issuer for x509Top already exists anywhere in the chain.
+ // We check *validity* (signature + CA capability), not just subject DN equality,
+ // because re-issued or cross-signed intermediates may share the same subject DN
+ // but have a different key and therefore cannot validate x509Top's signature.
+ X509Certificate validIssuerInChain = null;
+ int validIssuerIndex = -1;
+ for (int i = 0; i < chain.size(); i++) {
+ Certificate cert = chain.get(i);
+ if (cert instanceof X509Certificate) {
+ X509Certificate candidate = (X509Certificate) cert;
+ if (candidate.getSubjectX500Principal().equals(x509Top.getIssuerX500Principal())
+ && isValidIssuer(candidate, x509Top)) {
+ validIssuerInChain = candidate;
+ validIssuerIndex = i;
+ LOGGER.log(FINE, "Valid issuer [{0}] already present in chain at index {1}.",
+ new Object[] { candidate.getSubjectX500Principal().getName(), i });
+ break;
+ }
+ }
+ }
+
+ if (validIssuerInChain != null) {
+ // Issuer exists in the chain. If it's not in the expected position (validChainEnd+1),
+ // move it to make the chain contiguous
+ if (validIssuerIndex != validChainEnd + 1) {
+ LOGGER.log(FINE, "Valid issuer found but not at contiguous position. Moving from index {0} to {1}.",
+ new Object[] { validIssuerIndex, validChainEnd + 1 });
+ chain.remove(validIssuerIndex);
+ chain.add(validChainEnd + 1, validIssuerInChain);
+ } else {
+ LOGGER.log(FINE, "Valid issuer already at correct contiguous position.");
+ }
+ // Continue the loop to potentially download the issuer's issuer
+ continue;
+ }
+
+ // Try to download the issuer certificate via the AIA extension.
+ // Decrement maxDownloads for each attempted issuer resolution to avoid infinite loops.
+ if (--maxDownloads < 0) {
+ LOGGER.log(FINE, "Reached maximum AIA download attempts ({0}). Certificate chain may be incomplete.",
+ 10);
+ break;
+ }
+
+ X509Certificate issuer = downloadIssuerCertificateFromAia(x509Top);
+ if (issuer == null) {
+ LOGGER.log(FINE, "Could not download issuer certificate for [{0}] via AIA extension. "
+ + "Certificate chain may be incomplete.", x509Top.getSubjectX500Principal().getName());
+ break;
+ }
+
+ // Validate: the downloaded cert's subject must match the expected issuer DN
+ // AND verify that it can actually sign the current certificate (issuer validation)
+ X500Principal expectedIssuerPrincipal = x509Top.getIssuerX500Principal();
+ X500Principal issuerPrincipal = issuer.getSubjectX500Principal();
+ if (!issuerPrincipal.equals(expectedIssuerPrincipal)) {
+ LOGGER.log(WARNING,
+ "Downloaded certificate subject [{0}] does not match expected issuer DN [{1}]. "
+ + "Ignoring and stopping AIA chain completion.",
+ new Object[] { issuerPrincipal.getName(), expectedIssuerPrincipal.getName() });
+ break;
+ }
+
+ // Verify that the downloaded certificate is a CA and can verify the current certificate's signature
+ if (!isValidIssuer(issuer, x509Top)) {
+ LOGGER.log(WARNING,
+ "Downloaded certificate cannot verify signature on current certificate or is not a CA. "
+ + "Stopping AIA chain completion.");
+ break;
+ }
+
+ LOGGER.log(FINE, "Downloaded intermediate CA certificate via AIA: {0}",
+ issuer.getSubjectX500Principal().getName());
+ // Insert the downloaded issuer immediately after the valid chain end, before any extra certs
+ chain.add(validChainEnd + 1, issuer);
+ }
+
+ Certificate[] result = chain.toArray(new Certificate[0]);
+
+ // Log the completed chain for debugging
+ if (LOGGER.isLoggable(java.util.logging.Level.FINE)) {
+ logCertificateChain("Certificate chain after AIA completion", result);
+ }
+
+ return result;
+ }
+
+ /**
+ * Finds the end position of the valid (contiguous) issuer chain.
+ * Starting from position 0, walks the chain as long as the next certificate is the issuer of the current one.
+ * Stops at the first position where the issuer relationship breaks or at a self-signed certificate.
+ *
+ * @param chain the certificate chain
+ * @return the index of the last certificate in the valid chain, or -1 if empty
+ */
+ private static int findValidChainEnd(List chain) {
+ if (chain == null || chain.isEmpty()) {
+ return -1;
+ }
+
+ int pos = 0;
+ while (pos < chain.size()) {
+ Certificate cert = chain.get(pos);
+ if (!(cert instanceof X509Certificate)) {
+ // Stop at non-X509 certificate
+ break;
+ }
+
+ X509Certificate x509Cert = (X509Certificate) cert;
+
+ // If this is the last certificate, it's the end of the valid chain
+ if (pos == chain.size() - 1) {
+ return pos;
+ }
+
+ // Check if the next certificate is the issuer of this one
+ Certificate nextCert = chain.get(pos + 1);
+ if (!(nextCert instanceof X509Certificate)) {
+ // Next cert is not X509, stop here
+ return pos;
+ }
+
+ X509Certificate nextX509Cert = (X509Certificate) nextCert;
+ X500Principal issuerPrincipal = x509Cert.getIssuerX500Principal();
+ X500Principal nextSubjectPrincipal = nextX509Cert.getSubjectX500Principal();
+
+ if (!issuerPrincipal.equals(nextSubjectPrincipal)) {
+ // Issuer relationship broken, stop here
+ return pos;
+ }
+
+ // Verify that next cert can actually sign this one
+ if (!isValidIssuer(nextX509Cert, x509Cert)) {
+ // Next cert cannot validate this cert's signature, stop here
+ return pos;
+ }
+
+ // If this cert is self-signed, it's the end of the chain
+ if (isSelfSignedCertificate(x509Cert)) {
+ return pos;
+ }
+
+ pos++;
+ }
+
+ return pos > 0 ? pos - 1 : 0;
+ }
+
+ /**
+ * Logs the certificate chain for debugging purposes.
+ *
+ * @param label a descriptive label for the log
+ * @param certificates the certificate array to log
+ */
+ private static void logCertificateChain(String label, Certificate[] certificates) {
+ if (certificates == null || certificates.length == 0) {
+ LOGGER.log(FINE, "{0}: empty chain", label);
+ return;
+ }
+
+ StringBuilder sb = new StringBuilder();
+ sb.append(label).append(" [").append(certificates.length).append(" certs]:\n");
+
+ for (int i = 0; i < certificates.length; i++) {
+ if (certificates[i] instanceof X509Certificate) {
+ X509Certificate x509 = (X509Certificate) certificates[i];
+ X500Principal subject = x509.getSubjectX500Principal();
+ X500Principal issuer = x509.getIssuerX500Principal();
+ boolean isSelfSigned = isSelfSignedCertificate(x509);
+
+ sb.append(" [")
+ .append(i)
+ .append("] Subject: ")
+ .append(subject.getName())
+ .append(" | Issuer: ")
+ .append(issuer.getName())
+ .append(" | Self-Signed: ")
+ .append(isSelfSigned)
+ .append("\n");
+ } else {
+ sb.append(" [").append(i).append("] Non-X509 certificate\n");
+ }
+ }
+
+ LOGGER.log(FINE, sb.toString());
+ }
+
+ /**
+ * Verifies whether a certificate is self-signed (signed by its own private key).
+ * This is checked by verifying the certificate's signature using its own public key.
+ *
+ * @param cert the certificate to verify
+ * @return true if the certificate is self-signed, false otherwise
+ */
+ private static boolean isSelfSignedCertificate(X509Certificate cert) {
+ // First check: subject and issuer must be the same
+ if (!cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal())) {
+ return false;
+ }
+
+ // Second check: verify the signature using its own public key
+ try {
+ cert.verify(cert.getPublicKey());
+ return true;
+ } catch (Exception e) {
+ // If signature verification fails, it's not self-signed
+ return false;
+ }
+ }
+
+ /**
+ * Validates that an issuer certificate is legitimate for signing another certificate.
+ *
+ * This method performs two checks:
+ *
+ * - Verifies that the signature on the certificate was created by the issuer's private key
+ * - Verifies that the issuer is authorized to be a CA
+ * (either self-signed root or has CA bit set in basicConstraints)
+ *
+ *
+ * @param issuer the potential issuer certificate
+ * @param cert the certificate to verify
+ * @return true if the issuer certificate can validly issue the certificate, false otherwise
+ */
+ private static boolean isValidIssuer(X509Certificate issuer, X509Certificate cert) {
+ try {
+ // Verify the certificate's signature using the issuer's public key
+ cert.verify(issuer.getPublicKey());
+
+ // Check if the issuer is a CA (either self-signed or has CA basic constraints)
+ // A root CA is self-signed, intermediate CAs should have basicConstraints.CA=true
+ // basicConstraints >= 0 means CA is true
+ boolean isCA = isSelfSignedCertificate(issuer) || (issuer.getBasicConstraints() >= 0);
+ if (!isCA) {
+ return false;
+ }
+
+ // RFC 5280: if KeyUsage is present for a CA certificate, keyCertSign must be set.
+ boolean[] keyUsage = issuer.getKeyUsage();
+ if (keyUsage != null) {
+ // keyCertSign is bit 5; if missing or false, the cert must not issue other certs.
+ if (keyUsage.length <= 5 || !keyUsage[5]) {
+ return false;
+ }
+ }
+
+ return true;
+ } catch (GeneralSecurityException e) {
+ // If signature verification fails or any error occurs, it's not a valid issuer
+ return false;
+ }
+ }
+
+ /**
+ * Downloads the issuer certificate for the given certificate using the CA Issuers URL
+ * found in the certificate's AIA (Authority Information Access) extension.
+ *
+ * @param cert the certificate whose issuer should be downloaded
+ * @return the issuer {@link X509Certificate}, or {@code null} if it cannot be retrieved
+ */
+ static X509Certificate downloadIssuerCertificateFromAia(X509Certificate cert) {
+ try {
+ byte[] aiaValue = cert.getExtensionValue(Extension.authorityInfoAccess.getId());
+ if (aiaValue == null) {
+ return null;
+ }
+
+ // getExtensionValue() wraps the value in an OCTET STRING; unwrap it first
+ ASN1OctetString octStr = ASN1OctetString.getInstance(aiaValue);
+ AuthorityInformationAccess aia = AuthorityInformationAccess.getInstance(octStr.getOctets());
+
+ for (AccessDescription ad : aia.getAccessDescriptions()) {
+ // id-ad-caIssuers (1.3.6.1.5.5.7.48.2) points to the issuer's certificate
+ if (!X509ObjectIdentifiers.id_ad_caIssuers.equals(ad.getAccessMethod())) {
+ continue;
+ }
+ GeneralName location = ad.getAccessLocation();
+ if (location.getTagNo() != GeneralName.uniformResourceIdentifier) {
+ continue;
+ }
+ String url = location.getName().toString();
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
+ continue; // Only HTTP/HTTPS URLs are supported
+ }
+
+ LOGGER.log(FINE, "Downloading issuer certificate from AIA URL: {0}", url);
+ byte[] certBytes = HttpUtil.getBytes(url);
+ if (certBytes == null) {
+ LOGGER.log(FINE, "Failed to download issuer certificate from AIA URL: {0}", url);
+ continue;
+ }
+
+ CertificateFactory cf = CertificateFactory.getInstance("X.509");
+ try {
+ // Parse all certificates in the response. Some AIA endpoints return a bundle.
+ Collection extends Certificate> downloadedCerts
+ = cf.generateCertificates(new ByteArrayInputStream(certBytes));
+ X500Principal expectedIssuerPrincipal = cert.getIssuerX500Principal();
+ for (Certificate downloaded : downloadedCerts) {
+ if (!(downloaded instanceof X509Certificate)) {
+ continue;
+ }
+ X509Certificate downloadedX509Cert = (X509Certificate) downloaded;
+ if (expectedIssuerPrincipal.equals(downloadedX509Cert.getSubjectX500Principal())
+ && isValidIssuer(downloadedX509Cert, cert)) {
+ return downloadedX509Cert;
+ }
+ }
+ } catch (CertificateException e) {
+ // Fall back to PEM format
+ String pem = new String(certBytes, StandardCharsets.UTF_8);
+ if (pem.contains(BEGIN_CERTIFICATE)) {
+ Certificate[] pemCerts = loadCertificatesFromSecretBundleValuePem(pem);
+ X500Principal expectedIssuerPrincipal = cert.getIssuerX500Principal();
+ for (Certificate pemCert : pemCerts) {
+ if (!(pemCert instanceof X509Certificate)) {
+ continue;
+ }
+ X509Certificate pemX509Cert = (X509Certificate) pemCert;
+ if (expectedIssuerPrincipal.equals(pemX509Cert.getSubjectX500Principal())
+ && isValidIssuer(pemX509Cert, cert)) {
+ return pemX509Cert;
+ }
+ }
+ }
+ }
+ }
+ } catch (Exception e) {
+ LOGGER.log(FINE, "Failed to download issuer certificate from AIA extension.", e);
+ }
+ return null;
+ }
+
}
diff --git a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/HttpUtil.java b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/HttpUtil.java
index 4f4480cc0d52..b26f590f545a 100644
--- a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/HttpUtil.java
+++ b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/HttpUtil.java
@@ -5,6 +5,7 @@
import com.azure.security.keyvault.jca.implementation.JreKeyStoreFactory;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
@@ -19,6 +20,7 @@
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.http.io.entity.StringEntity;
import org.apache.hc.core5.ssl.SSLContexts;
+import org.apache.hc.core5.util.Timeout;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLContext;
@@ -78,6 +80,43 @@ public static String get(String uri, Map headers) {
return result;
}
+ /**
+ * Performs an HTTP GET request and returns the raw response body as a byte array.
+ * Used primarily for downloading DER-encoded certificates from CA Issuers URLs in
+ * AIA (Authority Information Access) certificate extensions.
+ *
+ * @param url the URL to fetch
+ * @return the response body bytes, or {@code null} if the request fails or returns non-2xx
+ */
+ public static byte[] getBytes(String url) {
+ try (CloseableHttpClient client = buildClient()) {
+ HttpGet httpGet = new HttpGet(url);
+ httpGet.addHeader(USER_AGENT_KEY, USER_AGENT_VALUE);
+ // Set reasonable timeouts to prevent indefinite hangs when fetching AIA certificate chain
+ RequestConfig config = RequestConfig.custom()
+ .setConnectTimeout(Timeout.ofSeconds(10))
+ .setResponseTimeout(Timeout.ofSeconds(10))
+ .build();
+ httpGet.setConfig(config);
+ return client.execute(httpGet, (ClassicHttpResponse response) -> {
+ int status = response.getCode();
+ if (status >= 200 && status < 300) {
+ HttpEntity entity = response.getEntity();
+ return entity != null ? EntityUtils.toByteArray(entity) : null;
+ }
+ LOGGER.log(WARNING, "HTTP GET returned status {0} for URL: {1}", new Object[] { status, url });
+ return null;
+ });
+ } catch (Exception e) {
+ // Catch all exceptions including IOException, IllegalArgumentException (malformed URL),
+ // and other runtime exceptions that may occur during HTTP execution.
+ // Gracefully return null to allow AIA completion to fail silently instead of breaking
+ // the entire jarsigner/signing operation.
+ LOGGER.log(WARNING, e, () -> "Unable to finish the HTTP GET (bytes) request for URL: " + url);
+ return null;
+ }
+ }
+
public static String post(String uri, String body, String contentType) {
return post(uri, null, body, contentType);
}
diff --git a/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/AiaCertificateChainTest.java b/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/AiaCertificateChainTest.java
new file mode 100644
index 000000000000..4cd9f5f3f32a
--- /dev/null
+++ b/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/AiaCertificateChainTest.java
@@ -0,0 +1,423 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.security.keyvault.jca.implementation.utils;
+
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x509.AccessDescription;
+import org.bouncycastle.asn1.x509.AuthorityInformationAccess;
+import org.bouncycastle.asn1.x509.BasicConstraints;
+import org.bouncycastle.asn1.x509.Extension;
+import org.bouncycastle.asn1.x509.GeneralName;
+import org.bouncycastle.asn1.x509.KeyUsage;
+import org.bouncycastle.asn1.x509.X509ObjectIdentifiers;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Execution;
+import org.junit.jupiter.api.parallel.ExecutionMode;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.math.BigInteger;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PrivateKey;
+import java.security.cert.CertPathBuilder;
+import java.security.cert.CertPathBuilderException;
+import java.security.cert.CertPathBuilderResult;
+import java.security.cert.CertStore;
+import java.security.cert.Certificate;
+import java.security.cert.CollectionCertStoreParameters;
+import java.security.cert.PKIXBuilderParameters;
+import java.security.cert.TrustAnchor;
+import java.security.cert.X509CertSelector;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicLong;
+
+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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for AIA-based certificate chain completion in {@link CertificateUtil}.
+ *
+ * Covers the scenario where a non-exportable certificate stored in Azure Key Vault has
+ * only its leaf certificate in the secret bundle. The missing intermediate CA certificates
+ * must be downloaded via the CA Issuers URL in the AIA extension of each certificate.
+ *
+ *
Tests must run sequentially because they share JVM-global state (system properties and
+ * Mockito static mocks). Parallel execution would cause property-pollution flakiness.
+ */
+@Execution(ExecutionMode.SAME_THREAD)
+public class AiaCertificateChainTest {
+
+ private static final String AIA_INTERMEDIATE_URL = "http://aia.example.com/intermediate.crt";
+ private static final String AIA_ROOT_URL = "http://aia.example.com/root.crt";
+ private static final String AIA_BAD_ISSUER_URL = "http://aia.example.com/bad-issuer.crt";
+ // Monotonic counter avoids duplicate serial numbers when certificates are created back-to-back
+ private static final AtomicLong SERIAL_COUNTER = new AtomicLong(1);
+
+ private static X509Certificate rootCert;
+ private static X509Certificate intermediateCert;
+ private static X509Certificate leafCert;
+
+ @BeforeAll
+ static void generateTestChain() throws Exception {
+ KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
+ keyGen.initialize(2048);
+
+ // Root CA (self-signed, no AIA needed)
+ KeyPair rootKeyPair = keyGen.generateKeyPair();
+ rootCert = buildCertificate(rootKeyPair.getPublic(), "CN=Test Root CA", "CN=Test Root CA",
+ rootKeyPair.getPrivate(), true, null);
+
+ // Intermediate CA (signed by root, AIA points to root cert)
+ KeyPair intermediateKeyPair = keyGen.generateKeyPair();
+ intermediateCert = buildCertificate(intermediateKeyPair.getPublic(), "CN=Test Intermediate CA",
+ "CN=Test Root CA", rootKeyPair.getPrivate(), true, AIA_ROOT_URL);
+
+ // Leaf certificate (signed by intermediate, AIA points to intermediate cert)
+ KeyPair leafKeyPair = keyGen.generateKeyPair();
+ leafCert = buildCertificate(leafKeyPair.getPublic(), "CN=Test Leaf", "CN=Test Intermediate CA",
+ intermediateKeyPair.getPrivate(), false, AIA_INTERMEDIATE_URL);
+ }
+
+ @BeforeEach
+ void setupClean() {
+ // Ensure each test starts with a clean state - clear the disable property
+ System.clearProperty(CertificateUtil.DISABLE_AIA_DOWNLOAD_PROPERTY);
+ }
+
+ @AfterEach
+ void cleanup() {
+ // Clear the property after each test to prevent interference with subsequent tests
+ System.clearProperty(CertificateUtil.DISABLE_AIA_DOWNLOAD_PROPERTY);
+ }
+
+ // -----------------------------------------------------------------------
+ // completeChainViaAia tests
+ // -----------------------------------------------------------------------
+
+ @Test
+ void completeChainViaAiaLeafOnlyDownloadsIntermediateAndRoot() throws Exception {
+ // Simulate AKV returning only the leaf cert (non-exportable, leaf-only secret)
+ Certificate[] leafOnly = new Certificate[] { leafCert };
+
+ try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) {
+ httpMock.when(() -> HttpUtil.getBytes(AIA_INTERMEDIATE_URL)).thenReturn(intermediateCert.getEncoded());
+ httpMock.when(() -> HttpUtil.getBytes(AIA_ROOT_URL)).thenReturn(rootCert.getEncoded());
+
+ Certificate[] completed = CertificateUtil.completeChainViaAia(leafOnly);
+
+ assertEquals(3, completed.length, "Chain should contain leaf + intermediate + root");
+ assertEquals(leafCert, completed[0], "First cert should be the leaf");
+ assertEquals(intermediateCert, completed[1], "Second cert should be the intermediate CA");
+ assertEquals(rootCert, completed[2], "Third cert should be the root CA");
+ }
+ }
+
+ @Test
+ void completeChainViaAiaLeafAndIntermediateDownloadsRootOnly() throws Exception {
+ // Chain already has leaf + intermediate; only root is missing
+ Certificate[] partial = new Certificate[] { leafCert, intermediateCert };
+
+ try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) {
+ httpMock.when(() -> HttpUtil.getBytes(AIA_ROOT_URL)).thenReturn(rootCert.getEncoded());
+
+ Certificate[] completed = CertificateUtil.completeChainViaAia(partial);
+
+ assertEquals(3, completed.length, "Chain should contain leaf + intermediate + root");
+ assertEquals(rootCert, completed[2]);
+ }
+ }
+
+ @Test
+ void completeChainViaAiaFullChainNoDownloadNeeded() throws Exception {
+ // Already complete: root is self-signed, no AIA download should happen
+ Certificate[] full = new Certificate[] { leafCert, intermediateCert, rootCert };
+
+ try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) {
+ Certificate[] result = CertificateUtil.completeChainViaAia(full);
+
+ assertEquals(3, result.length);
+ httpMock.verifyNoInteractions();
+ }
+ }
+
+ @Test
+ void completeChainViaAiaDownloadFailsReturnsOriginal() throws Exception {
+ Certificate[] leafOnly = new Certificate[] { leafCert };
+
+ try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) {
+ httpMock.when(() -> HttpUtil.getBytes(AIA_INTERMEDIATE_URL)).thenReturn(null);
+
+ Certificate[] result = CertificateUtil.completeChainViaAia(leafOnly);
+
+ assertEquals(1, result.length, "Should return original chain when download fails");
+ }
+ }
+
+ @Test
+ void completeChainViaAiaNullInputReturnsNull() {
+ assertNull(CertificateUtil.completeChainViaAia(null));
+ }
+
+ @Test
+ void completeChainViaAiaEmptyInputReturnsEmpty() {
+ Certificate[] result = CertificateUtil.completeChainViaAia(new Certificate[0]);
+ assertEquals(0, result.length);
+ }
+
+ // -----------------------------------------------------------------------
+ // downloadIssuerCertificateFromAia tests
+ // -----------------------------------------------------------------------
+
+ @Test
+ void downloadIssuerCertificateFromAiaReturnsDerEncodedCert() throws Exception {
+ try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) {
+ httpMock.when(() -> HttpUtil.getBytes(AIA_INTERMEDIATE_URL)).thenReturn(intermediateCert.getEncoded());
+
+ X509Certificate result = CertificateUtil.downloadIssuerCertificateFromAia(leafCert);
+
+ assertNotNull(result);
+ assertEquals(intermediateCert, result);
+ }
+ }
+
+ @Test
+ void downloadIssuerCertificateFromAiaNoCertWithoutAiaReturnsNull() throws Exception {
+ // Root cert has no AIA extension
+ X509Certificate result = CertificateUtil.downloadIssuerCertificateFromAia(rootCert);
+ assertNull(result);
+ }
+
+ @Test
+ void downloadIssuerCertificateFromAiaPemBundleSelectsMatchingIssuer() throws Exception {
+ String pemBundle = toPem(rootCert) + toPem(intermediateCert);
+
+ try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) {
+ httpMock.when(() -> HttpUtil.getBytes(AIA_INTERMEDIATE_URL))
+ .thenReturn(pemBundle.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+
+ X509Certificate result = CertificateUtil.downloadIssuerCertificateFromAia(leafCert);
+
+ assertNotNull(result);
+ assertEquals(intermediateCert, result,
+ "Should select the matching issuer from PEM bundle, not the first certificate");
+ }
+ }
+
+ @Test
+ void completeChainViaAiaRejectsIssuerWithoutKeyCertSign() throws Exception {
+ KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
+ keyGen.initialize(2048);
+
+ KeyPair badIssuerKeyPair = keyGen.generateKeyPair();
+ X509Certificate badIssuerCert = buildCertificate(badIssuerKeyPair.getPublic(), "CN=Bad Issuer", "CN=Bad Issuer",
+ badIssuerKeyPair.getPrivate(), true, null, KeyUsage.digitalSignature);
+
+ KeyPair leafKeyPair = keyGen.generateKeyPair();
+ X509Certificate leafWithBadIssuerAia = buildCertificate(leafKeyPair.getPublic(), "CN=Leaf With Bad Issuer",
+ "CN=Bad Issuer", badIssuerKeyPair.getPrivate(), false, AIA_BAD_ISSUER_URL);
+
+ try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) {
+ httpMock.when(() -> HttpUtil.getBytes(AIA_BAD_ISSUER_URL)).thenReturn(badIssuerCert.getEncoded());
+
+ Certificate[] result = CertificateUtil.completeChainViaAia(new Certificate[] { leafWithBadIssuerAia });
+
+ assertEquals(1, result.length,
+ "Issuer without keyCertSign should be rejected even if basicConstraints indicates CA");
+ assertEquals(leafWithBadIssuerAia, result[0]);
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // PKIX path-building tests – reproduce and verify the reported bug
+ //
+ // The issue reporter sees:
+ // "PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException:
+ // unable to find valid certification path to requested target"
+ //
+ // These tests confirm that:
+ // (a) the error is reproducible WITHOUT our fix (leaf-only chain), and
+ // (b) it is resolved WITH our fix (chain completed via AIA download).
+ // -----------------------------------------------------------------------
+
+ /**
+ * Reproduces the exact error from the issue without our fix.
+ *
+ * When Azure Key Vault returns only the leaf certificate (non-exportable key, leaf-only
+ * secret bundle), the PKIX path builder cannot trace a path to the trusted root CA because
+ * the intermediate CA certificate is absent. This is the root cause of the reported warning:
+ *
+ * PKIX path building failed: unable to find valid certification path to requested target
+ *
+ */
+ @Test
+ void pkixPathBuildingWithoutFixFailsWithReportedError() throws Exception {
+ // Trust store contains only the root CA – mirrors the system JRE cacerts behaviour
+ Set trustAnchors = Collections.singleton(new TrustAnchor(rootCert, null));
+
+ X509CertSelector selector = new X509CertSelector();
+ selector.setCertificate(leafCert);
+
+ PKIXBuilderParameters params = new PKIXBuilderParameters(trustAnchors, selector);
+ params.setRevocationEnabled(false);
+
+ // Only the leaf cert is available – this is what AKV returns without our fix
+ params.addCertStore(
+ CertStore.getInstance("Collection", new CollectionCertStoreParameters(Collections.singleton(leafCert))));
+
+ CertPathBuilder builder = CertPathBuilder.getInstance("PKIX");
+
+ CertPathBuilderException exception = assertThrows(CertPathBuilderException.class, () -> builder.build(params),
+ "PKIX path building must fail when the intermediate CA is missing");
+
+ // The CertPathBuilderException carries the inner error message directly.
+ // jarsigner then surfaces the full warning as:
+ // "PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException:
+ // unable to find valid certification path to requested target"
+ // Verify the root message matches what the issue reporter sees.
+ // Use String.valueOf() to guard against null message across different JDKs/providers
+ String message = String.valueOf(exception.getMessage());
+ assertTrue(message.contains("unable to find valid certification path to requested target"),
+ "Exception message should match the reported error. Actual: " + message);
+ }
+
+ /**
+ * Verifies that our AIA-based chain-completion fix resolves the reported PKIX error.
+ *
+ * After {@link CertificateUtil#completeChainViaAia} downloads the missing intermediate CA,
+ * the full chain (leaf → intermediate → root) is present and PKIX path building succeeds.
+ */
+ @Test
+ void pkixPathBuildingWithFixSucceeds() throws Exception {
+ // Simulate AKV returning only the leaf cert – the broken starting state
+ Certificate[] leafOnly = new Certificate[] { leafCert };
+ Certificate[] completedChain;
+
+ try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) {
+ httpMock.when(() -> HttpUtil.getBytes(AIA_INTERMEDIATE_URL)).thenReturn(intermediateCert.getEncoded());
+ httpMock.when(() -> HttpUtil.getBytes(AIA_ROOT_URL)).thenReturn(rootCert.getEncoded());
+ completedChain = CertificateUtil.completeChainViaAia(leafOnly);
+ }
+
+ assertEquals(3, completedChain.length, "Chain should be leaf + intermediate + root after fix");
+
+ // Now try PKIX path building with the completed chain – this is what jarsigner does
+ Set trustAnchors = Collections.singleton(new TrustAnchor(rootCert, null));
+
+ X509CertSelector selector = new X509CertSelector();
+ selector.setCertificate(leafCert);
+
+ PKIXBuilderParameters params = new PKIXBuilderParameters(trustAnchors, selector);
+ params.setRevocationEnabled(false);
+
+ List certList = Arrays.asList(completedChain);
+ params.addCertStore(CertStore.getInstance("Collection", new CollectionCertStoreParameters(certList)));
+
+ CertPathBuilder builder = CertPathBuilder.getInstance("PKIX");
+
+ // Should NOT throw – the full chain enables successful path validation
+ CertPathBuilderResult result = builder.build(params);
+ assertNotNull(result, "PKIX path building must succeed with the completed chain");
+ assertEquals(2, result.getCertPath().getCertificates().size(),
+ "Path should contain leaf + intermediate (root is the trust anchor, not in path)");
+ }
+
+ /**
+ * Verifies that AIA chain completion can be disabled via system property.
+ *
+ * When the system property {@code azure.keyvault.jca.disable-aia-download} is set to {@code true},
+ * the AIA chain completion is skipped and the original chain is returned unchanged.
+ */
+ @Test
+ void aiaDownloadDisabledBySystemProperty() throws Exception {
+ // Set the disable system property
+ String originalValue = System.getProperty(CertificateUtil.DISABLE_AIA_DOWNLOAD_PROPERTY);
+ System.setProperty(CertificateUtil.DISABLE_AIA_DOWNLOAD_PROPERTY, "true");
+
+ try {
+ // Simulate AKV returning only the leaf cert
+ Certificate[] leafOnly = new Certificate[] { leafCert };
+
+ // Mock HttpUtil BEFORE calling completeChainViaAia to ensure property check
+ // doesn't trigger real network I/O if it regresses
+ try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) {
+ // Call completeChainViaAia with the property set to true
+ // It should return the same array without downloading anything
+ Certificate[] result = CertificateUtil.completeChainViaAia(leafOnly);
+
+ // Verify the chain was NOT extended (still only 1 certificate)
+ assertEquals(1, result.length, "Chain should remain unchanged when AIA download is disabled");
+ assertEquals(leafCert, result[0], "The returned certificate should be the leaf certificate");
+
+ // Verify that no HTTP calls were made (HttpUtil.getBytes should not be called)
+ httpMock.verify(() -> HttpUtil.getBytes(Mockito.anyString()), Mockito.never());
+ }
+ } finally {
+ // Clean up: restore the original property value
+ if (originalValue != null) {
+ System.setProperty(CertificateUtil.DISABLE_AIA_DOWNLOAD_PROPERTY, originalValue);
+ } else {
+ System.clearProperty(CertificateUtil.DISABLE_AIA_DOWNLOAD_PROPERTY);
+ }
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Helper
+ // -----------------------------------------------------------------------
+
+ private static X509Certificate buildCertificate(java.security.PublicKey subjectPublicKey, String subjectDn,
+ String issuerDn, PrivateKey signingKey, boolean isCa, String aiaUrl) throws Exception {
+ return buildCertificate(subjectPublicKey, subjectDn, issuerDn, signingKey, isCa, aiaUrl, null);
+ }
+
+ private static X509Certificate buildCertificate(java.security.PublicKey subjectPublicKey, String subjectDn,
+ String issuerDn, PrivateKey signingKey, boolean isCa, String aiaUrl, Integer keyUsageFlags) throws Exception {
+
+ X500Name subject = new X500Name(subjectDn);
+ X500Name issuer = new X500Name(issuerDn);
+ Date notBefore = new Date(System.currentTimeMillis() - 86_400_000L);
+ Date notAfter = new Date(System.currentTimeMillis() + 86_400_000L * 365);
+ BigInteger serial = BigInteger.valueOf(SERIAL_COUNTER.getAndIncrement());
+
+ JcaX509v3CertificateBuilder builder
+ = new JcaX509v3CertificateBuilder(issuer, serial, notBefore, notAfter, subject, subjectPublicKey);
+
+ builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(isCa));
+
+ if (keyUsageFlags != null) {
+ builder.addExtension(Extension.keyUsage, true, new KeyUsage(keyUsageFlags));
+ }
+
+ if (aiaUrl != null) {
+ GeneralName accessLocation = new GeneralName(GeneralName.uniformResourceIdentifier, aiaUrl);
+ AccessDescription caIssuers = new AccessDescription(X509ObjectIdentifiers.id_ad_caIssuers, accessLocation);
+ builder.addExtension(Extension.authorityInfoAccess, false, new AuthorityInformationAccess(caIssuers));
+ }
+
+ ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(signingKey);
+ return new JcaX509CertificateConverter().getCertificate(builder.build(signer));
+ }
+
+ private static String toPem(X509Certificate certificate) throws Exception {
+ String base64 = Base64.getMimeEncoder(64, new byte[] { '\n' }).encodeToString(certificate.getEncoded());
+ return "-----BEGIN CERTIFICATE-----\n" + base64 + "\n-----END CERTIFICATE-----\n";
+ }
+}
diff --git a/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/CertificateOrderTest.java b/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/CertificateOrderTest.java
new file mode 100644
index 000000000000..495ef0ffb687
--- /dev/null
+++ b/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/CertificateOrderTest.java
@@ -0,0 +1,217 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.security.keyvault.jca.implementation.utils;
+
+import org.bouncycastle.pkcs.PKCSException;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.NoSuchProviderException;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class CertificateOrderTest {
+
+ /**
+ * Test to verify the certificate chain order from PEM files.
+ * The expected order is: end-entity (leaf) cert, intermediate CA(s), root CA.
+ */
+ @Test
+ public void testPemCertificateChainOrder() throws CertificateException, IOException, KeyStoreException,
+ NoSuchAlgorithmException, NoSuchProviderException, PKCSException {
+
+ String pemString = new String(
+ Files.readAllBytes(
+ Paths.get("src/test/resources/certificate-util/SecretBundle.value/3-certificates-in-chain.pem")),
+ StandardCharsets.UTF_8);
+
+ Certificate[] certs = CertificateUtil.loadCertificatesFromSecretBundleValue(pemString);
+
+ assertEquals(3, certs.length, "Should have 3 certificates in chain");
+
+ X509Certificate cert0 = (X509Certificate) certs[0];
+ X509Certificate cert1 = (X509Certificate) certs[1];
+ X509Certificate cert2 = (X509Certificate) certs[2];
+
+ // Certificate 0 should be the end-entity (leaf) certificate with CN=signer
+ assertTrue(cert0.getSubjectX500Principal().getName().contains("CN=signer"),
+ "First certificate should be the end-entity certificate");
+
+ // Certificate 1 should be the intermediate CA
+ assertTrue(cert1.getSubjectX500Principal().getName().contains("CN=Intermediate CA"),
+ "Second certificate should be the intermediate CA");
+
+ // Certificate 2 should be the root CA
+ assertTrue(cert2.getSubjectX500Principal().getName().contains("CN=Root CA"),
+ "Third certificate should be the root CA");
+
+ // Verify the chain: cert0 should be issued by cert1
+ assertEquals(cert0.getIssuerX500Principal(), cert1.getSubjectX500Principal(),
+ "End-entity cert should be issued by intermediate CA");
+
+ // Verify the chain: cert1 should be issued by cert2
+ assertEquals(cert1.getIssuerX500Principal(), cert2.getSubjectX500Principal(),
+ "Intermediate CA should be issued by root CA");
+ }
+
+ /**
+ * Test to verify the certificate chain order from PKCS12 files.
+ * The expected order is: end-entity (leaf) cert, intermediate CA(s), root CA.
+ */
+ @Test
+ public void testPkcs12CertificateChainOrder() throws CertificateException, IOException, KeyStoreException,
+ NoSuchAlgorithmException, NoSuchProviderException, PKCSException {
+
+ String pfxString = new String(
+ Files.readAllBytes(
+ Paths.get("src/test/resources/certificate-util/SecretBundle.value/3-certificates-in-chain.pfx")),
+ StandardCharsets.UTF_8);
+
+ Certificate[] certs = CertificateUtil.loadCertificatesFromSecretBundleValue(pfxString);
+
+ assertEquals(3, certs.length, "Should have 3 certificates in chain");
+
+ X509Certificate cert0 = (X509Certificate) certs[0];
+ X509Certificate cert1 = (X509Certificate) certs[1];
+ X509Certificate cert2 = (X509Certificate) certs[2];
+
+ // Certificate 0 should be the end-entity (leaf) certificate
+ assertTrue(cert0.getSubjectX500Principal().getName().contains("CN=signer"),
+ "First certificate should be the end-entity certificate");
+
+ // Certificate 1 should be the intermediate CA
+ assertTrue(cert1.getSubjectX500Principal().getName().contains("CN=Intermediate CA"),
+ "Second certificate should be the intermediate CA");
+
+ // Certificate 2 should be the root CA
+ assertTrue(cert2.getSubjectX500Principal().getName().contains("CN=Root CA"),
+ "Third certificate should be the root CA");
+
+ // Verify the chain: cert0 should be issued by cert1
+ assertEquals(cert0.getIssuerX500Principal(), cert1.getSubjectX500Principal(),
+ "End-entity cert should be issued by intermediate CA");
+
+ // Verify the chain: cert1 should be issued by cert2
+ assertEquals(cert1.getIssuerX500Principal(), cert2.getSubjectX500Principal(),
+ "Intermediate CA should be issued by root CA");
+ }
+
+ /**
+ * Test to verify that the orderCertificateChain method correctly orders
+ * a reversed certificate chain (root CA, intermediate, leaf).
+ */
+ @Test
+ public void testOrderCertificateChainReversed() throws CertificateException, IOException, KeyStoreException,
+ NoSuchAlgorithmException, NoSuchProviderException, PKCSException {
+
+ String pemString = new String(
+ Files.readAllBytes(
+ Paths.get("src/test/resources/certificate-util/SecretBundle.value/3-certificates-in-chain.pem")),
+ StandardCharsets.UTF_8);
+
+ Certificate[] certs = CertificateUtil.loadCertificatesFromSecretBundleValue(pemString);
+
+ // Reverse the certificate order to simulate the issue
+ Certificate[] reversedCerts = new Certificate[certs.length];
+ for (int i = 0; i < certs.length; i++) {
+ reversedCerts[i] = certs[certs.length - 1 - i];
+ }
+
+ // Now order the reversed chain
+ Certificate[] orderedCerts = CertificateUtil.orderCertificateChain(reversedCerts);
+
+ assertEquals(3, orderedCerts.length, "Should have 3 certificates in chain");
+
+ X509Certificate cert0 = (X509Certificate) orderedCerts[0];
+ X509Certificate cert1 = (X509Certificate) orderedCerts[1];
+ X509Certificate cert2 = (X509Certificate) orderedCerts[2];
+
+ // After ordering, certificate 0 should be the end-entity (leaf) certificate
+ assertTrue(cert0.getSubjectX500Principal().getName().contains("CN=signer"),
+ "First certificate should be the end-entity certificate after ordering");
+
+ // Certificate 1 should be the intermediate CA
+ assertTrue(cert1.getSubjectX500Principal().getName().contains("CN=Intermediate CA"),
+ "Second certificate should be the intermediate CA after ordering");
+
+ // Certificate 2 should be the root CA
+ assertTrue(cert2.getSubjectX500Principal().getName().contains("CN=Root CA"),
+ "Third certificate should be the root CA after ordering");
+ }
+
+ /**
+ * Test to verify that orderCertificateChain handles null and empty arrays correctly.
+ */
+ @Test
+ public void testOrderCertificateChainEdgeCases() throws CertificateException, IOException, KeyStoreException,
+ NoSuchAlgorithmException, NoSuchProviderException, PKCSException {
+ // Test null array
+ Certificate[] result = CertificateUtil.orderCertificateChain(null);
+ assertNull(result, "Should return null for null input");
+
+ // Test empty array
+ result = CertificateUtil.orderCertificateChain(new Certificate[0]);
+ assertEquals(0, result.length, "Should return empty array for empty input");
+
+ // Test single certificate
+ String pemString = new String(
+ Files.readAllBytes(
+ Paths.get("src/test/resources/certificate-util/SecretBundle.value/3-certificates-in-chain.pem")),
+ StandardCharsets.UTF_8);
+ Certificate concreteSingleCert = CertificateUtil.loadCertificatesFromSecretBundleValue(pemString)[0];
+ Certificate[] singleCert = new Certificate[] { concreteSingleCert };
+ result = CertificateUtil.orderCertificateChain(singleCert);
+ assertEquals(1, result.length, "Should return single certificate unchanged");
+ assertSame(concreteSingleCert, result[0], "Should return the same certificate instance for single input");
+ }
+
+ /**
+ * Regression test: When input is [root, leaf] (incomplete chain),
+ * orderCertificateChain should not misidentify the root as the leaf.
+ * Previously, a self-signed root appearing before the leaf could be
+ * incorrectly selected as the leaf because its issuer (itself) appears
+ * in the chain, breaking leaf selection early and returning [root, leaf]
+ * instead of [leaf, root].
+ */
+ @Test
+ public void testOrderCertificateChainIncompleteRootFirst() throws CertificateException, IOException,
+ KeyStoreException, NoSuchAlgorithmException, NoSuchProviderException, PKCSException {
+ // Load full chain from PEM (leaf, intermediate, root)
+ String fullChainPem = new String(
+ Files.readAllBytes(
+ Paths.get("src/test/resources/certificate-util/SecretBundle.value/3-certificates-in-chain.pem")),
+ StandardCharsets.UTF_8);
+
+ Certificate[] fullChain = CertificateUtil.loadCertificatesFromSecretBundleValue(fullChainPem);
+ assertEquals(3, fullChain.length, "Full chain should have 3 certificates");
+
+ // Create incomplete chain: [root, leaf] (missing intermediate)
+ Certificate[] incompleteChain = new Certificate[] { fullChain[2], fullChain[0] };
+
+ // Order the incomplete chain
+ Certificate[] result = CertificateUtil.orderCertificateChain(incompleteChain);
+
+ // Verify the leaf (fullChain[0]) is now first, not the root
+ X509Certificate leafCert = (X509Certificate) fullChain[0];
+ X509Certificate rootCert = (X509Certificate) fullChain[2];
+ X509Certificate resultFirst = (X509Certificate) result[0];
+ X509Certificate resultSecond = (X509Certificate) result[1];
+
+ assertEquals(leafCert.getSubjectX500Principal(), resultFirst.getSubjectX500Principal(),
+ "First cert should be the leaf");
+ assertEquals(rootCert.getSubjectX500Principal(), resultSecond.getSubjectX500Principal(),
+ "Second cert should be the root");
+ }
+}