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,
+ *
+ * - Short form. One octet. Bit 8 has value "0" and bits 7-1 give the length.
+ *
- Long form. Two to 127 octets (only 4 is supported here). Bit 8 of first octet has value "1" and bits 7-1 give
+ * the number of additional length octets. Second and following octets give the length, base 256, most significant
+ * digit first.
+ *
+ *
+ * @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 |
+ * -------------------------------------------------
+ *
+ *
+ * - Class: Universal, Application, Context or Private
+ *
- CF: Constructed flag. If 1, the field is constructed.
+ *
- Type: This is actually called tag in ASN.1. It indicates data type (Integer, String) or a construct
+ * (sequence, choice, set).
+ *
+ *
+ * @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