diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultOperations.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultOperations.java index c8022651..052a716e 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultOperations.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultOperations.java @@ -72,6 +72,19 @@ public interface VaultOperations { */ VaultTransitOperations opsForTransit(String path); + /** + * @return the operations interface to interact with the Vault PKI backend. + */ + VaultPkiOperations opsForPki(); + + /** + * Returns {@link VaultPkiOperations} if the PKI backend is mounted on a different path than {@code pki}. + * + * @param path the mount path + * @return the operations interface to interact with the Vault PKI backend. + */ + VaultPkiOperations opsForPki(String path); + /** * Read from a secret backend. Reading data using this method is suitable for secret backends that do not require a * request body. diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultPkiOperations.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultPkiOperations.java new file mode 100644 index 00000000..bff137f5 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultPkiOperations.java @@ -0,0 +1,49 @@ +/* + * Copyright 2016 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 + * + * http://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.core; + +import org.springframework.vault.client.VaultException; +import org.springframework.vault.support.CertificateBundle; +import org.springframework.vault.support.VaultCertificateRequest; +import org.springframework.vault.support.VaultCertificateResponse; + +/** + * Interface that specifies PKI backend-related operations. + *

+ * The PKI secret backend for Vault generates X.509 certificates dynamically based on configured roles. This means + * services can get certificates needed for both client and server authentication without going through the usual manual + * process of generating a private key and CSR, submitting to a CA, and waiting for a verification and signing process + * to complete. Vault's built-in authentication and authorization mechanisms provide the verification functionality. + * + * @author Mark Paluch + * @see https://www.vaultproject.io/docs/secrets/pki/index.html + */ +public interface VaultPkiOperations { + + /** + * Requests a certificate bundle (private key and certificate) from Vault's PKI backend given a {@code roleName} and + * {@link VaultCertificateRequest}. The issuing CA certificate is returned as well, so that only the root CA need be + * in a client's trust store. Certificates use DER format and are base64 encoded. + * + * @param roleName must not be empty or {@literal null}. + * @param certificateRequest must not be {@literal null}. + * @return the {@link VaultCertificateResponse} containing a {@link CertificateBundle}. + * @see POST /pki/issue/[role name] + */ + VaultCertificateResponse issueCertificate(String roleName, VaultCertificateRequest certificateRequest) + throws VaultException; +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultPkiTemplate.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultPkiTemplate.java new file mode 100644 index 00000000..154b0291 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultPkiTemplate.java @@ -0,0 +1,107 @@ +/* + * Copyright 2016 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 + * + * http://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.core; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.vault.client.VaultException; +import org.springframework.vault.client.VaultResponseEntity; +import org.springframework.vault.support.VaultCertificateRequest; +import org.springframework.vault.support.VaultCertificateResponse; + +/** + * Default implementation of {@link VaultPkiOperations}. + * + * @author Mark Paluch + */ +public class VaultPkiTemplate implements VaultPkiOperations { + + private final VaultOperations vaultOperations; + + private final String path; + + /** + * Create a new {@link VaultPkiTemplate} given {@link VaultPkiOperations} and the mount {@code path}. + * + * @param vaultOperations must not be {@literal null}. + * @param path must not be empty or {@literal null}. + */ + public VaultPkiTemplate(VaultOperations vaultOperations, String path) { + + Assert.notNull(vaultOperations, "VaultOperations must not be null"); + Assert.hasText(path, "Path must not be empty"); + + this.vaultOperations = vaultOperations; + this.path = path; + } + + @Override + public VaultCertificateResponse issueCertificate(final String roleName, VaultCertificateRequest certificateRequest) + throws VaultException { + + Assert.hasText(roleName, "Role name must not be empty"); + Assert.notNull(certificateRequest, "Certificate request must not be null"); + + final Map request = new HashMap(); + request.put("common_name", certificateRequest.getCommonName()); + + if (!certificateRequest.getAltNames().isEmpty()) { + request.put("alt_names", StringUtils.collectionToDelimitedString(certificateRequest.getAltNames(), ",")); + } + + if (!certificateRequest.getIpSubjectAltNames().isEmpty()) { + request.put("ip_sans", StringUtils.collectionToDelimitedString(certificateRequest.getIpSubjectAltNames(), ",")); + } + + if (certificateRequest.getTtl() != null) { + request.put("ttl", certificateRequest.getTtl()); + } + + request.put("format", "der"); + + if (certificateRequest.isExcludeCommonNameFromSubjectAltNames()) { + request.put("exclude_cn_from_sans", true); + } + + VaultResponseEntity entity = vaultOperations + .doWithVault(new VaultOperations.SessionCallback>() { + @Override + public VaultResponseEntity doWithVault(VaultOperations.VaultSession session) { + + return session.postForEntity(String.format("%s/issue/%s", path, roleName), request, + VaultCertificateResponse.class); + } + }); + + if (entity.isSuccessful() && entity.hasBody()) { + return entity.getBody(); + } + + throw new VaultException(buildExceptionMessage(entity)); + } + + private static String buildExceptionMessage(VaultResponseEntity response) { + + if (StringUtils.hasText(response.getMessage())) { + return String.format("Status %s URI %s: %s", response.getStatusCode(), response.getUri(), response.getMessage()); + } + + return String.format("Status %s URI %s", response.getStatusCode(), response.getUri()); + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultSysOperations.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultSysOperations.java index 03b6a1df..2d48ebbe 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultSysOperations.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultSysOperations.java @@ -25,8 +25,7 @@ import org.springframework.vault.support.VaultMount; import org.springframework.vault.support.VaultUnsealStatus; /** - * Interface that specified a basic set of Vault operations, implemented by {@link VaultTemplate}. Request errors are - * wrapped within {@link VaultException}. + * Interface that specifies a basic set of administrative Vault operations. * * @author Mark Paluch */ diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultTemplate.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultTemplate.java index dd6a16ff..845c8996 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultTemplate.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultTemplate.java @@ -139,6 +139,16 @@ public class VaultTemplate implements InitializingBean, VaultOperations { return new VaultTransitTemplate(this, path); } + @Override + public VaultPkiOperations opsForPki() { + return opsForPki("pki"); + } + + @Override + public VaultPkiOperations opsForPki(String path) { + return new VaultPkiTemplate(this, path); + } + @Override public T doWithVault(ClientCallback clientCallback) { diff --git a/spring-vault-core/src/main/java/org/springframework/vault/support/Base64.java b/spring-vault-core/src/main/java/org/springframework/vault/support/Base64.java new file mode 100644 index 00000000..f49bcbfe --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/support/Base64.java @@ -0,0 +1,102 @@ +/* + * Copyright 2016 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 + * + * http://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; + +class Base64 { + private Base64() {} + + public static byte[] decode(String in) { + // Ignore trailing '=' padding and whitespace from the input. + int limit = in.length(); + for (; limit > 0; limit--) { + char c = in.charAt(limit - 1); + if (c != '=' && c != '\n' && c != '\r' && c != ' ' && c != '\t') { + break; + } + } + + // If the input includes whitespace, this output array will be longer than necessary. + byte[] out = new byte[(int) (limit * 6L / 8L)]; + int outCount = 0; + int inCount = 0; + + int word = 0; + for (int pos = 0; pos < limit; pos++) { + char c = in.charAt(pos); + + int bits; + if (c >= 'A' && c <= 'Z') { + // char ASCII value + // A 65 0 + // Z 90 25 (ASCII - 65) + bits = c - 65; + } else if (c >= 'a' && c <= 'z') { + // char ASCII value + // a 97 26 + // z 122 51 (ASCII - 71) + bits = c - 71; + } else if (c >= '0' && c <= '9') { + // char ASCII value + // 0 48 52 + // 9 57 61 (ASCII + 4) + bits = c + 4; + } else if (c == '+' || c == '-') { + bits = 62; + } else if (c == '/' || c == '_') { + bits = 63; + } else if (c == '\n' || c == '\r' || c == ' ' || c == '\t') { + continue; + } else { + return null; + } + + // Append this char's 6 bits to the word. + word = (word << 6) | (byte) bits; + + // For every 4 chars of input, we accumulate 24 bits of output. Emit 3 bytes. + inCount++; + if (inCount % 4 == 0) { + out[outCount++] = (byte) (word >> 16); + out[outCount++] = (byte) (word >> 8); + out[outCount++] = (byte) word; + } + } + + int lastWordChars = inCount % 4; + if (lastWordChars == 1) { + // We read 1 char followed by "===". But 6 bits is a truncated byte! Fail. + return null; + } else if (lastWordChars == 2) { + // We read 2 chars followed by "==". Emit 1 byte with 8 of those 12 bits. + word = word << 12; + out[outCount++] = (byte) (word >> 16); + } else if (lastWordChars == 3) { + // We read 3 chars, followed by "=". Emit 2 bytes for 16 of those 18 bits. + word = word << 6; + out[outCount++] = (byte) (word >> 16); + out[outCount++] = (byte) (word >> 8); + } + + // If we sized our out array perfectly, we're done. + if (outCount == out.length) + return out; + + // Copy the decoded bytes to a new, right-sized array. + byte[] prefix = new byte[outCount]; + System.arraycopy(out, 0, prefix, 0, outCount); + return prefix; + } +} 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 new file mode 100644 index 00000000..f774b699 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/support/CertificateBundle.java @@ -0,0 +1,178 @@ +/* + * Copyright 2016 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 + * + * http://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.IOException; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.security.spec.KeySpec; + +import org.springframework.util.Assert; +import org.springframework.vault.client.VaultException; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Value object representing a certificate bundle consisting of a private key, the certificate and the issuer + * certificate. Certificate and keys can be either DER or PEM encoded. DER-encoded certificates can be converted to a + * {@link KeySpec} and {@link X509Certificate}. + * + * @author Mark Paluch + * @see #getPrivateKeySpec() + * @see #getX509Certificate() + * @see #getIssuingCaCertificate() + */ +public class CertificateBundle { + + private final String serialNumber; + + private final String certificate; + + private final String issuingCaCertificate; + + private final String privateKey; + + private CertificateBundle(@JsonProperty("serial_number") String serialNumber, + @JsonProperty("certificate") String certificate, @JsonProperty("issuing_ca") String issuingCaCertificate, + @JsonProperty("private_key") String privateKey) { + + this.serialNumber = serialNumber; + this.certificate = certificate; + this.issuingCaCertificate = issuingCaCertificate; + this.privateKey = privateKey; + } + + /** + * Create a {@link CertificateBundle} given a private key with certificates and the serial number. + * + * @param serialNumber must not be empty or {@literal null}. + * @param certificate must not be empty or {@literal null}. + * @param issuingCaCertificate must not be empty or {@literal null}. + * @param privateKey must not be empty or {@literal null}. + * @return the {@link CertificateBundle} + */ + public static CertificateBundle of(String serialNumber, String certificate, String issuingCaCertificate, + String privateKey) { + + Assert.hasText(serialNumber, "Serial number must not be empty"); + Assert.hasText(certificate, "Certificate must not be empty"); + Assert.hasText(issuingCaCertificate, "Issuing CA certificate must not be empty"); + Assert.hasText(privateKey, "Private key must not be empty"); + + return new CertificateBundle(serialNumber, certificate, issuingCaCertificate, privateKey); + } + + /** + * @return the serial number. + */ + public String getSerialNumber() { + return this.serialNumber; + } + + /** + * @return encoded certificate (PEM or DER-encoded). + */ + public String getCertificate() { + return this.certificate; + } + + /** + * @return encoded certificate of the issuing CA (PEM or DER-encoded). + */ + public String getIssuingCaCertificate() { + return this.issuingCaCertificate; + } + + /** + * @return the private key (decrypted form, PEM or DER-encoded) + */ + public String getPrivateKey() { + return this.privateKey; + } + + /** + * Retrieve the private key as {@link KeySpec}. Only supported if private key is DER-encoded. + * + * @return the private {@link KeySpec}. {@link java.security.KeyFactory} can generate a + * {@link java.security.PrivateKey} from this {@link KeySpec}. + */ + public KeySpec getPrivateKeySpec() { + + try { + byte[] bytes = Base64.decode(getPrivateKey()); + return KeystoreUtil.getRSAKeySpec(bytes); + } catch (IOException e) { + throw new VaultException("Cannot create KeySpec from private key", e); + } + } + + /** + * Retrieve the certificate as {@link X509Certificate}. Only supported if certificate is DER-encoded. + * + * @return the {@link X509Certificate}. + */ + public X509Certificate getX509Certificate() { + + try { + byte[] bytes = Base64.decode(getCertificate()); + return KeystoreUtil.getCertificate(bytes); + } catch (IOException e) { + throw new VaultException("Cannot create Certificate from certificate", e); + } catch (CertificateException e) { + throw new VaultException("Cannot create Certificate from certificate", e); + } + } + + /** + * Retrieve the issuing CA certificate as {@link X509Certificate}. Only supported if certificate is DER-encoded. + * + * @return the issuing CA {@link X509Certificate}. + */ + public X509Certificate getX509IssuerCertificate() { + + try { + byte[] bytes = Base64.decode(getIssuingCaCertificate()); + return KeystoreUtil.getCertificate(bytes); + } catch (IOException e) { + throw new VaultException("Cannot create Certificate from issuing CA certificate", e); + } catch (CertificateException e) { + throw new VaultException("Cannot create Certificate from issuing CA certificate", e); + } + } + + /** + * Create a {@link KeyStore} from this {@link CertificateBundle} containing the private key and certificate chain. + * Only supported if certificate and private key are DER-encoded. + * + * @param keyAlias the key alias to use. + * @return the {@link KeyStore} containing the private key and certificate chain. + */ + public KeyStore createKeyStore(String keyAlias) { + + Assert.hasText(keyAlias, "Key alias must not be empty"); + + try { + return KeystoreUtil.createKeyStore(keyAlias, getPrivateKeySpec(), getX509Certificate(), + getX509IssuerCertificate()); + } catch (GeneralSecurityException e) { + throw new VaultException("Cannot create KeyStore", e); + } catch (IOException e) { + throw new VaultException("Cannot create KeyStore", 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 new file mode 100644 index 00000000..83ead2dd --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/support/KeystoreUtil.java @@ -0,0 +1,433 @@ +/* + * Copyright 2016 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 + * + * http://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.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.KeySpec; +import java.security.spec.RSAPrivateCrtKeySpec; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Keystore utility to create a {@link KeyStore} containing a {@link CertificateBundle} with the certificate chain and + * its private key. + * + * @author Mark Paluch + */ +class KeystoreUtil { + + /** + * Creates a {@link KeyStore} containing the {@link KeySpec} and {@link X509Certificate certificates} using the given + * {@code keyAlias}. + * + * @param keyAlias + * @param certificates + * @return + * @throws GeneralSecurityException + * @throws IOException + */ + static KeyStore createKeyStore(String keyAlias, KeySpec privateKeySpec, X509Certificate... certificates) + throws GeneralSecurityException, IOException { + + KeyFactory kf = KeyFactory.getInstance("RSA"); + PrivateKey privateKey = kf.generatePrivate(privateKeySpec); + + KeyStore keyStore = createKeyStore(); + + List certChain = new ArrayList(); + Collections.addAll(certChain, certificates); + + keyStore.setKeyEntry(keyAlias, privateKey, new char[0], + certChain.toArray(new java.security.cert.Certificate[certChain.size()])); + + return keyStore; + } + + static X509Certificate getCertificate(byte[] source) throws CertificateException, IOException { + + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + + List certificates = getCertificates(certificateFactory, source); + + if (certificates.isEmpty()) { + return null; + } + + return certificates.get(0); + } + + /** + * Creates an empty {@link KeyStore}. + * + * @return + * @throws GeneralSecurityException + * @throws IOException + */ + private static KeyStore createKeyStore() throws GeneralSecurityException, IOException { + + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, new char[0]); + + return keyStore; + } + + private static List getCertificates(CertificateFactory cf, byte[] source) + throws CertificateException, IOException { + + List x509Certificates = new ArrayList(); + + ByteArrayInputStream bis = new ByteArrayInputStream(source); + while (bis.available() > 0) { + java.security.cert.Certificate cert = cf.generateCertificate(bis); + + if (cert instanceof X509Certificate) { + x509Certificates.add((X509Certificate) cert); + } + } + + return x509Certificates; + } + + /** + * Convert PKCS#1 encoded private key into RSAPrivateCrtKeySpec. + *

+ *

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

+ * + *

+	 * --
+	 * -- Representation of RSA private key with information for the CRT algorithm.
+	 * --
+	 * RSAPrivateKey ::= SEQUENCE {
+	 *   version           Version,
+	 *   modulus           INTEGER,  -- n
+	 *   publicExponent    INTEGER,  -- e
+	 *   privateExponent   INTEGER,  -- d
+	 *   prime1            INTEGER,  -- p
+	 *   prime2            INTEGER,  -- q
+	 *   exponent1         INTEGER,  -- d mod (p-1)
+	 *   exponent2         INTEGER,  -- d mod (q-1)
+	 *   coefficient       INTEGER,  -- (inverse of q) mod p
+	 *   otherPrimeInfos   OtherPrimeInfos OPTIONAL
+	 * }
+	 * 
+ * + * @param keyBytes PKCS#1 encoded key + * @return KeySpec + * @throws IOException + */ + static RSAPrivateCrtKeySpec getRSAKeySpec(byte[] keyBytes) throws IOException { + 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(); + + parser.read(); // Skip version + BigInteger modulus = parser.read().getInteger(); + BigInteger publicExp = parser.read().getInteger(); + BigInteger privateExp = parser.read().getInteger(); + BigInteger prime1 = parser.read().getInteger(); + BigInteger prime2 = parser.read().getInteger(); + BigInteger exp1 = parser.read().getInteger(); + BigInteger exp2 = parser.read().getInteger(); + BigInteger crtCoef = parser.read().getInteger(); + + return new RSAPrivateCrtKeySpec(modulus, publicExp, privateExp, prime1, prime2, exp1, exp2, crtCoef); + } + + /** + * 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 tag. + *

+ *

+ * This parser can only handle one layer. To parse nested constructs, get a new parser for each layer using + * Asn1Object.getParser(). + *

+ *

+ * There are many DER decoders in JRE but using them will tie this program to a specific JCE/JVM. + */ + private static class DerParser { + + // Classes + public final static int UNIVERSAL = 0x00; + public final static int APPLICATION = 0x40; + public final static int CONTEXT = 0x80; + public final static int PRIVATE = 0xC0; + + // Constructed Flag + public final static int CONSTRUCTED = 0x20; + + // Tag and data types + public final static int ANY = 0x00; + public final static int BOOLEAN = 0x01; + public final static int INTEGER = 0x02; + public final static int BIT_STRING = 0x03; + public final static int OCTET_STRING = 0x04; + public final static int NULL = 0x05; + public final static int REAL = 0x09; + public final static int ENUMERATED = 0x0a; + + public final static int SEQUENCE = 0x10; + public final static int SET = 0x11; + + public final static int NUMERIC_STRING = 0x12; + public final static int PRINTABLE_STRING = 0x13; + public final static int VIDEOTEX_STRING = 0x15; + public final static int IA5_STRING = 0x16; + public final static int GRAPHIC_STRING = 0x19; + public final static int ISO646_STRING = 0x1A; + public final static int GENERAL_STRING = 0x1B; + + public final static int UTF8_STRING = 0x0C; + public final static int UNIVERSAL_STRING = 0x1C; + public final static int BMP_STRING = 0x1E; + + public final static int UTC_TIME = 0x17; + + protected InputStream in; + + /** + * Create a new DER decoder from an input stream. + * + * @param in The DER encoded stream + */ + public DerParser(InputStream in) { + this.in = in; + } + + /** + * Create a new DER decoder from a byte array. + * + * @param bytes The encoded bytes + */ + public DerParser(byte[] bytes) { + this(new ByteArrayInputStream(bytes)); + } + + /** + * Read next object. If it's constructed, the value holds encoded content and it should be parsed by a new parser + * from Asn1Object.getParser. + * + * @return A object + * @throws IOException + */ + public Asn1Object read() throws IOException { + + int tag = in.read(); + + if (tag == -1) { + throw new IllegalStateException("Invalid DER: stream too short, missing tag"); + } + + int length = getLength(); + + byte[] value = new byte[length]; + int n = in.read(value); + if (n < length) { + throw new IllegalStateException("Invalid DER: stream too short, missing value"); + } + + return new Asn1Object(tag, length, value); + } + + /** + * Decode the length of the field. Can only support length encoding up to 4 octets. + *

+ *

+ * In BER/DER encoding, length can be encoded in 2 forms, + *

+ * + * @return The length as integer + * @throws IOException + */ + private int getLength() throws IOException { + + int i = in.read(); + if (i == -1) { + throw new IllegalStateException("Invalid DER: length missing"); + } + + // A single byte short length + if ((i & ~0x7F) == 0) { + return i; + } + + int num = i & 0x7F; + + // We can't handle length longer than 4 bytes + if (i >= 0xFF || num > 4) { + throw new IllegalStateException("Invalid DER: length field too big (" + i + ")"); + } + + byte[] bytes = new byte[num]; + int n = in.read(bytes); + if (n < num) { + throw new IllegalStateException("Invalid DER: length too short"); + } + + return new BigInteger(1, bytes).intValue(); + } + } + + /** + * An ASN.1 TLV. The object is not parsed. It can only handle integers and strings. + */ + private static class Asn1Object { + + private final int type; + private final int length; + private final byte[] value; + private final int tag; + + /** + * Construct a ASN.1 TLV. The TLV could be either a constructed or primitive entity. + *

+ *

+ * The first byte in DER encoding is made of following fields, + * + *

+		 * -------------------------------------------------
+		 * |Bit 8|Bit 7|Bit 6|Bit 5|Bit 4|Bit 3|Bit 2|Bit 1|
+		 * -------------------------------------------------
+		 * |  Class    | CF  |     +      Type             |
+		 * -------------------------------------------------
+		 * 
+ * + * + * @param tag Tag or Identifier + * @param length Length of the field + * @param value Encoded octet string for the field. + */ + public Asn1Object(int tag, int length, byte[] value) { + this.tag = tag; + this.type = tag & 0x1F; + this.length = length; + this.value = value; + } + + public int getType() { + return type; + } + + public int getLength() { + return length; + } + + public byte[] getValue() { + return value; + } + + public boolean isConstructed() { + return (tag & DerParser.CONSTRUCTED) == DerParser.CONSTRUCTED; + } + + /** + * For constructed field, return a parser for its content. + * + * @return A parser for the construct. + * @throws IOException + */ + public DerParser getParser() throws IOException { + if (!isConstructed()) { + throw new IllegalStateException("Invalid DER: can't parse primitive entity"); + } + + return new DerParser(value); + } + + /** + * Get the value as integer + * + * @return BigInteger + */ + public BigInteger getInteger() { + + if (type != DerParser.INTEGER) { + throw new IllegalStateException("Invalid DER: object is not integer"); + } + + return new BigInteger(value); + } + + /** + * Get value as string. Most strings are treated as ISO-8859-1. + * + * @return Java string + * @throws IOException + */ + public String getString() throws IOException { + + String encoding; + + switch (type) { + + // Not all are ISO-8859-1 but it's the closest thing + case DerParser.NUMERIC_STRING: + case DerParser.PRINTABLE_STRING: + case DerParser.VIDEOTEX_STRING: + case DerParser.IA5_STRING: + case DerParser.GRAPHIC_STRING: + case DerParser.ISO646_STRING: + case DerParser.GENERAL_STRING: + encoding = "ISO-8859-1"; + break; + + case DerParser.BMP_STRING: + encoding = "UTF-16BE"; + break; + + case DerParser.UTF8_STRING: + encoding = "UTF-8"; + break; + + case DerParser.UNIVERSAL_STRING: + throw new IOException("Invalid DER: can't handle UCS-4 string"); + + default: + throw new IOException("Invalid DER: object is not a string"); + } + + return new String(value, encoding); + } + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/support/VaultCertificateRequest.java b/spring-vault-core/src/main/java/org/springframework/vault/support/VaultCertificateRequest.java new file mode 100644 index 00000000..17bf8564 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/support/VaultCertificateRequest.java @@ -0,0 +1,272 @@ +/* + * Copyright 2016 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 + * + * http://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.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.springframework.util.Assert; + +/** + * Request for a Certificate. + * + * @author Mark Paluch + */ +public class VaultCertificateRequest { + + /** + * The CN of the certificate. Should match the host name. + */ + private final String commonName; + + /** + * Alternate CN names for additional host names. + */ + private final List altNames; + + /** + * Requested IP Subject Alternative Names. + */ + private final List ipSubjectAltNames; + + /** + * Requested Time To Live + */ + private final Integer ttl; + + /** + * If {@literal true}, the given common name will not be included in DNS or Email Subject Alternate Names (as + * appropriate). Useful if the CN is not a hostname or email address, but is instead some human-readable identifier. + */ + private final boolean excludeCommonNameFromSubjectAltNames; + + VaultCertificateRequest(String commonName, List altNames, List ipSubjectAltNames, Integer ttl, + Boolean excludeCommonNameFromSubjectAltNames) { + + this.commonName = commonName; + this.altNames = altNames; + this.ipSubjectAltNames = ipSubjectAltNames; + this.ttl = ttl; + this.excludeCommonNameFromSubjectAltNames = excludeCommonNameFromSubjectAltNames != null + && excludeCommonNameFromSubjectAltNames.booleanValue(); + } + + /** + * @return a new {@link VaultCertificateRequestBuilder}. + */ + public static VaultCertificateRequestBuilder builder() { + return new VaultCertificateRequestBuilder(); + } + + /** + * Creates a new {@link VaultCertificateRequest} given a {@code commonName}. + * + * @param commonName must not be empty or {@literal null}. + * @return the created {@link VaultCertificateRequest}. + */ + public static VaultCertificateRequest create(String commonName) { + return builder().commonName(commonName).build(); + } + + public String getCommonName() { + return commonName; + } + + public List getAltNames() { + return altNames; + } + + public List getIpSubjectAltNames() { + return ipSubjectAltNames; + } + + public Integer getTtl() { + return ttl; + } + + public boolean isExcludeCommonNameFromSubjectAltNames() { + return excludeCommonNameFromSubjectAltNames; + } + + public static class VaultCertificateRequestBuilder { + + private String commonName; + private List altNames = new ArrayList(); + private List ipSubjectAltNames = new ArrayList(); + private Integer ttl; + private Boolean excludeCommonNameFromSubjectAltNames; + + VaultCertificateRequestBuilder() {} + + /** + * Configure the common name. + * + * @param commonName must not be empty or {@literal null}. + * @return {@code this} {@link VaultCertificateRequestBuilder}. + */ + public VaultCertificateRequestBuilder commonName(String commonName) { + + Assert.hasText(commonName, "Common name must not be empty"); + + this.commonName = commonName; + return this; + } + + /** + * Configures alternative names. Replaces previously configured alt names + * + * @param altNames must not be {@literal null}. + * @return {@code this} {@link VaultCertificateRequestBuilder}. + */ + public VaultCertificateRequestBuilder altNames(Iterable altNames) { + + Assert.notNull(altNames, "Alt names must not be null"); + + this.altNames = toList(altNames); + return this; + } + + /** + * Add an alternative name. + * + * @param altName must not be empty or {@literal null}. + * @return {@code this} {@link VaultCertificateRequestBuilder}. + */ + public VaultCertificateRequestBuilder withAltName(String altName) { + + Assert.hasText(altName, "Alt name must not be empty"); + + this.altNames.add(altName); + return this; + } + + /** + * Configures IP subject alternative names. Replaces previously configured IP subject alt names. + * + * @param ipSubjectAltNames must not be {@literal null}. + * @return {@code this} {@link VaultCertificateRequestBuilder}. + */ + public VaultCertificateRequestBuilder ipSubjectAltNames(Iterable ipSubjectAltNames) { + + Assert.notNull(ipSubjectAltNames, "IP subject alt names must not be null"); + + this.ipSubjectAltNames = toList(ipSubjectAltNames); + return this; + } + + /** + * Add an IP subject alternative name. + * + * @param ipSubjectAltName must not be empty or {@literal null}. + * @return {@code this} {@link VaultCertificateRequestBuilder}. + */ + public VaultCertificateRequestBuilder withIpSubjectAltName(String ipSubjectAltName) { + + Assert.hasText(ipSubjectAltName, "IP subject alt name must not be empty"); + + this.ipSubjectAltNames.add(ipSubjectAltName); + return this; + } + + /** + * Configures a TTL. + * + * @param ttl the TTL, must be a positive number. + * @return {@code this} {@link VaultCertificateRequestBuilder}. + */ + public VaultCertificateRequestBuilder ttl(int ttl) { + + Assert.isTrue(ttl > 0, "TTL must be greater 0"); + + this.ttl = ttl; + return this; + } + + /** + * Configures a TTL. + * + * @param ttl the TTL, must be a positive number. + * @param timeUnit must not be {@literal null} + * @return {@code this} {@link VaultCertificateRequestBuilder}. + */ + public VaultCertificateRequestBuilder ttl(long ttl, TimeUnit timeUnit) { + + Assert.isTrue(ttl > 0, "TTL must be greater 0"); + Assert.notNull(timeUnit, "TimeUnit must be greater 0"); + + this.ttl = (int) timeUnit.toSeconds(ttl); + return this; + } + + /** + * The given common name will not be included in DNS or Email Subject Alternate Names (as appropriate). Useful if + * the CN is not a hostname or email address, but is instead some human-readable identifier. + * + * @return {@code this} {@link VaultCertificateRequestBuilder}. + */ + public VaultCertificateRequestBuilder excludeCommonNameFromSubjectAltNames() { + + this.excludeCommonNameFromSubjectAltNames = true; + return this; + } + + /** + * Builds a new {@link VaultCertificateRequest} instance. Requires {@link #commonName(String)} to be configured. + * + * @return a new {@link VaultCertificateRequest}. + */ + public VaultCertificateRequest build() { + + Assert.hasText(commonName, "Common name must not be empty"); + + List altNames; + switch (this.altNames.size()) { + case 0: + altNames = java.util.Collections.emptyList(); + break; + case 1: + altNames = java.util.Collections.singletonList(this.altNames.get(0)); + break; + default: + altNames = java.util.Collections.unmodifiableList(new ArrayList(this.altNames)); + } + + List ipSubjectAltNames; + switch (this.ipSubjectAltNames.size()) { + case 0: + ipSubjectAltNames = java.util.Collections.emptyList(); + break; + case 1: + ipSubjectAltNames = java.util.Collections.singletonList(this.ipSubjectAltNames.get(0)); + break; + default: + ipSubjectAltNames = java.util.Collections.unmodifiableList(new ArrayList(this.ipSubjectAltNames)); + } + + return new VaultCertificateRequest(commonName, altNames, ipSubjectAltNames, ttl, + excludeCommonNameFromSubjectAltNames); + } + + private static List toList(Iterable iter) { + List list = new ArrayList(); + for (E item : iter) { + list.add(item); + } + return list; + } + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/support/VaultCertificateResponse.java b/spring-vault-core/src/main/java/org/springframework/vault/support/VaultCertificateResponse.java new file mode 100644 index 00000000..b0099309 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/support/VaultCertificateResponse.java @@ -0,0 +1,23 @@ +/* + * Copyright 2016 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 + * + * http://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; + +/** + * Value object to bind Vault HTTP PKI issue certificate API responses. + * + * @author Mark Paluch + */ +public class VaultCertificateResponse extends VaultResponseSupport {} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/core/PkiSecretIntegrationTests.java b/spring-vault-core/src/test/java/org/springframework/vault/core/PkiSecretIntegrationTests.java new file mode 100644 index 00000000..f4aa0a9e --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/core/PkiSecretIntegrationTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2016 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 + * + * http://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.core; + +import static org.springframework.vault.util.Settings.*; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.assertj.core.util.Files; +import org.junit.Before; +import org.junit.Test; +import org.springframework.vault.util.IntegrationTestSupport; + +/** + * Integration test to request certificates from the Vault PKI backend. + * + * @author Mark Paluch + */ +public class PkiSecretIntegrationTests extends IntegrationTestSupport { + + /** + * Initialize the pki secret backend. + */ + @Before + public void setUp() { + + if (!prepare().hasSecret("pki")) { + prepare().mountSecret("pki"); + } + + File workDir = findWorkDir(new File(System.getProperty("user.dir"))); + + String cert = Files.contentOf(new File(workDir, "ca/certs/intermediate.cert.pem"), StandardCharsets.US_ASCII); + + String key = Files.contentOf(new File(workDir, "ca/private/intermediate.decrypted.key.pem"), + StandardCharsets.US_ASCII); + + Map pembundle = Collections.singletonMap("pem_bundle", cert + key); + + VaultOperations vaultOperations = prepare().getVaultOperations(); + vaultOperations.write("pki/config/ca", pembundle); + + Map role = new HashMap(); + role.put("allowed_domains", "localhost,example.com"); + role.put("allow_subdomains", "true"); + role.put("allow_localhost", "true"); + role.put("allow_ip_sans", "true"); + role.put("max_ttl", "72h"); + + vaultOperations.write("pki/roles/test", role); + + } + + @Test + public void shouldCreateCertificateCorrectly() { + + } +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/core/VaultPkiTemplateIntegrationTests.java b/spring-vault-core/src/test/java/org/springframework/vault/core/VaultPkiTemplateIntegrationTests.java new file mode 100644 index 00000000..98e34bcd --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/core/VaultPkiTemplateIntegrationTests.java @@ -0,0 +1,102 @@ +/* + * Copyright 2016 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 + * + * http://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.core; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.vault.util.Settings.*; + +import java.io.File; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.assertj.core.util.Files; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.vault.client.VaultException; +import org.springframework.vault.support.CertificateBundle; +import org.springframework.vault.support.VaultCertificateRequest; +import org.springframework.vault.support.VaultCertificateResponse; +import org.springframework.vault.util.IntegrationTestSupport; + +/** + * Integration tests for {@link VaultPkiTemplate} through {@link VaultPkiOperations}. + * + * @author Mark Paluch + */ +@RunWith(SpringRunner.class) +@ContextConfiguration(classes = VaultIntegrationTestConfiguration.class) +public class VaultPkiTemplateIntegrationTests extends IntegrationTestSupport { + + @Autowired private VaultOperations vaultOperations; + + private VaultPkiOperations pkiOperations; + + @Before + public void before() throws Exception { + + pkiOperations = vaultOperations.opsForPki(); + + if (!prepare().hasSecret("pki")) { + prepare().mountSecret("pki"); + } + + File workDir = findWorkDir(new File(System.getProperty("user.dir"))); + String cert = Files.contentOf(new File(workDir, "ca/certs/intermediate.cert.pem"), "US-ASCII"); + String key = Files.contentOf(new File(workDir, "ca/private/intermediate.decrypted.key.pem"), "US-ASCII"); + + Map pembundle = Collections.singletonMap("pem_bundle", cert + key); + + vaultOperations.write("pki/config/ca", pembundle); + + Map role = new HashMap(); + role.put("allowed_domains", "localhost,example.com"); + role.put("allow_subdomains", "true"); + role.put("allow_localhost", "true"); + role.put("allow_ip_sans", "true"); + role.put("max_ttl", "72h"); + + vaultOperations.write("pki/roles/testrole", role); + } + + @Test + public void issueCertificateShouldCreateCertificate() { + + VaultCertificateRequest request = VaultCertificateRequest.create("hello.example.com"); + + VaultCertificateResponse certificateResponse = pkiOperations.issueCertificate("testrole", request); + + CertificateBundle data = certificateResponse.getData(); + + assertThat(data.getPrivateKey()).isNotEmpty(); + assertThat(data.getCertificate()).isNotEmpty(); + assertThat(data.getIssuingCaCertificate()).isNotEmpty(); + assertThat(data.getSerialNumber()).isNotEmpty(); + assertThat(data.getX509Certificate().getSubjectX500Principal().getName()).isEqualTo("CN=hello.example.com"); + } + + @Test(expected = VaultException.class) + public void issueCertificateFail() { + + VaultCertificateRequest request = VaultCertificateRequest.create("not.supported"); + + pkiOperations.issueCertificate("testrole", request); + } +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/support/CertificateBundleUnitTests.java b/spring-vault-core/src/test/java/org/springframework/vault/support/CertificateBundleUnitTests.java new file mode 100644 index 00000000..e41b5878 --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/support/CertificateBundleUnitTests.java @@ -0,0 +1,83 @@ +/* + * Copyright 2016 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 + * + * http://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 static org.assertj.core.api.Assertions.*; + +import java.security.KeyFactory; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Unit tests for {@link CertificateBundle}. + * + * @author Mark Paluch + */ +public class CertificateBundleUnitTests { + + CertificateBundle certificateBundle; + + @SuppressWarnings("unchecked") + @Before + public void before() throws Exception { + Map data = new ObjectMapper().readValue(getClass().getResource("/certificate.json"), Map.class); + + certificateBundle = CertificateBundle.of(data.get("serial_number"), data.get("certificate"), data.get("issuing_ca"), + data.get("private_key")); + } + + @Test + public void getPrivateKeySpecShouldCreatePrivateKey() throws Exception { + + KeyFactory kf = KeyFactory.getInstance("RSA"); + PrivateKey privateKey = kf.generatePrivate(certificateBundle.getPrivateKeySpec()); + + assertThat(privateKey.getAlgorithm()).isEqualTo("RSA"); + assertThat(privateKey.getFormat()).isEqualTo("PKCS#8"); + } + + @Test + public void getX509CertificateShouldReturnCertificate() throws Exception { + + X509Certificate x509Certificate = certificateBundle.getX509Certificate(); + + assertThat(x509Certificate.getSubjectDN().getName()).isEqualTo("CN=hello.example.com"); + } + + @Test + public void getX509IssuerCertificateShouldReturnCertificate() throws Exception { + + X509Certificate x509Certificate = certificateBundle.getX509IssuerCertificate(); + + assertThat(x509Certificate.getSubjectDN().getName()).startsWith("CN=Intermediate CA Certificate"); + } + + @Test + public void getAsKeystore() throws Exception { + + KeyStore keyStore = certificateBundle.createKeyStore("mykey"); + + assertThat(keyStore.size()).isEqualTo(1); + assertThat(keyStore.getCertificateChain("mykey")).hasSize(2); + } +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/support/VaultCertificateRequestUnitTests.java b/spring-vault-core/src/test/java/org/springframework/vault/support/VaultCertificateRequestUnitTests.java new file mode 100644 index 00000000..7075a875 --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/support/VaultCertificateRequestUnitTests.java @@ -0,0 +1,58 @@ +/* + * Copyright 2016 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 + * + * http://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 static org.assertj.core.api.Assertions.*; + +import org.junit.Test; + +/** + * Unit tests for {@link VaultCertificateRequest}. + * + * @author Mark Paluch + */ +public class VaultCertificateRequestUnitTests { + + @Test(expected = IllegalArgumentException.class) + public void shouldRejectUnconfiguredBuilder() throws Exception { + VaultCertificateRequest.builder().build(); + } + + @Test + public void shouldBuildRequestWithCommonName() throws Exception { + + VaultCertificateRequest request = VaultCertificateRequest.builder().commonName("hello.com").build(); + + assertThat(request.getCommonName()).isEqualTo("hello.com"); + } + + @Test + public void shouldBuildFullyConfiguredRequest() throws Exception { + + VaultCertificateRequest request = VaultCertificateRequest.builder() // + .commonName("hello.com") // + .withAltName("alt") // + .withIpSubjectAltName("127.0.0.1") // + .excludeCommonNameFromSubjectAltNames() // + .build(); + + assertThat(request.getCommonName()).isEqualTo("hello.com"); + assertThat(request.getAltNames()).hasSize(1).contains("alt"); + assertThat(request.getIpSubjectAltNames()).hasSize(1).contains("127.0.0.1"); + assertThat(request.isExcludeCommonNameFromSubjectAltNames()).isTrue(); + assertThat(request.getCommonName()).isEqualTo("hello.com"); + } +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/util/PrepareVault.java b/spring-vault-core/src/test/java/org/springframework/vault/util/PrepareVault.java index 2c0ba45f..ec57fe28 100644 --- a/spring-vault-core/src/test/java/org/springframework/vault/util/PrepareVault.java +++ b/spring-vault-core/src/test/java/org/springframework/vault/util/PrepareVault.java @@ -145,7 +145,7 @@ public class PrepareVault { public boolean hasSecret(String secretBackend) { Assert.hasText(secretBackend, "SecretBackend must not be empty"); - return adminOperations.getMounts().containsKey(secretBackend); + return adminOperations.getMounts().containsKey(secretBackend + "/"); } public VaultOperations getVaultOperations() { diff --git a/spring-vault-core/src/test/resources/certificate.json b/spring-vault-core/src/test/resources/certificate.json new file mode 100644 index 00000000..6419d187 --- /dev/null +++ b/spring-vault-core/src/test/resources/certificate.json @@ -0,0 +1,7 @@ +{ + "serial_number": "11:34:10:87:5e:be:bc:0b:22:7b:18:c5:80:77:7a:dc:47:52:b4:53", + "certificate": "MIIDpDCCAoygAwIBAgIUETQQh16+vAsiexjFgHd63EdStFMwDQYJKoZIhvcNAQELBQAwaTELMAkGA1UEBhMCTk4xEDAOBgNVBAgMB1Vua25vd24xIjAgBgNVBAoMGXNwcmluZy1jbG91ZC12YXVsdC1jb25maWcxJDAiBgNVBAMMG0ludGVybWVkaWF0ZSBDQSBDZXJ0aWZpY2F0ZTAeFw0xNjA5MjUxODIxMzlaFw0xNjA5MjgxODIyMDlaMBwxGjAYBgNVBAMTEWhlbGxvLmV4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuc4sOQoLFiC6pwFWuFsG592xfP0fYTTTe6p/+5b6OjdhNGlYb1VpLczdBNuJXtf6YjsN38h/R3w8FBOMiIDwmbJ7fD4xcu7AWpTG0WlW6hs2gi9aOS0x2UoL/9B86mTd6y6St5fAhOtNyYvVy5fu29y++qKOpFNoN67gggXc2Rq5MRXmAoXwmsmdXhczdiGYpsaRqL3aXZ582zXgXzH419i0wc5QRoB+QfoxPclTgzkOiMP88bY8RdvTbhAe5dv4ws4W2OQYC7yQ3814duuZKb06vyzBb3j4GBTiX32K1H97Qx4jcFGD/DlsF+7N6EY1pxUm96qg/k/q7rZnikNcBwIDAQABo4GQMIGNMA4GA1UdDwEB/wQEAwIDqDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0OBBYEFGCl7M1plNMrWXzalhTXGbhsE3FnMB8GA1UdIwQYMBaAFHjYUnRFNIYGPS2OMuwJKGXP1g8QMBwGA1UdEQQVMBOCEWhlbGxvLmV4YW1wbGUuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQBDshpRSurHQNpB7Bc+wbsNEVjASg8wK4UAWQIzyVBQv7b6wauErFsbtYNM7dYBy0+ABVuSs+kRP/n5EPOvQ/aCQcSg9ghVOB1ZNiUAsPotjfxYP9I7Lo2BUI2ghonyoOaBj/vL7y+7gbG6EoYo+TYO6ccdRg121qA6SzAKGpX5W2ppoiVpgVLMO5oLG2IHl7uu2iEkE5E32qceKwggSzkXiITiNIEVW9iHnT+U76vdFmVtpnKNi/0oPw5p6+f0p7G7x7788st3xEk5HCvqNTF1nxRnPL0/wabvkH1s5EzoLKwcd5vgQS7Y0N7g9docIl/aoWNBtRKv9iqLHpUVV1Jb", + "issuing_ca": "MIIDuTCCAqGgAwIBAgICEAIwDQYJKoZIhvcNAQELBQAwbjELMAkGA1UEBhMCTk4xEDAOBgNVBAgMB1Vua25vd24xEDAOBgNVBAcMB1Vua25vd24xIjAgBgNVBAoMGXNwcmluZy1jbG91ZC12YXVsdC1jb25maWcxFzAVBgNVBAMMDkNBIENlcnRpZmljYXRlMB4XDTE2MDkyNTE4MjAyNFoXDTI2MDkyMzE4MjAyNFowaTELMAkGA1UEBhMCTk4xEDAOBgNVBAgMB1Vua25vd24xIjAgBgNVBAoMGXNwcmluZy1jbG91ZC12YXVsdC1jb25maWcxJDAiBgNVBAMMG0ludGVybWVkaWF0ZSBDQSBDZXJ0aWZpY2F0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJu47f0402Brk0xKcRM2mNXGsVdT59Yxt2jhu0Jtsmc03sUZ97rrrG70JsovUNzmK+vfTgJWt/W2aHzKoYPUpBgcFhaOTQye28QE+rqFGplsknM9PWXaeyC22d3src8R5Mg0UmQsWZaY7lat5upgNnvvGzpbLv7n5XiVD2k75zp/1o2TOUVcb1reT2hlB2/Vse6552e1bfLhn6VPKZNt+zkZ6itx2l5lzHtFBlVa8eOuhLEoZjIFl7LyeoLWqq+j3D3L+wTmkXAl1NeGHkAHVwAcR30gvJob7XHC42kM8at0oaFtGiriaiUMKCcYhNDHY1bVSQJNCg80QVk/ZDP3NI0CAwEAAaNmMGQwHQYDVR0OBBYEFHjYUnRFNIYGPS2OMuwJKGXP1g8QMB8GA1UdIwQYMBaAFMBZt5wTqO7CgiHEYfaN/JKweWBZMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4IBAQCPEwM66l+DivQRUw0RQ4menGLR67sM88MTgtxJ5E3NAz9J7r0cZT7KV6pAk7sOJoqiU6rRkdr3VPLvdHloPrFlAdmEh1kTBQZmhv2qg9D69VdTrezkfI3aCn2EBmpNbyLgL9nodi2pBSOclI6ANQShNHwqkc6lPH7K53RsJ6Oj0VJrJjMVssn3IJODB4U/0R3jBkV1mDjyV60LC29uXTLpdM6aYDXRjsSlA8X5GNsTHCj7xG0Pgwd/c6esE5hhakQlGbszLWheJi6/un7qn7y+83on7wYOArp4D0olQHx9q6hAOHxNm32eXVw4PiNL24C6BnLb5k1eyh3Jn0oEuhEL", + "private_key": "MIIEowIBAAKCAQEAuc4sOQoLFiC6pwFWuFsG592xfP0fYTTTe6p/+5b6OjdhNGlYb1VpLczdBNuJXtf6YjsN38h/R3w8FBOMiIDwmbJ7fD4xcu7AWpTG0WlW6hs2gi9aOS0x2UoL/9B86mTd6y6St5fAhOtNyYvVy5fu29y++qKOpFNoN67gggXc2Rq5MRXmAoXwmsmdXhczdiGYpsaRqL3aXZ582zXgXzH419i0wc5QRoB+QfoxPclTgzkOiMP88bY8RdvTbhAe5dv4ws4W2OQYC7yQ3814duuZKb06vyzBb3j4GBTiX32K1H97Qx4jcFGD/DlsF+7N6EY1pxUm96qg/k/q7rZnikNcBwIDAQABAoIBAEbvSzrLdsgP3rJ7dFwVhGFo5aXODPjTbhm8Uff+/7DSC64yuaxncywlFgwQtDUdR13RJc7VgKIN7/HKNNC4SSKkXThllgNG2vC5891m1eOyV/EDmvJRKsLF3ibftZDWyVJMCX9F1B3qp1vWotyDOJF/iPmurb5WiWcYwGVjT5kiT8YNQo3FrOSGdyI3LIHtEJCHuPShLIaFEzDYe6iAo/iO0TfOqMKRz6kByAj76bCFwMlZC6dsjt4jUz7SkGf4YbGq35uBdWMyg+VkZ6T6EnfBFuUuXAEbmbJX6PvbzH1BHHQxHfGojSKp6zvHlY9qeNm0TYME/6zFL1qq7fFHNeECgYEA6OpcUDdnZC7mU1Nzly+AbhUSUEjSRwgKsiMe+iytuUyDjbFWmzo3/oae9pAiWDBpof4yVyga/7wK78RjK6EgCAW2mLnJJndJ/HbmfuX/Qf0LdqO9W7qRaioDIyB9ZmMcTKfND9mPBddJCrVnlu530p4DGNOObNWO3pN7cq8/DbUCgYEAzDiDOj/CedFk3lBlfvXyIsbTI+dJKhKQIcBxI38MKa1GErzQRJu0+WuyebgMX0NtY8rD2ugDX3ZvWHJ8tHXMQNpuSU0OP0DLEuA+OJ7xlbvSUSkCmbsMMlCbEJyiVWGSuNh8pGbuYQ2URlCIQ+DMn8+D104y2ZGuyx6s7sFX+EsCgYB6I4PbMOKP/6QaqBVif69qirQ606UpSIdLWO2CpXFITV5jLE7NQd9hIrkzdOv9sQ2DvGgrmkVmO/bEOwkCNRE2A8D4H9zixgVPVg6TFwsfUJH1WfIPqytcL8HLZts7mNebPxdiq2S73GRaMDytQkolcWUQXMhAPFEaWN6QimOXTQKBgHNhXmrziQnQTz/3hlCD3kjdsn7vdnvJvxsCoile511Io3WErdqFamsc+NoiLQNJki02aw9r1DQEMwZysJg4kaz2fCb33RvJnLJOdXkB/BYP5tSp6ek3L73hoqpxBJpepUnALjOoX/f/dw1/BG3tPU7xLFjhuoMH9wyViTjfjbcfAoGBAI8hJE2GH/oqcnVcOOKISsMHthFANNIkmRLJh5/Npq+OENMjXRhO1Ns6BALNGjqp+lnrFmVLDQXy1bWZ2tFpM5nGLjx7QbI+uNEhVRRXbx0MeKmU1U3reiY3oNSUXgRNi9wznrJ7eKUd8ViQTEFKfIdIvjatWlazggIp/MLGn1va" +} + diff --git a/src/test/bash/create_certificates.sh b/src/test/bash/create_certificates.sh index cc1b6025..5eb69e61 100755 --- a/src/test/bash/create_certificates.sh +++ b/src/test/bash/create_certificates.sh @@ -46,6 +46,7 @@ openssl rsa -in ${CA_DIR}/private/localhost.key.pem \ -passin pass:changeit chmod 400 ${CA_DIR}/private/localhost.key.pem +chmod 400 ${CA_DIR}/private/localhost.decrypted.key.pem echo "[INFO] Generating server certificate request" openssl req -config ${DIR}/openssl.cnf \ @@ -96,10 +97,35 @@ openssl pkcs12 -export -clcerts \ -passout pass:changeit \ -out ${CA_DIR}/client.p12 -KEYTOOL=${JAVA_HOME}/bin/keytool - -${KEYTOOL} -importcert -keystore ${KEYSTORE_FILE} -file ${CA_DIR}/certs/ca.cert.pem -noprompt -storepass changeit -${KEYTOOL} -importkeystore \ +${JAVA_HOME}/bin/keytool -importcert -keystore ${KEYSTORE_FILE} -file ${CA_DIR}/certs/ca.cert.pem -noprompt -storepass changeit +${JAVA_HOME}/bin/keytool -importkeystore \ -srckeystore ${CA_DIR}/client.p12 -srcstoretype PKCS12 -srcstorepass changeit\ -destkeystore ${CLIENT_CERT_KEYSTORE} -deststoretype JKS \ -noprompt -storepass changeit + +echo "[INFO] Generating intermediate CA private key" +# Less bits = less secure = faster to generate +openssl genrsa -passout pass:changeit -aes256 -out ${CA_DIR}/private/intermediate.key.pem 2048 + +openssl rsa -in ${CA_DIR}/private/intermediate.key.pem \ + -out ${CA_DIR}/private/intermediate.decrypted.key.pem \ + -passin pass:changeit + +chmod 400 ${CA_DIR}/private/intermediate.key.pem +chmod 400 ${CA_DIR}/private/intermediate.decrypted.key.pem + +echo "[INFO] Generating intermediate certificate" +openssl req -config ${DIR}/intermediate.cnf \ + -key ${CA_DIR}/private/intermediate.key.pem \ + -new -sha256 \ + -out ${CA_DIR}/csr/intermediate.csr.pem \ + -passin pass:changeit \ + -subj "/C=NN/ST=Unknown/L=Unknown/O=spring-cloud-vault-config/CN=Intermediate CA Certificate" + +echo "[INFO] Signing intermediate certificate request" +openssl ca -config ${DIR}/openssl.cnf \ + -days 3650 -notext -md sha256 -extensions v3_intermediate_ca \ + -passin pass:changeit \ + -batch \ + -in ${CA_DIR}/csr/intermediate.csr.pem \ + -out ${CA_DIR}/certs/intermediate.cert.pem diff --git a/src/test/bash/intermediate.cnf b/src/test/bash/intermediate.cnf new file mode 100644 index 00000000..ae05f49c --- /dev/null +++ b/src/test/bash/intermediate.cnf @@ -0,0 +1,68 @@ +[ ca ] +# `man ca` +default_ca = CA_default + +[ CA_default ] +# Directory and file locations. +dir = work/ca +certs = $dir/certs +crl_dir = $dir/crl +new_certs_dir = $dir/newcerts +database = $dir/index.txt +serial = $dir/serial +RANDFILE = $dir/private/.rand + +# The intermediate key and root certificate. +private_key = $dir/private/intermediate.key.pem +certificate = $dir/certs/intermediate.cert.pem + +# For certificate revocation lists. +crlnumber = $dir/crlnumber +crl = $dir/crl/intermediate.crl.pem +crl_extensions = crl_ext +default_crl_days = 30 + +# SHA-1 is deprecated, so use SHA-2 instead. +default_md = sha256 + +name_opt = ca_default +cert_opt = ca_default +default_days = 375 +preserve = no +policy = policy_loose + +[ policy_loose ] +countryName = optional +stateOrProvinceName = optional +localityName = optional +organizationName = optional +organizationalUnitName = optional +commonName = supplied +emailAddress = optional + +[ req ] +# Options for the `req` tool (`man req`). +default_bits = 2048 +distinguished_name = req_distinguished_name +string_mask = utf8only + +# SHA-1 is deprecated, so use SHA-2 instead. +default_md = sha256 + +[ req_distinguished_name ] +# See . +countryName = Country Name (2 letter code) +stateOrProvinceName = State or Province Name +localityName = Locality Name +0.organizationName = Organization Name +organizationalUnitName = Organizational Unit Name +commonName = Common Name +emailAddress = Email Address + +# Optionally, specify some defaults. +countryName_default = NN +stateOrProvinceName_default = Vault Test +localityName_default = +0.organizationName_default = spring-cloud-vault-config +#organizationalUnitName_default = +#emailAddress_default = info@spring-cloud-vault-config.dummy