diff --git a/sdk/keyvault/azure-security-keyvault-jca/CHANGELOG.md b/sdk/keyvault/azure-security-keyvault-jca/CHANGELOG.md index a83d3dab37e4..71184af4ae98 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/CHANGELOG.md +++ b/sdk/keyvault/azure-security-keyvault-jca/CHANGELOG.md @@ -7,8 +7,11 @@ ### Breaking Changes ### Bugs Fixed +- Stopped recording sensitive data in `FINER` level logs. Review any logs captured at the `FINER` level or lower in previous library versions and rotate any sensitive data contained there. +- 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. When the certificate chain returned by Azure Key Vault does not end in a self-signed root, the missing issuer certificates are now resolved at runtime using the CA Issuers URL in the AIA (Authority Information Access) extension of each certificate. Responses are cached by URL so subsequent loads can reuse them without another network request. ([#44267](https://github.com/Azure/azure-sdk-for-java/issues/44267)) ### Other Changes +- Added system property `azure.keyvault.jca.disable-aia-download` to disable automatic AIA chain completion. AIA chain completion downloads certificates from URLs embedded in certificate extensions, so this allows locked-down environments to prevent those outbound HTTP(S) requests, mitigating potential SSRF-like attack vectors when loading untrusted certificates. Set to `true` to disable (defaults to `false` for backward compatibility). ## 2.12.0 (2026-07-24) diff --git a/sdk/keyvault/azure-security-keyvault-jca/README.md b/sdk/keyvault/azure-security-keyvault-jca/README.md index 5940351269aa..3977b25618b2 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. Chain completion is only attempted when the chain returned by Azure Key Vault is incomplete, meaning it holds a single certificate or is missing an intermediate CA. 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..fe339ad9481b 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/checkstyle-suppressions.xml +++ b/sdk/keyvault/azure-security-keyvault-jca/checkstyle-suppressions.xml @@ -15,6 +15,8 @@ + + diff --git a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/KeyVaultClient.java b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/KeyVaultClient.java index 411f6b1db75f..8d1373c35968 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/KeyVaultClient.java +++ b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/KeyVaultClient.java @@ -577,7 +577,8 @@ public byte[] getSignedWithPrivateKey(String digestName, String digestValue, Str private PrivateKey createPrivateKeyFromPem(String pemString, String keyType) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { - LOGGER.entering("KeyVaultClient", "createPrivateKeyFromPem", new Object[] { pemString, keyType }); + // The PEM string holds the private key, so it must stay out of the log. + LOGGER.entering("KeyVaultClient", "createPrivateKeyFromPem", keyType); StringBuilder builder = new StringBuilder(); diff --git a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java index 8e210265b611..271a7b03f22b 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java +++ b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtil.java @@ -124,8 +124,8 @@ public static AccessToken getAccessToken(String resource, String identity) { */ public static AccessToken getAccessToken(String resource, String aadAuthenticationUrl, String tenantId, String clientId, String clientSecret) { - LOGGER.entering("AccessTokenUtil", "getAccessToken", - new Object[] { resource, tenantId, clientId, clientSecret }); + // The client secret is deliberately left out: entering() renders every parameter in clear text. + LOGGER.entering("AccessTokenUtil", "getAccessToken", new Object[] { resource, tenantId, clientId }); LOGGER.info("Getting access token using client ID / client secret"); AccessToken result = null; diff --git a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AiaCertificateChainUtil.java b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AiaCertificateChainUtil.java new file mode 100644 index 000000000000..0198b658f86f --- /dev/null +++ b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AiaCertificateChainUtil.java @@ -0,0 +1,666 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.security.keyvault.jca.implementation.utils; + +import org.bouncycastle.asn1.ASN1OctetString; +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 java.io.ByteArrayInputStream; +import java.io.IOException; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateExpiredException; +import java.security.cert.CertificateFactory; +import java.security.cert.CertificateNotYetValidException; +import java.security.cert.X509Certificate; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import javax.security.auth.x500.X500Principal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +import static java.util.logging.Level.FINE; +import static java.util.logging.Level.WARNING; + +/** + * Utility class used for completing an incomplete certificate chain with the issuer certificates published in the AIA + * (Authority Information Access) extension of the certificates it already holds. + * + *

Azure Key Vault's secrets endpoint returns only the leaf certificate for a non-exportable certificate whose + * caller merged just the leaf during CSR completion. Without the intermediate CA certificates, jarsigner cannot build + * a valid PKIX path to a trusted root CA and reports "PKIX path building failed" on verify. + * + *

