From 4fe87aec43caa612472cc445bfc38f88614136e1 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Wed, 25 Mar 2020 16:32:07 +0100 Subject: [PATCH] Accept PEM-encoded certificates in SslConfiguration. We now accept PEM-encoded certificates when configuring SSL settings. KeyStoreConfiguration keystore = KeyStoreConfiguration .of(new ClassPathResource("ca.pem")).withStoreType("PEM"); SslConfiguration configuration = SslConfiguration.forTrustStore(keystore); Closes gh-514. --- .../client/ClientHttpConnectorFactory.java | 7 +- .../ClientHttpRequestFactoryFactory.java | 77 +++++-- .../config/EnvironmentVaultConfiguration.java | 17 +- .../vault/support/CertificateBundle.java | 2 +- .../vault/support/KeystoreUtil.java | 158 +++++++++++++- .../vault/support/PemObject.java | 197 +++++++++++++++++- .../vault/support/SslConfiguration.java | 48 ++++- ...RequestFactoryFactoryIntegrationTests.java | 25 +++ .../vault/support/PemObjectUnitTests.java | 119 +++++++++++ .../support/SslConfigurationUnitTests.java | 11 + src/main/asciidoc/new-features.adoc | 8 +- .../asciidoc/reference/client-support.adoc | 18 +- .../reference/imperative-template.adoc | 2 + src/test/bash/create_certificates.sh | 5 +- 14 files changed, 645 insertions(+), 49 deletions(-) create mode 100644 spring-vault-core/src/test/java/org/springframework/vault/support/PemObjectUnitTests.java diff --git a/spring-vault-core/src/main/java/org/springframework/vault/client/ClientHttpConnectorFactory.java b/spring-vault-core/src/main/java/org/springframework/vault/client/ClientHttpConnectorFactory.java index 51ad406b..84331e62 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/client/ClientHttpConnectorFactory.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/client/ClientHttpConnectorFactory.java @@ -18,9 +18,6 @@ package org.springframework.vault.client; import java.io.IOException; import java.security.GeneralSecurityException; import java.security.KeyStore; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; -import java.security.cert.CertificateException; import io.netty.channel.ChannelOption; import io.netty.handler.ssl.SslContextBuilder; @@ -176,8 +173,8 @@ public class ClientHttpConnectorFactory { } private static org.eclipse.jetty.client.HttpClient getHttpClient( - SslConfiguration sslConfiguration) throws KeyStoreException, IOException, - NoSuchAlgorithmException, CertificateException { + SslConfiguration sslConfiguration) + throws IOException, GeneralSecurityException { if (hasSslConfiguration(sslConfiguration)) { diff --git a/spring-vault-core/src/main/java/org/springframework/vault/client/ClientHttpRequestFactoryFactory.java b/spring-vault-core/src/main/java/org/springframework/vault/client/ClientHttpRequestFactoryFactory.java index da358371..f4a8f984 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/client/ClientHttpRequestFactoryFactory.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/client/ClientHttpRequestFactoryFactory.java @@ -27,9 +27,9 @@ import java.security.NoSuchAlgorithmException; import java.security.Principal; import java.security.PrivateKey; import java.security.UnrecoverableKeyException; -import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.Arrays; +import java.util.List; import java.util.concurrent.TimeUnit; import javax.net.ssl.KeyManager; @@ -61,10 +61,13 @@ import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.http.client.Netty4ClientHttpRequestFactory; import org.springframework.http.client.OkHttp3ClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; +import org.springframework.util.FileCopyUtils; import org.springframework.util.StringUtils; import org.springframework.vault.support.ClientOptions; +import org.springframework.vault.support.PemObject; import org.springframework.vault.support.SslConfiguration; import org.springframework.vault.support.SslConfiguration.KeyStoreConfiguration; @@ -140,10 +143,7 @@ public class ClientHttpRequestFactoryFactory { return Netty.usingNetty(options, sslConfiguration); } } - catch (GeneralSecurityException e) { - throw new IllegalStateException(e); - } - catch (IOException e) { + catch (GeneralSecurityException | IOException e) { throw new IllegalStateException(e); } @@ -155,7 +155,7 @@ public class ClientHttpRequestFactoryFactory { return new SimpleClientHttpRequestFactory(); } - static SSLContext getSSLContext(SslConfiguration sslConfiguration, + private static SSLContext getSSLContext(SslConfiguration sslConfiguration, TrustManager[] trustManagers) throws GeneralSecurityException, IOException { KeyConfiguration keyConfiguration = sslConfiguration.getKeyConfiguration(); @@ -170,6 +170,7 @@ public class ClientHttpRequestFactoryFactory { return sslContext; } + @Nullable private static TrustManager[] getTrustManagers(SslConfiguration sslConfiguration) throws GeneralSecurityException, IOException { @@ -207,18 +208,25 @@ public class ClientHttpRequestFactoryFactory { } static KeyStore getKeyStore(KeyStoreConfiguration keyStoreConfiguration) - throws KeyStoreException, IOException, NoSuchAlgorithmException, - CertificateException { + throws IOException, GeneralSecurityException { - KeyStore keyStore = KeyStore - .getInstance(StringUtils.hasText(keyStoreConfiguration.getStoreType()) - ? keyStoreConfiguration.getStoreType() - : KeyStore.getDefaultType()); + KeyStore keyStore = KeyStore.getInstance(getKeyStoreType(keyStoreConfiguration)); loadKeyStore(keyStoreConfiguration, keyStore); return keyStore; } + private static String getKeyStoreType(KeyStoreConfiguration keyStoreConfiguration) { + + if (StringUtils.hasText(keyStoreConfiguration.getStoreType()) + && !SslConfiguration.PEM_KEYSTORE_TYPE + .equalsIgnoreCase(keyStoreConfiguration.getStoreType())) { + return keyStoreConfiguration.getStoreType(); + } + + return KeyStore.getDefaultType(); + } + static TrustManagerFactory createTrustManagerFactory( KeyStoreConfiguration keyStoreConfiguration) throws GeneralSecurityException, IOException { @@ -233,13 +241,31 @@ public class ClientHttpRequestFactoryFactory { } private static void loadKeyStore(KeyStoreConfiguration keyStoreConfiguration, - KeyStore keyStore) - throws IOException, NoSuchAlgorithmException, CertificateException { + KeyStore keyStore) throws IOException, GeneralSecurityException { + + if (logger.isDebugEnabled()) { + logger.debug(String.format("Loading keystore from %s", + keyStoreConfiguration.getResource())); + } InputStream inputStream = null; try { inputStream = keyStoreConfiguration.getResource().getInputStream(); - keyStore.load(inputStream, keyStoreConfiguration.getStorePassword()); + + if (SslConfiguration.PEM_KEYSTORE_TYPE + .equalsIgnoreCase(keyStoreConfiguration.getStoreType())) { + + keyStore.load(null); + loadFromPem(keyStore, inputStream); + } + else { + keyStore.load(inputStream, keyStoreConfiguration.getStorePassword()); + } + + if (logger.isDebugEnabled()) { + logger.debug(String.format("Keystore loaded with %d entries", + keyStore.size())); + } } finally { if (inputStream != null) { @@ -248,6 +274,27 @@ public class ClientHttpRequestFactoryFactory { } } + private static void loadFromPem(KeyStore keyStore, InputStream inputStream) + throws IOException, KeyStoreException { + + List pemObjects = PemObject + .parse(new String(FileCopyUtils.copyToByteArray(inputStream))); + + for (PemObject pemObject : pemObjects) { + if (pemObject.isCertificate()) { + X509Certificate cert = pemObject.getCertificate(); + String alias = cert.getSubjectX500Principal().getName(); + + if (logger.isDebugEnabled()) { + logger.debug( + String.format("Adding certificate with alias %s", alias)); + } + + keyStore.setCertificateEntry(alias, cert); + } + } + } + static boolean hasSslConfiguration(SslConfiguration sslConfiguration) { return sslConfiguration.getTrustStoreConfiguration().isPresent() || sslConfiguration.getKeyStoreConfiguration().isPresent(); diff --git a/spring-vault-core/src/main/java/org/springframework/vault/config/EnvironmentVaultConfiguration.java b/spring-vault-core/src/main/java/org/springframework/vault/config/EnvironmentVaultConfiguration.java index 0400c165..9815d8ff 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/config/EnvironmentVaultConfiguration.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/config/EnvironmentVaultConfiguration.java @@ -102,8 +102,10 @@ import org.springframework.web.client.RestOperations; * * *
  • Authentication method: {@code vault.authentication} (defaults to {@literal TOKEN}, @@ -228,29 +230,34 @@ public class EnvironmentVaultConfiguration extends AbstractVaultConfiguration public SslConfiguration sslConfiguration() { KeyStoreConfiguration keyStoreConfiguration = getKeyStoreConfiguration( - "vault.ssl.key-store", "vault.ssl.key-store-password"); + "vault.ssl.key-store", "vault.ssl.key-store-password", + "vault.ssl.key-store-type"); KeyStoreConfiguration trustStoreConfiguration = getKeyStoreConfiguration( - "vault.ssl.trust-store", "vault.ssl.trust-store-password"); + "vault.ssl.trust-store", "vault.ssl.trust-store-password", + "vault.ssl.trust-store-type"); return new SslConfiguration(keyStoreConfiguration, trustStoreConfiguration); } private KeyStoreConfiguration getKeyStoreConfiguration(String resourceProperty, - String passwordProperty) { + String passwordProperty, String keystoreTypeProperty) { Resource keyStore = getResource(resourceProperty); String keyStorePassword = getProperty(passwordProperty); + String keystoreType = getProperty(keystoreTypeProperty, + SslConfiguration.PEM_KEYSTORE_TYPE); if (keyStore == null) { return KeyStoreConfiguration.unconfigured(); } if (StringUtils.hasText(keyStorePassword)) { - return KeyStoreConfiguration.of(keyStore, keyStorePassword.toCharArray()); + return KeyStoreConfiguration.of(keyStore, keyStorePassword.toCharArray(), + keystoreType); } - return KeyStoreConfiguration.of(keyStore); + return KeyStoreConfiguration.of(keyStore).withStoreType(keystoreType); } @Override diff --git a/spring-vault-core/src/main/java/org/springframework/vault/support/CertificateBundle.java b/spring-vault-core/src/main/java/org/springframework/vault/support/CertificateBundle.java index 254d18c8..3ec5f541 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/support/CertificateBundle.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/support/CertificateBundle.java @@ -91,7 +91,7 @@ public class CertificateBundle extends Certificate { try { byte[] bytes = Base64Utils.decodeFromString(getPrivateKey()); - return KeystoreUtil.getRSAKeySpec(bytes); + return KeystoreUtil.getRSAPrivateKeySpec(bytes); } catch (IOException e) { throw new VaultException("Cannot create KeySpec from private key", e); diff --git a/spring-vault-core/src/main/java/org/springframework/vault/support/KeystoreUtil.java b/spring-vault-core/src/main/java/org/springframework/vault/support/KeystoreUtil.java index b9b7451c..4c37bc76 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/support/KeystoreUtil.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/support/KeystoreUtil.java @@ -29,6 +29,7 @@ import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.security.spec.KeySpec; import java.security.spec.RSAPrivateCrtKeySpec; +import java.security.spec.RSAPublicKeySpec; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -113,8 +114,7 @@ class KeystoreUtil { return keyStore; } - static X509Certificate getCertificate(byte[] source) - throws CertificateException { + static X509Certificate getCertificate(byte[] source) throws CertificateException { List certificates = getCertificates(CERTIFICATE_FACTORY, source); @@ -155,6 +155,75 @@ class KeystoreUtil { return x509Certificates; } + /** + * Convert PKCS#1 encoded public key into RSAPublicKeySpec. + *

    + *

    + * The ASN.1 syntax for the public key with CRT is + *

    + * + *

    +	 * --
    +	 * -- Representation of RSA public key with information for the CRT algorithm.
    +	 * --
    +	 * RSAPublicKey ::= SEQUENCE {
    +	 *   modulus           INTEGER,  -- n
    +	 *   publicExponent    INTEGER,  -- e
    +	 * }
    +	 * 
    + * + * Supports PEM objects with a {@code SEQUENCE} and {@code OBJECT IDENTIFIER} header + * where the actual key sequence is represented as {@code BIT_STRING} (as of + * {@code openssl -pubout} format). + * + * @param keyBytes PKCS#1 encoded key + * @return KeySpec + * @since 2.3 + */ + static RSAPublicKeySpec getRSAPublicKeySpec(byte[] keyBytes) + throws IOException, IllegalStateException { + DerParser parser = new DerParser(keyBytes); + + Asn1Object sequence = parser.read(); + if (sequence.getType() != DerParser.SEQUENCE) { + throw new IllegalStateException("Invalid DER: not a sequence"); + } + + // Parse inside the sequence + parser = sequence.getParser(); + Asn1Object object = parser.read(); + + if (object.type == DerParser.SEQUENCE) { + + Asn1Object read = object.getParser().read(); + if (!ObjectIdentifiers.RSA.equalsIgnoreCase(read.getString())) { + throw new IllegalStateException( + "Unsupported Public Key Algorithm. Expected RSA (" + + ObjectIdentifiers.RSA + "), but was: " + + read.getString()); + } + + Asn1Object bitString = parser.read(); + if (bitString.getType() != DerParser.BIT_STRING) { + throw new IllegalStateException("Invalid DER: not a bit string"); + } + + parser = new DerParser(bitString.getValue()); + sequence = parser.read(); + + if (sequence.getType() != DerParser.SEQUENCE) { + throw new IllegalStateException("Invalid DER: not a sequence"); + } + + parser = sequence.getParser(); + } + + BigInteger modulus = parser.read().getInteger(); + BigInteger publicExp = parser.read().getInteger(); + + return new RSAPublicKeySpec(modulus, publicExp); + } + /** * Convert PKCS#1 encoded private key into RSAPrivateCrtKeySpec. *

    @@ -183,7 +252,7 @@ class KeystoreUtil { * @param keyBytes PKCS#1 encoded key * @return KeySpec */ - static RSAPrivateCrtKeySpec getRSAKeySpec(byte[] keyBytes) throws IOException { + static RSAPrivateCrtKeySpec getRSAPrivateKeySpec(byte[] keyBytes) throws IOException { DerParser parser = new DerParser(keyBytes); Asn1Object sequence = parser.read(); @@ -208,6 +277,10 @@ class KeystoreUtil { exp1, exp2, crtCoef); } + private static class ObjectIdentifiers { + static final String RSA = "1.2.840.113549.1.1.1"; + } + /** * A bare-minimum ASN.1 DER decoder, just having enough functions to decode PKCS#1 * private keys. Especially, it doesn't handle explicitly tagged types with an outer @@ -240,6 +313,7 @@ class KeystoreUtil { static final int BIT_STRING = 0x03; static final int OCTET_STRING = 0x04; static final int NULL = 0x05; + static final int OID = 0x06; static final int REAL = 0x09; static final int ENUMERATED = 0x0a; @@ -297,6 +371,12 @@ class KeystoreUtil { int length = getLength(); + if (tag == BIT_STRING) { + // Not sure what to do with this one. + int padBits = in.read(); + length--; + } + byte[] value = new byte[length]; int n = in.read(value); if (n < length) { @@ -358,6 +438,8 @@ class KeystoreUtil { */ static class Asn1Object { + private static final long LONG_LIMIT = (Long.MAX_VALUE >> 7) - 0x7f; + private final int type; private final int length; private final byte[] value; @@ -435,7 +517,8 @@ class KeystoreUtil { BigInteger getInteger() { if (type != DerParser.INTEGER) { - throw new IllegalStateException("Invalid DER: object is not integer"); + throw new IllegalStateException( + String.format("Invalid DER: object (%d) is not integer.", type)); } return new BigInteger(value); @@ -474,11 +557,76 @@ class KeystoreUtil { case DerParser.UNIVERSAL_STRING: throw new IOException("Invalid DER: can't handle UCS-4 string"); + case DerParser.OID: + return getObjectIdentifier(value); default: - throw new IOException("Invalid DER: object is not a string"); + throw new IOException( + String.format("Invalid DER: object (%d) is not a string", type)); } return new String(value, encoding); } + + private static String getObjectIdentifier(byte bytes[]) { + StringBuffer objId = new StringBuffer(); + long value = 0; + BigInteger bigValue = null; + boolean first = true; + + for (int i = 0; i != bytes.length; i++) { + int b = bytes[i] & 0xff; + + if (value <= LONG_LIMIT) { + value += (b & 0x7f); + if ((b & 0x80) == 0) // end of number reached + { + if (first) { + if (value < 40) { + objId.append('0'); + } + else if (value < 80) { + objId.append('1'); + value -= 40; + } + else { + objId.append('2'); + value -= 80; + } + first = false; + } + + objId.append('.'); + objId.append(value); + value = 0; + } + else { + value <<= 7; + } + } + else { + if (bigValue == null) { + bigValue = BigInteger.valueOf(value); + } + bigValue = bigValue.or(BigInteger.valueOf(b & 0x7f)); + if ((b & 0x80) == 0) { + if (first) { + objId.append('2'); + bigValue = bigValue.subtract(BigInteger.valueOf(80)); + first = false; + } + + objId.append('.'); + objId.append(bigValue); + bigValue = null; + value = 0; + } + else { + bigValue = bigValue.shiftLeft(7); + } + } + } + + return objId.toString(); + } } } diff --git a/spring-vault-core/src/main/java/org/springframework/vault/support/PemObject.java b/spring-vault-core/src/main/java/org/springframework/vault/support/PemObject.java index e23910ea..054a17e4 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/support/PemObject.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/support/PemObject.java @@ -16,7 +16,12 @@ package org.springframework.vault.support; import java.io.IOException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; import java.security.spec.RSAPrivateCrtKeySpec; +import java.security.spec.RSAPublicKeySpec; +import java.util.ArrayList; +import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -33,35 +38,203 @@ import org.springframework.util.Base64Utils; */ public class PemObject { - private static final Pattern KEY_PATTERN = Pattern + private static final Pattern PRIVATE_KEY_PATTERN = Pattern .compile("-+BEGIN\\s+.*PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+" + // Header "([a-z0-9+/=\\r\\n]+)" + // Base64 text "-+END\\s+.*PRIVATE\\s+KEY[^-]*-+", // Footer Pattern.CASE_INSENSITIVE); - private final byte[] content; + private static final Pattern PUBLIC_KEY_PATTERN = Pattern + .compile("-+BEGIN\\s+.*PUBLIC\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+" + // Header + "([a-z0-9+/=\\r\\n]+)" + // Base64 text + "-+END\\s+.*PUBLIC\\s+KEY[^-]*-+", // Footer + Pattern.CASE_INSENSITIVE); - private PemObject(String content) { + private static final Pattern CERTIFICATE_PATTERN = Pattern + .compile("-+BEGIN\\s+.*CERTIFICATE[^-]*-+(?:\\s|\\r|\\n)+" + // Header + "([a-z0-9+/=\\r\\n]+)" + // Base64 text + "-+END\\s+.*CERTIFICATE[^-]*-+", // Footer + Pattern.CASE_INSENSITIVE); + + private static final Pattern[] PATTERNS = new Pattern[] { PRIVATE_KEY_PATTERN, + PUBLIC_KEY_PATTERN, CERTIFICATE_PATTERN }; + + private final byte[] content; + private final Pattern matchingPattern; + + private PemObject(String content, Pattern matchingPattern) { + this.matchingPattern = matchingPattern; String sanitized = content.replaceAll("\r", "").replaceAll("\n", ""); this.content = Base64Utils.decodeFromString(sanitized); } /** - * Create a{@link PemObject} from PEM {@code content} that is enclosed with - * {@code -BEGIN PRIVATE KEY-} and {@code -END PRIVATE KEY-}. + * Create a {@link PemObject} from PEM {@code content} that is enclosed with + * {@code -BEGIN PRIVATE KEY-} and {@code -END PRIVATE KEY-}. This method returns + * either the first PEM object ot throws {@link IllegalArgumentException} of no object + * could be found. * * @param content the PEM content. * @return the {@link PemObject} from PEM {@code content}. + * @throws IllegalArgumentException if no PEM object could be found. */ public static PemObject fromKey(String content) { - Matcher m = KEY_PATTERN.matcher(content); + Matcher m = PRIVATE_KEY_PATTERN.matcher(content); if (!m.find()) { throw new IllegalArgumentException("Could not find a PKCS #8 private key"); } - return new PemObject(m.group(1)); + return new PemObject(m.group(1), PRIVATE_KEY_PATTERN); + } + + /** + * Create a {@link PemObject} from PEM {@code content} that is enclosed with + * {@code -BEGIN PRIVATE KEY-} or {@code -BEGIN PUBLIC KEY-}. This method returns + * either the first PEM object ot throws {@link IllegalArgumentException} of no object + * could be found. + * + * @param content the PEM content. + * @return the {@link PemObject} from PEM {@code content}. + * @throws IllegalArgumentException if no PEM object could be found. + * @since 2.3 + */ + public static PemObject parseFirst(String content) { + + List objects = parse(content); + + if (objects.isEmpty()) { + throw new IllegalArgumentException("Cannot find PEM object"); + } + + return objects.get(0); + } + + /** + * Create one or more {@link PemObject}s from PEM {@code content}. Accepts + * concatenated PEM objects. + * + * @param content the PEM content. + * @return the list of {@link PemObject} from PEM {@code content}. + * @since 2.3 + */ + public static List parse(String content) { + + List objects = new ArrayList<>(); + int index = 0; + + boolean found; + + do { + found = false; + + Matcher discoveredMatcher = null; + int indexDiscoveredIndex = 0; + + for (Pattern pattern : PATTERNS) { + + Matcher m = pattern.matcher(content); + + if (!m.find(index)) { + continue; + } + + // discover which pattern is the next applicable one. + if (indexDiscoveredIndex == 0 || indexDiscoveredIndex > m.start()) { + discoveredMatcher = m; + indexDiscoveredIndex = m.start(); + } + } + + // extract using the matching pattern. + if (discoveredMatcher != null) { + found = true; + index = discoveredMatcher.end(); + objects.add(new PemObject(discoveredMatcher.group(1), + discoveredMatcher.pattern())); + } + + } + while (found); + + return objects; + } + + /** + * @return {@literal true} if the object was identified to contain a private key. + * @since 2.3 + */ + public boolean isCertificate() { + return this.matchingPattern.equals(CERTIFICATE_PATTERN); + } + + /** + * @return {@literal true} if the object was identified to contain a private key. + * @since 2.3 + */ + public boolean isPrivateKey() { + return this.matchingPattern.equals(PRIVATE_KEY_PATTERN); + } + + /** + * @return {@literal true} if the object was identified to contain a public key. + * @since 2.3 + */ + public boolean isPublicKey() { + return this.matchingPattern.equals(PUBLIC_KEY_PATTERN); + } + + /** + * Retrieve a {@link RSAPrivateCrtKeySpec}. + * + * @return the {@link RSAPrivateCrtKeySpec}. + * @deprecated since 2.3. Use {@link #getRSAPrivateKeySpec()} instead that uses an + * improved name to indicate what the method is supposed to return. + */ + @Deprecated + public RSAPrivateCrtKeySpec getRSAKeySpec() { + return getRSAPrivateKeySpec(); + } + + /** + * Retrieve a {@link X509Certificate}. + * + * @return the {@link X509Certificate}. + * @since 2.3 + */ + public X509Certificate getCertificate() { + + if (!isCertificate()) { + throw new IllegalStateException("PEM object is not a certificate"); + } + + try { + return KeystoreUtil.getCertificate(this.content); + } + catch (CertificateException e) { + throw new IllegalStateException("Cannot obtain Certificate", e); + } + } + + /** + * Retrieve a {@link RSAPrivateCrtKeySpec}. + * + * @return the {@link RSAPrivateCrtKeySpec}. + * @since 2.3 + */ + public RSAPrivateCrtKeySpec getRSAPrivateKeySpec() { + + if (!isPrivateKey()) { + throw new IllegalStateException("PEM object is not a private key"); + } + + try { + return KeystoreUtil.getRSAPrivateKeySpec(this.content); + } + catch (IOException e) { + throw new IllegalStateException("Cannot obtain PrivateKey", e); + } } /** @@ -69,13 +242,17 @@ public class PemObject { * * @return the {@link RSAPrivateCrtKeySpec}. */ - public RSAPrivateCrtKeySpec getRSAKeySpec() { + public RSAPublicKeySpec getRSAPublicKeySpec() { + + if (!isPublicKey()) { + throw new IllegalStateException("PEM object is not a public key"); + } try { - return KeystoreUtil.getRSAKeySpec(this.content); + return KeystoreUtil.getRSAPublicKeySpec(this.content); } catch (IOException e) { - throw new IllegalArgumentException("Cannot obtain PrivateKey", e); + throw new IllegalStateException("Cannot obtain PrivateKey", e); } } } diff --git a/spring-vault-core/src/main/java/org/springframework/vault/support/SslConfiguration.java b/spring-vault-core/src/main/java/org/springframework/vault/support/SslConfiguration.java index 07aa101b..a803a21b 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/support/SslConfiguration.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/support/SslConfiguration.java @@ -40,7 +40,19 @@ import org.springframework.util.Assert; */ public class SslConfiguration { - private static final String DEFAULT_KEYSTORE_TYPE = KeyStore.getDefaultType(); + /** + * Constant for PEM-based keystore type. + * + * @since 2.3 + */ + public static final String PEM_KEYSTORE_TYPE = "PEM"; + + /** + * Constant for system-default keystore type. + * + * @since 2.3 + */ + public static final String DEFAULT_KEYSTORE_TYPE = KeyStore.getDefaultType(); private final KeyStoreConfiguration keyStoreConfiguration; @@ -515,8 +527,23 @@ public class SslConfiguration { */ public static KeyStoreConfiguration of(Resource resource, @Nullable char[] storePassword) { - return new KeyStoreConfiguration(resource, storePassword, - DEFAULT_KEYSTORE_TYPE); + return of(resource, storePassword, DEFAULT_KEYSTORE_TYPE); + } + + /** + * Create a new {@link KeyStoreConfiguration} given {@link Resource}, + * {@code storePassword}, and {@code keyStoreType}. + * + * @param resource resource referencing the key store, must not be {@literal null} + * . + * @param storePassword key store password, may be {@literal null}. + * @param keyStoreType key store type, must not be {@literal null}. + * @return the {@link KeyStoreConfiguration} for {@code resource}. + * @since 2.3 + */ + public static KeyStoreConfiguration of(Resource resource, + @Nullable char[] storePassword, String keyStoreType) { + return new KeyStoreConfiguration(resource, storePassword, keyStoreType); } /** @@ -560,6 +587,21 @@ public class SslConfiguration { public String getStoreType() { return storeType; } + + /** + * Create a new {@link KeyStoreConfiguration} by applying all values from this + * object and the given {@code storeType}. + * + * @param storeType must not be {@literal null}. + * @return a new {@link KeyStoreConfiguration}. + * @since 2.3 + */ + public KeyStoreConfiguration withStoreType(String storeType) { + + Assert.notNull(storeType, "Key store type must not be null"); + return new KeyStoreConfiguration(this.resource, this.storePassword, + storeType); + } } /** diff --git a/spring-vault-core/src/test/java/org/springframework/vault/client/ClientHttpRequestFactoryFactoryIntegrationTests.java b/spring-vault-core/src/test/java/org/springframework/vault/client/ClientHttpRequestFactoryFactoryIntegrationTests.java index ce165c94..ad19044c 100644 --- a/spring-vault-core/src/test/java/org/springframework/vault/client/ClientHttpRequestFactoryFactoryIntegrationTests.java +++ b/spring-vault-core/src/test/java/org/springframework/vault/client/ClientHttpRequestFactoryFactoryIntegrationTests.java @@ -15,10 +15,13 @@ */ package org.springframework.vault.client; +import java.io.File; + import org.junit.jupiter.api.Test; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.InitializingBean; +import org.springframework.core.io.FileSystemResource; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; import org.springframework.http.client.ClientHttpRequestFactory; @@ -29,6 +32,7 @@ import org.springframework.vault.client.ClientHttpRequestFactoryFactory.HttpComp import org.springframework.vault.client.ClientHttpRequestFactoryFactory.Netty; import org.springframework.vault.client.ClientHttpRequestFactoryFactory.OkHttp3; import org.springframework.vault.support.ClientOptions; +import org.springframework.vault.support.SslConfiguration; import org.springframework.vault.util.Settings; import org.springframework.web.client.HttpStatusCodeException; import org.springframework.web.client.RestTemplate; @@ -59,6 +63,27 @@ class ClientHttpRequestFactoryFactoryIntegrationTests { ((DisposableBean) factory).destroy(); } + @Test + void httpComponentsClientUsingPemShouldWork() throws Exception { + + File caCertificate = new File(Settings.findWorkDir(), "ca/certs/ca.cert.pem"); + SslConfiguration sslConfiguration = SslConfiguration + .forTrustStore(SslConfiguration.KeyStoreConfiguration + .of(new FileSystemResource(caCertificate)) + .withStoreType(SslConfiguration.PEM_KEYSTORE_TYPE)); + + ClientHttpRequestFactory factory = HttpComponents + .usingHttpComponents(new ClientOptions(), sslConfiguration); + RestTemplate template = new RestTemplate(factory); + + String response = request(template); + + assertThat(factory).isInstanceOf(HttpComponentsClientHttpRequestFactory.class); + assertThat(response).isNotNull().contains("initialized"); + + ((DisposableBean) factory).destroy(); + } + @Test void nettyClientShouldWork() throws Exception { diff --git a/spring-vault-core/src/test/java/org/springframework/vault/support/PemObjectUnitTests.java b/spring-vault-core/src/test/java/org/springframework/vault/support/PemObjectUnitTests.java new file mode 100644 index 00000000..1ac2dee1 --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/support/PemObjectUnitTests.java @@ -0,0 +1,119 @@ +/* + * Copyright 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.vault.support; + +import java.io.File; +import java.io.IOException; +import java.security.spec.RSAPrivateCrtKeySpec; +import java.security.spec.RSAPublicKeySpec; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.util.FileCopyUtils; +import org.springframework.vault.util.Settings; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link PemObject}. + * + * @author Mark Paluch + */ +class PemObjectUnitTests { + + final File workdir = Settings.findWorkDir(); + final File privateDir = new File(workdir, "ca/private"); + + @BeforeEach + void setUp() { + assertThat(privateDir).exists().isDirectoryContaining( + file -> file.getName().equalsIgnoreCase("localhost.public.key.pem")); + assertThat(privateDir).exists().isDirectoryContaining( + file -> file.getName().equalsIgnoreCase("localhost.decrypted.key.pem")); + } + + @Test + void shouldDecodePublicKey() throws IOException { + + String content = new String(FileCopyUtils + .copyToByteArray(new File(privateDir, "localhost.public.key.pem"))); + + PemObject pemObject = PemObject.parseFirst(content); + assertThat(pemObject.isPrivateKey()).isFalse(); + assertThat(pemObject.isPublicKey()).isTrue(); + assertThat(pemObject.getRSAPublicKeySpec()).isNotNull(); + } + + @Test + void shouldDecodePrivateKey() throws IOException { + + String content = new String(FileCopyUtils + .copyToByteArray(new File(privateDir, "localhost.decrypted.key.pem"))); + + PemObject pemObject = PemObject.parseFirst(content); + assertThat(pemObject.isPrivateKey()).isTrue(); + assertThat(pemObject.isPublicKey()).isFalse(); + assertThat(pemObject.getRSAPrivateKeySpec()).isNotNull(); + } + + @Test + void shouldDecodeConcatenatedPEMContent() throws IOException { + + String content1 = new String(FileCopyUtils + .copyToByteArray(new File(privateDir, "localhost.public.key.pem"))); + String content2 = new String(FileCopyUtils + .copyToByteArray(new File(privateDir, "localhost.decrypted.key.pem"))); + + List pemObjects = PemObject.parse(content1 + content2); + + assertThat(pemObjects).hasSize(2); + assertThat(pemObjects.get(0).isPublicKey()).isTrue(); + assertThat(pemObjects.get(1).isPrivateKey()).isTrue(); + } + + @Test + void keysShouldMatch() throws IOException { + + PemObject publicKey = PemObject.parseFirst(new String(FileCopyUtils + .copyToByteArray(new File(privateDir, "localhost.public.key.pem")))); + + PemObject privateKey = PemObject.parseFirst(new String(FileCopyUtils + .copyToByteArray(new File(privateDir, "localhost.decrypted.key.pem")))); + + RSAPublicKeySpec publicSpec = publicKey.getRSAPublicKeySpec(); + RSAPrivateCrtKeySpec privateKeySpec = privateKey.getRSAPrivateKeySpec(); + + assertThat(publicSpec.getModulus()).isEqualTo(privateKeySpec.getModulus()); + assertThat(publicSpec.getPublicExponent()) + .isEqualTo(privateKeySpec.getPublicExponent()); + } + + @Test + void shouldDecodeX509Certificate() throws IOException { + + String content = new String( + FileCopyUtils.copyToByteArray(new File(workdir, "ca/certs/ca.cert.pem"))); + + PemObject pemObject = PemObject.parseFirst(content); + + assertThat(pemObject.isCertificate()).isTrue(); + assertThat(pemObject.getCertificate().getSubjectDN().getName()) + .contains("O=spring-cloud-vault-config"); + } + +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/support/SslConfigurationUnitTests.java b/spring-vault-core/src/test/java/org/springframework/vault/support/SslConfigurationUnitTests.java index fd04c616..6e6aaab0 100644 --- a/spring-vault-core/src/test/java/org/springframework/vault/support/SslConfigurationUnitTests.java +++ b/spring-vault-core/src/test/java/org/springframework/vault/support/SslConfigurationUnitTests.java @@ -65,4 +65,15 @@ class SslConfigurationUnitTests { assertThat(tsConfig.getTrustStoreConfiguration()).isSameAs(keystore); assertThat(tsConfig.getKeyStoreConfiguration().isPresent()).isFalse(); } + + @Test + void shouldCreatePemConfiguration() { + + KeyStoreConfiguration keystore = KeyStoreConfiguration + .of(new ClassPathResource("certificate.json")).withStoreType("PEM"); + SslConfiguration configuration = SslConfiguration.forTrustStore(keystore); + + assertThat(configuration.getTrustStoreConfiguration().getStoreType()) + .isEqualTo("PEM"); + } } diff --git a/src/main/asciidoc/new-features.adoc b/src/main/asciidoc/new-features.adoc index 4240af6a..6edaff86 100644 --- a/src/main/asciidoc/new-features.adoc +++ b/src/main/asciidoc/new-features.adoc @@ -1,6 +1,11 @@ [[new-features]] == New & Noteworthy +[[new-features.2-3-0]] +=== What's new in Spring Vault 2.3 + +* Support for PEM-encoded certificates for keystore and truststore usage. + [[new-features.2-2-0]] === What's new in Spring Vault 2.2 @@ -9,7 +14,8 @@ * Add support for Jetty as reactive HttpClient. * `LifecycleAwareSessionManager` and `ReactiveLifecycleAwareSessionManager` emit now ``AuthenticationEvent``s. * <>. -* Deprecation of `AppIdAuthentication`. Use `AppRoleAuthentication` instead as recommended by HashiCorp Vault. +* Deprecation of `AppIdAuthentication`. +Use `AppRoleAuthentication` instead as recommended by HashiCorp Vault. * `CubbyholeAuthentication` and wrapped `AppRoleAuthentication` now use `sys/wrapping/unwrap` endpoints by default. * Kotlin Coroutines support for `ReactiveVaultOperations`. diff --git a/src/main/asciidoc/reference/client-support.adoc b/src/main/asciidoc/reference/client-support.adoc index d733d6bb..718a0c90 100644 --- a/src/main/asciidoc/reference/client-support.adoc +++ b/src/main/asciidoc/reference/client-support.adoc @@ -127,6 +127,18 @@ SslConfiguration.forKeyStore(new FileSystemResource("keystore.jks"), <4> <4> Configuring only key store settings with providing a key-configuration. ==== -Please note that providing `SslConfiguration` can be only -applied when either Apache Http Components or the OkHttp client -is on your class-path. +Please note that providing `SslConfiguration` can be only applied when either Apache Http Components or the OkHttp client is on your class-path. + +The SSL configuration supports also PEM-encoded certificates as alternative to a Java Key Store. + +==== +[source,java] +---- +KeyStoreConfiguration keystore = KeyStoreConfiguration + .of(new ClassPathResource("ca.pem")).withStoreType("PEM"); +SslConfiguration configuration = SslConfiguration.forTrustStore(keystore); +---- +==== + +PEM files may contain one or more certificates (blocks of `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----`). +Certificates added to the underlying `KeyStore` use the full subject name as alias. diff --git a/src/main/asciidoc/reference/imperative-template.adoc b/src/main/asciidoc/reference/imperative-template.adoc index f16d0fbe..1aaa2e89 100644 --- a/src/main/asciidoc/reference/imperative-template.adoc +++ b/src/main/asciidoc/reference/imperative-template.adoc @@ -172,8 +172,10 @@ vault.token=00000000-0000-0000-0000-000000000000 * SSL Configuration ** Keystore resource: `vault.ssl.key-store` (optional) ** Keystore password: `vault.ssl.key-store-password` (optional) +** Keystore type: `vault.ssl.key-store-type` (optional, typically `jks`, supports also `pem`) ** Truststore resource: `vault.ssl.trust-store` (optional) ** Truststore password: `vault.ssl.trust-store-password` (optional) +** Truststore type: `vault.ssl.trust-store-type` (optional, typically `jks`, supports also `pem`) * Authentication method: `vault.authentication` (defaults to `TOKEN`, supported authentication methods are: `TOKEN`, `APPID`, `APPROLE`, `AWS_EC2`, `AZURE`, `CERT`, `CUBBYHOLE`, `KUBERNETES`) **Authentication-specific property keys** diff --git a/src/test/bash/create_certificates.sh b/src/test/bash/create_certificates.sh index 8347480f..b98b9161 100755 --- a/src/test/bash/create_certificates.sh +++ b/src/test/bash/create_certificates.sh @@ -62,6 +62,10 @@ openssl rsa -in ${CA_DIR}/private/localhost.key.pem \ -out ${CA_DIR}/private/localhost.decrypted.key.pem \ -passin pass:changeit +openssl rsa -in ${CA_DIR}/private/localhost.key.pem \ + -pubout -out ${CA_DIR}/private/localhost.public.key.pem \ + -passin pass:changeit + chmod 400 ${CA_DIR}/private/localhost.key.pem chmod 400 ${CA_DIR}/private/localhost.decrypted.key.pem @@ -82,7 +86,6 @@ openssl ca -config ${DIR}/openssl.cnf \ -in ${CA_DIR}/csr/localhost.csr.pem \ -out ${CA_DIR}/certs/localhost.cert.pem - echo "[INFO] Generating client auth private key" openssl genrsa -aes256 \ -passout pass:changeit \