diff --git a/sdk/keyvault/azure-security-keyvault-jca/CHANGELOG.md b/sdk/keyvault/azure-security-keyvault-jca/CHANGELOG.md index fbfbab4dae26..3d75f5de6ee0 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/CHANGELOG.md +++ b/sdk/keyvault/azure-security-keyvault-jca/CHANGELOG.md @@ -7,9 +7,14 @@ ### Breaking Changes ### Bugs Fixed +- Fixed bug: `jarsigner` reports invalid certificate chain (`PKIX path building failed: unable to find valid certification path to requested target`) when using a non-exportable Azure Key Vault certificate. The fix downloads missing intermediate CA certificates at runtime using the CA Issuers URL in the AIA (Authority Information Access) extension of each certificate. ([#44267](https://github.com/Azure/azure-sdk-for-java/issues/44267)) - Fixed an issue where a disabled certificate in Azure Key Vault caused keystore initialization to fail with an HTTP 403 error. Disabled certificates are now skipped when loading aliases and a warning is logged for each skipped certificate. [#49730](https://github.com/Azure/azure-sdk-for-java/pull/49730) ### Other Changes +- Added system property `azure.keyvault.jca.disable-aia-download` to disable automatic AIA chain completion. This allows locked-down environments to prevent outbound HTTP(S) requests triggered by embedded certificate AIA extensions, mitigating potential SSRF-like attack vectors when loading untrusted certificates. Set to `true` to disable (defaults to `false` for backward compatibility). + +### Security Advisory +- **AIA Chain Completion**: The AIA chain completion feature downloads certificates from URLs embedded in certificate extensions. In locked-down environments or when processing untrusted certificates, set `azure.keyvault.jca.disable-aia-download=true` to disable this feature and prevent unexpected network requests. ## 2.11.0 (2026-02-28) diff --git a/sdk/keyvault/azure-security-keyvault-jca/README.md b/sdk/keyvault/azure-security-keyvault-jca/README.md index f31a7ca5e90a..8558a8aec510 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/README.md +++ b/sdk/keyvault/azure-security-keyvault-jca/README.md @@ -142,6 +142,7 @@ The JCA library supports configuring the following options: * `azure.keyvault.jca.certificates-refresh-interval`: The refresh interval time. * `azure.keyvault.jca.certificates-refresh-interval-in-ms`: The refresh interval time. * `azure.keyvault.disable-challenge-resource-verification`: Indicates whether to disable verification that the authentication challenge resource matches the Key Vault or Managed HSM domain. +* `azure.keyvault.jca.disable-aia-download`: Set to `true` to disable automatic AIA (Authority Information Access) certificate chain completion. When disabled, the provider will return certificate chains as provided by Azure Key Vault without downloading missing intermediate CA certificates. Use this in locked-down environments or when processing untrusted certificates to prevent outbound HTTP(S) requests to URLs embedded in certificate extensions. Defaults to `false` for backward compatibility. You can configure these properties using: ```java diff --git a/sdk/keyvault/azure-security-keyvault-jca/checkstyle-suppressions.xml b/sdk/keyvault/azure-security-keyvault-jca/checkstyle-suppressions.xml index 1796f495a42b..c0762376234c 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/checkstyle-suppressions.xml +++ b/sdk/keyvault/azure-security-keyvault-jca/checkstyle-suppressions.xml @@ -15,6 +15,7 @@ + diff --git a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/CertificateUtil.java b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/CertificateUtil.java index 014513520a84..8c8c7c2ab28b 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/CertificateUtil.java +++ b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/CertificateUtil.java @@ -2,8 +2,14 @@ // Licensed under the MIT License. package com.azure.security.keyvault.jca.implementation.utils; +import org.bouncycastle.asn1.ASN1OctetString; import org.bouncycastle.asn1.pkcs.ContentInfo; import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x509.AccessDescription; +import org.bouncycastle.asn1.x509.AuthorityInformationAccess; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.X509ObjectIdentifiers; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.pkcs.PKCS12PfxPdu; import org.bouncycastle.pkcs.PKCS12SafeBag; @@ -17,28 +23,51 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; 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.CertificateFactory; +import java.security.cert.X509Certificate; +import javax.security.auth.x500.X500Principal; import java.util.ArrayList; +import java.util.Arrays; import java.util.Base64; +import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.logging.Logger; import java.util.stream.Collectors; +import static java.util.logging.Level.FINE; +import static java.util.logging.Level.WARNING; + public final class CertificateUtil { + private static final Logger LOGGER = Logger.getLogger(CertificateUtil.class.getName()); private static final String BEGIN_CERTIFICATE = "-----BEGIN CERTIFICATE-----"; private static final String END_CERTIFICATE = "-----END CERTIFICATE-----"; + static final String DISABLE_AIA_DOWNLOAD_PROPERTY = "azure.keyvault.jca.disable-aia-download"; public static Certificate[] loadCertificatesFromSecretBundleValue(String string) throws CertificateException, IOException, KeyStoreException, NoSuchAlgorithmException, NoSuchProviderException, PKCSException { + Certificate[] certificates; if (string.contains(BEGIN_CERTIFICATE)) { - return loadCertificatesFromSecretBundleValuePem(string); + certificates = loadCertificatesFromSecretBundleValuePem(string); } else { - return loadCertificatesFromSecretBundleValuePKCS12(string); + certificates = loadCertificatesFromSecretBundleValuePKCS12(string); } + + // Ensure certificates are in the correct order: end-entity (leaf) → intermediate(s) → root CA + // This is required for jarsigner and other Java security tools + certificates = orderCertificateChain(certificates); + // Complete the chain by downloading any missing intermediate CA certificates via the AIA extension. + // This handles the case where only the leaf certificate was stored in Azure Key Vault + // (e.g. a non-exportable certificate where the caller only merged the leaf cert during CSR completion). + certificates = completeChainViaAia(certificates); + return certificates; } private static Certificate[] loadCertificatesFromSecretBundleValuePem(InputStream inputStream) @@ -113,4 +142,560 @@ public static String getCertificateNameFromCertificateItemId(String id) { return id.substring(id.indexOf(keyWord) + keyWord.length()); } + /** + * Orders a certificate chain to ensure it's in the correct order for jarsigner and Java security tools. + * The correct order is: end-entity (leaf) certificate, intermediate CA(s), root CA. + * + * This method identifies the end-entity certificate (the one not issuing any other certificate in the chain) + * and builds the chain from leaf to root by following the issuer relationships. + * + * @param certificates The array of certificates to order + * @return The ordered array of certificates, or the original array if ordering cannot be determined + */ + static Certificate[] orderCertificateChain(Certificate[] certificates) { + if (certificates == null || certificates.length <= 1) { + return certificates; + } + + try { + // Convert to X509Certificate for easier manipulation + X509Certificate[] x509Certs = new X509Certificate[certificates.length]; + for (int i = 0; i < certificates.length; i++) { + if (!(certificates[i] instanceof X509Certificate)) { + // If not X509, return original order + return certificates; + } + x509Certs[i] = (X509Certificate) certificates[i]; + } + + // Create a map of subject X500Principal to list of certificates + // This preserves multiple certs with the same subject DN (e.g. cross-signed roots) + Map> subjectToCerts = new HashMap<>(); + for (X509Certificate cert : x509Certs) { + X500Principal subject = cert.getSubjectX500Principal(); + subjectToCerts.computeIfAbsent(subject, k -> new ArrayList<>()).add(cert); + } + + // Find the end-entity (leaf) certificate + // Prioritize: a cert whose issuer exists in the chain (true end-entity), otherwise not an issuer of others + // Avoid: selecting a self-signed root as leaf if a true leaf with missing issuer exists + X509Certificate leafCert = null; + X509Certificate selfSignedFallback = null; + + for (X509Certificate cert : x509Certs) { + boolean isIssuerOfOther = false; + X500Principal certSubject = cert.getSubjectX500Principal(); + + for (X509Certificate otherCert : x509Certs) { + if (cert != otherCert) { + X500Principal otherIssuer = otherCert.getIssuerX500Principal(); + if (certSubject.equals(otherIssuer)) { + isIssuerOfOther = true; + break; + } + } + } + + if (!isIssuerOfOther) { + // This cert is not the issuer of any other cert in the chain + X500Principal issuerPrincipal = cert.getIssuerX500Principal(); + + if (!isSelfSignedCertificate(cert)) { + // Non-self-signed cert: check if issuer exists in the chain AND can verify signature + List potentialIssuers = subjectToCerts.get(issuerPrincipal); + if (potentialIssuers != null) { + // Check if any potential issuer can actually verify this cert's signature + for (X509Certificate potentialIssuer : potentialIssuers) { + if (isValidIssuer(potentialIssuer, cert)) { + // Valid issuer found and verified, this is a true leaf/end-entity + leafCert = cert; + break; + } + } + if (leafCert != null) { + break; // Found valid issuer, stop searching + } + } + if (leafCert == null) { + // No verified issuer in chain and not self-signed = true leaf with missing intermediate + leafCert = cert; + } + } else if (selfSignedFallback == null) { + // Self-signed cert with no issuer in chain = likely a root, remember as fallback + selfSignedFallback = cert; + } + } + } + + // Use fallback (self-signed root) only if no non-self-signed leaf was found + if (leafCert == null) { + leafCert = selfSignedFallback; + } + + if (leafCert == null) { + // Couldn't identify leaf certificate, return original order + return certificates; + } + + // Build the chain from leaf to root + List orderedChain = new ArrayList<>(); + X509Certificate current = leafCert; + + while (orderedChain.size() < x509Certs.length) { + orderedChain.add(current); + + // Find the issuer of the current certificate + X500Principal issuerPrincipal = current.getIssuerX500Principal(); + + // Check if this is actually a self-signed certificate (root CA) by verifying signature + if (isSelfSignedCertificate(current)) { + // Truly self-signed, we've reached the root + break; + } + + // Look for the issuer in the certificate chain + // There may be multiple candidates with the same subject DN (e.g. cross-signed roots) + List 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: + *

    + *
  1. Verifies that the signature on the certificate was created by the issuer's private key
  2. + *
  3. Verifies that the issuer is authorized to be a CA + * (either self-signed root or has CA bit set in basicConstraints)
  4. + *
+ * + * @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 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"); + } +}