Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@
import java.security.KeyFactory;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
import java.security.SignatureException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
Expand All @@ -64,17 +67,22 @@
*
* <p>Everything else matches the upstream method: key store type (JKS), alias, empty password,
* accepted key format (PKCS#8 {@code PRIVATE KEY}), which private key is chosen, and error
* messages. Any input that the upstream method accepted is still accepted, even though more of it
* is now used: once a certificate and a private key have been found (the point where the upstream
* method stopped reading), a malformed later PEM section ends the input instead of failing, and an
* extra certificate that cannot be parsed is left out of the chain. Unlike the upstream method, the
* input is decoded as UTF-8 rather than the platform default charset, and it is read to the end.
* messages. Any valid certificate-and-key pair that the upstream method accepted is still accepted,
* even though more of the input is now used: once a certificate and a private key have been found
* (the point where the upstream method stopped reading), a malformed later PEM section ends the
* input instead of failing, and an extra certificate that cannot be parsed is left out of the
* chain. Unlike the upstream method, the input is decoded as UTF-8 rather than the platform default
* charset, it is read to the end, and the private key is verified against the leaf certificate's
* public key.
*/
@NullMarked
final class MtlsKeyStoreUtils {

private static final Logger LOGGER = Logger.getLogger(MtlsKeyStoreUtils.class.getName());

private static final byte[] VERIFICATION_DATA =
"verification-data".getBytes(StandardCharsets.UTF_8);

private MtlsKeyStoreUtils() {}

/**
Expand All @@ -89,7 +97,8 @@ private MtlsKeyStoreUtils() {}
* @throws IllegalArgumentException if the input has no certificate or no private key, or if a PEM
* section before both a certificate and a private key have been read is malformed
* @throws GeneralSecurityException if the first certificate or the private key cannot be parsed,
* or the key store cannot be created
* if the private key does not match the leaf certificate's public key, or if the key store
* cannot be created
* @throws IOException if the input cannot be read
*/
static KeyStore createMtlsKeyStore(InputStream certAndKey)
Expand Down Expand Up @@ -143,16 +152,56 @@ static KeyStore createMtlsKeyStore(InputStream certAndKey)
LOGGER.log(Level.FINE, "Skipping unparsable extra certificate in mTLS chain", e);
}
}
X509Certificate leaf = chain.get(0);
PrivateKey key =
KeyFactory.getInstance(chain.get(0).getPublicKey().getAlgorithm())
KeyFactory.getInstance(leaf.getPublicKey().getAlgorithm())
.generatePrivate(new PKCS8EncodedKeySpec(privateKey));
verifyKeyPair(leaf, key);

KeyStore keyStore = KeyStore.getInstance("JKS");
keyStore.load(null);
keyStore.setKeyEntry("alias", key, new char[] {}, chain.toArray(new X509Certificate[0]));
return keyStore;
}

/**
* Verifies that {@code privateKey} corresponds to the public key in {@code cert} by performing a
* test signature and verification.
*/
private static void verifyKeyPair(X509Certificate cert, PrivateKey privateKey)
throws GeneralSecurityException {
PublicKey publicKey = cert.getPublicKey();
String keyAlgorithm = publicKey.getAlgorithm();
String sigAlg;
if ("RSA".equals(keyAlgorithm)) {
sigAlg = "SHA256withRSA";
} else if ("EC".equals(keyAlgorithm)) {
sigAlg = "SHA256withECDSA";
} else {
return;
}

Signature signer = Signature.getInstance(sigAlg);
signer.initSign(privateKey);
signer.update(VERIFICATION_DATA);
byte[] signature = signer.sign();

Signature verifier = Signature.getInstance(sigAlg);
verifier.initVerify(publicKey);
verifier.update(VERIFICATION_DATA);

boolean matches;
try {
matches = verifier.verify(signature);
} catch (SignatureException e) {
throw new GeneralSecurityException("Certificate and private key do not match", e);
}

if (!matches) {
throw new GeneralSecurityException("Certificate and private key do not match");
}
}

