Skip to content

Commit 4332ffc

Browse files
authoredSep 18, 2024··
feat: Support for CAS server certificate authority. (#2060)
For Cloud SQL instances with CAS enabled, the connector will use the certificate chain of trust reported by the SQL Admin API to validate the instance connection.
1 parent 5dc7e80 commit 4332ffc

11 files changed

+236
-46
lines changed
 

‎core/src/main/java/com/google/cloud/sql/core/Connector.java

+1
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ Socket connect(ConnectionConfig config, long timeoutMs) throws IOException {
118118
SSLSocket socket = (SSLSocket) metadata.getSslContext().getSocketFactory().createSocket();
119119
socket.setKeepAlive(true);
120120
socket.setTcpNoDelay(true);
121+
121122
socket.connect(new InetSocketAddress(instanceIp, serverProxyPort));
122123

123124
try {

‎core/src/main/java/com/google/cloud/sql/core/DefaultConnectionInfoRepository.java

+15-11
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@
4545
import java.security.cert.CertificateFactory;
4646
import java.security.cert.X509Certificate;
4747
import java.time.Instant;
48+
import java.util.ArrayList;
4849
import java.util.Arrays;
4950
import java.util.Base64;
50-
import java.util.Collections;
5151
import java.util.HashMap;
5252
import java.util.List;
5353
import java.util.Map;
@@ -84,10 +84,15 @@ private void checkDatabaseCompatibility(
8484
}
8585

8686
// Creates a Certificate object from a provided string.
87-
private Certificate createCertificate(String cert) throws CertificateException {
87+
private List<Certificate> parseCertificateChain(String cert) throws CertificateException {
8888
byte[] certBytes = cert.getBytes(StandardCharsets.UTF_8);
8989
ByteArrayInputStream certStream = new ByteArrayInputStream(certBytes);
90-
return CertificateFactory.getInstance("X.509").generateCertificate(certStream);
90+
List<Certificate> certificates = new ArrayList<>();
91+
while (certStream.available() > 0) {
92+
Certificate c = CertificateFactory.getInstance("X.509").generateCertificate(certStream);
93+
certificates.add(c);
94+
}
95+
return certificates;
9196
}
9297

9398
private String generatePublicKeyCert(KeyPair keyPair) {
@@ -296,18 +301,17 @@ private InstanceMetadata fetchMetadata(CloudSqlInstanceName instanceName, AuthTy
296301
+ "IP address.",
297302
instanceName.getConnectionName()));
298303
}
299-
300304
// Update the Server CA certificate used to create the SSL connection with the instance.
301305
try {
302-
Certificate instanceCaCertificate =
303-
createCertificate(instanceMetadata.getServerCaCert().getCert());
306+
List<Certificate> instanceCaCertificates =
307+
parseCertificateChain(instanceMetadata.getServerCaCert().getCert());
304308

305309
logger.debug(String.format("[%s] METADATA DONE", instanceName));
306310

307311
return new InstanceMetadata(
308312
instanceName,
309313
ipAddrs,
310-
Collections.singletonList(instanceCaCertificate),
314+
instanceCaCertificates,
311315
"GOOGLE_MANAGED_CAS_CA".equals(instanceMetadata.getServerCaMode()),
312316
instanceMetadata.getDnsName(),
313317
pscEnabled);
@@ -371,7 +375,9 @@ private Certificate fetchEphemeralCertificate(
371375
// Parse the certificate from the response.
372376
Certificate ephemeralCertificate;
373377
try {
374-
ephemeralCertificate = createCertificate(response.getEphemeralCert().getCert());
378+
// The response contains a single certificate. This uses the parseCertificateChain method
379+
// to parse the response, and then uses the first, and only, certificate.
380+
ephemeralCertificate = parseCertificateChain(response.getEphemeralCert().getCert()).get(0);
375381
} catch (CertificateException ex) {
376382
throw new RuntimeException(
377383
String.format(
@@ -407,8 +413,7 @@ private SslData createSslData(
407413
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
408414
kmf.init(authKeyStore, new char[0]);
409415

410-
TrustManagerFactory tmf =
411-
InstanceCheckingTrustManagerFactory.newInstance(instanceName, instanceMetadata);
416+
TrustManagerFactory tmf = InstanceCheckingTrustManagerFactory.newInstance(instanceMetadata);
412417

413418
SSLContext sslContext;
414419

@@ -428,7 +433,6 @@ private SslData createSslData(
428433
sslContext = SSLContext.getInstance("TLSv1.2");
429434
}
430435
}
431-
432436
sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), new SecureRandom());
433437

434438
logger.debug(

‎core/src/main/java/com/google/cloud/sql/core/InstanceCheckingTrustManagerFactory.java

+22-9
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import java.security.KeyStore;
2121
import java.security.KeyStoreException;
2222
import java.security.NoSuchAlgorithmException;
23+
import java.security.cert.Certificate;
2324
import java.security.cert.CertificateException;
2425
import javax.net.ssl.TrustManagerFactory;
2526

@@ -39,32 +40,44 @@
3940
* <p>class ConscryptWorkaroundTrustManager - the workaround for the Conscrypt bug.
4041
*
4142
* <p>class InstanceCheckingTrustManager - delegates TLS checks to the default provider and then
42-
* checks that the Subject CN field contains the Cloud SQL instance ID.
43+
* does custom hostname checking in accordance with these rules:
44+
*
45+
* <p>If the instance supports CAS certificates (instanceMetadata.casEnabled == true), or the
46+
* connection is being made to a PSC endpoint (instanceMetadata.pscEnabled == true) the connector
47+
* should validate that the server certificate subjectAlterantiveNames contains an entry that
48+
* matches instanceMetadata.dnsName.
49+
*
50+
* <p>Otherwise, the connector should check that the Subject CN field contains the Cloud SQL
51+
* instance ID in the form: "project-name:instance-name"
4352
*/
4453
class InstanceCheckingTrustManagerFactory extends TrustManagerFactory {
4554

46-
static InstanceCheckingTrustManagerFactory newInstance(
47-
CloudSqlInstanceName instanceName, InstanceMetadata instanceMetadata)
55+
static TrustManagerFactory newInstance(InstanceMetadata instanceMetadata)
4856
throws NoSuchAlgorithmException, KeyStoreException, CertificateException, IOException {
4957

5058
TrustManagerFactory delegate = TrustManagerFactory.getInstance("X.509");
5159
KeyStore trustedKeyStore = KeyStore.getInstance(KeyStore.getDefaultType());
5260
trustedKeyStore.load(null, null);
53-
trustedKeyStore.setCertificateEntry(
54-
"instance", instanceMetadata.getInstanceCaCertificates().get(0));
5561

56-
InstanceCheckingTrustManagerFactory tmf =
57-
new InstanceCheckingTrustManagerFactory(instanceName, delegate);
62+
// Add all the certificates in the chain of trust to the trust keystore.
63+
for (Certificate cert : instanceMetadata.getInstanceCaCertificates()) {
64+
trustedKeyStore.setCertificateEntry("ca" + cert.hashCode(), cert);
65+
}
5866

67+
// Use a custom trust manager factory that checks the CN against the instance name
68+
// The delegate TrustManagerFactory will check the certificate chain, but will not do
69+
// hostname checking.
70+
InstanceCheckingTrustManagerFactory tmf =
71+
new InstanceCheckingTrustManagerFactory(instanceMetadata, delegate);
5972
tmf.init(trustedKeyStore);
6073

6174
return tmf;
6275
}
6376

6477
private InstanceCheckingTrustManagerFactory(
65-
CloudSqlInstanceName instanceName, TrustManagerFactory delegate) {
78+
InstanceMetadata instanceMetadata, TrustManagerFactory delegate) {
6679
super(
67-
new InstanceCheckingTrustManagerFactorySpi(instanceName, delegate),
80+
new InstanceCheckingTrustManagerFactorySpi(instanceMetadata, delegate),
6881
delegate.getProvider(),
6982
delegate.getAlgorithm());
7083
}

‎core/src/main/java/com/google/cloud/sql/core/InstanceCheckingTrustManagerFactorySpi.java

+4-4
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@
3131
*/
3232
class InstanceCheckingTrustManagerFactorySpi extends TrustManagerFactorySpi {
3333
private final TrustManagerFactory delegate;
34-
private final CloudSqlInstanceName instanceName;
34+
private final InstanceMetadata instanceMetadata;
3535

3636
InstanceCheckingTrustManagerFactorySpi(
37-
CloudSqlInstanceName instanceName, TrustManagerFactory delegate) {
38-
this.instanceName = instanceName;
37+
InstanceMetadata instanceMetadata, TrustManagerFactory delegate) {
38+
this.instanceMetadata = instanceMetadata;
3939
this.delegate = delegate;
4040
}
4141

@@ -65,7 +65,7 @@ protected TrustManager[] engineGetTrustManagers() {
6565
tm = new ConscryptWorkaroundDelegatingTrustManger(tm);
6666
}
6767

68-
delegates[i] = new InstanceCheckingTrustManger(instanceName, tm);
68+
delegates[i] = new InstanceCheckingTrustManger(instanceMetadata, tm);
6969
} else {
7070
delegates[i] = tms[i];
7171
}

‎core/src/main/java/com/google/cloud/sql/core/InstanceCheckingTrustManger.java

+70-4
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
import java.net.Socket;
2020
import java.security.cert.CertificateException;
2121
import java.security.cert.X509Certificate;
22+
import java.util.ArrayList;
23+
import java.util.Collection;
24+
import java.util.List;
2225
import javax.naming.InvalidNameException;
2326
import javax.naming.ldap.LdapName;
2427
import javax.naming.ldap.Rdn;
@@ -37,11 +40,11 @@
3740
*/
3841
class InstanceCheckingTrustManger extends X509ExtendedTrustManager {
3942
private final X509ExtendedTrustManager tm;
40-
private final CloudSqlInstanceName instanceName;
43+
private final InstanceMetadata instanceMetadata;
4144

4245
public InstanceCheckingTrustManger(
43-
CloudSqlInstanceName instanceName, X509ExtendedTrustManager tm) {
44-
this.instanceName = instanceName;
46+
InstanceMetadata instanceMetadata, X509ExtendedTrustManager tm) {
47+
this.instanceMetadata = instanceMetadata;
4548
this.tm = tm;
4649
}
4750

@@ -92,6 +95,66 @@ private void checkCertificateChain(X509Certificate[] chain) throws CertificateEx
9295
throw new CertificateException("Subject is missing");
9396
}
9497

98+
if (instanceMetadata.isCasManagedCertificate() || instanceMetadata.isPscEnabled()) {
99+
checkSan(chain);
100+
} else {
101+
checkCn(chain);
102+
}
103+
}
104+
105+
private void checkSan(X509Certificate[] chain) throws CertificateException {
106+
List<String> sans = getSans(chain[0]);
107+
String dns = instanceMetadata.getDnsName();
108+
if (dns == null || dns.isEmpty()) {
109+
throw new CertificateException(
110+
"Instance metadata for " + instanceMetadata.getInstanceName() + " has an empty dnsName");
111+
}
112+
for (String san : sans) {
113+
if (san.equalsIgnoreCase(dns)) {
114+
return;
115+
}
116+
}
117+
throw new CertificateException(
118+
"Server certificate does not contain expected name '"
119+
+ instanceMetadata.getDnsName()
120+
+ "' for Cloud SQL instance "
121+
+ instanceMetadata.getInstanceName());
122+
}
123+
124+
private List<String> getSans(X509Certificate cert) throws CertificateException {
125+
ArrayList<String> names = new ArrayList<>();
126+
127+
Collection<List<?>> sanAsn1Field = cert.getSubjectAlternativeNames();
128+
if (sanAsn1Field == null) {
129+
return names;
130+
}
131+
132+
for (List item : sanAsn1Field) {
133+
Integer type = (Integer) item.get(0);
134+
// RFC 5280 section 4.2.1.6. "Subject Alternative Name"
135+
// describes the structure of subjectAlternativeName record.
136+
// type == 0 means this contains an "otherName"
137+
// type == 2 means this contains a "dNSName"
138+
if (type == 0 || type == 2) {
139+
Object value = item.get(1);
140+
if (value instanceof byte[]) {
141+
// This would only happen if the customer provided a non-standard JSSE encryption
142+
// provider. The standard JSSE providers all return a list of Strings for the SAN.
143+
// To handle this case, the project would need to add the BouncyCastle crypto library
144+
// as a dependency, and follow the example to decode an ASN1 SAN data structure:
145+
// https://stackoverflow.com/questions/30993879/retrieve-subject-alternative-names-of-x-509-certificate-in-java
146+
throw new UnsupportedOperationException(
147+
"Server certificate SAN field cannot be decoded.");
148+
} else if (value instanceof String) {
149+
names.add((String) value);
150+
}
151+
}
152+
}
153+
return names;
154+
}
155+
156+
private void checkCn(X509Certificate[] chain) throws CertificateException {
157+
95158
String cn = null;
96159

97160
try {
@@ -111,7 +174,10 @@ private void checkCertificateChain(X509Certificate[] chain) throws CertificateEx
111174
}
112175

113176
// parse CN from subject. CN always comes last in the list.
114-
String instName = this.instanceName.getProjectId() + ":" + this.instanceName.getInstanceId();
177+
String instName =
178+
this.instanceMetadata.getInstanceName().getProjectId()
179+
+ ":"
180+
+ this.instanceMetadata.getInstanceName().getInstanceId();
115181
if (!instName.equals(cn)) {
116182
throw new CertificateException(
117183
"Server certificate CN does not match instance name. Server certificate CN="

‎core/src/test/java/com/google/cloud/sql/core/CloudSqlCoreTestingBase.java

+19-5
Original file line numberDiff line numberDiff line change
@@ -133,19 +133,30 @@ public void setup() throws GeneralSecurityException {
133133
}
134134

135135
MockHttpTransport fakeSuccessHttpTransport(Duration certDuration) {
136-
return fakeSuccessHttpTransport(TestKeys.getServerCertPem(), certDuration, null);
136+
return fakeSuccessHttpTransport(TestKeys.getServerCertPem(), certDuration, null, false, false);
137137
}
138138

139139
MockHttpTransport fakeSuccessHttpTransport(Duration certDuration, String baseUrl) {
140-
return fakeSuccessHttpTransport(TestKeys.getServerCertPem(), certDuration, baseUrl);
140+
return fakeSuccessHttpTransport(
141+
TestKeys.getServerCertPem(), certDuration, baseUrl, false, false);
141142
}
142143

143144
MockHttpTransport fakeSuccessHttpTransport(String serverCert, Duration certDuration) {
144-
return fakeSuccessHttpTransport(serverCert, certDuration, null);
145+
return fakeSuccessHttpTransport(serverCert, certDuration, null, false, false);
146+
}
147+
148+
MockHttpTransport fakeSuccessHttpCasTransport(Duration certDuration) {
149+
return fakeSuccessHttpTransport(
150+
TestKeys.getCasServerCertChainPem(), certDuration, null, true, false);
151+
}
152+
153+
MockHttpTransport fakeSuccessHttpPscCasTransport(Duration certDuration) {
154+
return fakeSuccessHttpTransport(
155+
TestKeys.getCasServerCertChainPem(), certDuration, null, true, true);
145156
}
146157

147158
MockHttpTransport fakeSuccessHttpTransport(
148-
String serverCert, Duration certDuration, String baseUrl) {
159+
String serverCert, Duration certDuration, String baseUrl, boolean cas, boolean psc) {
149160
final JsonFactory jsonFactory = new GsonFactory();
150161
return new MockHttpTransport() {
151162
@Override
@@ -167,7 +178,10 @@ public LowLevelHttpResponse execute() throws IOException {
167178
new IpMapping().setIpAddress(PRIVATE_IP).setType("PRIVATE")))
168179
.setServerCaCert(new SslCert().setCert(serverCert))
169180
.setDatabaseVersion("POSTGRES14")
170-
.setRegion("myRegion");
181+
.setRegion("myRegion")
182+
.setPscEnabled(psc ? Boolean.TRUE : null)
183+
.setDnsName(cas || psc ? "db.example.com" : null)
184+
.setServerCaMode(cas ? "GOOGLE_MANAGED_CAS_CA" : null);
171185
settings.setFactory(jsonFactory);
172186
response
173187
.setContent(settings.toPrettyString())

‎core/src/test/java/com/google/cloud/sql/core/ConnectorTest.java

+35
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
import java.net.Socket;
3333
import java.nio.file.Files;
3434
import java.nio.file.Path;
35+
import java.security.PrivateKey;
36+
import java.security.cert.X509Certificate;
3537
import java.time.Duration;
3638
import java.time.Instant;
3739
import java.util.Collections;
@@ -147,6 +149,39 @@ public void create_successfulPublicConnection() throws IOException, InterruptedE
147149
assertThat(readLine(socket)).isEqualTo(SERVER_MESSAGE);
148150
}
149151

152+
@Test
153+
public void create_successfulPublicCasConnection() throws IOException, InterruptedException {
154+
PrivateKey privateKey = TestKeys.getServerKeyPair().getPrivate();
155+
X509Certificate[] cert = TestKeys.getCasServerCertChain();
156+
157+
FakeSslServer sslServer = new FakeSslServer(privateKey, cert);
158+
ConnectionConfig config =
159+
new ConnectionConfig.Builder()
160+
.withCloudSqlInstance("myProject:myRegion:myInstance")
161+
.withIpTypes("PRIMARY")
162+
.build();
163+
164+
int port = sslServer.start(PUBLIC_IP);
165+
166+
ConnectionInfoRepositoryFactory factory =
167+
new StubConnectionInfoRepositoryFactory(fakeSuccessHttpCasTransport(Duration.ZERO));
168+
169+
Connector connector =
170+
new Connector(
171+
config.getConnectorConfig(),
172+
factory,
173+
stubCredentialFactoryProvider.getInstanceCredentialFactory(config.getConnectorConfig()),
174+
defaultExecutor,
175+
clientKeyPair,
176+
10,
177+
TEST_MAX_REFRESH_MS,
178+
port);
179+
180+
Socket socket = connector.connect(config, TEST_MAX_REFRESH_MS);
181+
182+
assertThat(readLine(socket)).isEqualTo(SERVER_MESSAGE);
183+
}
184+
150185
private boolean isWindows() {
151186
String os = System.getProperty("os.name").toLowerCase();
152187
return os.contains("win");

0 commit comments

Comments
 (0)
Please sign in to comment.