Security note: completion issues outbound HTTP(S) requests to URLs embedded in certificates. + * Set the system property {@code azure.keyvault.jca.disable-aia-download=true} to disable it in locked-down + * environments or when loading untrusted certificates. + */ +final class AiaCertificateChainUtil { + private static final Logger LOGGER = Logger.getLogger(AiaCertificateChainUtil.class.getName()); + static final String DISABLE_AIA_DOWNLOAD_PROPERTY = "azure.keyvault.jca.disable-aia-download"; + private static final int AIA_CACHE_MAX_SIZE = 128; + private static final long MAX_SUCCESS_TTL_IN_MILLIS = TimeUnit.HOURS.toMillis(24); + private static final long NEGATIVE_TTL_IN_MILLIS = TimeUnit.MINUTES.toMillis(1); + private static final AiaResponseCache AIA_CACHE + = new AiaResponseCache(AIA_CACHE_MAX_SIZE, System::currentTimeMillis); + + /** + * Determines whether a certificate chain should be completed with issuer certificates resolved via AIA. + * + *

The chain is complete only when its valid, contiguous path from the leaf ends in a self-signed root. + * A contiguous chain that ends in a non-self-signed intermediate may still be missing one or more issuer + * certificates above that intermediate. Resolution remains cache-first, so a valid cached response avoids a + * repeated network request. + * + * @param certificates the ordered certificate chain + * @return true if the valid chain ends in a non-self-signed X.509 certificate, false otherwise + */ + static boolean shouldCompleteChainViaAia(Certificate[] certificates) { + if (certificates == null || certificates.length == 0) { + return false; + } + + int validChainEnd = findValidChainEnd(Arrays.asList(certificates)); + if (validChainEnd < 0 || !(certificates[validChainEnd] instanceof X509Certificate)) { + return false; + } + + return !CertificateUtil.isSelfSignedCertificate((X509Certificate) certificates[validChainEnd]); + } + + /** + * Completes an incomplete certificate chain by downloading missing intermediate CA certificates + * using the AIA (Authority Information Access) extension embedded in each certificate. + * + *

Because completion may issue outbound HTTP requests on a cache miss, callers must restrict it to chains + * whose valid path does not end in a self-signed root (see {@link #shouldCompleteChainViaAia(Certificate[])}). + * + *

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. + * + * @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 + // Defence in depth. Repositioning below takes `continue` without decrementing maxDownloads, so it is the one + // path whose termination rests on findValidChainEnd() advancing. It does advance today, because a reposition + // only touches positions after the valid prefix, but this cap keeps the loop bounded should a later change + // break that invariant. The bound is generous so it never truncates a legitimately completable chain. + int remainingIterations = 4 * (chain.size() + maxDownloads) + 16; + + while (true) { + if (--remainingIterations < 0) { + LOGGER.log(FINE, "Reached maximum certificate chain-completion iterations. Stopping to guard against " + + "non-terminating input (possible duplicate or cross-signed intermediates)."); + break; + } + + // 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 (CertificateUtil.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()) + && CertificateUtil.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) { + if (validIssuerIndex > validChainEnd + 1) { + // Valid issuer sits among the appended/unplaced certs after the valid prefix. + // Moving it up into the contiguous slot is safe: the removal is beyond the valid + // prefix, so it cannot break an earlier link. This makes forward progress, so + // re-evaluate the chain from the top. + 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); + continue; + } else if (validIssuerIndex <= validChainEnd) { + // The matching issuer lies *inside* the already-valid prefix. This can occur with + // duplicate or cross-signed intermediates that share a subject DN and key. Removing + // it would break the valid prefix and could keep the loop oscillating between two + // chain arrangements without ever decrementing maxDownloads, so do NOT reposition here. + // Fall through to the bounded AIA download branch, which inserts a fresh issuer copy + // contiguously and makes guaranteed forward progress. + LOGGER.log(FINE, + "Valid issuer for [{0}] found inside the valid prefix at index {1} (likely duplicate or " + + "cross-signed). Not repositioning; attempting AIA download instead.", + new Object[] { x509Top.getSubjectX500Principal().getName(), validIssuerIndex }); + } else { + // validIssuerIndex == validChainEnd + 1: already contiguous. findValidChainEnd would + // normally have consumed it already; fall through rather than spinning on `continue`. + LOGGER.log(FINE, "Valid issuer already at correct contiguous position."); + } + // Fall through to the AIA download branch below. + } + + // 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 (!CertificateUtil.isValidIssuer(issuer, x509Top)) { + LOGGER.log(WARNING, + "Downloaded certificate cannot verify signature on current certificate or is not a CA. " + + "Stopping AIA chain completion."); + break; + } + + // The certificate may come from the response cache, and it may be a root rather than an intermediate, + // so this message must not claim either. + LOGGER.log(FINE, "Resolved issuer 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(FINE)) { + CertificateUtil.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 (!CertificateUtil.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 (CertificateUtil.isSelfSignedCertificate(x509Cert)) { + return pos; + } + + pos++; + } + + return pos > 0 ? pos - 1 : 0; + } + + /** + * Resolves and validates the issuer, refreshing a cached miss once before briefly suppressing that target. + * + * @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 + } + + AiaResponseCache.LookupResult initial = getAiaResponse(url); + X509Certificate issuer = findValidIssuer(cert, initial.getCertificates()); + if (issuer != null) { + return issuer; + } + + if (initial.isNegative()) { + continue; + } + + TargetIdentity targetIdentity + = new TargetIdentity(cert.getIssuerX500Principal(), cert.getSerialNumber()); + // A normal load already performed HTTP, so do not download twice in one resolution attempt. + if (initial.getSource() != AiaResponseCache.Source.CACHE) { + AIA_CACHE.suppressRefresh(url, targetIdentity, initial.getGeneration(), NEGATIVE_TTL_IN_MILLIS); + continue; + } + if (AIA_CACHE.isRefreshSuppressed(url, targetIdentity, initial.getGeneration())) { + continue; + } + + // Refresh only the generation observed above; concurrent callers share the same refresh. + AiaResponseCache.LookupResult refreshed + = AIA_CACHE.refreshIfUnchanged(url, initial.getGeneration(), () -> loadAiaResponse(url)); + issuer = findValidIssuer(cert, refreshed.getCertificates()); + if (issuer != null) { + AIA_CACHE.clearRefreshSuppression(url, targetIdentity); + return issuer; + } + AIA_CACHE.suppressRefresh(url, targetIdentity, refreshed.getGeneration(), NEGATIVE_TTL_IN_MILLIS); + } + } catch (Exception e) { + LOGGER.log(FINE, "Failed to download issuer certificate from AIA extension.", e); + } + return null; + } + + /** + * Retrieves a cached AIA response while validating certificate candidates on every use. + * + * @param url the CA Issuers URL taken from an AIA extension + * @return the certificates published at the URL, or an empty list if they cannot be retrieved or parsed + */ + static List fetchCertificatesFromAiaUrl(String url) { + return getAiaResponse(url).getCertificates(); + } + + /** + * Gets an AIA response and the cache metadata needed by the refresh decision. + * + * @param url the CA Issuers URL + * @return the cached or loaded response + */ + private static AiaResponseCache.LookupResult getAiaResponse(String url) { + return AIA_CACHE.getOrLoadResult(url, () -> loadAiaResponse(url), + () -> LOGGER.log(FINE, "Reusing the cached AIA response for URL: {0}", url)); + } + + /** + * Finds a currently valid candidate that can issue the target certificate. + * + * @param target the certificate that needs an issuer + * @param candidates the certificates published by the AIA endpoint + * @return a valid issuer, or null when no candidate matches + */ + private static X509Certificate findValidIssuer(X509Certificate target, List candidates) { + X500Principal expectedIssuerPrincipal = target.getIssuerX500Principal(); + for (X509Certificate candidate : candidates) { + // Validation runs on every use, including cache hits, so a cached certificate can never shortcut + // subject, validity or issuer verification. + if (expectedIssuerPrincipal.equals(candidate.getSubjectX500Principal()) + && isCurrentlyValid(candidate) + && CertificateUtil.isValidIssuer(candidate, target)) { + return candidate; + } + } + return null; + } + + /** + * Downloads and parses one AIA response into a cache entry. + * + * @param url the CA Issuers URL + * @return a positive or negative cache entry + */ + private static AiaResponseCache.Entry loadAiaResponse(String url) { + LOGGER.log(FINE, "Downloading issuer certificate from AIA URL: {0}", url); + long now = System.currentTimeMillis(); + HttpUtil.BinaryHttpResponse response = HttpUtil.getBytesWithMetadata(url); + byte[] certBytes = response.getBody(); + if (certBytes == null) { + LOGGER.log(FINE, "Failed to download issuer certificate from AIA URL: {0}", url); + return new AiaResponseCache.Entry(Collections.emptyList(), calculateNegativeExpiration(response, now)); + } + + List certificates = parseCertificates(certBytes); + if (certificates.isEmpty()) { + return new AiaResponseCache.Entry(certificates, calculateNegativeExpiration(response, now)); + } + + return new AiaResponseCache.Entry(certificates, calculateResponseExpiration(response, now)); + } + + /** + * Calculates how long an HTTP response may remain cached. + * + * @param response the HTTP response and its freshness headers + * @param nowInMillis the current time in epoch milliseconds + * @return the expiration time in epoch milliseconds + */ + static long calculateResponseExpiration(HttpUtil.BinaryHttpResponse response, long nowInMillis) { + String cacheControl = response.getCacheControl(); + if (hasCacheDirective(cacheControl, "no-store") || hasCacheDirective(cacheControl, "no-cache")) { + return nowInMillis; + } + + long expiresAt = safeAdd(nowInMillis, MAX_SUCCESS_TTL_IN_MILLIS); + Long maxAgeInSeconds = getCacheDirectiveSeconds(cacheControl, "max-age"); + long ageInSeconds = parseNonNegativeLong(response.getAge(), 0L); + Long dateHeader = parseHttpDate(response.getDate()); + long apparentAgeInMillis = dateHeader == null ? 0L : Math.max(0L, nowInMillis - dateHeader); + long currentAgeInMillis = Math.max(apparentAgeInMillis, secondsToMillis(ageInSeconds)); + if (maxAgeInSeconds != null) { + long freshnessLifetime = secondsToMillis(maxAgeInSeconds); + long remaining = Math.max(0L, freshnessLifetime - currentAgeInMillis); + expiresAt = Math.min(expiresAt, safeAdd(nowInMillis, remaining)); + } else { + Long expiresHeader = parseHttpDate(response.getExpires()); + if (expiresHeader != null) { + long freshnessLifetime = Math.max(0L, expiresHeader - (dateHeader == null ? nowInMillis : dateHeader)); + long remaining = Math.max(0L, freshnessLifetime - currentAgeInMillis); + expiresAt = Math.min(expiresAt, safeAdd(nowInMillis, remaining)); + } + } + + return expiresAt; + } + + /** + * Calculates the short cache period for a failed or empty response. + * + * @param response the HTTP response and its cache directives + * @param nowInMillis the current time in epoch milliseconds + * @return the expiration time in epoch milliseconds + */ + private static long calculateNegativeExpiration(HttpUtil.BinaryHttpResponse response, long nowInMillis) { + String cacheControl = response.getCacheControl(); + return hasCacheDirective(cacheControl, "no-store") || hasCacheDirective(cacheControl, "no-cache") + ? nowInMillis + : safeAdd(nowInMillis, NEGATIVE_TTL_IN_MILLIS); + } + + private static boolean hasCacheDirective(String cacheControl, String expectedDirective) { + if (cacheControl == null) { + return false; + } + for (String directive : cacheControl.split(",")) { + String normalized = directive.trim().toLowerCase(Locale.ROOT); + if (normalized.equals(expectedDirective) || normalized.startsWith(expectedDirective + "=")) { + return true; + } + } + return false; + } + + private static Long getCacheDirectiveSeconds(String cacheControl, String expectedDirective) { + if (cacheControl == null) { + return null; + } + for (String directive : cacheControl.split(",")) { + String[] parts = directive.trim().split("=", 2); + if (parts.length == 2 && expectedDirective.equalsIgnoreCase(parts[0].trim())) { + String value = parts[1].trim(); + if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { + value = value.substring(1, value.length() - 1); + } + long parsed = parseNonNegativeLong(value, -1L); + return parsed < 0 ? null : parsed; + } + } + return null; + } + + private static long parseNonNegativeLong(String value, long defaultValue) { + if (value == null) { + return defaultValue; + } + String normalized = value.trim(); + if (normalized.isEmpty()) { + return defaultValue; + } + for (int i = 0; i < normalized.length(); i++) { + if (!Character.isDigit(normalized.charAt(i))) { + return defaultValue; + } + } + try { + return Long.parseLong(normalized); + } catch (NumberFormatException e) { + return Long.MAX_VALUE; + } + } + + private static Long parseHttpDate(String value) { + if (value == null) { + return null; + } + try { + return ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant().toEpochMilli(); + } catch (DateTimeParseException e) { + return null; + } + } + + private static long secondsToMillis(long seconds) { + return seconds > Long.MAX_VALUE / 1000L ? Long.MAX_VALUE : seconds * 1000L; + } + + private static long safeAdd(long value, long increment) { + return increment > Long.MAX_VALUE - value ? Long.MAX_VALUE : value + increment; + } + + /** + * Clears the cached AIA responses. + * + *

Used by tests to keep certificate downloads isolated from each other. + */ + static void clearAiaCache() { + AIA_CACHE.clear(); + } + + /** + * Parses the certificates contained in an AIA response, which may be DER- or PEM-encoded and may hold a bundle + * rather than a single certificate. + * + * @param certBytes the raw AIA response body + * @return the parsed certificates, or an empty list if the response cannot be parsed + */ + private static List parseCertificates(byte[] certBytes) { + try { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return toX509Certificates(cf.generateCertificates(new ByteArrayInputStream(certBytes))); + } catch (CertificateException e) { + // Fall back to PEM format + String pem = new String(certBytes, StandardCharsets.UTF_8); + if (pem.contains(CertificateUtil.BEGIN_CERTIFICATE)) { + try { + return toX509Certificates( + Arrays.asList(CertificateUtil.loadCertificatesFromSecretBundleValuePem(pem))); + } catch (IOException | CertificateException pemException) { + LOGGER.log(FINE, "Failed to parse the AIA response as PEM.", pemException); + } + } + } + + return Collections.emptyList(); + } + + private static List toX509Certificates(Collection certificates) { + List x509Certificates = new ArrayList<>(certificates.size()); + for (Certificate certificate : certificates) { + if (certificate instanceof X509Certificate) { + x509Certificates.add((X509Certificate) certificate); + } + } + + return Collections.unmodifiableList(x509Certificates); + } + + /** + * Verifies that a certificate is currently within its validity period. + * + *

An expired, or not yet valid, intermediate downloaded via AIA must not be inserted into the chain: + * embedding it would still fail PKIX path validation at verify time, and silently accepting it would mask the + * real "this CA certificate needs to be renewed" condition. + * + * @param certificate the certificate to check + * @return true if the certificate is currently valid, false if it is expired or not yet valid + */ + private static boolean isCurrentlyValid(X509Certificate certificate) { + try { + certificate.checkValidity(); + return true; + } catch (CertificateExpiredException | CertificateNotYetValidException e) { + LOGGER.log(FINE, "Issuer certificate [{0}] is expired or not yet valid; rejecting it as an issuer.", + certificate.getSubjectX500Principal().getName()); + return false; + } + } + + /** + * Identifies the target certificate for refresh suppression. + * + *

The issuer principal and serial number distinguish certificates that use the same AIA URL. + */ + private static final class TargetIdentity { + private final X500Principal issuerPrincipal; + private final BigInteger serialNumber; + + private TargetIdentity(X500Principal issuerPrincipal, BigInteger serialNumber) { + this.issuerPrincipal = issuerPrincipal; + this.serialNumber = serialNumber; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof TargetIdentity)) { + return false; + } + TargetIdentity other = (TargetIdentity) obj; + return issuerPrincipal.equals(other.issuerPrincipal) && serialNumber.equals(other.serialNumber); + } + + @Override + public int hashCode() { + return Objects.hash(issuerPrincipal, serialNumber); + } + } + +} diff --git a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AiaResponseCache.java b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AiaResponseCache.java new file mode 100644 index 000000000000..94ff6819ed9f --- /dev/null +++ b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/AiaResponseCache.java @@ -0,0 +1,473 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.security.keyvault.jca.implementation.utils; + +import java.security.cert.X509Certificate; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongSupplier; + +/** + * A bounded, access-ordered cache for AIA resolution results. + * + *

Completed entries and refresh suppressions use short synchronized sections. Loaders run outside that lock. + * Concurrent loads or refreshes for the same URL share one in-flight result without blocking other URLs. + */ +final class AiaResponseCache { + private final int maximumSize; + private final LongSupplier clock; + private final Map entries = new LinkedHashMap<>(16, 0.75f, true); + private final Map refreshSuppressions = new LinkedHashMap<>(16, 0.75f, true); + private final ConcurrentHashMap> inFlight = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> refreshes = new ConcurrentHashMap<>(); + // Monotonic entry version; intentionally not reset by clear() so observed generations cannot collide. + private final AtomicLong generations = new AtomicLong(); + // Cache-wide invalidation version that prevents requests started before clear() from repopulating the cache. + private long epoch; + + /** + * Creates a cache with a maximum number of completed entries. + * + * @param maximumSize the maximum number of completed entries and refresh suppressions + * @param clock the clock used to evaluate expiration times + */ + AiaResponseCache(int maximumSize, LongSupplier clock) { + if (maximumSize <= 0) { + throw new IllegalArgumentException("maximumSize must be greater than zero"); + } + this.maximumSize = maximumSize; + this.clock = Objects.requireNonNull(clock, "clock cannot be null"); + } + + /** + * Returns a fresh cached response, or loads and caches one when absent. + * + * @param url the AIA URL used as the cache key + * @param loader the response loader used on a cache miss + * @return the certificates in the cached or loaded response + */ + List getOrLoad(String url, Loader loader) { + return getOrLoadResult(url, loader, () -> { + }).certificates; + } + + /** + * Returns a fresh cached response and runs an action when the response comes from the cache. + * + * @param url the AIA URL used as the cache key + * @param loader the response loader used on a cache miss + * @param cacheHitAction the action to run on a cache hit + * @return the certificates in the cached or loaded response + */ + List getOrLoad(String url, Loader loader, Runnable cacheHitAction) { + return getOrLoadResult(url, loader, cacheHitAction).certificates; + } + + /** + * Returns a response together with its generation and source. + * + *

Concurrent misses for the same URL share one loader call. + * + * @param url the AIA URL used as the cache key + * @param loader the response loader used on a cache miss + * @param cacheHitAction the action to run on a cache hit + * @return the cached or loaded response and its cache metadata + */ + LookupResult getOrLoadResult(String url, Loader loader, Runnable cacheHitAction) { + Objects.requireNonNull(url, "url cannot be null"); + Objects.requireNonNull(loader, "loader cannot be null"); + Objects.requireNonNull(cacheHitAction, "cacheHitAction cannot be null"); + Entry cached = getIfFresh(url); + if (cached != null) { + cacheHitAction.run(); + return new LookupResult(cached, Source.CACHE); + } + + CompletableFuture created = new CompletableFuture<>(); + CompletableFuture existing = inFlight.putIfAbsent(url, created); + if (existing != null) { + return new LookupResult(await(existing), Source.LOAD); + } + + long loadEpoch = getEpoch(); // Captured before loading so clear() can invalidate the pending publication. + try { + Entry rechecked = getIfFresh(url); + Entry result = rechecked != null ? rechecked : Objects.requireNonNull(loader.load(), "loader result"); + if (rechecked != null) { + cacheHitAction.run(); + created.complete(rechecked); + return new LookupResult(rechecked, Source.CACHE); + } + Entry published = putIfFresh(url, result, loadEpoch); + created.complete(published); + return new LookupResult(published, Source.LOAD); + } catch (RuntimeException e) { + created.completeExceptionally(e); + return new LookupResult(created.join(), Source.LOAD); + } finally { + if (!created.isDone()) { + created.completeExceptionally( + new IllegalStateException("AIA resolution terminated before producing a result")); + } + inFlight.remove(url, created); + } + } + + /** + * Refreshes an entry only if it has not changed since the caller observed it. + * + *

Concurrent refreshes for the same URL share one loader call. A failed refresh does not replace an existing + * positive entry. + * + * @param url the AIA URL used as the cache key + * @param observedGeneration the generation observed by the caller + * @param loader the response loader used when a refresh is still required + * @return the current or refreshed response and its cache metadata + */ + LookupResult refreshIfUnchanged(String url, long observedGeneration, Loader loader) { + Objects.requireNonNull(url, "url cannot be null"); + Objects.requireNonNull(loader, "loader cannot be null"); + Entry current = getIfFresh(url); + // A different generation means another caller has already published a newer response for this URL. + if (current != null && current.generation != observedGeneration) { + return new LookupResult(current, Source.CACHE); + } + + CompletableFuture created = new CompletableFuture<>(); + CompletableFuture existing = refreshes.putIfAbsent(url, created); + if (existing != null) { + return new LookupResult(await(existing), Source.REFRESH); + } + + long refreshEpoch = getEpoch(); // Applies the same clear() barrier to forced refreshes. + try { + Entry rechecked = getIfFresh(url); + // Close the race between the first generation check and winning the refresh single-flight registration. + if (rechecked != null && rechecked.generation != observedGeneration) { + created.complete(rechecked); + return new LookupResult(rechecked, Source.CACHE); + } + + Entry loaded = Objects.requireNonNull(loader.load(), "loader result"); + Entry result = shouldReplaceOnRefresh(loaded) + ? putIfFresh(url, loaded, refreshEpoch) + : loaded.withGeneration(rechecked == null ? observedGeneration : rechecked.generation); + created.complete(result); + return new LookupResult(result, Source.REFRESH); + } catch (RuntimeException e) { + created.completeExceptionally(e); + return new LookupResult(created.join(), Source.REFRESH); + } finally { + if (!created.isDone()) { + created.completeExceptionally( + new IllegalStateException("AIA refresh terminated before producing a result")); + } + refreshes.remove(url, created); + } + } + + /** Clears cached and coordination state, advancing the epoch so pending requests cannot repopulate the cache. */ + synchronized void clear() { + epoch++; + entries.clear(); + refreshSuppressions.clear(); + inFlight.clear(); + refreshes.clear(); + } + + /** + * Returns the number of fresh completed entries. + * + * @return the number of fresh completed entries + */ + synchronized int size() { + removeExpiredEntries(clock.getAsLong()); + return entries.size(); + } + + /** + * Checks whether a recent miss suppresses another refresh for the same target and entry generation. + * + * @param url the AIA URL + * @param targetIdentity the identity of the certificate that needs an issuer + * @param generation the current URL entry generation + * @return true when another refresh should be suppressed + */ + synchronized boolean isRefreshSuppressed(String url, Object targetIdentity, long generation) { + SuppressionKey key = new SuppressionKey(url, targetIdentity); + Suppression suppression = refreshSuppressions.get(key); + if (suppression == null) { + return false; + } + if (suppression.isExpired(clock.getAsLong()) || suppression.generation != generation) { + refreshSuppressions.remove(key); + return false; + } + return true; + } + + /** + * Suppresses another refresh for one target and URL entry generation. + * + * @param url the AIA URL + * @param targetIdentity the identity of the certificate that needs an issuer + * @param generation the current URL entry generation + * @param ttlInMillis the suppression duration in milliseconds + */ + synchronized void suppressRefresh(String url, Object targetIdentity, long generation, long ttlInMillis) { + long now = clock.getAsLong(); + removeExpiredSuppressions(now); + refreshSuppressions.put(new SuppressionKey(url, targetIdentity), + new Suppression(generation, safeAdd(now, ttlInMillis))); + while (refreshSuppressions.size() > maximumSize) { + Iterator iterator = refreshSuppressions.keySet().iterator(); + iterator.next(); + iterator.remove(); + } + } + + /** + * Removes a refresh suppression after the target issuer is resolved. + * + * @param url the AIA URL + * @param targetIdentity the identity of the certificate that needs an issuer + */ + synchronized void clearRefreshSuppression(String url, Object targetIdentity) { + refreshSuppressions.remove(new SuppressionKey(url, targetIdentity)); + } + + private synchronized Entry getIfFresh(String url) { + Entry entry = entries.get(url); + if (entry == null) { + return null; + } + if (entry.isExpired(clock.getAsLong())) { + entries.remove(url); + return null; + } + return entry; + } + + /** + * Publishes a fresh entry only when no clear occurred after its request started, assigning a new generation. + * + * @param url the AIA URL used as the cache key + * @param entry the loaded entry + * @param expectedEpoch the epoch captured before loading started + * @return the published entry, or the original unpublished entry when publication is rejected + */ + private synchronized Entry putIfFresh(String url, Entry entry, long expectedEpoch) { + long now = clock.getAsLong(); + removeExpiredEntries(now); + if (entry.isExpired(now) || epoch != expectedEpoch) { + return entry; + } + + Entry versioned = entry.withGeneration(generations.incrementAndGet()); + entries.put(url, versioned); + while (entries.size() > maximumSize) { + Iterator iterator = entries.keySet().iterator(); + iterator.next(); + iterator.remove(); + } + return versioned; + } + + private synchronized long getEpoch() { + return epoch; + } + + /** + * Checks whether a refresh result can replace the current positive entry. + * + * @param entry the refresh result + * @return true when the result is positive and fresh + */ + private boolean shouldReplaceOnRefresh(Entry entry) { + return !entry.certificates.isEmpty() && !entry.isExpired(clock.getAsLong()); + } + + private void removeExpiredEntries(long now) { + entries.entrySet().removeIf(entry -> entry.getValue().isExpired(now)); + } + + private void removeExpiredSuppressions(long now) { + refreshSuppressions.entrySet().removeIf(entry -> entry.getValue().isExpired(now)); + } + + private static long safeAdd(long value, long increment) { + return increment > Long.MAX_VALUE - value ? Long.MAX_VALUE : value + increment; + } + + private static Entry await(CompletableFuture future) { + try { + return future.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return propagate(e); + } catch (ExecutionException e) { + return propagate(e.getCause()); + } + } + + private static Entry propagate(Throwable cause) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(cause); + return failed.join(); + } + + interface Loader { + /** + * Loads and parses one AIA response. + * + * @return the loaded cache entry + */ + Entry load(); + } + + /** Identifies how a lookup result was obtained. */ + enum Source { + /** A fresh completed entry. */ + CACHE, + + /** A normal cache-miss load. */ + LOAD, + + /** A forced refresh of an observed entry. */ + REFRESH + } + + /** + * A cache response with the metadata needed to decide whether a forced refresh is safe. + */ + static final class LookupResult { + private final List certificates; + private final long generation; + private final Source source; + private final boolean negative; + + private LookupResult(Entry entry, Source source) { + this.certificates = entry.certificates; + this.generation = entry.generation; + this.source = source; + this.negative = entry.certificates.isEmpty(); + } + + /** + * Gets the certificates in the response. + * + * @return the response certificates + */ + List getCertificates() { + return certificates; + } + + /** + * Gets the generation assigned when the URL entry was cached. + * + * @return the entry generation, or zero for a response that was not published + */ + long getGeneration() { + return generation; + } + + /** + * Gets the source of the response. + * + * @return the response source + */ + Source getSource() { + return source; + } + + /** + * Indicates whether the response contains no certificates. + * + * @return true when the response is negative + */ + boolean isNegative() { + return negative; + } + } + + /** + * A parsed AIA response and its expiration time. + */ + static final class Entry { + private final List certificates; + private final long expiresAtInMillis; + private final long generation; + + /** + * Creates an uncached entry. The cache assigns its generation when the entry is published. + * + * @param certificates the parsed response certificates + * @param expiresAtInMillis the expiration time in epoch milliseconds + */ + Entry(List certificates, long expiresAtInMillis) { + this(certificates, expiresAtInMillis, 0L); + } + + private Entry(List certificates, long expiresAtInMillis, long generation) { + this.certificates = Objects.requireNonNull(certificates, "certificates cannot be null"); + this.expiresAtInMillis = expiresAtInMillis; + this.generation = generation; + } + + private boolean isExpired(long now) { + return now >= expiresAtInMillis; + } + + private Entry withGeneration(long generation) { + return new Entry(certificates, expiresAtInMillis, generation); + } + } + + private static final class SuppressionKey { + private final String url; + private final Object targetIdentity; + + private SuppressionKey(String url, Object targetIdentity) { + this.url = Objects.requireNonNull(url, "url cannot be null"); + this.targetIdentity = Objects.requireNonNull(targetIdentity, "targetIdentity cannot be null"); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof SuppressionKey)) { + return false; + } + SuppressionKey other = (SuppressionKey) obj; + return url.equals(other.url) && targetIdentity.equals(other.targetIdentity); + } + + @Override + public int hashCode() { + return Objects.hash(url, targetIdentity); + } + } + + private static final class Suppression { + private final long generation; + private final long expiresAtInMillis; + + private Suppression(long generation, long expiresAtInMillis) { + this.generation = generation; + this.expiresAtInMillis = expiresAtInMillis; + } + + private boolean isExpired(long now) { + return now >= expiresAtInMillis; + } + } +} 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..6f8159bdc79f 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 @@ -17,28 +17,50 @@ 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.Base64; +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; + public final class CertificateUtil { - private static final String BEGIN_CERTIFICATE = "-----BEGIN CERTIFICATE-----"; + private static final Logger LOGGER = Logger.getLogger(CertificateUtil.class.getName()); + static final String BEGIN_CERTIFICATE = "-----BEGIN CERTIFICATE-----"; private static final String END_CERTIFICATE = "-----END CERTIFICATE-----"; 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); + + // A contiguous chain may still be missing issuers above its terminal certificate. Resolution remains + // cache-first, and a chain ending in a self-signed root does not enter the AIA completion path. + if (AiaCertificateChainUtil.shouldCompleteChainViaAia(certificates)) { + certificates = AiaCertificateChainUtil.completeChainViaAia(certificates); + } + + return certificates; } private static Certificate[] loadCertificatesFromSecretBundleValuePem(InputStream inputStream) @@ -62,7 +84,7 @@ private static Certificate[] loadCertificatesFromSecretBundleValuePem(InputStrea return certificates.toArray(new Certificate[0]); } - private static Certificate[] loadCertificatesFromSecretBundleValuePem(String string) + static Certificate[] loadCertificatesFromSecretBundleValuePem(String string) throws IOException, CertificateException { InputStream inputStream = new ByteArrayInputStream(string.getBytes(StandardCharsets.UTF_8)); return loadCertificatesFromSecretBundleValuePem(inputStream); @@ -113,4 +135,277 @@ 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; + } + } + + /** + * Logs the certificate chain for debugging purposes. + * + * @param label a descriptive label for the log + * @param certificates the certificate array to log + */ + 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 + */ + 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 the following 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. + *
  5. Verifies that, when a KeyUsage extension is present, the keyCertSign bit is set (RFC 5280)
  6. + *
+ * + *

The issuer's validity period is deliberately not checked here, because this method also decides how a + * chain returned by Azure Key Vault is ordered and how far it can be walked. Rejecting an expired certificate + * at that point would reorder existing chains and trigger downloads that were previously never performed. + * Certificates entering the chain from the network are checked separately by {@code AiaCertificateChainUtil}. + * + * @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 + */ + 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; + } + } } 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..51834978bc8a 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; @@ -13,12 +14,14 @@ import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HttpEntity; import org.apache.hc.core5.http.config.RegistryBuilder; import org.apache.hc.core5.http.io.HttpClientResponseHandler; 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 +81,114 @@ 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) { + return getBytesWithMetadata(url).body; + } + + static BinaryHttpResponse getBytesWithMetadata(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, response -> toBinaryResponse(response, url)); + } 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 BinaryHttpResponse.empty(); + } + } + + static BinaryHttpResponse toBinaryResponse(ClassicHttpResponse response, String url) throws IOException { + int status = response.getCode(); + if (status < 200 || status >= 300) { + LOGGER.log(WARNING, "HTTP GET returned status {0} for URL: {1}", new Object[] { status, url }); + return new BinaryHttpResponse(null, getCombinedHeaderValue(response, "Cache-Control"), + getHeaderValue(response, "Date"), getHeaderValue(response, "Age"), getHeaderValue(response, "Expires")); + } + + HttpEntity entity = response.getEntity(); + byte[] body = entity != null ? EntityUtils.toByteArray(entity) : null; + return new BinaryHttpResponse(body, getCombinedHeaderValue(response, "Cache-Control"), + getHeaderValue(response, "Date"), getHeaderValue(response, "Age"), getHeaderValue(response, "Expires")); + } + + private static String getCombinedHeaderValue(ClassicHttpResponse response, String name) { + Header[] headers = response.getHeaders(name); + if (headers.length == 0) { + return null; + } + + StringBuilder value = new StringBuilder(); + for (Header header : headers) { + if (value.length() > 0) { + value.append(", "); + } + value.append(header.getValue()); + } + return value.toString(); + } + + private static String getHeaderValue(ClassicHttpResponse response, String name) { + Header header = response.getFirstHeader(name); + return header == null ? null : header.getValue(); + } + + static final class BinaryHttpResponse { + private final byte[] body; + private final String cacheControl; + private final String date; + private final String age; + private final String expires; + + BinaryHttpResponse(byte[] body, String cacheControl, String date, String age, String expires) { + this.body = body; + this.cacheControl = cacheControl; + this.date = date; + this.age = age; + this.expires = expires; + } + + private static BinaryHttpResponse empty() { + return new BinaryHttpResponse(null, null, null, null, null); + } + + byte[] getBody() { + return body; + } + + String getCacheControl() { + return cacheControl; + } + + String getDate() { + return date; + } + + String getAge() { + return age; + } + + String getExpires() { + return expires; + } + } + 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/main/java/com/azure/security/keyvault/jca/implementation/utils/JsonConverterUtil.java b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/JsonConverterUtil.java index 74ebebed0b6b..66d071587a93 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/JsonConverterUtil.java +++ b/sdk/keyvault/azure-security-keyvault-jca/src/main/java/com/azure/security/keyvault/jca/implementation/utils/JsonConverterUtil.java @@ -39,7 +39,8 @@ public final class JsonConverterUtil { public static > T fromJson(ReadValueCallback deserializationFunction, String json) throws IOException { - LOGGER.entering("JsonConverterUtil", "fromJson", new Object[] { deserializationFunction, json }); + // Only the callback is logged. The payload carries access tokens and PKCS12 key bundles. + LOGGER.entering("JsonConverterUtil", "fromJson", deserializationFunction); try (JsonReader jsonReader = JsonProviders.createReader(json)) { T deserialized = deserializationFunction.read(jsonReader); diff --git a/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/KeyVaultClientTest.java b/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/KeyVaultClientTest.java index 19f7631bf4e6..9bda32d952ac 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/KeyVaultClientTest.java +++ b/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/KeyVaultClientTest.java @@ -5,9 +5,13 @@ import com.azure.security.keyvault.jca.PropertyConvertorUtils; import com.azure.security.keyvault.jca.implementation.model.AccessToken; +import com.azure.security.keyvault.jca.implementation.model.CertificateBundle; import com.azure.security.keyvault.jca.implementation.model.CertificateItem; import com.azure.security.keyvault.jca.implementation.model.CertificateItemAttributes; import com.azure.security.keyvault.jca.implementation.model.CertificateListResult; +import com.azure.security.keyvault.jca.implementation.model.CertificatePolicy; +import com.azure.security.keyvault.jca.implementation.model.KeyProperties; +import com.azure.security.keyvault.jca.implementation.model.SecretBundle; import com.azure.security.keyvault.jca.implementation.utils.AccessTokenUtil; import com.azure.security.keyvault.jca.implementation.utils.HttpUtil; import com.azure.security.keyvault.jca.implementation.utils.JsonConverterUtil; @@ -16,8 +20,17 @@ import org.mockito.MockedStatic; import org.mockito.Mockito; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.Key; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -332,6 +345,85 @@ public void testAuthenticationPriority() { } } + @Test + void getKeyDoesNotLogPrivateKeyPem() throws Exception { + String alias = "pem-certificate"; + String certificateSecretUri = KEY_VAULT_TEST_URI_GLOBAL + "secrets/" + alias; + String pemString = new String( + Files.readAllBytes( + Paths.get("src/test/resources/certificate-util/downloaded-from-portal/pem-exportable-key.pem")), + StandardCharsets.UTF_8); + + KeyProperties keyProperties = new KeyProperties(); + keyProperties.setExportable(true); + keyProperties.setKty("RSA"); + CertificatePolicy certificatePolicy = new CertificatePolicy(); + certificatePolicy.setKeyProperties(keyProperties); + CertificateBundle certificateBundle = new CertificateBundle(); + certificateBundle.setPolicy(certificatePolicy); + certificateBundle.setSid(certificateSecretUri); + + SecretBundle secretBundle = new SecretBundle(); + secretBundle.setContentType("application/x-pem-file"); + secretBundle.setValue(pemString); + + List loggedValues = new ArrayList<>(); + Handler collector = new Handler() { + @Override + public void publish(LogRecord logRecord) { + loggedValues.add(logRecord.getMessage()); + if (logRecord.getParameters() != null) { + for (Object parameter : logRecord.getParameters()) { + loggedValues.add(String.valueOf(parameter)); + } + } + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + + Logger logger = Logger.getLogger(KeyVaultClient.class.getName()); + Level originalLevel = logger.getLevel(); + boolean originalUseParentHandlers = logger.getUseParentHandlers(); + logger.addHandler(collector); + logger.setLevel(Level.ALL); + logger.setUseParentHandlers(false); + + try (MockedStatic httpUtilMockedStatic = Mockito.mockStatic(HttpUtil.class)) { + httpUtilMockedStatic.when(() -> HttpUtil.validateUri(anyString(), anyString())).thenCallRealMethod(); + httpUtilMockedStatic.when(() -> HttpUtil.addTrailingSlashIfRequired(anyString())).thenCallRealMethod(); + httpUtilMockedStatic + .when(() -> HttpUtil.get( + eq(KEY_VAULT_TEST_URI_GLOBAL + "certificates/" + alias + HttpUtil.API_VERSION_POSTFIX), anyMap())) + .thenReturn(JsonConverterUtil.toJson(certificateBundle)); + httpUtilMockedStatic + .when(() -> HttpUtil.get(eq(certificateSecretUri + HttpUtil.API_VERSION_POSTFIX), anyMap())) + .thenReturn(JsonConverterUtil.toJson(secretBundle)); + + KeyVaultClient keyVaultClient + = new KeyVaultClient(KEY_VAULT_TEST_URI_GLOBAL, null, null, null, null, "bearer-token", false); + Key key = keyVaultClient.getKey(alias, null); + + assertNotNull(key); + assertEquals("RSA", key.getAlgorithm()); + } finally { + logger.removeHandler(collector); + logger.setLevel(originalLevel); + logger.setUseParentHandlers(originalUseParentHandlers); + } + + assertFalse(loggedValues.contains(pemString), "The private-key PEM must never be logged"); + assertTrue(loggedValues.stream().noneMatch(value -> value.contains("BEGIN PRIVATE KEY")), + "No fragment of the private-key PEM may be logged"); + assertTrue(loggedValues.contains("RSA"), "The non-secret key type stays available for diagnostics"); + } + @EnabledIfEnvironmentVariable(named = "AZURE_KEYVAULT_CERTIFICATE_NAME", matches = "myalias") @Test public void testKeyVaultClients() { diff --git a/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtilTest.java b/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtilTest.java index 4490b574c8ed..71e01892e2ec 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtilTest.java +++ b/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/AccessTokenUtilTest.java @@ -5,10 +5,18 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; import static org.junit.jupiter.api.Assertions.*; @@ -33,4 +41,50 @@ void testReadFileWithNonExistentFile() { String actualContent = AccessTokenUtil.readFile("/non/existent/file.txt"); assertNull(actualContent); } + + @Test + void getAccessTokenDoesNotLogTheClientSecret() { + String clientSecret = "the-client-secret-value"; + List loggedValues = new ArrayList<>(); + Handler collector = new Handler() { + @Override + public void publish(LogRecord logRecord) { + if (logRecord.getParameters() != null) { + for (Object parameter : logRecord.getParameters()) { + loggedValues.add(String.valueOf(parameter)); + } + } + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + + Logger logger = Logger.getLogger(AccessTokenUtil.class.getName()); + Level originalLevel = logger.getLevel(); + boolean originalUseParentHandlers = logger.getUseParentHandlers(); + + logger.addHandler(collector); + logger.setLevel(Level.ALL); + logger.setUseParentHandlers(false); + + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + httpMock.when(() -> HttpUtil.post(Mockito.anyString(), Mockito.anyString(), Mockito.anyString())) + .thenReturn(null); + + AccessTokenUtil.getAccessToken("https://vault.azure.net", null, "tenant-id", "client-id", clientSecret); + } finally { + logger.removeHandler(collector); + logger.setLevel(originalLevel); + logger.setUseParentHandlers(originalUseParentHandlers); + } + + assertFalse(loggedValues.contains(clientSecret), "The client secret must never be logged"); + assertTrue(loggedValues.contains("client-id"), "Non-secret parameters stay available for diagnostics"); + } } 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..3b9e8293bacc --- /dev/null +++ b/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/AiaCertificateChainTest.java @@ -0,0 +1,1069 @@ +// 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.Timeout; +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.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +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.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +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.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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, the AIA response cache + * 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(AiaCertificateChainUtil.DISABLE_AIA_DOWNLOAD_PROPERTY); + AiaCertificateChainUtil.clearAiaCache(); + } + + @AfterEach + void cleanup() { + // Clear the property after each test to prevent interference with subsequent tests + System.clearProperty(AiaCertificateChainUtil.DISABLE_AIA_DOWNLOAD_PROPERTY); + AiaCertificateChainUtil.clearAiaCache(); + } + + // ----------------------------------------------------------------------- + // Chain-completion gating tests + // ----------------------------------------------------------------------- + + @Test + void shouldNotCompleteNullOrEmptyChainViaAia() { + assertFalse(AiaCertificateChainUtil.shouldCompleteChainViaAia(null)); + assertFalse(AiaCertificateChainUtil.shouldCompleteChainViaAia(new Certificate[0])); + } + + @Test + void shouldNotCompleteChainEndingInSelfSignedRootViaAia() { + assertFalse(AiaCertificateChainUtil.shouldCompleteChainViaAia(new Certificate[] { rootCert })); + assertFalse(AiaCertificateChainUtil + .shouldCompleteChainViaAia(new Certificate[] { leafCert, intermediateCert, rootCert })); + } + + @Test + void shouldCompleteChainEndingInNonSelfSignedCertificateViaAia() { + assertTrue(AiaCertificateChainUtil.shouldCompleteChainViaAia(new Certificate[] { leafCert })); + assertTrue(AiaCertificateChainUtil.shouldCompleteChainViaAia(new Certificate[] { leafCert, intermediateCert })); + } + + @Test + void shouldCompleteChainWithMissingIntermediateViaAia() { + assertTrue(AiaCertificateChainUtil.shouldCompleteChainViaAia(new Certificate[] { leafCert, rootCert })); + } + + // ----------------------------------------------------------------------- + // 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)) { + mockAiaResponse(httpMock, AIA_INTERMEDIATE_URL, intermediateCert.getEncoded()); + mockAiaResponse(httpMock, AIA_ROOT_URL, rootCert.getEncoded()); + + Certificate[] completed = AiaCertificateChainUtil.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)) { + mockAiaResponse(httpMock, AIA_ROOT_URL, rootCert.getEncoded()); + + Certificate[] completed = AiaCertificateChainUtil.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 = AiaCertificateChainUtil.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)) { + mockAiaResponse(httpMock, AIA_INTERMEDIATE_URL, null); + + Certificate[] result = AiaCertificateChainUtil.completeChainViaAia(leafOnly); + + assertEquals(1, result.length, "Should return original chain when download fails"); + } + } + + @Test + void completeChainViaAiaNullInputReturnsNull() { + assertNull(AiaCertificateChainUtil.completeChainViaAia(null)); + } + + @Test + void completeChainViaAiaEmptyInputReturnsEmpty() { + Certificate[] result = AiaCertificateChainUtil.completeChainViaAia(new Certificate[0]); + assertEquals(0, result.length); + } + + // ----------------------------------------------------------------------- + // downloadIssuerCertificateFromAia tests + // ----------------------------------------------------------------------- + + @Test + void downloadIssuerCertificateFromAiaReturnsDerEncodedCert() throws Exception { + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + mockAiaResponse(httpMock, AIA_INTERMEDIATE_URL, intermediateCert.getEncoded()); + + X509Certificate result = AiaCertificateChainUtil.downloadIssuerCertificateFromAia(leafCert); + + assertNotNull(result); + assertEquals(intermediateCert, result); + } + } + + @Test + void downloadIssuerCertificateFromAiaNoCertWithoutAiaReturnsNull() throws Exception { + // Root cert has no AIA extension + X509Certificate result = AiaCertificateChainUtil.downloadIssuerCertificateFromAia(rootCert); + assertNull(result); + } + + @Test + void downloadIssuerCertificateFromAiaPemBundleSelectsMatchingIssuer() throws Exception { + String pemBundle = toPem(rootCert) + toPem(intermediateCert); + + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + mockAiaResponse(httpMock, AIA_INTERMEDIATE_URL, pemBundle.getBytes(StandardCharsets.UTF_8)); + + X509Certificate result = AiaCertificateChainUtil.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)) { + mockAiaResponse(httpMock, AIA_BAD_ISSUER_URL, badIssuerCert.getEncoded()); + + Certificate[] result + = AiaCertificateChainUtil.completeChainViaAia(new Certificate[] { leafWithBadIssuerAia }); + + assertEquals(1, result.length, + "Issuer without keyCertSign should be rejected even if basicConstraints indicates CA"); + assertEquals(leafWithBadIssuerAia, result[0]); + } + } + + @Test + void completeChainViaAiaRejectsExpiredIssuer() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + + // Build an issuer that is a valid CA in every respect EXCEPT that it has already expired. + // Chain completion must reject it so it is never inserted into the chain. + Date expiredNotBefore = new Date(System.currentTimeMillis() - 86_400_000L * 30); + Date expiredNotAfter = new Date(System.currentTimeMillis() - 86_400_000L); + KeyPair expiredIssuerKeyPair = keyGen.generateKeyPair(); + X509Certificate expiredIssuerCert + = buildCertificate(expiredIssuerKeyPair.getPublic(), "CN=Expired Issuer", "CN=Expired Issuer", + expiredIssuerKeyPair.getPrivate(), true, null, KeyUsage.keyCertSign, expiredNotBefore, expiredNotAfter); + + KeyPair leafKeyPair = keyGen.generateKeyPair(); + X509Certificate leafWithExpiredAia = buildCertificate(leafKeyPair.getPublic(), "CN=Leaf", "CN=Expired Issuer", + expiredIssuerKeyPair.getPrivate(), false, AIA_BAD_ISSUER_URL); + + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + mockAiaResponse(httpMock, AIA_BAD_ISSUER_URL, expiredIssuerCert.getEncoded()); + + Certificate[] result + = AiaCertificateChainUtil.completeChainViaAia(new Certificate[] { leafWithExpiredAia }); + + assertEquals(1, result.length, + "An expired issuer certificate must be rejected and not inserted into the chain"); + assertEquals(leafWithExpiredAia, 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)) { + mockAiaResponse(httpMock, AIA_INTERMEDIATE_URL, intermediateCert.getEncoded()); + mockAiaResponse(httpMock, AIA_ROOT_URL, rootCert.getEncoded()); + completedChain = AiaCertificateChainUtil.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(AiaCertificateChainUtil.DISABLE_AIA_DOWNLOAD_PROPERTY); + System.setProperty(AiaCertificateChainUtil.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 = AiaCertificateChainUtil.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.getBytesWithMetadata(Mockito.anyString()), Mockito.never()); + } + } finally { + // Clean up: restore the original property value + if (originalValue != null) { + System.setProperty(AiaCertificateChainUtil.DISABLE_AIA_DOWNLOAD_PROPERTY, originalValue); + } else { + System.clearProperty(AiaCertificateChainUtil.DISABLE_AIA_DOWNLOAD_PROPERTY); + } + } + } + + // ----------------------------------------------------------------------- + // Certificate-loading integration tests + // ----------------------------------------------------------------------- + + @Test + void loadCertificatesCompletesLeafOnlyChain() throws Exception { + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + mockAiaResponse(httpMock, AIA_INTERMEDIATE_URL, intermediateCert.getEncoded()); + mockAiaResponse(httpMock, AIA_ROOT_URL, rootCert.getEncoded()); + + Certificate[] result = CertificateUtil.loadCertificatesFromSecretBundleValue(toPem(leafCert)); + + assertArrayEquals(new Certificate[] { leafCert, intermediateCert, rootCert }, result, + "A leaf-only bundle must be completed up to the root CA"); + } + } + + @Test + void loadCertificatesCompletesChainWithMissingIntermediate() throws Exception { + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + mockAiaResponse(httpMock, AIA_INTERMEDIATE_URL, intermediateCert.getEncoded()); + + Certificate[] result + = CertificateUtil.loadCertificatesFromSecretBundleValue(toPem(leafCert) + toPem(rootCert)); + + assertArrayEquals(new Certificate[] { leafCert, intermediateCert, rootCert }, result, + "An intermediate missing in the middle of the chain must still be downloaded"); + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(AIA_ROOT_URL), Mockito.never()); + } + } + + @Test + void loadCertificatesCompletesChainWithoutRootAndCachesIssuer() throws Exception { + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + mockAiaResponse(httpMock, AIA_ROOT_URL, rootCert.getEncoded()); + + Certificate[] firstResult + = CertificateUtil.loadCertificatesFromSecretBundleValue(toPem(leafCert) + toPem(intermediateCert)); + Certificate[] secondResult + = CertificateUtil.loadCertificatesFromSecretBundleValue(toPem(leafCert) + toPem(intermediateCert)); + + assertArrayEquals(new Certificate[] { leafCert, intermediateCert, rootCert }, firstResult, + "A contiguous chain must still be completed when its terminal certificate is not self-signed"); + assertArrayEquals(new Certificate[] { leafCert, intermediateCert, rootCert }, secondResult, + "A subsequent load must reuse the cached root certificate"); + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(AIA_ROOT_URL), Mockito.times(1)); + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL), Mockito.never()); + } + } + + @Test + void loadCertificatesSkipsAiaForCompleteChain() throws Exception { + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + Certificate[] result = CertificateUtil + .loadCertificatesFromSecretBundleValue(toPem(leafCert) + toPem(intermediateCert) + toPem(rootCert)); + + assertArrayEquals(new Certificate[] { leafCert, intermediateCert, rootCert }, result); + httpMock.verifyNoInteractions(); + } + } + + @Test + void loadCertificatesKeepsChainWithExpiredIssuerUntouched() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + + // An expired CA that still is the issuer of the leaf. Expiry is only rejected for certificates downloaded + // via AIA, so a chain returned by Key Vault must keep its order and must not trigger a download. + Date expiredNotBefore = new Date(System.currentTimeMillis() - 86_400_000L * 30); + Date expiredNotAfter = new Date(System.currentTimeMillis() - 86_400_000L); + KeyPair expiredCaKeyPair = keyGen.generateKeyPair(); + X509Certificate expiredCaCert = buildCertificate(expiredCaKeyPair.getPublic(), "CN=Expired CA", "CN=Expired CA", + expiredCaKeyPair.getPrivate(), true, null, KeyUsage.keyCertSign, expiredNotBefore, expiredNotAfter); + + KeyPair leafKeyPair = keyGen.generateKeyPair(); + X509Certificate leafOfExpiredCa = buildCertificate(leafKeyPair.getPublic(), "CN=Leaf Of Expired CA", + "CN=Expired CA", expiredCaKeyPair.getPrivate(), false, AIA_INTERMEDIATE_URL); + + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + Certificate[] result + = CertificateUtil.loadCertificatesFromSecretBundleValue(toPem(leafOfExpiredCa) + toPem(expiredCaCert)); + + assertArrayEquals(new Certificate[] { leafOfExpiredCa, expiredCaCert }, result, + "An expired certificate already in the chain must not change how the chain is ordered"); + httpMock.verifyNoInteractions(); + } + } + + // ----------------------------------------------------------------------- + // AIA response cache tests + // + // Issuer certificates are immutable, so the response of each CA Issuers URL + // is cached to avoid repeated round trips to public CA endpoints. Caching + // must never shortcut issuer validation. + // ----------------------------------------------------------------------- + + @Test + void aiaResponseIsCachedAcrossDownloads() throws Exception { + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + mockAiaResponse(httpMock, AIA_INTERMEDIATE_URL, intermediateCert.getEncoded()); + + assertEquals(intermediateCert, AiaCertificateChainUtil.downloadIssuerCertificateFromAia(leafCert)); + assertEquals(intermediateCert, AiaCertificateChainUtil.downloadIssuerCertificateFromAia(leafCert)); + + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL), Mockito.times(1)); + } + } + + @Test + void cachedAiaResponseIsValidatedBeforeAndAfterForcedRefresh() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + + // A certificate claiming the cached issuer's DN but signed by a different key. Neither the cached response + // nor the forced refresh may skip signature verification. + KeyPair impostorKeyPair = keyGen.generateKeyPair(); + KeyPair subjectKeyPair = keyGen.generateKeyPair(); + X509Certificate certSignedByAnotherKey = buildCertificate(subjectKeyPair.getPublic(), "CN=Other Leaf", + "CN=Test Intermediate CA", impostorKeyPair.getPrivate(), false, AIA_INTERMEDIATE_URL); + + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + mockAiaResponse(httpMock, AIA_INTERMEDIATE_URL, intermediateCert.getEncoded()); + + assertEquals(intermediateCert, AiaCertificateChainUtil.downloadIssuerCertificateFromAia(leafCert)); + assertNull(AiaCertificateChainUtil.downloadIssuerCertificateFromAia(certSignedByAnotherKey), + "A cache hit and its forced refresh must both reject a signature mismatch"); + + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL), Mockito.times(2)); + } + } + + @Test + void expiredExtraCertificateDoesNotPreventCachingValidIssuer() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + + Date expiredNotBefore = new Date(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30)); + Date expiredNotAfter = new Date(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)); + KeyPair expiredExtraKeyPair = keyGen.generateKeyPair(); + X509Certificate expiredExtra + = buildCertificate(expiredExtraKeyPair.getPublic(), "CN=Expired Extra CA", "CN=Expired Extra CA", + expiredExtraKeyPair.getPrivate(), true, null, KeyUsage.keyCertSign, expiredNotBefore, expiredNotAfter); + String pemBundle = toPem(expiredExtra) + toPem(intermediateCert); + + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + mockAiaResponse(httpMock, AIA_INTERMEDIATE_URL, pemBundle.getBytes(StandardCharsets.UTF_8)); + + assertEquals(intermediateCert, AiaCertificateChainUtil.downloadIssuerCertificateFromAia(leafCert)); + assertEquals(intermediateCert, AiaCertificateChainUtil.downloadIssuerCertificateFromAia(leafCert)); + + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL), Mockito.times(1)); + } + } + + @Test + void refreshesCachedResponseWhenIssuerRotates() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + + KeyPair rotatedIssuerKeyPair = keyGen.generateKeyPair(); + X509Certificate rotatedIssuer = buildCertificate(rotatedIssuerKeyPair.getPublic(), "CN=Test Intermediate CA", + "CN=Test Intermediate CA", rotatedIssuerKeyPair.getPrivate(), true, null); + KeyPair rotatedLeafKeyPair = keyGen.generateKeyPair(); + X509Certificate rotatedLeaf = buildCertificate(rotatedLeafKeyPair.getPublic(), "CN=Rotated Leaf", + "CN=Test Intermediate CA", rotatedIssuerKeyPair.getPrivate(), false, AIA_INTERMEDIATE_URL); + + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + httpMock.when(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL)) + .thenReturn(binaryResponse(intermediateCert.getEncoded()), binaryResponse(rotatedIssuer.getEncoded())); + + assertEquals(intermediateCert, AiaCertificateChainUtil.downloadIssuerCertificateFromAia(leafCert)); + assertEquals(rotatedIssuer, AiaCertificateChainUtil.downloadIssuerCertificateFromAia(rotatedLeaf)); + + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL), Mockito.times(2)); + } + } + + @Test + void suppressesRepeatedMissForSameTarget() throws Exception { + X509Certificate rotatedLeaf = buildRotatedLeafWithoutMatchingIssuer("CN=Suppressed Leaf"); + + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + httpMock.when(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL)) + .thenReturn(binaryResponse(intermediateCert.getEncoded())); + + assertEquals(intermediateCert, AiaCertificateChainUtil.downloadIssuerCertificateFromAia(leafCert)); + assertNull(AiaCertificateChainUtil.downloadIssuerCertificateFromAia(rotatedLeaf)); + assertNull(AiaCertificateChainUtil.downloadIssuerCertificateFromAia(rotatedLeaf)); + + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL), Mockito.times(2)); + } + } + + @Test + void doesNotSuppressDifferentTarget() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + X509Certificate firstRotatedLeaf = buildRotatedLeafWithoutMatchingIssuer("CN=First Rotated Leaf"); + + KeyPair secondIssuerKeyPair = keyGen.generateKeyPair(); + X509Certificate secondIssuer = buildCertificate(secondIssuerKeyPair.getPublic(), "CN=Test Intermediate CA", + "CN=Test Intermediate CA", secondIssuerKeyPair.getPrivate(), true, null); + KeyPair secondLeafKeyPair = keyGen.generateKeyPair(); + X509Certificate secondRotatedLeaf = buildCertificate(secondLeafKeyPair.getPublic(), "CN=Second Rotated Leaf", + "CN=Test Intermediate CA", secondIssuerKeyPair.getPrivate(), false, AIA_INTERMEDIATE_URL); + + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + httpMock.when(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL)) + .thenReturn(binaryResponse(intermediateCert.getEncoded()), + binaryResponse(intermediateCert.getEncoded()), binaryResponse(secondIssuer.getEncoded())); + + assertEquals(intermediateCert, AiaCertificateChainUtil.downloadIssuerCertificateFromAia(leafCert)); + assertNull(AiaCertificateChainUtil.downloadIssuerCertificateFromAia(firstRotatedLeaf)); + assertEquals(secondIssuer, AiaCertificateChainUtil.downloadIssuerCertificateFromAia(secondRotatedLeaf)); + + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL), Mockito.times(3)); + } + } + + @Test + void failedRefreshDoesNotReplaceUsefulPositiveEntry() throws Exception { + X509Certificate rotatedLeaf = buildRotatedLeafWithoutMatchingIssuer("CN=Failed Refresh Leaf"); + + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + httpMock.when(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL)) + .thenReturn(binaryResponse(intermediateCert.getEncoded()), binaryResponse(null)); + + assertEquals(intermediateCert, AiaCertificateChainUtil.downloadIssuerCertificateFromAia(leafCert)); + assertNull(AiaCertificateChainUtil.downloadIssuerCertificateFromAia(rotatedLeaf)); + assertEquals(intermediateCert, AiaCertificateChainUtil.downloadIssuerCertificateFromAia(leafCert)); + + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL), Mockito.times(2)); + } + } + + @Test + void clearAiaCacheForcesNewDownload() throws Exception { + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + mockAiaResponse(httpMock, AIA_INTERMEDIATE_URL, intermediateCert.getEncoded()); + + AiaCertificateChainUtil.downloadIssuerCertificateFromAia(leafCert); + AiaCertificateChainUtil.clearAiaCache(); + AiaCertificateChainUtil.downloadIssuerCertificateFromAia(leafCert); + + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL), Mockito.times(2)); + } + } + + @Test + void failedAiaResponseIsNegativelyCached() { + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + mockAiaResponse(httpMock, AIA_INTERMEDIATE_URL, null); + + assertTrue(AiaCertificateChainUtil.fetchCertificatesFromAiaUrl(AIA_INTERMEDIATE_URL).isEmpty()); + assertTrue(AiaCertificateChainUtil.fetchCertificatesFromAiaUrl(AIA_INTERMEDIATE_URL).isEmpty()); + + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL), Mockito.times(1)); + } + } + + @Test + void emptyAiaResponseIsNegativelyCached() { + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + mockAiaResponse(httpMock, AIA_INTERMEDIATE_URL, new byte[0]); + + assertTrue(AiaCertificateChainUtil.fetchCertificatesFromAiaUrl(AIA_INTERMEDIATE_URL).isEmpty()); + assertTrue(AiaCertificateChainUtil.fetchCertificatesFromAiaUrl(AIA_INTERMEDIATE_URL).isEmpty()); + + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL), Mockito.times(1)); + } + } + + @Test + void unparseableAiaResponseIsNegativelyCached() { + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + mockAiaResponse(httpMock, AIA_INTERMEDIATE_URL, "not a certificate".getBytes(StandardCharsets.UTF_8)); + + assertTrue(AiaCertificateChainUtil.fetchCertificatesFromAiaUrl(AIA_INTERMEDIATE_URL).isEmpty()); + assertTrue(AiaCertificateChainUtil.fetchCertificatesFromAiaUrl(AIA_INTERMEDIATE_URL).isEmpty()); + + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL), Mockito.times(1)); + } + } + + @Test + void noStoreAiaResponseIsNotCached() throws Exception { + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + httpMock.when(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL)) + .thenReturn(binaryResponse(intermediateCert.getEncoded(), "no-store")); + + assertEquals(intermediateCert, + AiaCertificateChainUtil.fetchCertificatesFromAiaUrl(AIA_INTERMEDIATE_URL).get(0)); + assertEquals(intermediateCert, + AiaCertificateChainUtil.fetchCertificatesFromAiaUrl(AIA_INTERMEDIATE_URL).get(0)); + + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL), Mockito.times(2)); + } + } + + @Test + void noStoreFailedAiaResponseIsNotCached() { + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + httpMock.when(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL)) + .thenReturn(binaryResponse(null, "no-store")); + + assertTrue(AiaCertificateChainUtil.fetchCertificatesFromAiaUrl(AIA_INTERMEDIATE_URL).isEmpty()); + assertTrue(AiaCertificateChainUtil.fetchCertificatesFromAiaUrl(AIA_INTERMEDIATE_URL).isEmpty()); + + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL), Mockito.times(2)); + } + } + + @Test + void noCacheUnparseableAiaResponseIsNotCached() { + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + httpMock.when(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL)) + .thenReturn(binaryResponse("not a certificate".getBytes(StandardCharsets.UTF_8), "no-cache")); + + assertTrue(AiaCertificateChainUtil.fetchCertificatesFromAiaUrl(AIA_INTERMEDIATE_URL).isEmpty()); + assertTrue(AiaCertificateChainUtil.fetchCertificatesFromAiaUrl(AIA_INTERMEDIATE_URL).isEmpty()); + + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL), Mockito.times(2)); + } + } + + @Test + void successfulResponseUsesFallbackTtlWithoutHeaders() { + long now = 1_000L; + + long expiresAt = AiaCertificateChainUtil.calculateResponseExpiration(binaryResponse(new byte[] { 1 }), now); + + assertEquals(now + TimeUnit.HOURS.toMillis(24), expiresAt); + } + + @Test + void successfulResponseHonorsMaxAgeAndAgeHeaders() { + long now = 1_000L; + HttpUtil.BinaryHttpResponse response + = new HttpUtil.BinaryHttpResponse(new byte[] { 1 }, "max-age=300", null, "30", null); + + long expiresAt = AiaCertificateChainUtil.calculateResponseExpiration(response, now); + + assertEquals(now + TimeUnit.SECONDS.toMillis(270), expiresAt); + } + + @Test + void successfulResponseAccountsForApparentAgeFromDateHeader() { + long now = ZonedDateTime.parse("Wed, 5 Aug 2026 12:00:00 GMT", DateTimeFormatter.RFC_1123_DATE_TIME) + .toInstant() + .toEpochMilli(); + HttpUtil.BinaryHttpResponse response = new HttpUtil.BinaryHttpResponse(new byte[] { 1 }, "max-age=3600", + "Wed, 5 Aug 2026 10:00:00 GMT", "0", null); + + long expiresAt = AiaCertificateChainUtil.calculateResponseExpiration(response, now); + + assertEquals(now, expiresAt); + } + + @Test + void successfulResponseUsesGreaterOfAgeAndApparentAge() { + long now = ZonedDateTime.parse("Wed, 5 Aug 2026 10:01:00 GMT", DateTimeFormatter.RFC_1123_DATE_TIME) + .toInstant() + .toEpochMilli(); + HttpUtil.BinaryHttpResponse response = new HttpUtil.BinaryHttpResponse(new byte[] { 1 }, "max-age=300", + "Wed, 5 Aug 2026 10:00:00 GMT", "120", null); + + long expiresAt = AiaCertificateChainUtil.calculateResponseExpiration(response, now); + + assertEquals(now + TimeUnit.SECONDS.toMillis(180), expiresAt); + } + + @Test + void successfulResponseWithNoStoreExpiresImmediately() { + long now = 1_000L; + + long expiresAt + = AiaCertificateChainUtil.calculateResponseExpiration(binaryResponse(new byte[] { 1 }, "no-store"), now); + + assertEquals(now, expiresAt); + } + + @Test + void successfulResponseWithNoCacheExpiresImmediately() { + long now = 1_000L; + + long expiresAt + = AiaCertificateChainUtil.calculateResponseExpiration(binaryResponse(new byte[] { 1 }, "no-cache"), now); + + assertEquals(now, expiresAt); + } + + @Test + void successfulResponseHonorsExpiresDateAndAgeHeaders() { + long now = 1_000L; + HttpUtil.BinaryHttpResponse response = new HttpUtil.BinaryHttpResponse(new byte[] { 1 }, null, + "Wed, 5 Aug 2026 10:00:00 GMT", "30", "Wed, 5 Aug 2026 10:05:00 GMT"); + + long expiresAt = AiaCertificateChainUtil.calculateResponseExpiration(response, now); + + assertEquals(now + TimeUnit.SECONDS.toMillis(270), expiresAt); + } + + @Test + void successfulResponseDoesNotReuseExpiredExpiresHeader() { + long now = ZonedDateTime.parse("Wed, 5 Aug 2026 12:00:00 GMT", DateTimeFormatter.RFC_1123_DATE_TIME) + .toInstant() + .toEpochMilli(); + HttpUtil.BinaryHttpResponse response = new HttpUtil.BinaryHttpResponse(new byte[] { 1 }, null, + "Wed, 5 Aug 2026 10:00:00 GMT", "0", "Wed, 5 Aug 2026 11:00:00 GMT"); + + long expiresAt = AiaCertificateChainUtil.calculateResponseExpiration(response, now); + + assertEquals(now, expiresAt); + } + + @Test + void malformedFreshnessHeadersUseFallbackTtl() { + long now = 1_000L; + HttpUtil.BinaryHttpResponse response = new HttpUtil.BinaryHttpResponse(new byte[] { 1 }, "max-age=invalid", + "not-a-date", "invalid", "also-not-a-date"); + + long expiresAt = AiaCertificateChainUtil.calculateResponseExpiration(response, now); + + assertEquals(now + TimeUnit.HOURS.toMillis(24), expiresAt); + } + + @Test + void overflowingAgeMakesResponseImmediatelyStale() { + long now = 1_000L; + HttpUtil.BinaryHttpResponse response = new HttpUtil.BinaryHttpResponse(new byte[] { 1 }, "max-age=300", null, + "999999999999999999999999999999999999999999", null); + + long expiresAt = AiaCertificateChainUtil.calculateResponseExpiration(response, now); + + assertEquals(now, expiresAt); + } + + @Test + void responseExpirationIsIndependentOfCandidateValidity() { + long now = 1_000L; + long expiresAt = AiaCertificateChainUtil.calculateResponseExpiration(binaryResponse(new byte[] { 1 }), now); + + assertEquals(now + TimeUnit.HOURS.toMillis(24), expiresAt); + } + + @Test + void aiaCacheEvictsLeastRecentlyUsedEntryWhenFull() throws Exception { + String firstUrl = "http://aia.example.com/cache-0.crt"; + + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + httpMock.when(() -> HttpUtil.getBytesWithMetadata(Mockito.anyString())) + .thenReturn(binaryResponse(intermediateCert.getEncoded())); + + AiaCertificateChainUtil.fetchCertificatesFromAiaUrl(firstUrl); + + // Fill the cache past its maximum size so its first entry can no longer be retained. + for (int i = 1; i <= 128; i++) { + AiaCertificateChainUtil.fetchCertificatesFromAiaUrl("http://aia.example.com/cache-" + i + ".crt"); + } + + AiaCertificateChainUtil.fetchCertificatesFromAiaUrl(firstUrl); + + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(firstUrl), Mockito.times(2)); + } + } + + @Test + void cachedIssuerIsNotReportedAsADownload() throws Exception { + List messages = new ArrayList<>(); + Handler collector = new Handler() { + @Override + public void publish(LogRecord logRecord) { + messages.add(logRecord.getMessage()); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + + Logger logger = Logger.getLogger(AiaCertificateChainUtil.class.getName()); + Level originalLevel = logger.getLevel(); + boolean originalUseParentHandlers = logger.getUseParentHandlers(); + + logger.addHandler(collector); + logger.setLevel(Level.FINE); + logger.setUseParentHandlers(false); + + try (MockedStatic httpMock = Mockito.mockStatic(HttpUtil.class)) { + mockAiaResponse(httpMock, AIA_INTERMEDIATE_URL, intermediateCert.getEncoded()); + mockAiaResponse(httpMock, AIA_ROOT_URL, rootCert.getEncoded()); + + // The second run resolves the same two issuers entirely from the cache. + AiaCertificateChainUtil.completeChainViaAia(new Certificate[] { leafCert }); + AiaCertificateChainUtil.completeChainViaAia(new Certificate[] { leafCert }); + + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(AIA_INTERMEDIATE_URL), Mockito.times(1)); + httpMock.verify(() -> HttpUtil.getBytesWithMetadata(AIA_ROOT_URL), Mockito.times(1)); + } finally { + logger.removeHandler(collector); + logger.setLevel(originalLevel); + logger.setUseParentHandlers(originalUseParentHandlers); + } + + assertEquals(4L, messages.stream().filter(m -> m.startsWith("Resolved issuer certificate via AIA")).count(), + "Both runs must report resolving the intermediate and the root"); + + assertEquals(2L, + messages.stream().filter(m -> m.startsWith("Downloading issuer certificate from AIA URL")).count(), + "Only the first run performs downloads; a cache hit must not be reported as one"); + } + + // ----------------------------------------------------------------------- + // Loop-termination tests + // ----------------------------------------------------------------------- + + @Test + @Timeout(30) + void completeChainViaAiaTerminatesOnCrossSignedIssuers() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + + // Two CA certificates issuing each other. Walking the chain upwards never reaches a self-signed root, and + // the issuer of the chain's top always sits inside the already-valid prefix, so repositioning it would + // break that prefix and let the loop oscillate between two arrangements. + KeyPair keyPairA = keyGen.generateKeyPair(); + KeyPair keyPairB = keyGen.generateKeyPair(); + X509Certificate crossSignedA = buildCertificate(keyPairA.getPublic(), "CN=Cross CA A", "CN=Cross CA B", + keyPairB.getPrivate(), true, null); + X509Certificate crossSignedB = buildCertificate(keyPairB.getPublic(), "CN=Cross CA B", "CN=Cross CA A", + keyPairA.getPrivate(), true, null); + + Certificate[] result + = AiaCertificateChainUtil.completeChainViaAia(new Certificate[] { crossSignedA, crossSignedB }); + + assertArrayEquals(new Certificate[] { crossSignedA, crossSignedB }, result, + "Cross-signed issuers must be left in place instead of being repositioned"); + } + + // ----------------------------------------------------------------------- + // Helper + // ----------------------------------------------------------------------- + + private static void mockAiaResponse(MockedStatic httpMock, String url, byte[] body) { + httpMock.when(() -> HttpUtil.getBytesWithMetadata(url)).thenReturn(binaryResponse(body)); + } + + private static HttpUtil.BinaryHttpResponse binaryResponse(byte[] body) { + return new HttpUtil.BinaryHttpResponse(body, null, null, null, null); + } + + private static HttpUtil.BinaryHttpResponse binaryResponse(byte[] body, String cacheControl) { + return new HttpUtil.BinaryHttpResponse(body, cacheControl, null, null, null); + } + + private static X509Certificate buildRotatedLeafWithoutMatchingIssuer(String subjectDn) throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + KeyPair rotatedIssuerKeyPair = keyGen.generateKeyPair(); + KeyPair rotatedLeafKeyPair = keyGen.generateKeyPair(); + return buildCertificate(rotatedLeafKeyPair.getPublic(), subjectDn, "CN=Test Intermediate CA", + rotatedIssuerKeyPair.getPrivate(), false, AIA_INTERMEDIATE_URL); + } + + private static X509Certificate buildCertificate(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(PublicKey subjectPublicKey, String subjectDn, String issuerDn, + PrivateKey signingKey, boolean isCa, String aiaUrl, Integer keyUsageFlags) throws Exception { + + Date notBefore = new Date(System.currentTimeMillis() - 86_400_000L); + Date notAfter = new Date(System.currentTimeMillis() + 86_400_000L * 365); + + return buildCertificate(subjectPublicKey, subjectDn, issuerDn, signingKey, isCa, aiaUrl, keyUsageFlags, + notBefore, notAfter); + } + + private static X509Certificate buildCertificate(PublicKey subjectPublicKey, String subjectDn, String issuerDn, + PrivateKey signingKey, boolean isCa, String aiaUrl, Integer keyUsageFlags, Date notBefore, Date notAfter) + throws Exception { + + X500Name subject = new X500Name(subjectDn); + X500Name issuer = new X500Name(issuerDn); + 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/AiaResponseCacheTest.java b/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/AiaResponseCacheTest.java new file mode 100644 index 000000000000..fb4c76ad461d --- /dev/null +++ b/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/AiaResponseCacheTest.java @@ -0,0 +1,379 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.security.keyvault.jca.implementation.utils; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +public class AiaResponseCacheTest { + private final AtomicLong clock = new AtomicLong(1_000L); + private final ExecutorService executor = Executors.newFixedThreadPool(16); + + @AfterEach + void shutdownExecutor() throws InterruptedException { + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + + @Test + void reusesSuccessfulResolutionBeforeExpiry() { + AiaResponseCache cache = new AiaResponseCache(128, clock::get); + AtomicInteger loads = new AtomicInteger(); + List certificates = Collections.singletonList(mock(X509Certificate.class)); + + assertSame(certificates, cache.getOrLoad("url", () -> entry(certificates, loads))); + assertSame(certificates, cache.getOrLoad("url", () -> entry(certificates, loads))); + + assertEquals(1, loads.get()); + } + + @Test + void reusesNegativeResolutionBeforeExpiry() { + AiaResponseCache cache = new AiaResponseCache(128, clock::get); + AtomicInteger loads = new AtomicInteger(); + + cache.getOrLoad("url", () -> entry(Collections.emptyList(), loads)); + cache.getOrLoad("url", () -> entry(Collections.emptyList(), loads)); + + assertEquals(1, loads.get()); + } + + @Test + void reloadsResolutionAfterExpiry() { + AiaResponseCache cache = new AiaResponseCache(128, clock::get); + AtomicInteger loads = new AtomicInteger(); + List certificates = Collections.singletonList(mock(X509Certificate.class)); + + cache.getOrLoad("url", () -> entry(certificates, loads)); + clock.set(2_001L); + cache.getOrLoad("url", () -> entry(certificates, loads)); + + assertEquals(2, loads.get()); + } + + @Test + void lookupResultReportsSourceAndGeneration() { + AiaResponseCache cache = new AiaResponseCache(128, clock::get); + List certificates = Collections.singletonList(mock(X509Certificate.class)); + + AiaResponseCache.LookupResult loaded + = cache.getOrLoadResult("url", () -> new AiaResponseCache.Entry(certificates, 2_000L), () -> { + }); + AiaResponseCache.LookupResult cached + = cache.getOrLoadResult("url", () -> new AiaResponseCache.Entry(certificates, 2_000L), () -> { + }); + + assertEquals(AiaResponseCache.Source.LOAD, loaded.getSource()); + assertEquals(AiaResponseCache.Source.CACHE, cached.getSource()); + assertTrue(loaded.getGeneration() > 0); + assertEquals(loaded.getGeneration(), cached.getGeneration()); + assertSame(certificates, cached.getCertificates()); + } + + @Test + void refreshIfUnchangedSkipsLoaderWhenEntryChanged() { + AiaResponseCache cache = new AiaResponseCache(128, clock::get); + AtomicInteger refreshLoads = new AtomicInteger(); + List firstCertificates = Collections.singletonList(mock(X509Certificate.class)); + List secondCertificates = Collections.singletonList(mock(X509Certificate.class)); + + AiaResponseCache.LookupResult first + = cache.getOrLoadResult("url", () -> new AiaResponseCache.Entry(firstCertificates, 2_000L), () -> { + }); + cache.clear(); + AiaResponseCache.LookupResult second + = cache.getOrLoadResult("url", () -> new AiaResponseCache.Entry(secondCertificates, 2_000L), () -> { + }); + + AiaResponseCache.LookupResult refreshed = cache.refreshIfUnchanged("url", first.getGeneration(), () -> { + refreshLoads.incrementAndGet(); + return new AiaResponseCache.Entry(firstCertificates, 2_000L); + }); + + assertEquals(0, refreshLoads.get()); + assertEquals(second.getGeneration(), refreshed.getGeneration()); + assertSame(secondCertificates, refreshed.getCertificates()); + } + + @Test + void coalescesConcurrentForcedRefreshes() throws Exception { + AiaResponseCache cache = new AiaResponseCache(128, clock::get); + List oldCertificates = Collections.singletonList(mock(X509Certificate.class)); + List newCertificates = Collections.singletonList(mock(X509Certificate.class)); + AiaResponseCache.LookupResult initial + = cache.getOrLoadResult("url", () -> new AiaResponseCache.Entry(oldCertificates, 2_000L), () -> { + }); + AtomicInteger refreshLoads = new AtomicInteger(); + CountDownLatch refreshStarted = new CountDownLatch(1); + CountDownLatch releaseRefresh = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + for (int i = 0; i < 10; i++) { + futures.add(executor.submit(() -> cache.refreshIfUnchanged("url", initial.getGeneration(), () -> { + refreshLoads.incrementAndGet(); + refreshStarted.countDown(); + await(releaseRefresh); + return new AiaResponseCache.Entry(newCertificates, 2_000L); + }))); + } + + assertTrue(refreshStarted.await(5, TimeUnit.SECONDS)); + releaseRefresh.countDown(); + for (Future future : futures) { + AiaResponseCache.LookupResult result = future.get(5, TimeUnit.SECONDS); + assertSame(newCertificates, result.getCertificates()); + } + assertEquals(1, refreshLoads.get()); + } + + @Test + void negativeForcedRefreshKeepsPositiveEntry() { + AiaResponseCache cache = new AiaResponseCache(128, clock::get); + List positiveCertificates = Collections.singletonList(mock(X509Certificate.class)); + AiaResponseCache.LookupResult initial + = cache.getOrLoadResult("url", () -> new AiaResponseCache.Entry(positiveCertificates, 2_000L), () -> { + }); + + AiaResponseCache.LookupResult refreshed = cache.refreshIfUnchanged("url", initial.getGeneration(), + () -> new AiaResponseCache.Entry(Collections.emptyList(), 2_000L)); + AiaResponseCache.LookupResult cached + = cache.getOrLoadResult("url", () -> new AiaResponseCache.Entry(Collections.emptyList(), 2_000L), () -> { + }); + + assertTrue(refreshed.isNegative()); + assertSame(positiveCertificates, cached.getCertificates()); + assertEquals(initial.getGeneration(), cached.getGeneration()); + } + + @Test + void targetSuppressionsAreIndependent() { + AiaResponseCache cache = new AiaResponseCache(128, clock::get); + + cache.suppressRefresh("url", "target-1", 1L, 1_000L); + + assertTrue(cache.isRefreshSuppressed("url", "target-1", 1L)); + assertFalse(cache.isRefreshSuppressed("url", "target-2", 1L)); + } + + @Test + void targetSuppressionIsScopedToEntryGeneration() { + AiaResponseCache cache = new AiaResponseCache(128, clock::get); + + cache.suppressRefresh("url", "target", 1L, 1_000L); + + assertTrue(cache.isRefreshSuppressed("url", "target", 1L)); + assertFalse(cache.isRefreshSuppressed("url", "target", 2L)); + } + + @Test + void targetSuppressionExpires() { + AiaResponseCache cache = new AiaResponseCache(128, clock::get); + cache.suppressRefresh("url", "target", 1L, 1_000L); + + clock.set(2_001L); + + assertFalse(cache.isRefreshSuppressed("url", "target", 1L)); + } + + @Test + void coalescesConcurrentMissesForSameUrl() throws Exception { + AiaResponseCache cache = new AiaResponseCache(128, clock::get); + AtomicInteger loads = new AtomicInteger(); + CountDownLatch loaderStarted = new CountDownLatch(1); + CountDownLatch releaseLoader = new CountDownLatch(1); + List certificates = Collections.singletonList(mock(X509Certificate.class)); + List>> futures = new ArrayList<>(); + + for (int i = 0; i < 16; i++) { + futures.add(executor.submit(() -> cache.getOrLoad("url", () -> { + loads.incrementAndGet(); + loaderStarted.countDown(); + await(releaseLoader); + return new AiaResponseCache.Entry(certificates, 2_000L); + }))); + } + + assertTrue(loaderStarted.await(5, TimeUnit.SECONDS)); + releaseLoader.countDown(); + for (Future> future : futures) { + assertSame(certificates, future.get(5, TimeUnit.SECONDS)); + } + assertEquals(1, loads.get()); + } + + @Test + void allowsDifferentUrlsToLoadConcurrently() throws Exception { + AiaResponseCache cache = new AiaResponseCache(128, clock::get); + CountDownLatch bothLoadersStarted = new CountDownLatch(2); + CountDownLatch releaseLoaders = new CountDownLatch(1); + List certificates = Collections.singletonList(mock(X509Certificate.class)); + + Future> first = executor.submit(() -> cache.getOrLoad("url-1", () -> { + bothLoadersStarted.countDown(); + await(releaseLoaders); + return new AiaResponseCache.Entry(certificates, 2_000L); + })); + Future> second = executor.submit(() -> cache.getOrLoad("url-2", () -> { + bothLoadersStarted.countDown(); + await(releaseLoaders); + return new AiaResponseCache.Entry(certificates, 2_000L); + })); + + assertTrue(bothLoadersStarted.await(5, TimeUnit.SECONDS)); + releaseLoaders.countDown(); + assertSame(certificates, first.get(5, TimeUnit.SECONDS)); + assertSame(certificates, second.get(5, TimeUnit.SECONDS)); + } + + @Test + void loaderFailureDoesNotLeaveInFlightEntry() { + AiaResponseCache cache = new AiaResponseCache(128, clock::get); + AtomicInteger loads = new AtomicInteger(); + List certificates = Collections.singletonList(mock(X509Certificate.class)); + + CompletionException exception = assertThrows(CompletionException.class, () -> cache.getOrLoad("url", () -> { + loads.incrementAndGet(); + throw new IllegalStateException("load failed"); + })); + assertTrue(exception.getCause() instanceof IllegalStateException); + assertSame(certificates, cache.getOrLoad("url", () -> entry(certificates, loads))); + + assertEquals(2, loads.get()); + } + + @Test + void loaderErrorPropagatesWithoutLeavingInFlightEntry() { + AiaResponseCache cache = new AiaResponseCache(128, clock::get); + AtomicInteger loads = new AtomicInteger(); + List certificates = Collections.singletonList(mock(X509Certificate.class)); + + AssertionError error = assertThrows(AssertionError.class, () -> cache.getOrLoad("url", () -> { + loads.incrementAndGet(); + throw new AssertionError("load failed"); + })); + assertEquals("load failed", error.getMessage()); + assertSame(certificates, cache.getOrLoad("url", () -> entry(certificates, loads))); + + assertEquals(2, loads.get()); + } + + @Test + void evictsOnlyLeastRecentlyUsedEntry() { + AiaResponseCache cache = new AiaResponseCache(2, clock::get); + AtomicInteger loads = new AtomicInteger(); + List certificates = Collections.singletonList(mock(X509Certificate.class)); + + cache.getOrLoad("url-1", () -> entry(certificates, loads)); + cache.getOrLoad("url-2", () -> entry(certificates, loads)); + cache.getOrLoad("url-1", () -> entry(certificates, loads)); + cache.getOrLoad("url-3", () -> entry(certificates, loads)); + cache.getOrLoad("url-1", () -> entry(certificates, loads)); + cache.getOrLoad("url-2", () -> entry(certificates, loads)); + + assertEquals(4, loads.get()); + assertEquals(2, cache.size()); + } + + @Test + void removesExpiredEntriesBeforeLruEviction() { + AiaResponseCache cache = new AiaResponseCache(2, clock::get); + AtomicInteger loads = new AtomicInteger(); + List certificates = Collections.singletonList(mock(X509Certificate.class)); + + cache.getOrLoad("expired", () -> { + loads.incrementAndGet(); + return new AiaResponseCache.Entry(certificates, 1_500L); + }); + cache.getOrLoad("fresh", () -> { + loads.incrementAndGet(); + return new AiaResponseCache.Entry(certificates, 3_000L); + }); + clock.set(2_000L); + cache.getOrLoad("new", () -> { + loads.incrementAndGet(); + return new AiaResponseCache.Entry(certificates, 3_000L); + }); + cache.getOrLoad("fresh", () -> entry(certificates, loads)); + cache.getOrLoad("expired", () -> { + loads.incrementAndGet(); + return new AiaResponseCache.Entry(certificates, 3_000L); + }); + + assertEquals(4, loads.get()); + assertEquals(2, cache.size()); + } + + @Test + void clearRemovesCachedEntries() { + AiaResponseCache cache = new AiaResponseCache(128, clock::get); + AtomicInteger loads = new AtomicInteger(); + List certificates = Collections.singletonList(mock(X509Certificate.class)); + + cache.getOrLoad("url", () -> entry(certificates, loads)); + cache.clear(); + cache.getOrLoad("url", () -> entry(certificates, loads)); + + assertEquals(2, loads.get()); + } + + @Test + void clearDuringLoadDoesNotRepopulateCache() throws Exception { + AiaResponseCache cache = new AiaResponseCache(128, clock::get); + AtomicInteger loads = new AtomicInteger(); + CountDownLatch loadStarted = new CountDownLatch(1); + CountDownLatch releaseLoad = new CountDownLatch(1); + List certificates = Collections.singletonList(mock(X509Certificate.class)); + + Future> first = executor.submit(() -> cache.getOrLoad("url", () -> { + loads.incrementAndGet(); + loadStarted.countDown(); + await(releaseLoad); + return new AiaResponseCache.Entry(certificates, 2_000L); + })); + + assertTrue(loadStarted.await(5, TimeUnit.SECONDS)); + cache.clear(); + releaseLoad.countDown(); + assertSame(certificates, first.get(5, TimeUnit.SECONDS)); + assertSame(certificates, cache.getOrLoad("url", () -> entry(certificates, loads))); + + assertEquals(2, loads.get()); + } + + private AiaResponseCache.Entry entry(List certificates, AtomicInteger loads) { + loads.incrementAndGet(); + return new AiaResponseCache.Entry(certificates, 2_000L); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting for test latch"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for test latch", e); + } + } +} 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"); + } +} diff --git a/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/HttpUtilTest.java b/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/HttpUtilTest.java index d50c99d7a68e..ffdd9f7ec42b 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/HttpUtilTest.java +++ b/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/HttpUtilTest.java @@ -5,6 +5,9 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.io.entity.ByteArrayEntity; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; import static com.azure.security.keyvault.jca.implementation.utils.HttpUtil.DEFAULT_USER_AGENT_VALUE_PREFIX; import static com.azure.security.keyvault.jca.implementation.utils.HttpUtil.VERSION; @@ -35,4 +38,49 @@ public void testHttpUtilGet1() { assertNotNull(result); assertFalse(result.isEmpty()); } + + @Test + void binaryResponsePreservesBodyAndFreshnessHeaders() throws Exception { + BasicClassicHttpResponse response = new BasicClassicHttpResponse(200); + byte[] body = new byte[] { 1, 2, 3 }; + response.setEntity(new ByteArrayEntity(body, ContentType.APPLICATION_OCTET_STREAM)); + response.addHeader("Cache-Control", "public, max-age=300"); + response.addHeader("Date", "Wed, 05 Aug 2026 10:00:00 GMT"); + response.addHeader("Age", "30"); + response.addHeader("Expires", "Wed, 05 Aug 2026 10:05:00 GMT"); + + HttpUtil.BinaryHttpResponse result = HttpUtil.toBinaryResponse(response, "https://example.test/cert.crt"); + + assertArrayEquals(body, result.getBody()); + assertEquals("public, max-age=300", result.getCacheControl()); + assertEquals("Wed, 05 Aug 2026 10:00:00 GMT", result.getDate()); + assertEquals("30", result.getAge()); + assertEquals("Wed, 05 Aug 2026 10:05:00 GMT", result.getExpires()); + } + + @Test + void binaryResponseForFailureHasNoBodyOrFreshnessMetadata() throws Exception { + BasicClassicHttpResponse response = new BasicClassicHttpResponse(503); + response.addHeader("Cache-Control", "max-age=3600"); + + HttpUtil.BinaryHttpResponse result = HttpUtil.toBinaryResponse(response, "https://example.test/cert.crt"); + + assertNull(result.getBody()); + assertEquals("max-age=3600", result.getCacheControl()); + assertNull(result.getDate()); + assertNull(result.getAge()); + assertNull(result.getExpires()); + } + + @Test + void binaryResponseCombinesMultipleCacheControlHeaders() throws Exception { + BasicClassicHttpResponse response = new BasicClassicHttpResponse(200); + response.setEntity(new ByteArrayEntity(new byte[] { 1 }, ContentType.APPLICATION_OCTET_STREAM)); + response.addHeader("Cache-Control", "public, max-age=300"); + response.addHeader("Cache-Control", "no-store"); + + HttpUtil.BinaryHttpResponse result = HttpUtil.toBinaryResponse(response, "https://example.test/cert.crt"); + + assertEquals("public, max-age=300, no-store", result.getCacheControl()); + } } diff --git a/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/JsonConverterUtilTest.java b/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/JsonConverterUtilTest.java index 8c3639174e81..d0e11918ed04 100644 --- a/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/JsonConverterUtilTest.java +++ b/sdk/keyvault/azure-security-keyvault-jca/src/test/java/com/azure/security/keyvault/jca/implementation/utils/JsonConverterUtilTest.java @@ -9,6 +9,12 @@ import org.junit.jupiter.api.Test; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -61,4 +67,46 @@ void testFromJsonWithTokenResponseBody() { assertNotNull(accessToken); assertEquals("test_access_token_value", accessToken.getAccessToken()); } + + @Test + void fromJsonDoesNotLogThePayload() throws IOException { + List loggedValues = new ArrayList<>(); + Handler collector = new Handler() { + @Override + public void publish(LogRecord logRecord) { + if (logRecord.getParameters() != null) { + for (Object parameter : logRecord.getParameters()) { + loggedValues.add(String.valueOf(parameter)); + } + } + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + + Logger logger = Logger.getLogger(JsonConverterUtil.class.getName()); + Level originalLevel = logger.getLevel(); + boolean originalUseParentHandlers = logger.getUseParentHandlers(); + + logger.addHandler(collector); + logger.setLevel(Level.ALL); + logger.setUseParentHandlers(false); + + try { + JsonConverterUtil.fromJson(AccessToken::fromJson, DUMMY_TOKEN_RESPONSE_BODY); + } finally { + logger.removeHandler(collector); + logger.setLevel(originalLevel); + logger.setUseParentHandlers(originalUseParentHandlers); + } + + assertTrue(loggedValues.stream().noneMatch(value -> value.contains("test_access_token_value")), + "The payload must never be logged: it carries access tokens and PKCS12 key bundles"); + } }