private static boolean containsCertificate(List<byte[]> certificates, byte[] candidate) {
for (byte[] existing : certificates) {
if (Arrays.equals(existing, candidate)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,16 @@
import com.google.auth.oauth2.PropertyProvider;
import com.google.auth.oauth2.SystemEnvironmentProvider;
import com.google.auth.oauth2.SystemPropertyProvider;
import com.google.common.annotations.VisibleForTesting;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.SequenceInputStream;
import java.security.KeyStore;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

Expand All @@ -52,6 +55,25 @@
@NullMarked
@InternalApi
public class X509Provider implements MtlsProvider {
private static final Logger LOGGER = Logger.getLogger(X509Provider.class.getName());

// Retries for verifying certificate and private key matching during atomic key rotation.
static final int CERT_KEY_MATCH_RETRIES = 3;
static final long CERT_KEY_MATCH_RETRY_INTERVAL_MS = 100;

@VisibleForTesting
@FunctionalInterface
interface TimeService {
void sleep(long millis) throws InterruptedException;
}

private static volatile TimeService timeService = Thread::sleep;

@VisibleForTesting
static void setTimeServiceForTesting(@Nullable TimeService service) {
timeService = service != null ? service : Thread::sleep;
}

private final EnvironmentProvider envProvider;
private final PropertyProvider propProvider;
private final @Nullable String certConfigPathOverride;
Expand Down Expand Up @@ -193,22 +215,45 @@ public KeyStore getKeyStore() throws CertificateSourceUnavailableException, IOEx
throw configUnavailable;
}

// Read the certificate and private key file paths into streams.
try (InputStream certStream = new FileInputStream(new File(workloadCertConfig.getCertPath()));
InputStream privateKeyStream =
new FileInputStream(new File(workloadCertConfig.getPrivateKeyPath()));
SequenceInputStream certAndPrivateKeyStream =
new SequenceInputStream(certStream, privateKeyStream)) {
// Read the certificate and private key file paths into streams, retrying to handle transient
// mid-rotation mismatches or file replacements when separate cert_path and key_path files are
// updated non-atomically relative to each other.
Exception lastException = null;
for (int attempt = 0; attempt < CERT_KEY_MATCH_RETRIES; attempt++) {
try (InputStream certStream =
new FileInputStream(new File(workloadCertConfig.getCertPath()));
InputStream privateKeyStream =
new FileInputStream(new File(workloadCertConfig.getPrivateKeyPath()));
SequenceInputStream certAndPrivateKeyStream =
new SequenceInputStream(certStream, privateKeyStream)) {

// Build a key store using the combined stream.
return MtlsKeyStoreUtils.createMtlsKeyStore(certAndPrivateKeyStream);
} catch (CertificateSourceUnavailableException e) {
// Throw the CertificateSourceUnavailableException without wrapping.
throw e;
} catch (Exception e) {
// Wrap all other exception types to an IOException.
throw new IOException("X509Provider: Unexpected IOException:", e);
// Build a key store using the combined stream.
return MtlsKeyStoreUtils.createMtlsKeyStore(certAndPrivateKeyStream);
} catch (CertificateSourceUnavailableException e) {
// Throw the CertificateSourceUnavailableException without wrapping.
throw e;
} catch (Exception e) {
lastException = e;
if (attempt + 1 < CERT_KEY_MATCH_RETRIES) {
LOGGER.log(
Level.FINE,
"X509Provider: Failed to load or verify certificate and private key on attempt "
+ (attempt + 1)
+ ", retrying",
e);
try {
timeService.sleep(CERT_KEY_MATCH_RETRY_INTERVAL_MS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IOException(
"X509Provider: Interrupted while waiting for certificate and private key match.",
interrupted);
}
}
}
}
// Wrap all other exception types to an IOException.
throw new IOException("X509Provider: Unexpected IOException:", lastException);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

Expand All @@ -44,13 +45,20 @@
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.SignatureException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.util.Base64;
import java.util.Collections;
import org.junit.jupiter.api.Test;

Expand Down Expand Up @@ -310,6 +318,97 @@ void createMtlsKeyStore_missingPrivateKey_throws() throws Exception {
assertEquals("private key is missing from certAndKey string", e.getMessage());
}

@Test
void createMtlsKeyStore_mismatchedRsaKeyPairSameKeySize_throws() throws Exception {
// Generate a 2048-bit RSA key whose modulus is strictly smaller than SINGLE_CERT_PATH's
// 2048-bit RSA modulus, guaranteeing that Signature.verify() returns false (rather than
// throwing SignatureException on s >= n).
X509Certificate singleCert =
(X509Certificate)
CertificateFactory.getInstance("X.509")
.generateCertificate(
new ByteArrayInputStream(read(SINGLE_CERT_PATH).getBytes(UTF_8)));
RSAPublicKey certPublicKey = (RSAPublicKey) singleCert.getPublicKey();
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(certPublicKey.getModulus().bitLength());
KeyPair otherRsaKeyPair;
do {
otherRsaKeyPair = kpg.generateKeyPair();
} while (((RSAPublicKey) otherRsaKeyPair.getPublic())
.getModulus()
.compareTo(certPublicKey.getModulus())
>= 0);
String otherRsaKeyPem = toPkcs8Pem(otherRsaKeyPair.getPrivate());

GeneralSecurityException e =
assertThrows(
GeneralSecurityException.class,
() ->
MtlsKeyStoreUtils.createMtlsKeyStore(
stream(read(SINGLE_CERT_PATH), otherRsaKeyPem)));
assertEquals("Certificate and private key do not match", e.getMessage());
assertNull(e.getCause());
}

@Test
void createMtlsKeyStore_mismatchedRsaKeyPairDifferentKeySize_throws() throws Exception {
// Pairing a 1024-bit RSA private key with the 2048-bit RSA SINGLE_CERT_PATH deterministically
// causes Signature.verify() to throw SignatureException ("Bad signature length: got 128 but was
// expecting 256"), which must be translated into GeneralSecurityException("Certificate and
// private key do not match").
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(1024);
KeyPair otherRsaKeyPair = kpg.generateKeyPair();
String otherRsaKeyPem = toPkcs8Pem(otherRsaKeyPair.getPrivate());

GeneralSecurityException e =
assertThrows(
GeneralSecurityException.class,
() ->
MtlsKeyStoreUtils.createMtlsKeyStore(
stream(read(SINGLE_CERT_PATH), otherRsaKeyPem)));
assertEquals("Certificate and private key do not match", e.getMessage());
assertTrue(e.getCause() instanceof SignatureException);
}

@Test
void createMtlsKeyStore_mismatchedEcKeyPair_throws() throws Exception {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
kpg.initialize(256);
KeyPair otherEcKeyPair = kpg.generateKeyPair();
String otherEcKeyPem = toPkcs8Pem(otherEcKeyPair.getPrivate());

GeneralSecurityException e =
assertThrows(
GeneralSecurityException.class,
() ->
MtlsKeyStoreUtils.createMtlsKeyStore(stream(read(CHAIN_CERT_PATH), otherEcKeyPem)));
assertEquals("Certificate and private key do not match", e.getMessage());
assertNull(e.getCause());
}

@Test
void createMtlsKeyStore_mismatchedEcKeyPairDifferentCurve_throws() throws Exception {
// Pairing a 384-bit (secp384r1) EC private key with the 256-bit (secp256r1) EC CHAIN_CERT_PATH.
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
kpg.initialize(384);
KeyPair otherEcKeyPair = kpg.generateKeyPair();
String otherEcKeyPem = toPkcs8Pem(otherEcKeyPair.getPrivate());

GeneralSecurityException e =
assertThrows(
GeneralSecurityException.class,
() ->
MtlsKeyStoreUtils.createMtlsKeyStore(stream(read(CHAIN_CERT_PATH), otherEcKeyPem)));
assertEquals("Certificate and private key do not match", e.getMessage());
}

static String toPkcs8Pem(PrivateKey privateKey) {
return "-----BEGIN PRIVATE KEY-----\n"
+ Base64.getMimeEncoder(64, "\n".getBytes(UTF_8)).encodeToString(privateKey.getEncoded())
+ "\n-----END PRIVATE KEY-----\n";
}

static String read(String path) throws IOException {
return new String(Files.readAllBytes(new File(path).toPath()), UTF_8);
}
Expand Down
Loading
Loading