From fbc06149c0d5ed05a74348a69161350310ab4c96 Mon Sep 17 00:00:00 2001 From: Julian Hjortshoj Date: Thu, 30 Mar 2023 17:00:04 -0700 Subject: [PATCH] add Eureka TLS/mTLS support - read `ca.crt`, `tls.crt` and `tls.key` optional properties in eureka bindings - process them into keystore and truststore, and wire up eureka client/instance properties - place the trust-store and keystore in the `$TMPDIR` and reference them by absolute path. - add a `pem` package for parsing and converting PEM encoded RSA keys and certificates. (This package may eventually be replaced by the PEM package in spring-boot, but not until 3.1 is the minimum supported boot version. The package here is copied from spring-boot but simplified and made Java 8 compatible.) Co-authored-by: Dave Walter Co-authored-by: Andrew Wittrock Co-authored-by: Matt Royal Co-authored-by: Paul Aly --- README.md | 29 ++- .../EurekaBindingsPropertiesProcessor.java | 81 +++++- .../boot/pem/PemCertificateParser.java | 95 +++++++ .../cloud/bindings/boot/pem/PemContent.java | 64 +++++ .../boot/pem/PemPrivateKeyParser.java | 245 ++++++++++++++++++ .../bindings/boot/pem/PemSslStoreHelper.java | 63 +++++ .../cloud/bindings/boot/pem/package-info.java | 25 ++ ...EurekaBindingsPropertiesProcessorTest.java | 222 +++++++++++++++- .../cloud/bindings/boot/TestHelper.java | 17 ++ .../boot/pem/PemCertificateParserTests.java | 56 ++++ .../bindings/boot/pem/PemContentTests.java | 83 ++++++ .../boot/pem/PemPrivateKeyParserTests.java | 61 +++++ .../boot/pem/PemSslStoreHelperTests.java | 65 +++++ src/test/resources/pem/test-banner.txt | 1 + src/test/resources/pem/test-cert-chain.pem | 32 +++ src/test/resources/pem/test-cert.pem | 17 ++ src/test/resources/pem/test-ec-key.pem | 5 + src/test/resources/pem/test-key.pem | 28 ++ 18 files changed, 1169 insertions(+), 20 deletions(-) create mode 100644 src/main/java/org/springframework/cloud/bindings/boot/pem/PemCertificateParser.java create mode 100644 src/main/java/org/springframework/cloud/bindings/boot/pem/PemContent.java create mode 100644 src/main/java/org/springframework/cloud/bindings/boot/pem/PemPrivateKeyParser.java create mode 100644 src/main/java/org/springframework/cloud/bindings/boot/pem/PemSslStoreHelper.java create mode 100644 src/main/java/org/springframework/cloud/bindings/boot/pem/package-info.java create mode 100644 src/test/java/org/springframework/cloud/bindings/boot/TestHelper.java create mode 100644 src/test/java/org/springframework/cloud/bindings/boot/pem/PemCertificateParserTests.java create mode 100644 src/test/java/org/springframework/cloud/bindings/boot/pem/PemContentTests.java create mode 100644 src/test/java/org/springframework/cloud/bindings/boot/pem/PemPrivateKeyParserTests.java create mode 100644 src/test/java/org/springframework/cloud/bindings/boot/pem/PemSslStoreHelperTests.java create mode 100644 src/test/resources/pem/test-banner.txt create mode 100644 src/test/resources/pem/test-cert-chain.pem create mode 100644 src/test/resources/pem/test-cert.pem create mode 100644 src/test/resources/pem/test-ec-key.pem create mode 100644 src/test/resources/pem/test-key.pem diff --git a/README.md b/README.md index 7121cb6..8a0b947 100644 --- a/README.md +++ b/README.md @@ -270,16 +270,31 @@ Disable Property: `org.springframework.cloud.bindings.boot.config.enable` | `spring.cloud.config.client.oauth2.clientSecret` | `{client-secret}` | | `spring.cloud.config.client.oauth2.accessTokenUri` | `{access-token-uri}` | -### SCS Eureka +## SCS Eureka + Type: `eureka` Disable Property: `org.springframework.cloud.bindings.boot.eureka.enable` -| Property | Value | -| --------------------------------------- | -------------------- | -| `eureka.client.oauth2.client-id` | `{client-id}` | -| `eureka.client.oauth2.access-token-uri` | `{access-token-uri}` | -| `eureka.client.region` | `default` | -| `eureka.client.serviceUrl.defaultZone` | `{uri}/eureka/` | +| Property | Value | +|------------------------------------------|--------------------------------------------------------------------| +| `eureka.client.oauth2.client-id` | `{client-id}` | +| `eureka.client.oauth2.access-token-uri` | `{access-token-uri}` | +| `eureka.client.region` | `default` | +| `eureka.client.serviceUrl.defaultZone` | `{uri}/eureka/` | +| `eureka.client.tls.enabled` | `true` when `{ca.crt}` is set | +| `eureka.client.tls.trust-store` | derived from `{ca.crt}` | +| `eureka.client.tls.trust-store-type` | `"PKCS12"` when `{ca.crt}` is set | +| `eureka.client.tls.trust-store-password` | random string when `{ca.crt}` is set | +| `eureka.instance.preferIpAddress` | `true` when `{ca.crt}` is set[^1] | +| `eureka.client.tls.key-alias` | `"eureka"` when `{ca.crt}`, `{tls.crt}` and `{tls.key}` are set | +| `eureka.client.tls.key-store` | derived from `{tls.crt}` and `{tls.key}` | +| `eureka.client.tls.key-store-type` | `"PKCS12"` when `{ca.crt}`, `{tls.crt}` and `{tls.key}` are set | +| `eureka.client.tls.key-store-password` | random string when `{ca.crt}`, `{tls.crt}` and `{tls.key}` are set | +| `eureka.client.tls.key-password` | `""` when `{ca.crt}`, `{tls.crt}` and `{tls.key}` are set | + +> [^1]: Note that `eureka.instance.perferIpAddress` will not be overwritten by the Eureka auto-configuration if it is +> already set in the environment. Applications wishing to set an explicit endpoint with `eureka.instance.host` can +> set `eureka.instance.perferIpAddress` to `false` and it will not be overwritten. ## Spring Security OAuth2 Type: `oauth2` diff --git a/src/main/java/org/springframework/cloud/bindings/boot/EurekaBindingsPropertiesProcessor.java b/src/main/java/org/springframework/cloud/bindings/boot/EurekaBindingsPropertiesProcessor.java index ec05737..5c45e88 100644 --- a/src/main/java/org/springframework/cloud/bindings/boot/EurekaBindingsPropertiesProcessor.java +++ b/src/main/java/org/springframework/cloud/bindings/boot/EurekaBindingsPropertiesProcessor.java @@ -19,8 +19,15 @@ package org.springframework.cloud.bindings.boot; import org.springframework.cloud.bindings.Binding; import org.springframework.cloud.bindings.Bindings; import org.springframework.core.env.Environment; +import org.springframework.cloud.bindings.boot.pem.PemSslStoreHelper; +import org.springframework.util.StringUtils; +import java.io.*; +import java.nio.file.Paths; +import java.security.*; +import java.security.cert.CertificateException; import java.util.Map; +import java.util.Random; import static org.springframework.cloud.bindings.boot.Guards.isTypeEnabled; @@ -40,16 +47,84 @@ final class EurekaBindingsPropertiesProcessor implements BindingsPropertiesProce } bindings.filterBindings(TYPE).forEach(binding -> { - MapMapper map = new MapMapper(binding.getSecret(), properties); + Map secret = binding.getSecret(); + MapMapper map = new MapMapper(secret, properties); map.from("client-id").to("eureka.client.oauth2.client-id"); map.from("access-token-uri").to("eureka.client.oauth2.access-token-uri"); map.from("uri").to("eureka.client.serviceUrl.defaultZone", (uri) -> String.format("%s/eureka/", uri) ); - properties.put("eureka.client.region", "default"); - }); + String caCert = secret.get("ca.crt"); + if (caCert != null && !caCert.isEmpty()) { + // generally apps using TLS bindings will be running in k8s where the host name is not meaningful, + // but we don't want to override the endpoint behavior the app has already set, in case they want to + // explicitly set eureka.instance.hostname to route traffic through normal ingress. + if (! environment.containsProperty("eureka.instance.preferIpAddress")) { + properties.put("eureka.instance.preferIpAddress", true); + } + + Random random = new Random(); + String generatedPassword = random.ints(97 /* letter a */, 122 /* letter z */ + 1) + .limit(10) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString(); + + // Create a trust store from the CA cert + String trustFilePath = Paths.get(System.getProperty("java.io.tmpdir"), "client-truststore.p12").toString(); + KeyStore trustStore = PemSslStoreHelper.createKeyStore("trust", "PKCS12", caCert, null, "rootca"); + createStoreFile("truststore", generatedPassword, trustFilePath, trustStore); + properties.put("eureka.client.tls.enabled", true); + properties.put("eureka.client.tls.trust-store", "file:"+trustFilePath); + properties.put("eureka.client.tls.trust-store-type", "PKCS12"); + properties.put("eureka.client.tls.trust-store-password", generatedPassword); + + // When tls.crt and tls.key are set, enable mTLS for Eureka + String clientKey = secret.get("tls.key"); + String clientCert = secret.get("tls.crt"); + if (StringUtils.hasText(clientCert) != StringUtils.hasText(clientKey)) { + throw new IllegalArgumentException("binding secret error: tls.key and tls.crt must both be set if either is set"); + } + if (clientKey != null && !clientKey.isEmpty()) { + + // Create a keystore + String keyFilePath = Paths.get(System.getProperty("java.io.tmpdir"), "client-keystore.p12").toString(); + KeyStore keyStore = PemSslStoreHelper.createKeyStore("key", "PKCS12", clientCert, clientKey, "eureka"); + createStoreFile("keystore", generatedPassword, keyFilePath, keyStore); + properties.put("eureka.client.tls.key-alias", "eureka"); + properties.put("eureka.client.tls.key-store", "file:" + keyFilePath); + properties.put("eureka.client.tls.key-store-type", "PKCS12"); + properties.put("eureka.client.tls.key-store-password", generatedPassword); + properties.put("eureka.client.tls.key-password", ""); + } + } + }); + } + + private static void createStoreFile(String storeType, String generatedPassword, String filePath, KeyStore ks) { + try { + FileOutputStream fos = new FileOutputStream(filePath); + try { + ks.store(fos, generatedPassword.toCharArray()); + } catch (KeyStoreException e) { + throw new IllegalStateException("Unable to write " + storeType, e); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Cryptographic algorithm not available", e); + } catch (CertificateException e) { + throw new IllegalStateException("Unable to process certificate", e); + } catch (IOException e) { + throw new IllegalStateException("Unable to create " + storeType, e); + } finally { + try { + fos.close(); + } catch (IOException e) { + throw new IllegalStateException("Unable to close " + storeType + " output file", e); + } + } + } catch (FileNotFoundException e) { + throw new IllegalStateException("Unable to open " + storeType + " output file", e); + } } } diff --git a/src/main/java/org/springframework/cloud/bindings/boot/pem/PemCertificateParser.java b/src/main/java/org/springframework/cloud/bindings/boot/pem/PemCertificateParser.java new file mode 100644 index 0000000..31966ee --- /dev/null +++ b/src/main/java/org/springframework/cloud/bindings/boot/pem/PemCertificateParser.java @@ -0,0 +1,95 @@ +/* + * Copyright 2012-2023 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.bindings.boot.pem; + +import java.io.ByteArrayInputStream; +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.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parser for X.509 certificates in PEM format. + * + * @author Scott Frederick + * @author Phillip Webb + */ +final class PemCertificateParser { + + private static final String HEADER = "-+BEGIN\\s+.*CERTIFICATE[^-]*-+(?:\\s|\\r|\\n)+"; + + private static final String BASE64_TEXT = "([a-z0-9+/=\\r\\n]+)"; + + private static final String FOOTER = "-+END\\s+.*CERTIFICATE[^-]*-+"; + + private static final Pattern PATTERN = Pattern.compile(HEADER + BASE64_TEXT + FOOTER, Pattern.CASE_INSENSITIVE); + + private PemCertificateParser() { + } + + /** + * Parse certificates from the specified string. + * @param certificates the certificates to parse + * @return the parsed certificates + */ + static X509Certificate[] parse(String certificates) { + if (certificates == null) { + return null; + } + CertificateFactory factory = getCertificateFactory(); + List certs = new ArrayList<>(); + readCertificates(certificates, factory, certs::add); + return (!certs.isEmpty()) ? certs.stream().toArray(X509Certificate[]::new) : null; + } + + private static CertificateFactory getCertificateFactory() { + try { + return CertificateFactory.getInstance("X.509"); + } + catch (CertificateException ex) { + throw new IllegalStateException("Unable to get X.509 certificate factory", ex); + } + } + + private static void readCertificates(String text, CertificateFactory factory, Consumer consumer) { + try { + Matcher matcher = PATTERN.matcher(text); + while (matcher.find()) { + String encodedText = matcher.group(1); + byte[] decodedBytes = decodeBase64(encodedText); + ByteArrayInputStream inputStream = new ByteArrayInputStream(decodedBytes); + while (inputStream.available() > 0) { + consumer.accept((X509Certificate) factory.generateCertificate(inputStream)); + } + } + } + catch (CertificateException ex) { + throw new IllegalStateException("Error reading certificate: " + ex.getMessage(), ex); + } + } + + private static byte[] decodeBase64(String content) { + byte[] bytes = content.replaceAll("\r", "").replaceAll("\n", "").getBytes(); + return Base64.getDecoder().decode(bytes); + } + +} diff --git a/src/main/java/org/springframework/cloud/bindings/boot/pem/PemContent.java b/src/main/java/org/springframework/cloud/bindings/boot/pem/PemContent.java new file mode 100644 index 0000000..16bb137 --- /dev/null +++ b/src/main/java/org/springframework/cloud/bindings/boot/pem/PemContent.java @@ -0,0 +1,64 @@ +/* + * Copyright 2012-2023 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.bindings.boot.pem; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.regex.Pattern; + +import org.springframework.util.FileCopyUtils; +import org.springframework.util.ResourceUtils; + +/** + * Utility to load PEM content. + * + * @author Scott Frederick + * @author Phillip Webb + */ +final class PemContent { + + private static final Pattern PEM_HEADER = Pattern.compile("-+BEGIN\\s+[^-]*-+", Pattern.CASE_INSENSITIVE); + + private static final Pattern PEM_FOOTER = Pattern.compile("-+END\\s+[^-]*-+", Pattern.CASE_INSENSITIVE); + + private PemContent() { + } + + static String load(String content) { + if (content == null || isPemContent(content)) { + return content; + } + try { + URL url = ResourceUtils.getURL(content); + try (Reader reader = new InputStreamReader(url.openStream(), StandardCharsets.UTF_8)) { + return FileCopyUtils.copyToString(reader); + } + } + catch (IOException ex) { + throw new IllegalStateException( + "Error reading certificate or key from file '" + content + "':" + ex.getMessage(), ex); + } + } + + private static boolean isPemContent(String content) { + return content != null && PEM_HEADER.matcher(content).find() && PEM_FOOTER.matcher(content).find(); + } + +} diff --git a/src/main/java/org/springframework/cloud/bindings/boot/pem/PemPrivateKeyParser.java b/src/main/java/org/springframework/cloud/bindings/boot/pem/PemPrivateKeyParser.java new file mode 100644 index 0000000..78e86b2 --- /dev/null +++ b/src/main/java/org/springframework/cloud/bindings/boot/pem/PemPrivateKeyParser.java @@ -0,0 +1,245 @@ +/* + * Copyright 2012-2023 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.bindings.boot.pem; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parser for PKCS private key files in PEM format. + * + * @author Scott Frederick + * @author Phillip Webb + */ +final class PemPrivateKeyParser { + + private static final String PKCS1_HEADER = "-+BEGIN\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+"; + + private static final String PKCS1_FOOTER = "-+END\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+"; + + private static final String PKCS8_HEADER = "-+BEGIN\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+"; + + private static final String PKCS8_FOOTER = "-+END\\s+PRIVATE\\s+KEY[^-]*-+"; + + private static final String EC_HEADER = "-+BEGIN\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+"; + + private static final String EC_FOOTER = "-+END\\s+EC\\s+PRIVATE\\s+KEY[^-]*-+"; + + private static final String BASE64_TEXT = "([a-z0-9+/=\\r\\n]+)"; + + private static final List PEM_PARSERS; + static { + List parsers = new ArrayList<>(); + parsers.add(new PemParser(PKCS1_HEADER, PKCS1_FOOTER, "RSA", PemPrivateKeyParser::createKeySpecForPkcs1)); + parsers.add(new PemParser(EC_HEADER, EC_FOOTER, "EC", PemPrivateKeyParser::createKeySpecForEc)); + parsers.add(new PemParser(PKCS8_HEADER, PKCS8_FOOTER, "RSA", PKCS8EncodedKeySpec::new)); + PEM_PARSERS = Collections.unmodifiableList(parsers); + } + + /** + * ASN.1 encoded object identifier {@literal 1.2.840.113549.1.1.1}. + */ + private static final int[] RSA_ALGORITHM = { 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01 }; + + /** + * ASN.1 encoded object identifier {@literal 1.2.840.10045.2.1}. + */ + private static final int[] EC_ALGORITHM = { 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01 }; + + /** + * ASN.1 encoded object identifier {@literal 1.3.132.0.34}. + */ + private static final int[] EC_PARAMETERS = { 0x2b, 0x81, 0x04, 0x00, 0x22 }; + + private PemPrivateKeyParser() { + } + + private static PKCS8EncodedKeySpec createKeySpecForPkcs1(byte[] bytes) { + return createKeySpecForAlgorithm(bytes, RSA_ALGORITHM, null); + } + + private static PKCS8EncodedKeySpec createKeySpecForEc(byte[] bytes) { + return createKeySpecForAlgorithm(bytes, EC_ALGORITHM, EC_PARAMETERS); + } + + private static PKCS8EncodedKeySpec createKeySpecForAlgorithm(byte[] bytes, int[] algorithm, int[] parameters) { + try { + DerEncoder encoder = new DerEncoder(); + encoder.integer(0x00); // Version 0 + DerEncoder algorithmIdentifier = new DerEncoder(); + algorithmIdentifier.objectIdentifier(algorithm); + algorithmIdentifier.objectIdentifier(parameters); + byte[] byteArray = algorithmIdentifier.toByteArray(); + encoder.sequence(byteArray); + encoder.octetString(bytes); + return new PKCS8EncodedKeySpec(encoder.toSequence()); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + /** + * Parse a private key from the specified string. + * @param key the private key to parse + * @return the parsed private key + */ + static PrivateKey parse(String key) { + if (key == null) { + return null; + } + try { + for (PemParser pemParser : PEM_PARSERS) { + PrivateKey privateKey = pemParser.parse(key); + if (privateKey != null) { + return privateKey; + } + } + throw new IllegalStateException("Unrecognized private key format"); + } + catch (Exception ex) { + throw new IllegalStateException("Error loading private key file: " + ex.getMessage(), ex); + } + } + + /** + * Parser for a specific PEM format. + */ + private static class PemParser { + + private final Pattern pattern; + + private final String algorithm; + + private final Function keySpecFactory; + + PemParser(String header, String footer, String algorithm, + Function keySpecFactory) { + this.pattern = Pattern.compile(header + BASE64_TEXT + footer, Pattern.CASE_INSENSITIVE); + this.algorithm = algorithm; + this.keySpecFactory = keySpecFactory; + } + + PrivateKey parse(String text) { + Matcher matcher = this.pattern.matcher(text); + return (!matcher.find()) ? null : parse(decodeBase64(matcher.group(1))); + } + + private static byte[] decodeBase64(String content) { + byte[] contentBytes = content.replaceAll("\r", "").replaceAll("\n", "").getBytes(); + return Base64.getDecoder().decode(contentBytes); + } + + private PrivateKey parse(byte[] bytes) { + try { + PKCS8EncodedKeySpec keySpec = this.keySpecFactory.apply(bytes); + KeyFactory keyFactory = KeyFactory.getInstance(this.algorithm); + return keyFactory.generatePrivate(keySpec); + } + catch (GeneralSecurityException ex) { + throw new IllegalArgumentException("Unexpected key format", ex); + } + } + + } + + /** + * Simple ASN.1 DER encoder. + */ + static class DerEncoder { + + private final ByteArrayOutputStream stream = new ByteArrayOutputStream(); + + void objectIdentifier(int... encodedObjectIdentifier) throws IOException { + int code = (encodedObjectIdentifier != null) ? 0x06 : 0x05; + codeLengthBytes(code, bytes(encodedObjectIdentifier)); + } + + void integer(int... encodedInteger) throws IOException { + codeLengthBytes(0x02, bytes(encodedInteger)); + } + + void octetString(byte[] bytes) throws IOException { + codeLengthBytes(0x04, bytes); + } + + void sequence(int... elements) throws IOException { + sequence(bytes(elements)); + } + + void sequence(byte[] bytes) throws IOException { + codeLengthBytes(0x30, bytes); + } + + void codeLengthBytes(int code, byte[] bytes) throws IOException { + this.stream.write(code); + int length = (bytes != null) ? bytes.length : 0; + if (length <= 127) { + this.stream.write(length & 0xFF); + } + else { + ByteArrayOutputStream lengthStream = new ByteArrayOutputStream(); + while (length != 0) { + lengthStream.write(length & 0xFF); + length = length >> 8; + } + byte[] lengthBytes = lengthStream.toByteArray(); + this.stream.write(0x80 | lengthBytes.length); + for (int i = lengthBytes.length - 1; i >= 0; i--) { + this.stream.write(lengthBytes[i]); + } + } + if (bytes != null) { + this.stream.write(bytes); + } + } + + private static byte[] bytes(int... elements) { + if (elements == null) { + return null; + } + byte[] result = new byte[elements.length]; + for (int i = 0; i < elements.length; i++) { + result[i] = (byte) elements[i]; + } + return result; + } + + byte[] toSequence() throws IOException { + DerEncoder sequenceEncoder = new DerEncoder(); + sequenceEncoder.sequence(toByteArray()); + return sequenceEncoder.toByteArray(); + } + + byte[] toByteArray() { + return this.stream.toByteArray(); + } + + } + +} diff --git a/src/main/java/org/springframework/cloud/bindings/boot/pem/PemSslStoreHelper.java b/src/main/java/org/springframework/cloud/bindings/boot/pem/PemSslStoreHelper.java new file mode 100644 index 0000000..a4cb089 --- /dev/null +++ b/src/main/java/org/springframework/cloud/bindings/boot/pem/PemSslStoreHelper.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-2023 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.bindings.boot.pem; + +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; + +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * helper for creating stores from PEM-encoded certificates and private keys. + */ +public class PemSslStoreHelper { + private static final String DEFAULT_KEY_ALIAS = "ssl"; + public static KeyStore createKeyStore(String name, String storeType, String certificate, String privateKey, String keyAlias) { + try { + Assert.notNull(certificate, "CertificateContent must not be null"); + String type = StringUtils.hasText(storeType) ? storeType : KeyStore.getDefaultType(); + KeyStore store = KeyStore.getInstance(type); + store.load(null); + String certificateContent = PemContent.load(certificate); + String privateKeyContent = PemContent.load(privateKey); + X509Certificate[] certificates = PemCertificateParser.parse(certificateContent); + PrivateKey pk = PemPrivateKeyParser.parse(privateKeyContent); + addCertificates(store, certificates, pk, keyAlias); + return store; + } + catch (Exception ex) { + throw new IllegalStateException(String.format("Unable to create %s store: %s", name, ex.getMessage()), ex); + } + } + + private static void addCertificates(KeyStore keyStore, X509Certificate[] certificates, PrivateKey privateKey, String keyAlias) + throws KeyStoreException { + String alias = (keyAlias != null) ? keyAlias : DEFAULT_KEY_ALIAS; + if (privateKey != null) { + keyStore.setKeyEntry(alias, privateKey, null, certificates); + } + else { + for (int index = 0; index < certificates.length; index++) { + keyStore.setCertificateEntry(alias + "-" + index, certificates[index]); + } + } + } + +} diff --git a/src/main/java/org/springframework/cloud/bindings/boot/pem/package-info.java b/src/main/java/org/springframework/cloud/bindings/boot/pem/package-info.java new file mode 100644 index 0000000..ab99a45 --- /dev/null +++ b/src/main/java/org/springframework/cloud/bindings/boot/pem/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2012-2023 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. + */ + +/** + * SSL trust material provider for PEM-encoded certificates. + * + * This package is copied from org.springframework.boot.ssl.pem introduced into spring-boot in v3.1.0. + * It is simplified somewhat here, and modified for Java 8 compatibility. + * We copied it because the spring-cloud-bindings library needs to work with older versions of spring boot, and must be + * Java 8 compatible. + */ +package org.springframework.cloud.bindings.boot.pem; diff --git a/src/test/java/org/springframework/cloud/bindings/boot/EurekaBindingsPropertiesProcessorTest.java b/src/test/java/org/springframework/cloud/bindings/boot/EurekaBindingsPropertiesProcessorTest.java index a56663f..67d94a4 100644 --- a/src/test/java/org/springframework/cloud/bindings/boot/EurekaBindingsPropertiesProcessorTest.java +++ b/src/test/java/org/springframework/cloud/bindings/boot/EurekaBindingsPropertiesProcessorTest.java @@ -16,41 +16,83 @@ package org.springframework.cloud.bindings.boot; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.cloud.bindings.Binding; import org.springframework.cloud.bindings.Bindings; import org.springframework.cloud.bindings.FluentMap; +import org.springframework.core.io.ClassPathResource; import org.springframework.mock.env.MockEnvironment; +import java.io.File; import java.nio.file.Paths; import java.util.HashMap; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.springframework.cloud.bindings.boot.EurekaBindingsPropertiesProcessor.TYPE; @DisplayName("Eureka BindingsPropertiesProcessor") final class EurekaBindingsPropertiesProcessorTest { - - private final Bindings bindings = new Bindings( + private Bindings bindings = new Bindings( new Binding("test-name", Paths.get("test-path"), new FluentMap() .withEntry(Binding.TYPE, TYPE) .withEntry("uri", "test-uri") - .withEntry("client-id", "test-client-id") - .withEntry("client-secret", "test-client-secret") - .withEntry("access-token-uri", "test-access-token-uri") ) ); - private final MockEnvironment environment = new MockEnvironment(); - private final HashMap properties = new HashMap<>(); + private String cert; + private String key; + + @BeforeEach + void fetchCerts() { + assertDoesNotThrow(() -> { + this.cert = TestHelper.resourceAsString(new ClassPathResource("pem/test-cert.pem")); + this.key = TestHelper.resourceAsString(new ClassPathResource("pem/test-key.pem")); + }); + } @Test - @DisplayName("contributes properties") - void test() { + @DisplayName("contributes only base properties when no auth is set") + void testNoAuth() { new EurekaBindingsPropertiesProcessor().process(environment, bindings, properties); + + assertThat(properties) + .containsEntry("eureka.client.region", "default") + .containsEntry("eureka.client.serviceUrl.defaultZone", "test-uri/eureka/") + .doesNotContainKey("eureka.client.oauth2.client-id") + .doesNotContainKey("eureka.client.oauth2.access-token-uri") + .doesNotContainKey("eureka.client.tls.trust-store") + .doesNotContainKey("eureka.client.tls.trust-store-type") + .doesNotContainKey("eureka.client.tls.trust-store-password") + .doesNotContainKey("eureka.client.tls.key-alias") + .doesNotContainKey("eureka.client.tls.key-store") + .doesNotContainKey("eureka.client.tls.key-store-type") + .doesNotContainKey("eureka.client.tls.key-store-password") + .doesNotContainKey("eureka.client.tls.key-password") + .doesNotContainKey("eureka.instance.preferIpAddress"); + } + + @Test + @DisplayName("contributes oauth properties when set") + void testOAuth2() { + bindings = new Bindings( + new Binding("test-name", Paths.get("test-path"), + new FluentMap() + .withEntry(Binding.TYPE, TYPE) + .withEntry("uri", "test-uri") + .withEntry("client-id", "test-client-id") + .withEntry("client-secret", "test-client-secret") + .withEntry("access-token-uri", "test-access-token-uri") + ) + ); + + new EurekaBindingsPropertiesProcessor().process(environment, bindings, properties); + assertThat(properties) .containsEntry("eureka.client.region", "default") .containsEntry("eureka.client.oauth2.client-id", "test-client-id") @@ -58,6 +100,167 @@ final class EurekaBindingsPropertiesProcessorTest { .containsEntry("eureka.client.serviceUrl.defaultZone", "test-uri/eureka/"); } + @Test + @DisplayName("contributes tls properties when set") + void testTls() { + bindings = new Bindings( + new Binding("test-name", Paths.get("test-path"), + new FluentMap() + .withEntry(Binding.TYPE, TYPE) + .withEntry("uri", "test-uri") + .withEntry("ca.crt", cert) + ) + ); + + new EurekaBindingsPropertiesProcessor().process(environment, bindings, properties); + + assertThat(properties) + .containsEntry("eureka.client.region", "default") + .containsKey("eureka.client.tls.trust-store") + .containsEntry("eureka.client.tls.trust-store-type", "PKCS12") + .containsKey("eureka.client.tls.trust-store-password") + .containsEntry("eureka.instance.preferIpAddress", true); + assertDoesNotThrow(() -> { + String path = properties.get("eureka.client.tls.trust-store").toString().substring(5); + File f = new File(path); + assertThat(f.isFile()).isTrue(); + f.delete(); + }); + } + + @Test + @DisplayName("throws when bad tls values are set") + void testBadTls() { + bindings = new Bindings( + new Binding("test-name", Paths.get("test-path"), + new FluentMap() + .withEntry(Binding.TYPE, TYPE) + .withEntry("uri", "test-uri") + .withEntry("ca.crt", "this isn't a valid certificate") + ) + ); + + assertThrows(IllegalStateException.class, () -> { + new EurekaBindingsPropertiesProcessor().process(environment, bindings, properties); + }); + } + + @Test + @DisplayName("does not change PreferIpAddress if already set elsewhere") + void testTlsNoIp() { + bindings = new Bindings( + new Binding("test-name", Paths.get("test-path"), + new FluentMap() + .withEntry(Binding.TYPE, TYPE) + .withEntry("uri", "test-uri") + .withEntry("ca.crt", cert) + ) + ); + environment.setProperty("eureka.instance.preferIpAddress", "false"); + + new EurekaBindingsPropertiesProcessor().process(environment, bindings, properties); + + assertThat(properties).doesNotContainKey("eureka.instance.preferIpAddress"); + assertDoesNotThrow(() -> { + String path = properties.get("eureka.client.tls.trust-store").toString().substring(5); + File f = new File(path); + assertThat(f.isFile()).isTrue(); + f.delete(); + }); + } + + @Test + @DisplayName("contributes mTLS properties when set") + void testMtls() { + bindings = new Bindings( + new Binding("test-name", Paths.get("test-path"), + new FluentMap() + .withEntry(Binding.TYPE, TYPE) + .withEntry("uri", "test-uri") + .withEntry("ca.crt", cert) + .withEntry("tls.crt", cert) + .withEntry("tls.key", key) + ) + ); + + new EurekaBindingsPropertiesProcessor().process(environment, bindings, properties); + + assertThat(properties) + .containsEntry("eureka.client.region", "default") + .containsKey("eureka.client.tls.trust-store") + .containsEntry("eureka.client.tls.trust-store-type", "PKCS12") + .containsKey("eureka.client.tls.trust-store-password") + .containsEntry("eureka.client.tls.key-alias", "eureka") + .containsKey("eureka.client.tls.key-store") + .containsEntry("eureka.client.tls.key-store-type", "PKCS12") + .containsKey("eureka.client.tls.key-store-password") + .containsEntry("eureka.client.tls.key-password", ""); + + assertDoesNotThrow(() -> { + String path = properties.get("eureka.client.tls.key-store").toString().substring(5); + File f = new File(path); + assertThat(f.isFile()).isTrue(); + f.delete(); + path = properties.get("eureka.client.tls.trust-store").toString().substring(5); + f = new File(path); + assertThat(f.isFile()).isTrue(); + f.delete(); + }); + } + + @Test + @DisplayName("throws when bad mTls values are set") + void testBadMtls() { + bindings = new Bindings( + new Binding("test-name", Paths.get("test-path"), + new FluentMap() + .withEntry(Binding.TYPE, TYPE) + .withEntry("uri", "test-uri") + .withEntry("ca.crt", cert) + .withEntry("tls.crt", "this is not a valid certificate") + .withEntry("tls.key", "this is not a valid key") + ) + ); + + assertThrows(IllegalStateException.class, () -> { + new EurekaBindingsPropertiesProcessor().process(environment, bindings, properties); + }); + } + @Test + @DisplayName("throws when tls.crt is set but tls.key isn't") + void testNoTlsKey() { + bindings = new Bindings( + new Binding("test-name", Paths.get("test-path"), + new FluentMap() + .withEntry(Binding.TYPE, TYPE) + .withEntry("uri", "test-uri") + .withEntry("ca.crt", cert) + .withEntry("tls.crt", cert) + ) + ); + + assertThrows(IllegalArgumentException.class, () -> { + new EurekaBindingsPropertiesProcessor().process(environment, bindings, properties); + }); + } + @Test + @DisplayName("throws when tls.key is set but tls.crt isn't") + void testNoTlsCrt() { + bindings = new Bindings( + new Binding("test-name", Paths.get("test-path"), + new FluentMap() + .withEntry(Binding.TYPE, TYPE) + .withEntry("uri", "test-uri") + .withEntry("ca.crt", cert) + .withEntry("tls.key", key) + ) + ); + + assertThrows(IllegalArgumentException.class, () -> { + new EurekaBindingsPropertiesProcessor().process(environment, bindings, properties); + }); + } + @Test @DisplayName("can be disabled") void disabled() { @@ -67,5 +270,4 @@ final class EurekaBindingsPropertiesProcessorTest { assertThat(properties).isEmpty(); } - } diff --git a/src/test/java/org/springframework/cloud/bindings/boot/TestHelper.java b/src/test/java/org/springframework/cloud/bindings/boot/TestHelper.java new file mode 100644 index 0000000..8c4fefe --- /dev/null +++ b/src/test/java/org/springframework/cloud/bindings/boot/TestHelper.java @@ -0,0 +1,17 @@ +package org.springframework.cloud.bindings.boot; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.FileCopyUtils; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; + +public class TestHelper { + public static String resourceAsString(ClassPathResource resource) throws IOException { + Reader reader = new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8); + return FileCopyUtils.copyToString(reader); + } + +} diff --git a/src/test/java/org/springframework/cloud/bindings/boot/pem/PemCertificateParserTests.java b/src/test/java/org/springframework/cloud/bindings/boot/pem/PemCertificateParserTests.java new file mode 100644 index 0000000..45523df --- /dev/null +++ b/src/test/java/org/springframework/cloud/bindings/boot/pem/PemCertificateParserTests.java @@ -0,0 +1,56 @@ +/* + * Copyright 2012-2023 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.bindings.boot.pem; + +import java.io.IOException; +import java.security.cert.X509Certificate; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.ClassPathResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.cloud.bindings.boot.TestHelper.resourceAsString; + +/** + * Tests for {@link PemCertificateParser}. + * + * @author Scott Frederick + */ +class PemCertificateParserTests { + + @Test + void parseCertificate() throws Exception { + X509Certificate[] certificates = PemCertificateParser.parse(read("pem/test-cert.pem")); + assertThat(certificates).isNotNull(); + assertThat(certificates).hasSize(1); + assertThat(certificates[0].getType()).isEqualTo("X.509"); + } + + @Test + void parseCertificateChain() throws Exception { + X509Certificate[] certificates = PemCertificateParser.parse(read("pem/test-cert-chain.pem")); + assertThat(certificates).isNotNull(); + assertThat(certificates).hasSize(2); + assertThat(certificates[0].getType()).isEqualTo("X.509"); + assertThat(certificates[1].getType()).isEqualTo("X.509"); + } + + private String read(String path) throws IOException { + return resourceAsString(new ClassPathResource(path)); + } +} diff --git a/src/test/java/org/springframework/cloud/bindings/boot/pem/PemContentTests.java b/src/test/java/org/springframework/cloud/bindings/boot/pem/PemContentTests.java new file mode 100644 index 0000000..416ae28 --- /dev/null +++ b/src/test/java/org/springframework/cloud/bindings/boot/pem/PemContentTests.java @@ -0,0 +1,83 @@ +/* + * Copyright 2012-2023 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.bindings.boot.pem; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.FileCopyUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link PemContent}. + * + * @author Phillip Webb + */ +class PemContentTests { + + @Test + void loadWhenContentIsNullReturnsNull() { + assertThat(PemContent.load(null)).isNull(); + } + + @Test + void loadWhenContentIsPemContentReturnsContent() { + String content = "-----BEGIN CERTIFICATE-----\n" + + "MIICpDCCAYwCCQCDOqHKPjAhCTANBgkqhkiG9w0BAQUFADAUMRIwEAYDVQQDDAls\n" + + "b2NhbGhvc3QwHhcNMTQwOTEwMjE0MzA1WhcNMTQxMDEwMjE0MzA1WjAUMRIwEAYD\n" + + "VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDR\n" + + "0KfxUw7MF/8RB5/YXOM7yLnoHYb/M/6dyoulMbtEdKKhQhU28o5FiDkHcEG9PJQL\n" + + "gqrRgAjl3VmCC9omtfZJQ2EpfkTttkJjnKOOroXhYE51/CYSckapBYCVh8GkjUEJ\n" + + "uEfnp07cTfYZFqViIgIWPZyjkzl3w4girS7kCuzNdDntVJVx5F/EsFwMA8n3C0Qa\n" + + "zHQoM5s00Fer6aTwd6AW0JD5QkADavpfzZ554e4HrVGwHlM28WKQQkFzzGu44FFX\n" + + "yVuEF3HeyVPug8GRHAc8UU7ijVgJB5TmbvRGYowIErD5i4VvGLuOv9mgR3aVyN0S\n" + + "dJ1N7aJnXpeSQjAgf03jAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAE4yvwhbPldg\n" + + "Bpl7sBw/m2B3bfiNeSqa4tII1PQ7ysgWVb9HbFNKkriScwDWlqo6ljZfJ+SDFCoj\n" + + "bQz4fOFdMAOzRnpTrG2NAKMoJLY0/g/p7XO00PiC8T3h3BOJ5SHuW3gUyfGXmAYs\n" + + "DnJxJOrwPzj57xvNXjNSbDOJ3DRfCbB0CWBexOeGDiUokoEq3Gnz04Q4ZfHyAcpZ\n" + + "3deMw8Od5p9WAoCh3oClpFyOSzXYKZd+3ppMMtfc4wnbfocnfSFxj0UCpOEJw4Ez\n" + + "+lGuHKdhNOVW9CmqPD1y76o6c8PQKuF7KZEoY2jvy3GeIfddBvqXgZ4PbWvFz1jO\n" + + "32C9XWHwRA4=\n" + + "-----END CERTIFICATE-----"; + assertThat(PemContent.load(content)).isEqualTo(content); + } + + @Test + void loadWhenClasspathLocationReturnsContent() throws IOException { + String actual = PemContent.load("classpath:pem/test-cert.pem"); + String expected = asString(new ClassPathResource("pem/test-cert.pem")); + assertThat(actual).isEqualTo(expected); + } + + @Test + void loadWhenFileLocationReturnsContent() throws IOException { + String actual = PemContent.load("src/test/resources/pem/test-cert.pem"); + String expected = asString(new ClassPathResource("pem/test-cert.pem")); + assertThat(actual).isEqualTo(expected); + } + + public static String asString(ClassPathResource resource) throws IOException { + Reader reader = new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8); + return FileCopyUtils.copyToString(reader); + } +} diff --git a/src/test/java/org/springframework/cloud/bindings/boot/pem/PemPrivateKeyParserTests.java b/src/test/java/org/springframework/cloud/bindings/boot/pem/PemPrivateKeyParserTests.java new file mode 100644 index 0000000..5656a1e --- /dev/null +++ b/src/test/java/org/springframework/cloud/bindings/boot/pem/PemPrivateKeyParserTests.java @@ -0,0 +1,61 @@ +/* + * Copyright 2012-2023 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.bindings.boot.pem; + +import java.io.IOException; +import java.security.PrivateKey; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.ClassPathResource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.springframework.cloud.bindings.boot.TestHelper.resourceAsString; + +/** + * Tests for {@link PemPrivateKeyParser}. + * + * @author Scott Frederick + */ +class PemPrivateKeyParserTests { + + @Test + void parsePkcs8KeyFile() throws Exception { + PrivateKey privateKey = PemPrivateKeyParser.parse(read("pem/test-key.pem")); + assertThat(privateKey).isNotNull(); + assertThat(privateKey.getFormat()).isEqualTo("PKCS#8"); + assertThat(privateKey.getAlgorithm()).isEqualTo("RSA"); + } + + @Test + void parsePkcs8KeyFileWithEcdsa() throws Exception { + PrivateKey privateKey = PemPrivateKeyParser.parse(read("pem/test-ec-key.pem")); + assertThat(privateKey).isNotNull(); + assertThat(privateKey.getFormat()).isEqualTo("PKCS#8"); + assertThat(privateKey.getAlgorithm()).isEqualTo("EC"); + } + + @Test + void parseWithNonKeyTextWillThrowException() { + assertThatIllegalStateException().isThrownBy(() -> PemPrivateKeyParser.parse(read("pem/test-banner.txt"))); + } + + private String read(String path) throws IOException { + return resourceAsString(new ClassPathResource(path)); + } +} diff --git a/src/test/java/org/springframework/cloud/bindings/boot/pem/PemSslStoreHelperTests.java b/src/test/java/org/springframework/cloud/bindings/boot/pem/PemSslStoreHelperTests.java new file mode 100644 index 0000000..a95ac64 --- /dev/null +++ b/src/test/java/org/springframework/cloud/bindings/boot/pem/PemSslStoreHelperTests.java @@ -0,0 +1,65 @@ +/* + * Copyright 2012-2023 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.bindings.boot.pem; + +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for {@link PemSslStoreHelper}. + * + */ +class PemSslStoreHelperTests { + @Test + void whenNullValues() { + assertThrows(java.lang.IllegalStateException.class, () -> { + PemSslStoreHelper.createKeyStore("key", "PKCS12", null, null, "some-alias"); + }); + } + + @Test + void whenHasKeyStoreDetailsCertAndKey() { + KeyStore keyStore = PemSslStoreHelper.createKeyStore("key", "PKCS12", "classpath:pem/test-cert.pem", "classpath:pem/test-key.pem", "some-alias"); + assertDoesNotThrow(() -> { + assertThat(keyStore).isNotNull(); + assertThat(keyStore.getType()).isEqualTo("PKCS12"); + assertThat(keyStore.containsAlias("some-alias")).isTrue(); + assertThat(keyStore.getCertificate("some-alias")).isNotNull(); + assertThat(keyStore.getKey("some-alias", new char[]{})).isNotNull(); + }); + } + + @Test + void whenHasTrustStoreDetailsWithoutKey() throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException { + KeyStore keyStore = PemSslStoreHelper.createKeyStore("trust", "PKCS12", "classpath:pem/test-cert.pem", null, null); + assertDoesNotThrow(() -> { + assertThat(keyStore).isNotNull(); + assertThat(keyStore.getType()).isEqualTo("PKCS12"); + assertThat(keyStore.containsAlias("ssl-0")).isTrue(); + assertThat(keyStore.getCertificate("ssl-0")).isNotNull(); + assertThat(keyStore.getKey("ssl-0", new char[]{})).isNull(); + }); + } +} diff --git a/src/test/resources/pem/test-banner.txt b/src/test/resources/pem/test-banner.txt new file mode 100644 index 0000000..d60fc28 --- /dev/null +++ b/src/test/resources/pem/test-banner.txt @@ -0,0 +1 @@ +Running a Test! \ No newline at end of file diff --git a/src/test/resources/pem/test-cert-chain.pem b/src/test/resources/pem/test-cert-chain.pem new file mode 100644 index 0000000..df10377 --- /dev/null +++ b/src/test/resources/pem/test-cert-chain.pem @@ -0,0 +1,32 @@ +-----BEGIN TRUSTED CERTIFICATE----- +MIIClzCCAgACCQCPbjkRoMVEQDANBgkqhkiG9w0BAQUFADCBjzELMAkGA1UEBhMC +VVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28x +DTALBgNVBAoMBFRlc3QxDTALBgNVBAsMBFRlc3QxFDASBgNVBAMMC2V4YW1wbGUu +Y29tMR8wHQYJKoZIhvcNAQkBFhB0ZXN0QGV4YW1wbGUuY29tMB4XDTIwMDMyNzIx +NTgwNFoXDTIxMDMyNzIxNTgwNFowgY8xCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApD +YWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMQ0wCwYDVQQKDARUZXN0 +MQ0wCwYDVQQLDARUZXN0MRQwEgYDVQQDDAtleGFtcGxlLmNvbTEfMB0GCSqGSIb3 +DQEJARYQdGVzdEBleGFtcGxlLmNvbTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkC +gYEA1YzixWEoyzrd20C2R1gjyPCoPfFLlG6UYTyT0tueNy6yjv6qbJ8lcZg7616O +3I9LuOHhZh9U+fCDCgPfiDdyJfDEW/P+dsOMFyMUXPrJPze2yPpOnvV8iJ5DM93u +fEVhCCyzLdYu0P2P3hU2W+T3/Im9DA7FOPA2vF1SrIJ2qtUCAwEAATANBgkqhkiG +9w0BAQUFAAOBgQBdShkwUv78vkn1jAdtfbB+7mpV9tufVdo29j7pmotTCz3ny5fc +zLEfeu6JPugAR71JYbc2CqGrMneSk1zT91EH6ohIz8OR5VNvzB7N7q65Ci7OFMPl +ly6k3rHpMCBtHoyNFhNVfPLxGJ9VlWFKLgIAbCmL4OIQm1l6Fr1MSM38Zw== +-----END TRUSTED CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICjzCCAfgCAQEwDQYJKoZIhvcNAQEFBQAwgY8xCzAJBgNVBAYTAlVTMRMwEQYD +VQQIDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMQ0wCwYDVQQK +DARUZXN0MQ0wCwYDVQQLDARUZXN0MRQwEgYDVQQDDAtleGFtcGxlLmNvbTEfMB0G +CSqGSIb3DQEJARYQdGVzdEBleGFtcGxlLmNvbTAeFw0yMDAzMjcyMjAxNDZaFw0y +MTAzMjcyMjAxNDZaMIGPMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5p +YTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwEVGVzdDENMAsGA1UE +CwwEVGVzdDEUMBIGA1UEAwwLZXhhbXBsZS5jb20xHzAdBgkqhkiG9w0BCQEWEHRl +c3RAZXhhbXBsZS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAM7kd2cj +F49wm1+OQ7Q5GE96cXueWNPr/Nwei71tf6G4BmE0B+suXHEvnLpHTj9pdX/ZzBIK +8jIZ/x8RnSduK/Ky+zm1QMYUWZtWCAgCW8WzgB69Cn/hQG8KSX3S9bqODuQAvP54 +GQJD7+4kVuNBGjFb4DaD4nvMmPtALSZf8ZCZAgMBAAEwDQYJKoZIhvcNAQEFBQAD +gYEAOn6X8+0VVlDjF+TvTgI0KIasA6nDm+KXe7LVtfvqWqQZH4qyd2uiwcDM3Aux +a/OsPdOw0j+NqFDBd3mSMhSVgfvXdK6j9WaxY1VGXyaidLARgvn63wfzgr857sQW +c8eSxbwEQxwlMvVxW6Os4VhCfUQr8VrBrvPa2zs+6IlK+Ug= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/src/test/resources/pem/test-cert.pem b/src/test/resources/pem/test-cert.pem new file mode 100644 index 0000000..1e912b9 --- /dev/null +++ b/src/test/resources/pem/test-cert.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICpDCCAYwCCQCDOqHKPjAhCTANBgkqhkiG9w0BAQUFADAUMRIwEAYDVQQDDAls +b2NhbGhvc3QwHhcNMTQwOTEwMjE0MzA1WhcNMTQxMDEwMjE0MzA1WjAUMRIwEAYD +VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDR +0KfxUw7MF/8RB5/YXOM7yLnoHYb/M/6dyoulMbtEdKKhQhU28o5FiDkHcEG9PJQL +gqrRgAjl3VmCC9omtfZJQ2EpfkTttkJjnKOOroXhYE51/CYSckapBYCVh8GkjUEJ +uEfnp07cTfYZFqViIgIWPZyjkzl3w4girS7kCuzNdDntVJVx5F/EsFwMA8n3C0Qa +zHQoM5s00Fer6aTwd6AW0JD5QkADavpfzZ554e4HrVGwHlM28WKQQkFzzGu44FFX +yVuEF3HeyVPug8GRHAc8UU7ijVgJB5TmbvRGYowIErD5i4VvGLuOv9mgR3aVyN0S +dJ1N7aJnXpeSQjAgf03jAgMBAAEwDQYJKoZIhvcNAQEFBQADggEBAE4yvwhbPldg +Bpl7sBw/m2B3bfiNeSqa4tII1PQ7ysgWVb9HbFNKkriScwDWlqo6ljZfJ+SDFCoj +bQz4fOFdMAOzRnpTrG2NAKMoJLY0/g/p7XO00PiC8T3h3BOJ5SHuW3gUyfGXmAYs +DnJxJOrwPzj57xvNXjNSbDOJ3DRfCbB0CWBexOeGDiUokoEq3Gnz04Q4ZfHyAcpZ +3deMw8Od5p9WAoCh3oClpFyOSzXYKZd+3ppMMtfc4wnbfocnfSFxj0UCpOEJw4Ez ++lGuHKdhNOVW9CmqPD1y76o6c8PQKuF7KZEoY2jvy3GeIfddBvqXgZ4PbWvFz1jO +32C9XWHwRA4= +-----END CERTIFICATE----- diff --git a/src/test/resources/pem/test-ec-key.pem b/src/test/resources/pem/test-ec-key.pem new file mode 100644 index 0000000..b3a1ce0 --- /dev/null +++ b/src/test/resources/pem/test-ec-key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIBEZhSR+d8kwL5L/K0f/eNBm4RfzyyA1jfg+dV1/8WvqoAoGCCqGSM49 +AwEHoUQDQgAEBbfdBTSUWuui7O2R+W9mDPjAHjgdBJsjrjnvkjnq8f/k4U/OqvjK +qnHEZwYgdaF2WqYdqBYMns0n+tSMgBoonQ== +-----END EC PRIVATE KEY----- diff --git a/src/test/resources/pem/test-key.pem b/src/test/resources/pem/test-key.pem new file mode 100644 index 0000000..00d439e --- /dev/null +++ b/src/test/resources/pem/test-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDR0KfxUw7MF/8R +B5/YXOM7yLnoHYb/M/6dyoulMbtEdKKhQhU28o5FiDkHcEG9PJQLgqrRgAjl3VmC +C9omtfZJQ2EpfkTttkJjnKOOroXhYE51/CYSckapBYCVh8GkjUEJuEfnp07cTfYZ +FqViIgIWPZyjkzl3w4girS7kCuzNdDntVJVx5F/EsFwMA8n3C0QazHQoM5s00Fer +6aTwd6AW0JD5QkADavpfzZ554e4HrVGwHlM28WKQQkFzzGu44FFXyVuEF3HeyVPu +g8GRHAc8UU7ijVgJB5TmbvRGYowIErD5i4VvGLuOv9mgR3aVyN0SdJ1N7aJnXpeS +QjAgf03jAgMBAAECggEBAIhQyzwj3WJGWOZkkLqOpufJotcmj/Wwf0VfOdkq9WMl +cB/bAlN/xWVxerPVgDCFch4EWBzi1WUaqbOvJZ2u7QNubmr56aiTmJCFTVI/GyZx +XqiTGN01N6lKtN7xo6LYTyAUhUsBTWAemrx0FSErvTVb9C/mUBj6hbEZ2XQ5kN5t +7qYX4Lu0zyn7s1kX5SLtm5I+YRq7HSwB6wLy+DSroO71izZ/VPwME3SwT5SN+c87 +3dkklR7fumNd9dOpSWKrLPnq4aMko00rvIGc63xD1HrEpXUkB5v24YEn7HwCLEH7 +b8jrp79j2nCvvR47inpf+BR8FIWAHEOUUqCEzjQkdiECgYEA6ifjMM0f02KPeIs7 +zXd1lI7CUmJmzkcklCIpEbKWf/t/PHv3QgqIkJzERzRaJ8b+GhQ4zrSwAhrGUmI8 +kDkXIqe2/2ONgIOX2UOHYHyTDQZHnlXyDecvHUTqs2JQZCGBZkXyZ9i0j3BnTymC +iZ8DvEa0nxsbP+U3rgzPQmXiQVMCgYEA5WN2Y/RndbriNsNrsHYRldbPO5nfV9rp +cDzcQU66HRdK5VIdbXT9tlMYCJIZsSqE0tkOwTgEB/sFvF/tIHSCY5iO6hpIyk6g +kkUzPcld4eM0dEPAge7SYUbakB9CMvA7MkDQSXQNFyZ0mH83+UikwT6uYHFh7+ox +N1P+psDhXzECgYEA1gXLVQnIcy/9LxMkgDMWV8j8uMyUZysDthpbK3/uq+A2dhRg +9g4msPd5OBQT65OpIjElk1n4HpRWfWqpLLHiAZ0GWPynk7W0D7P3gyuaRSdeQs0P +x8FtgPVDCN9t13gAjHiWjnC26Py2kNbCKAQeJ/MAmQTvrUFX2VCACJKTcV0CgYAj +xJWSUmrLfb+GQISLOG3Xim434e9keJsLyEGj4U29+YLRLTOvfJ2PD3fg5j8hU/rw +Ea5uTHi8cdTcIa0M8X3fX8txD3YoLYh2JlouGTcNYOst8d6TpBSj3HN6I5Wj8beZ +R2fy/CiKYpGtsbCdq0kdZNO18BgQW9kewncjs1GxEQKBgQCf8q34h6KuHpHSDh9h +YkDTypk0FReWBAVJCzDNDUMhVLFivjcwtaMd2LiC3FMKZYodr52iKg60cj43vbYI +frmFFxoL37rTmUocCTBKc0LhWj6MicI+rcvQYe1uwTrpWdFf1aZJMYRLRczeKtev +OWaE/9hVZ5+9pild1NukGpOydw== +-----END PRIVATE KEY-----