diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/configuration/SSLContextFactory.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/configuration/SSLContextFactory.java new file mode 100644 index 000000000..381c59d34 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/configuration/SSLContextFactory.java @@ -0,0 +1,119 @@ +/* + * Copyright 2017-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.configuration; + +import java.io.IOException; +import java.io.InputStream; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.UnrecoverableKeyException; + +import javax.net.ssl.SSLContext; + +import org.apache.http.ssl.SSLContextBuilder; + +import org.springframework.core.io.Resource; + +public class SSLContextFactory { + + private TlsProperties properties; + + public SSLContextFactory(TlsProperties properties) { + this.properties = properties; + } + + public SSLContext createSSLContext() throws GeneralSecurityException, IOException { + SSLContextBuilder builder = new SSLContextBuilder(); + char[] keyPassword = properties.keyPassword(); + KeyStore keyStore = createKeyStore(); + + try { + builder.loadKeyMaterial(keyStore, keyPassword); + } + catch (UnrecoverableKeyException e) { + if (keyPassword.length == 0) { + // Retry if empty password, see + // https://rt.openssl.org/Ticket/Display.html?id=1497&user=guest&pass=guest + builder.loadKeyMaterial(keyStore, new char[] { '\0' }); + } + else { + throw e; + } + } + + KeyStore trust = createTrustStore(); + if (trust != null) { + builder.loadTrustMaterial(trust, null); + } + + return builder.build(); + } + + public KeyStore createKeyStore() throws GeneralSecurityException, IOException { + if (properties.getKeyStore() == null) { + throw new KeyStoreException("Keystore not specified."); + } + if (!properties.getKeyStore().exists()) { + throw new KeyStoreException( + "Keystore not exists: " + properties.getKeyStore()); + } + + KeyStore result = KeyStore.getInstance(properties.getKeyStoreType()); + char[] keyStorePassword = properties.keyStorePassword(); + + try { + loadKeyStore(result, properties.getKeyStore(), keyStorePassword); + } + catch (IOException e) { + // Retry if empty password, see + // https://rt.openssl.org/Ticket/Display.html?id=1497&user=guest&pass=guest + if (keyStorePassword.length == 0) { + loadKeyStore(result, properties.getKeyStore(), new char[] { '\0' }); + } + else { + throw e; + } + } + + return result; + } + + private static void loadKeyStore(KeyStore keyStore, Resource keyStoreResource, + char[] keyStorePassword) throws IOException, GeneralSecurityException { + try (InputStream inputStream = keyStoreResource.getInputStream()) { + keyStore.load(inputStream, keyStorePassword); + } + } + + public KeyStore createTrustStore() throws GeneralSecurityException, IOException { + if (properties.getTrustStore() == null) { + return null; + } + if (!properties.getTrustStore().exists()) { + throw new KeyStoreException( + "KeyStore not exists: " + properties.getTrustStore()); + } + + KeyStore result = KeyStore.getInstance(properties.getTrustStoreType()); + try (InputStream input = properties.getTrustStore().getInputStream()) { + result.load(input, properties.trustStorePassword()); + } + return result; + } + +} diff --git a/spring-cloud-commons/src/main/java/org/springframework/cloud/configuration/TlsProperties.java b/spring-cloud-commons/src/main/java/org/springframework/cloud/configuration/TlsProperties.java new file mode 100644 index 000000000..43aba0e32 --- /dev/null +++ b/spring-cloud-commons/src/main/java/org/springframework/cloud/configuration/TlsProperties.java @@ -0,0 +1,162 @@ +/* + * Copyright 2017-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.configuration; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import javax.annotation.PostConstruct; + +import org.springframework.core.io.Resource; + +/** + * Common client TLS properties. + */ +public class TlsProperties { + + private static final String DEFAULT_STORE_TYPE = "PKCS12"; + + private static final Map EXTENSION_STORE_TYPES = extTypes(); + + private boolean enabled; + + private Resource keyStore; + + private String keyStoreType; + + private String keyStorePassword = ""; + + private String keyPassword = ""; + + private Resource trustStore; + + private String trustStoreType; + + private String trustStorePassword = ""; + + private static Map extTypes() { + Map result = new HashMap<>(); + + result.put("p12", "PKCS12"); + result.put("pfx", "PKCS12"); + result.put("jks", "JKS"); + + return Collections.unmodifiableMap(result); + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public Resource getKeyStore() { + return keyStore; + } + + public void setKeyStore(Resource keyStore) { + this.keyStore = keyStore; + } + + public String getKeyStoreType() { + return keyStoreType; + } + + public void setKeyStoreType(String keyStoreType) { + this.keyStoreType = keyStoreType; + } + + public String getKeyStorePassword() { + return keyStorePassword; + } + + public void setKeyStorePassword(String keyStorePassword) { + this.keyStorePassword = keyStorePassword; + } + + public char[] keyStorePassword() { + return keyStorePassword.toCharArray(); + } + + public String getKeyPassword() { + return keyPassword; + } + + public void setKeyPassword(String keyPassword) { + this.keyPassword = keyPassword; + } + + public char[] keyPassword() { + return keyPassword.toCharArray(); + } + + public Resource getTrustStore() { + return trustStore; + } + + public void setTrustStore(Resource trustStore) { + this.trustStore = trustStore; + } + + public String getTrustStoreType() { + return trustStoreType; + } + + public void setTrustStoreType(String trustStoreType) { + this.trustStoreType = trustStoreType; + } + + public String getTrustStorePassword() { + return trustStorePassword; + } + + public void setTrustStorePassword(String trustStorePassword) { + this.trustStorePassword = trustStorePassword; + } + + public char[] trustStorePassword() { + return trustStorePassword.toCharArray(); + } + + @PostConstruct + public void postConstruct() { + if (keyStore != null && keyStoreType == null) { + keyStoreType = storeTypeOf(keyStore); + } + if (trustStore != null && trustStoreType == null) { + trustStoreType = storeTypeOf(trustStore); + } + } + + private String storeTypeOf(Resource resource) { + String extension = fileExtensionOf(resource); + String type = EXTENSION_STORE_TYPES.get(extension); + + return (type == null) ? DEFAULT_STORE_TYPE : type; + } + + private String fileExtensionOf(Resource resource) { + String name = resource.getFilename(); + int index = name.lastIndexOf('.'); + + return index < 0 ? "" : name.substring(index + 1).toLowerCase(); + } + +} diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/configuration/KeyAndCert.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/configuration/KeyAndCert.java new file mode 100644 index 000000000..e2c7e0066 --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/configuration/KeyAndCert.java @@ -0,0 +1,94 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.configuration; + +import java.security.KeyPair; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; + +public class KeyAndCert { + + private KeyPair keyPair; + + private X509Certificate certificate; + + public KeyAndCert(KeyPair keyPair, X509Certificate certificate) { + this.keyPair = keyPair; + this.certificate = certificate; + } + + public KeyPair keyPair() { + return keyPair; + } + + public PublicKey publicKey() { + return keyPair.getPublic(); + } + + public PrivateKey privateKey() { + return keyPair.getPrivate(); + } + + public X509Certificate certificate() { + return certificate; + } + + public String subject() { + String dn = certificate.getSubjectDN().getName(); + int index = dn.indexOf('='); + return dn.substring(index + 1); + } + + public KeyAndCert sign(String subject) throws Exception { + KeyTool tool = new KeyTool(); + return tool.signCertificate(subject, this); + } + + public KeyAndCert sign(KeyPair keyPair, String subject) throws Exception { + KeyTool tool = new KeyTool(); + return tool.signCertificate(keyPair, subject, this); + } + + public KeyStore storeKeyAndCert(String keyPassword) throws Exception { + KeyStore result = KeyStore.getInstance("PKCS12"); + result.load(null); + + result.setKeyEntry(subject(), keyPair.getPrivate(), keyPassword.toCharArray(), + certChain()); + return result; + } + + private Certificate[] certChain() { + return new Certificate[] { certificate() }; + } + + public KeyStore storeCert() throws Exception { + return storeCert("PKCS12"); + } + + public KeyStore storeCert(String storeType) throws Exception { + KeyStore result = KeyStore.getInstance(storeType); + result.load(null); + + result.setCertificateEntry(subject(), certificate()); + return result; + } + +} diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/configuration/KeyTool.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/configuration/KeyTool.java new file mode 100644 index 000000000..48d4c888c --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/configuration/KeyTool.java @@ -0,0 +1,126 @@ +/* + * Copyright 2018-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.configuration; + +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.Date; + +import org.bouncycastle.asn1.DERSequence; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +public class KeyTool { + + private static final long ONE_DAY = 1000L * 60L * 60L * 24L; + + private static final long TEN_YEARS = ONE_DAY * 365L * 10L; + + public KeyAndCert createCA(String ca) throws Exception { + KeyPair keyPair = createKeyPair(); + X509Certificate certificate = createCert(keyPair, ca); + return new KeyAndCert(keyPair, certificate); + } + + public KeyAndCert signCertificate(String subject, KeyAndCert signer) + throws Exception { + return signCertificate(createKeyPair(), subject, signer); + } + + public KeyAndCert signCertificate(KeyPair keyPair, String subject, KeyAndCert signer) + throws Exception { + X509Certificate certificate = createCert(keyPair.getPublic(), signer.privateKey(), + signer.subject(), subject); + KeyAndCert result = new KeyAndCert(keyPair, certificate); + + return result; + } + + public KeyPair createKeyPair() throws Exception { + return createKeyPair(1024); + } + + public KeyPair createKeyPair(int keySize) throws Exception { + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(keySize, new SecureRandom()); + return gen.generateKeyPair(); + } + + public X509Certificate createCert(KeyPair keyPair, String ca) throws Exception { + JcaX509v3CertificateBuilder builder = certBuilder(keyPair.getPublic(), ca, ca); + builder.addExtension(Extension.keyUsage, true, + new KeyUsage(KeyUsage.keyCertSign)); + builder.addExtension(Extension.basicConstraints, false, + new BasicConstraints(true)); + + return signCert(builder, keyPair.getPrivate()); + } + + public X509Certificate createCert(PublicKey publicKey, PrivateKey privateKey, + String issuer, String subject) throws Exception { + JcaX509v3CertificateBuilder builder = certBuilder(publicKey, issuer, subject); + builder.addExtension(Extension.keyUsage, true, + new KeyUsage(KeyUsage.digitalSignature)); + builder.addExtension(Extension.basicConstraints, false, + new BasicConstraints(false)); + + GeneralName[] names = new GeneralName[] { + new GeneralName(GeneralName.dNSName, "localhost") }; + builder.addExtension(Extension.subjectAlternativeName, false, + GeneralNames.getInstance(new DERSequence(names))); + + return signCert(builder, privateKey); + } + + private JcaX509v3CertificateBuilder certBuilder(PublicKey publicKey, String issuer, + String subject) { + X500Name issuerName = new X500Name(String.format("dc=%s", issuer)); + X500Name subjectName = new X500Name(String.format("dc=%s", subject)); + + long now = System.currentTimeMillis(); + BigInteger serialNum = BigInteger.valueOf(now); + Date notBefore = new Date(now - ONE_DAY); + Date notAfter = new Date(now + TEN_YEARS); + + return new JcaX509v3CertificateBuilder(issuerName, serialNum, notBefore, notAfter, + subjectName, publicKey); + } + + private X509Certificate signCert(JcaX509v3CertificateBuilder builder, + PrivateKey privateKey) throws Exception { + ContentSigner signer = new JcaContentSignerBuilder("SHA256WithRSA") + .build(privateKey); + X509CertificateHolder holder = builder.build(signer); + + return new JcaX509CertificateConverter().getCertificate(holder); + } + +} diff --git a/spring-cloud-commons/src/test/java/org/springframework/cloud/configuration/SSHContextFactoryTests.java b/spring-cloud-commons/src/test/java/org/springframework/cloud/configuration/SSHContextFactoryTests.java new file mode 100644 index 000000000..049a64810 --- /dev/null +++ b/spring-cloud-commons/src/test/java/org/springframework/cloud/configuration/SSHContextFactoryTests.java @@ -0,0 +1,142 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.configuration; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.security.GeneralSecurityException; +import java.security.Key; +import java.security.KeyStore; +import java.security.cert.Certificate; + +import javax.net.ssl.SSLContext; + +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; + +import static org.assertj.core.api.Assertions.assertThat; + +public class SSHContextFactoryTests { + + private static final String KEY_STORE_PASSWORD = "test-key-store-password"; + + private static final String KEY_PASSWORD = "test-key-password"; + + private static KeyAndCert ca; + + private static KeyAndCert cert; + + private static File keyStore; + + private static File trustStore; + + private TlsProperties properties; + + @BeforeClass + public static void createKeyStoreAndTrustStore() throws Exception { + KeyTool tool = new KeyTool(); + + ca = tool.createCA("MyCA"); + cert = ca.sign("MyCert"); + + keyStore = saveKeyAndCert(cert); + trustStore = saveCert(ca); + } + + private static File saveKeyAndCert(KeyAndCert keyCert) throws Exception { + return saveKeyStore(keyCert.subject(), + () -> keyCert.storeKeyAndCert(KEY_PASSWORD)); + } + + private static File saveCert(KeyAndCert keyCert) throws Exception { + return saveKeyStore(keyCert.subject(), () -> keyCert.storeCert()); + } + + private static File saveKeyStore(String prefix, KeyStoreSupplier func) + throws Exception { + File result = File.createTempFile(prefix, ".p12"); + result.deleteOnExit(); + + try (OutputStream output = new FileOutputStream(result)) { + KeyStore store = func.createKeyStore(); + store.store(output, KEY_STORE_PASSWORD.toCharArray()); + } + return result; + } + + @Before + public void createProperties() { + properties = new TlsProperties(); + + properties.setEnabled(true); + properties.setKeyStore(resourceOf(keyStore)); + properties.setKeyStorePassword(KEY_STORE_PASSWORD); + properties.setKeyPassword(KEY_PASSWORD); + properties.setTrustStore(resourceOf(trustStore)); + properties.setTrustStorePassword(KEY_STORE_PASSWORD); + + properties.postConstruct(); + } + + private Resource resourceOf(File file) { + return new FileSystemResource(file); + } + + @Test + public void createKeyStoreFromProperties() + throws GeneralSecurityException, IOException { + SSLContextFactory factory = new SSLContextFactory(properties); + KeyStore store = factory.createKeyStore(); + + Certificate c = store.getCertificate("MyCert"); + assertThat(c).isEqualTo(cert.certificate()); + + Key key = store.getKey("MyCert", KEY_PASSWORD.toCharArray()); + assertThat(key).isEqualTo(cert.privateKey()); + } + + @Test + public void createTrustStoreFromProperties() + throws GeneralSecurityException, IOException { + SSLContextFactory factory = new SSLContextFactory(properties); + KeyStore store = factory.createTrustStore(); + + Certificate c = store.getCertificate("MyCA"); + assertThat(c).isEqualTo(ca.certificate()); + } + + @Test + public void createSSLContextFromProperties() + throws GeneralSecurityException, IOException { + SSLContextFactory factory = new SSLContextFactory(properties); + SSLContext context = factory.createSSLContext(); + assertThat(context).isNotNull(); + } + + interface KeyStoreSupplier { + + KeyStore createKeyStore() throws Exception; + + } + +}