Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,24 @@
import static com.google.common.base.Preconditions.checkNotNull;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Paths;
import java.security.cert.Certificate;
import java.security.cert.CertificateEncodingException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* Provider for retrieving the subject tokens for {@link IdentityPoolCredentials} by reading an
Expand All @@ -56,6 +64,9 @@ public class CertificateIdentityPoolSubjectTokenSupplier

private final IdentityPoolCredentialSource credentialSource;

private static final Pattern PEM_CERT_PATTERN =
Pattern.compile("-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", Pattern.DOTALL);

CertificateIdentityPoolSubjectTokenSupplier(IdentityPoolCredentialSource credentialSource) {
this.credentialSource = checkNotNull(credentialSource, "credentialSource cannot be null");
// This check ensures that the credential source was intended for certificate usage.
Expand All @@ -66,10 +77,21 @@ public class CertificateIdentityPoolSubjectTokenSupplier
+ " CertificateIdentityPoolSubjectTokenSupplier");
}

private static X509Certificate loadLeafCertificate(String path)
throws IOException, CertificateException {
byte[] leafCertBytes = Files.readAllBytes(Paths.get(path));
return parseCertificate(leafCertBytes);
private static String loadAndEncodeLeafCertificate(String path) throws IOException {
try {
byte[] leafCertBytes = Files.readAllBytes(Paths.get(path));
X509Certificate leafCert = parseCertificate(leafCertBytes);
return encodeCert(leafCert);
} catch (NoSuchFileException e) {
throw new IOException(String.format("Leaf certificate file not found: %s", path), e);
} catch (CertificateException e) {
throw new IOException(
String.format("Failed to parse leaf certificate from file: %s", path), e);
} catch (IOException e) {
// This catches any other general I/O errors during leaf certificate file reading (e.g.,
// permissions).
throw new IOException(String.format("Failed to read leaf certificate file: %s", path), e);
}
}

@VisibleForTesting
Expand Down Expand Up @@ -97,33 +119,177 @@ private static String encodeCert(X509Certificate certificate)

/**
* Retrieves the X509 subject token. This method loads the leaf certificate specified by the
* {@code credentialSource.credentialLocation}. The subject token is constructed as a JSON array
* containing the base64-encoded (DER format) leaf certificate. This JSON array serves as the
* subject token for mTLS authentication.
* {@code credentialSource.credentialLocation}. If a trust chain path is configured in the {@code
* credentialSource.certificateConfig}, it also loads and includes the trust chain certificates.
* The subject token is constructed as a JSON array containing the base64-encoded (DER format)
* leaf certificate, followed by the base64-encoded (DER format) certificates in the trust chain.
* This JSON array serves as the subject token for mTLS authentication.
*
* @param context The external account supplier context. This parameter is currently not used in
* this implementation.
* @return The JSON string representation of the base64-encoded leaf certificate in a JSON array.
* @throws IOException If an I/O error occurs while reading the certificate file.
* @return The JSON string representation of the base64-encoded certificate chain (leaf
* certificate followed by the trust chain, if present).
* @throws IOException If an I/O error occurs while reading the certificate file(s).
*/
@Override
public String getSubjectToken(ExternalAccountSupplierContext context) throws IOException {
try {
// credentialSource.credentialLocation is expected to be non-null here,
// set during IdentityPoolCredentials construction for certificate type.
X509Certificate leafCert = loadLeafCertificate(credentialSource.getCredentialLocation());
String encodedLeafCert = encodeCert(leafCert);
String leafCertPath = credentialSource.getCredentialLocation();
String trustChainPath = null;
if (credentialSource.getCertificateConfig() != null) {
trustChainPath = credentialSource.getCertificateConfig().getTrustChainPath();
}

// Load and encode the leaf certificate.
String encodedLeafCert = loadAndEncodeLeafCertificate(leafCertPath);

java.util.List<String> certChain = new java.util.ArrayList<>();
certChain.add(encodedLeafCert);
// Initialize the certificate chain for the subject token. The Security Token Service (STS)
// requires that the leaf certificate (the one used for authenticating this workload) must be
// the first certificate in this chain.
List<String> certChain = new ArrayList<>();
certChain.add(encodedLeafCert);

// Handle trust chain loading and processing.
try {
// Read the trust chain.
List<X509Certificate> trustChainCerts = readTrustChain(trustChainPath);

return OAuth2Utils.JSON_FACTORY.toString(certChain);
// Process the trust chain certificates read from the file.
if (!trustChainCerts.isEmpty()) {
populateCertChainFromTrustChain(certChain, trustChainCerts, encodedLeafCert);
}
} catch (IllegalArgumentException e) {
// This catches the specific error for misconfigured trust chain (e.g., leaf in wrong place).
throw new IOException("Trust chain misconfiguration: " + e.getMessage(), e);
} catch (NoSuchFileException e) {
throw new IOException(String.format("Trust chain file not found: %s", trustChainPath), e);
} catch (CertificateException e) {
// Catch CertificateException to provide a more specific error message including
// the path of the file that failed to parse, and re-throw as IOException
// as expected by the getSubjectToken method signature for I/O related issues.
throw new IOException(
"Failed to parse certificate(s) from: " + credentialSource.getCredentialLocation(), e);
String.format("Failed to parse certificate(s) from trust chain file: %s", trustChainPath),
e);
} catch (IOException e) {
// This catches any other general I/O errors during trust chain file reading (e.g.,
// permissions).
throw new IOException(
String.format("Failed to read trust chain file: %s", trustChainPath), e);
}

return OAuth2Utils.JSON_FACTORY.toString(certChain);
}

/**
* Extends {@code certChainToPopulate} with encoded certificates from {@code trustChainCerts},
* applying validation rules for the leaf certificate's presence and order within the trust chain.
*
* @param certChainToPopulate The list of encoded certificate strings to populate.
* @param trustChainCerts The list of X509Certificates from the trust chain file (non-empty).
* @param encodedLeafCert The Base64-encoded leaf certificate.
* @throws CertificateEncodingException If an error occurs during certificate encoding.
* @throws IllegalArgumentException If the leaf certificate is found in an invalid position in the
* trust chain.
*/
private void populateCertChainFromTrustChain(
List<String> certChainToPopulate,
List<X509Certificate> trustChainCerts,
String encodedLeafCert)
throws CertificateEncodingException, IllegalArgumentException {

// Get the first certificate from the user-provided trust chain file.
X509Certificate firstTrustCert = trustChainCerts.get(0);
String encodedFirstTrustCert = encodeCert(firstTrustCert);

// If the first certificate in the user-provided trust chain file is *not* the leaf
// certificate (which has already been added as the first element to `certChainToPopulate`),
// then add this certificate. This handles cases where the user's trust chain file
// starts with an intermediate certificate. If the first certificate in the trust chain file
// *is* the leaf certificate, this means the user has explicitly included the leaf in their
// trust chain file. In this case, we skip adding it again to prevent duplication, as the
// leaf is already at the beginning of `certChainToPopulate`.
if (!encodedFirstTrustCert.equals(encodedLeafCert)) {
certChainToPopulate.add(encodedFirstTrustCert);
}

// Iterate over the remaining certificates in the trust chain.
for (int i = 1; i < trustChainCerts.size(); i++) {
X509Certificate currentCert = trustChainCerts.get(i);
String encodedCurrentCert = encodeCert(currentCert);

// Throw an error if the current certificate (from the user-provided trust chain file,
// at an index beyond the first) is the same as the leaf certificate.
// This enforces that if the leaf certificate is included in the trust chain file by the
// user, it must be the very first certificate in that file. It should not appear
// elsewhere in the chain.
if (encodedCurrentCert.equals(encodedLeafCert)) {
throw new IllegalArgumentException(
"The leaf certificate should only appear at the beginning of the trust chain file, or be omitted entirely.");
}

// Add the current certificate to the chain.
certChainToPopulate.add(encodedCurrentCert);
}
}

/**
* Reads a file containing PEM-encoded X509 certificates and returns a list of parsed
* certificates. It splits the file content based on PEM headers and parses each certificate.
* Returns an empty list if the trust chain path is empty.
*
* @param trustChainPath The path to the trust chain file.
* @return A list of parsed X509 certificates.
* @throws IOException If an error occurs while reading the file.
* @throws CertificateException If an error occurs while parsing a certificate.
*/
@VisibleForTesting
static List<X509Certificate> readTrustChain(String trustChainPath)
throws IOException, CertificateException {
List<X509Certificate> certificateTrustChain = new ArrayList<>();

// If no trust chain path is provided, return an empty list.
if (Strings.isNullOrEmpty(trustChainPath)) {
return certificateTrustChain;
}

// initialize certificate factory to retrieve x509 certificates.
CertificateFactory cf = CertificateFactory.getInstance("X.509");

// Read the trust chain file.
byte[] trustChainData;
trustChainData = Files.readAllBytes(Paths.get(trustChainPath));

// Split the file content into PEM certificate blocks.
String content = new String(trustChainData, StandardCharsets.UTF_8);

Matcher matcher = PEM_CERT_PATTERN.matcher(content);

while (matcher.find()) {
String pemCertBlock = matcher.group(0);
try (InputStream certStream =
new ByteArrayInputStream(pemCertBlock.getBytes(StandardCharsets.UTF_8))) {
// Parse the certificate data.
Certificate cert = cf.generateCertificate(certStream);

// Append the certificate to the trust chain.
if (cert instanceof X509Certificate) {
certificateTrustChain.add((X509Certificate) cert);
} else {
throw new CertificateException(
"Found non-X.509 certificate in trust chain file: " + trustChainPath);
}
} catch (CertificateException e) {
// If parsing an individual PEM block fails, re-throw with more context.
throw new CertificateException(
"Error loading PEM certificates from the trust chain file: "
+ trustChainPath
+ " - "
+ e.getMessage(),
e);
}
}

if (trustChainData.length > 0 && certificateTrustChain.isEmpty()) {
throw new CertificateException(
"Trust chain file was not empty but no PEM certificates were found: " + trustChainPath);
}

return certificateTrustChain;
}
}
Loading
Loading