From 0c7caabea113a6c22e21277074fe70c39063b3d8 Mon Sep 17 00:00:00 2001 From: Tim Vernum Date: Mon, 30 Oct 2017 13:08:31 +1100 Subject: [PATCH] Usability enhancements for certificate generation (elastic/x-pack-elasticsearch#2561) This commit adds a new `certutil` command and deprecates the `certgen` command. The new certuil consists of sub commands that are (by default) are simpler to use than the old monolithic command, but still support all the previous behaviours. Original commit: elastic/x-pack-elasticsearch@3f57687da9f68284e0093425906b1fc8cfb2c71a --- plugin/bin/x-pack/certgen | 2 +- plugin/bin/x-pack/certgen.bat | Bin 593 -> 601 bytes plugin/bin/x-pack/certutil | 18 + plugin/bin/x-pack/certutil.bat | Bin 0 -> 593 bytes .../elasticsearch/xpack/ssl/CertUtils.java | 126 +- .../xpack/ssl/CertificateGenerateTool.java | 725 ++++++++++ .../xpack/ssl/CertificateTool.java | 1244 +++++++++++------ .../org/elasticsearch/test/TestMatchers.java | 2 +- .../ssl/CertificateGenerateToolTests.java | 535 +++++++ .../xpack/ssl/CertificateToolTests.java | 468 ++++++- 10 files changed, 2630 insertions(+), 490 deletions(-) create mode 100644 plugin/bin/x-pack/certutil create mode 100644 plugin/bin/x-pack/certutil.bat create mode 100644 plugin/src/main/java/org/elasticsearch/xpack/ssl/CertificateGenerateTool.java create mode 100644 plugin/src/test/java/org/elasticsearch/xpack/ssl/CertificateGenerateToolTests.java diff --git a/plugin/bin/x-pack/certgen b/plugin/bin/x-pack/certgen index 9e64004a405..4bda8ce90f5 100644 --- a/plugin/bin/x-pack/certgen +++ b/plugin/bin/x-pack/certgen @@ -14,5 +14,5 @@ exec \ -Des.path.home="$ES_HOME" \ -Des.path.conf="$ES_PATH_CONF" \ -cp "$ES_CLASSPATH" \ - org.elasticsearch.xpack.ssl.CertificateTool \ + org.elasticsearch.xpack.ssl.CertificateGenerateTool \ "$@" diff --git a/plugin/bin/x-pack/certgen.bat b/plugin/bin/x-pack/certgen.bat index e4ee1adb9495d1ff29533193fa0b22240f62345f..91c3d931853cdbe169ae329ae26fa29743361a69 100644 GIT binary patch delta 17 Ycmcb}a+76)5fiI>YF=v5WPK(j05)jR_(Svma+kiky2dK3{z)oUI zply7sX^j?EB7A64QS&Q|GZv7HlfnR6Q5vk!h(4}eD{Rot{^ZzE{B;C@vFI|8q@PuL z$@yzEo|+n=ednd11a`YPk|r3JaCK(sMy-&W|Agsd_L8OW#sJbuvD%+j^To0_T#fEA z@K%uG$~AbC>?PCr(#|g*2>jPe7%jqhgKR+BbvW>GxUp~ukl!5G?VDDkxU0&TU$qW}N^ literal 0 HcmV?d00001 diff --git a/plugin/src/main/java/org/elasticsearch/xpack/ssl/CertUtils.java b/plugin/src/main/java/org/elasticsearch/xpack/ssl/CertUtils.java index af6c9fa19ed..29ba6ec5ab4 100644 --- a/plugin/src/main/java/org/elasticsearch/xpack/ssl/CertUtils.java +++ b/plugin/src/main/java/org/elasticsearch/xpack/ssl/CertUtils.java @@ -22,6 +22,7 @@ import java.net.SocketException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.security.Key; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.KeyStore; @@ -35,12 +36,18 @@ import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.function.Function; import java.util.function.Supplier; +import java.util.stream.Collectors; import org.bouncycastle.asn1.ASN1Encodable; import org.bouncycastle.asn1.ASN1ObjectIdentifier; @@ -93,7 +100,8 @@ public class CertUtils { private static final int SERIAL_BIT_LENGTH = 20 * 8; static final BouncyCastleProvider BC_PROV = new BouncyCastleProvider(); - private CertUtils() {} + private CertUtils() { + } /** * Resolves a path with or without an {@link Environment} as we may be running in a transport client where we do not have access to @@ -107,16 +115,36 @@ public class CertUtils { return PathUtils.get(path).normalize(); } + /** + * Creates a {@link KeyStore} from a PEM encoded certificate and key file + */ + static KeyStore getKeyStoreFromPEM(Path certificatePath, Path keyPath, char[] keyPassword) + throws IOException, CertificateException, KeyStoreException, NoSuchAlgorithmException { + final PrivateKey key; + try (Reader reader = Files.newBufferedReader(keyPath, StandardCharsets.UTF_8)) { + key = CertUtils.readPrivateKey(reader, () -> keyPassword); + } + final Certificate[] certificates = readCertificates(Collections.singletonList(certificatePath)); + return getKeyStore(certificates, key, keyPassword); + } + + /** * Returns a {@link X509ExtendedKeyManager} that is built from the provided private key and certificate chain */ public static X509ExtendedKeyManager keyManager(Certificate[] certificateChain, PrivateKey privateKey, char[] password) throws NoSuchAlgorithmException, UnrecoverableKeyException, KeyStoreException, IOException, CertificateException { + KeyStore keyStore = getKeyStore(certificateChain, privateKey, password); + return keyManager(keyStore, password, KeyManagerFactory.getDefaultAlgorithm()); + } + + private static KeyStore getKeyStore(Certificate[] certificateChain, PrivateKey privateKey, char[] password) + throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException { KeyStore keyStore = KeyStore.getInstance("jks"); keyStore.load(null, null); // password must be non-null for keystore... keyStore.setKeyEntry("key", privateKey, password, certificateChain); - return keyManager(keyStore, password, KeyManagerFactory.getDefaultAlgorithm()); + return keyStore; } /** @@ -137,12 +165,19 @@ public class CertUtils { /** * Creates a {@link X509ExtendedTrustManager} based on the provided certificates + * * @param certificates the certificates to trust * @return a trust manager that trusts the provided certificates */ public static X509ExtendedTrustManager trustManager(Certificate[] certificates) throws NoSuchAlgorithmException, UnrecoverableKeyException, KeyStoreException, IOException, CertificateException { - assert certificates != null : "Cannot create trust manager with null certificates"; + KeyStore store = trustStore(certificates); + return trustManager(store, TrustManagerFactory.getDefaultAlgorithm()); + } + + static KeyStore trustStore(Certificate[] certificates) + throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException { + assert certificates != null : "Cannot create trust store with null certificates"; KeyStore store = KeyStore.getInstance("jks"); store.load(null, null); int counter = 0; @@ -150,25 +185,32 @@ public class CertUtils { store.setCertificateEntry("cert" + counter, certificate); counter++; } - return trustManager(store, TrustManagerFactory.getDefaultAlgorithm()); + return store; } /** * Loads the truststore and creates a {@link X509ExtendedTrustManager} - * @param trustStorePath the path to the truststore - * @param trustStorePassword the password to the truststore + * + * @param trustStorePath the path to the truststore + * @param trustStorePassword the password to the truststore * @param trustStoreAlgorithm the algorithm to use for the truststore - * @param env the environment to use for file resolution. May be {@code null} + * @param env the environment to use for file resolution. May be {@code null} * @return a trust manager with the trust material from the store */ public static X509ExtendedTrustManager trustManager(String trustStorePath, String trustStoreType, char[] trustStorePassword, String trustStoreAlgorithm, @Nullable Environment env) throws NoSuchAlgorithmException, UnrecoverableKeyException, KeyStoreException, IOException, CertificateException { - try (InputStream in = Files.newInputStream(resolvePath(trustStorePath, env))) { - KeyStore trustStore = KeyStore.getInstance(trustStoreType); - assert trustStorePassword != null; - trustStore.load(in, trustStorePassword); - return trustManager(trustStore, trustStoreAlgorithm); + KeyStore trustStore = readKeyStore(resolvePath(trustStorePath, env), trustStoreType, trustStorePassword); + return trustManager(trustStore, trustStoreAlgorithm); + } + + static KeyStore readKeyStore(Path path, String type, char[] password) + throws IOException, KeyStoreException, CertificateException, NoSuchAlgorithmException { + try (InputStream in = Files.newInputStream(path)) { + KeyStore store = KeyStore.getInstance(type); + assert password != null; + store.load(in, password); + return store; } } @@ -182,7 +224,7 @@ public class CertUtils { TrustManager[] trustManagers = tmf.getTrustManagers(); for (TrustManager trustManager : trustManagers) { if (trustManager instanceof X509ExtendedTrustManager) { - return (X509ExtendedTrustManager) trustManager ; + return (X509ExtendedTrustManager) trustManager; } } throw new IllegalStateException("failed to find a X509ExtendedTrustManager"); @@ -190,16 +232,22 @@ public class CertUtils { /** * Reads the provided paths and parses them into {@link Certificate} objects - * @param certPaths the paths to the PEM encoded certificates + * + * @param certPaths the paths to the PEM encoded certificates * @param environment the environment to resolve files against. May be {@code null} * @return an array of {@link Certificate} objects */ public static Certificate[] readCertificates(List certPaths, @Nullable Environment environment) throws CertificateException, IOException { + final List resolvedPaths = certPaths.stream().map(p -> resolvePath(p, environment)).collect(Collectors.toList()); + return readCertificates(resolvedPaths); + } + + static Certificate[] readCertificates(List certPaths) throws CertificateException, IOException { List certificates = new ArrayList<>(certPaths.size()); CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); - for (String path : certPaths) { - try (Reader reader = Files.newBufferedReader(resolvePath(path, environment), StandardCharsets.UTF_8)) { + for (Path path : certPaths) { + try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { readCertificates(reader, certificates, certFactory); } } @@ -280,6 +328,30 @@ public class CertUtils { return privateKeyInfo; } + + /** + * Read all certificate-key pairs from a PKCS#12 container. + * + * @param path The path to the PKCS#12 container file. + * @param password The password for the container file + * @param keyPassword A supplier for the password for each key. The key alias is supplied as an argument to the function, and it should + * return the password for that key. If it returns {@code null}, then the key-pair for that alias is not read. + */ + static Map readPkcs12KeyPairs(Path path, char[] password, Function keyPassword, Environment env) + throws CertificateException, NoSuchAlgorithmException, KeyStoreException, IOException, UnrecoverableKeyException { + final KeyStore store = readKeyStore(path, "PKCS12", password); + final Enumeration enumeration = store.aliases(); + final Map map = new HashMap<>(store.size()); + while (enumeration.hasMoreElements()) { + final String alias = enumeration.nextElement(); + if (store.isKeyEntry(alias)) { + final char[] pass = keyPassword.apply(alias); + map.put(store.getCertificate(alias), store.getKey(alias, pass)); + } + } + return map; + } + /** * Generates a CA certificate */ @@ -292,24 +364,25 @@ public class CertUtils { * Generates a signed certificate using the provided CA private key and information from the CA certificate */ public static X509Certificate generateSignedCertificate(X500Principal principal, GeneralNames subjectAltNames, KeyPair keyPair, - X509Certificate caCert, PrivateKey caPrivKey, int days) + X509Certificate caCert, PrivateKey caPrivKey, int days) throws OperatorCreationException, CertificateException, CertIOException, NoSuchAlgorithmException { return generateSignedCertificate(principal, subjectAltNames, keyPair, caCert, caPrivKey, false, days); } /** * Generates a signed certificate - * @param principal the principal of the certificate; commonly referred to as the distinguished name (DN) + * + * @param principal the principal of the certificate; commonly referred to as the distinguished name (DN) * @param subjectAltNames the subject alternative names that should be added to the certificate as an X509v3 extension. May be * {@code null} - * @param keyPair the key pair that will be associated with the certificate - * @param caCert the CA certificate. If {@code null}, this results in a self signed certificate - * @param caPrivKey the CA private key. If {@code null}, this results in a self signed certificate - * @param isCa whether or not the generated certificate is a CA + * @param keyPair the key pair that will be associated with the certificate + * @param caCert the CA certificate. If {@code null}, this results in a self signed certificate + * @param caPrivKey the CA private key. If {@code null}, this results in a self signed certificate + * @param isCa whether or not the generated certificate is a CA * @return a signed {@link X509Certificate} */ private static X509Certificate generateSignedCertificate(X500Principal principal, GeneralNames subjectAltNames, KeyPair keyPair, - X509Certificate caCert, PrivateKey caPrivKey, boolean isCa, int days) + X509Certificate caCert, PrivateKey caPrivKey, boolean isCa, int days) throws NoSuchAlgorithmException, CertificateException, CertIOException, OperatorCreationException { final DateTime notBefore = new DateTime(DateTimeZone.UTC); if (days < 1) { @@ -353,10 +426,11 @@ public class CertUtils { /** * Generates a certificate signing request - * @param keyPair the key pair that will be associated by the certificate generated from the certificate signing request + * + * @param keyPair the key pair that will be associated by the certificate generated from the certificate signing request * @param principal the principal of the certificate; commonly referred to as the distinguished name (DN) - * @param sanList the subject alternative names that should be added to the certificate as an X509v3 extension. May be -* {@code null} + * @param sanList the subject alternative names that should be added to the certificate as an X509v3 extension. May be + * {@code null} * @return a certificate signing request */ static PKCS10CertificationRequest generateCSR(KeyPair keyPair, X500Principal principal, GeneralNames sanList) diff --git a/plugin/src/main/java/org/elasticsearch/xpack/ssl/CertificateGenerateTool.java b/plugin/src/main/java/org/elasticsearch/xpack/ssl/CertificateGenerateTool.java new file mode 100644 index 00000000000..df4c2a1a4df --- /dev/null +++ b/plugin/src/main/java/org/elasticsearch/xpack/ssl/CertificateGenerateTool.java @@ -0,0 +1,725 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.ssl; + +import javax.security.auth.x500.X500Principal; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFilePermission; +import java.security.KeyPair; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import joptsimple.ArgumentAcceptingOptionSpec; +import joptsimple.OptionSet; +import joptsimple.OptionSpec; +import org.bouncycastle.asn1.DERIA5String; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.openssl.PEMEncryptor; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.bouncycastle.openssl.jcajce.JcePEMEncryptorBuilder; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.elasticsearch.ExceptionsHelper; +import org.elasticsearch.cli.EnvironmentAwareCommand; +import org.elasticsearch.cli.ExitCodes; +import org.elasticsearch.cli.Terminal; +import org.elasticsearch.cli.UserException; +import org.elasticsearch.common.ParseField; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.SuppressForbidden; +import org.elasticsearch.common.io.PathUtils; +import org.elasticsearch.common.network.InetAddresses; +import org.elasticsearch.common.util.set.Sets; +import org.elasticsearch.common.xcontent.ConstructingObjectParser; +import org.elasticsearch.common.xcontent.NamedXContentRegistry; +import org.elasticsearch.common.xcontent.ObjectParser; +import org.elasticsearch.common.xcontent.XContentParser; +import org.elasticsearch.common.xcontent.XContentType; +import org.elasticsearch.env.Environment; + +/** + * CLI tool to make generation of certificates or certificate requests easier for users + * @deprecated Replaced by {@link CertificateTool} + */ +@Deprecated +public class CertificateGenerateTool extends EnvironmentAwareCommand { + + private static final String AUTO_GEN_CA_DN = "CN=Elastic Certificate Tool Autogenerated CA"; + private static final String DESCRIPTION = "Simplifies certificate creation for use with the Elastic Stack"; + private static final String DEFAULT_CSR_FILE = "csr-bundle.zip"; + private static final String DEFAULT_CERT_FILE = "certificate-bundle.zip"; + private static final int DEFAULT_DAYS = 3 * 365; + private static final int FILE_EXTENSION_LENGTH = 4; + static final int MAX_FILENAME_LENGTH = 255 - FILE_EXTENSION_LENGTH; + private static final Pattern ALLOWED_FILENAME_CHAR_PATTERN = + Pattern.compile("[a-zA-Z0-9!@#$%^&{}\\[\\]()_+\\-=,.~'` ]{1," + MAX_FILENAME_LENGTH + "}"); + private static final int DEFAULT_KEY_SIZE = 2048; + + /** + * Wraps the certgen object parser. + */ + private static class InputFileParser { + private static final ObjectParser, Void> PARSER = new ObjectParser<>("certgen"); + + // if the class initializer here runs before the main method, logging will not have been configured; this will lead to status logger + // error messages from the class initializer for ParseField since it creates Logger instances; therefore, we bury the initialization + // of the parser in this class so that we can defer initialization until after logging has been initialized + static { + @SuppressWarnings("unchecked") final ConstructingObjectParser instanceParser = + new ConstructingObjectParser<>( + "instances", + a -> new CertificateInformation( + (String) a[0], (String) (a[1] == null ? a[0] : a[1]), + (List) a[2], (List) a[3], (List) a[4])); + instanceParser.declareString(ConstructingObjectParser.constructorArg(), new ParseField("name")); + instanceParser.declareString(ConstructingObjectParser.optionalConstructorArg(), new ParseField("filename")); + instanceParser.declareStringArray(ConstructingObjectParser.optionalConstructorArg(), new ParseField("ip")); + instanceParser.declareStringArray(ConstructingObjectParser.optionalConstructorArg(), new ParseField("dns")); + instanceParser.declareStringArray(ConstructingObjectParser.optionalConstructorArg(), new ParseField("cn")); + + PARSER.declareObjectArray(List::addAll, instanceParser, new ParseField("instances")); + } + } + + private final OptionSpec outputPathSpec; + private final OptionSpec csrSpec; + private final OptionSpec caCertPathSpec; + private final OptionSpec caKeyPathSpec; + private final OptionSpec caPasswordSpec; + private final OptionSpec caDnSpec; + private final OptionSpec keysizeSpec; + private final OptionSpec inputFileSpec; + private final OptionSpec daysSpec; + private final ArgumentAcceptingOptionSpec p12Spec; + + CertificateGenerateTool() { + super(DESCRIPTION); + outputPathSpec = parser.accepts("out", "path of the zip file that the output should be written to") + .withRequiredArg(); + csrSpec = parser.accepts("csr", "only generate certificate signing requests"); + caCertPathSpec = parser.accepts("cert", "path to an existing ca certificate").availableUnless(csrSpec).withRequiredArg(); + caKeyPathSpec = parser.accepts("key", "path to an existing ca private key") + .availableIf(caCertPathSpec) + .requiredIf(caCertPathSpec) + .withRequiredArg(); + caPasswordSpec = parser.accepts("pass", "password for an existing ca private key or the generated ca private key") + .availableUnless(csrSpec) + .withOptionalArg(); + caDnSpec = parser.accepts("dn", "distinguished name to use for the generated ca. defaults to " + AUTO_GEN_CA_DN) + .availableUnless(caCertPathSpec) + .availableUnless(csrSpec) + .withRequiredArg(); + keysizeSpec = parser.accepts("keysize", "size in bits of RSA keys").withRequiredArg().ofType(Integer.class); + inputFileSpec = parser.accepts("in", "file containing details of the instances in yaml format").withRequiredArg(); + daysSpec = parser.accepts("days", "number of days that the generated certificates are valid") + .availableUnless(csrSpec) + .withRequiredArg() + .ofType(Integer.class); + p12Spec = parser.accepts("p12", "output a p12 (PKCS#12) version for each certificate/key pair, with optional password") + .availableUnless(csrSpec) + .withOptionalArg(); + } + + public static void main(String[] args) throws Exception { + new CertificateGenerateTool().main(args, Terminal.DEFAULT); + } + + @Override + protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception { + final boolean csrOnly = options.has(csrSpec); + printIntro(terminal, csrOnly); + final Path outputFile = getOutputFile(terminal, outputPathSpec.value(options), csrOnly ? DEFAULT_CSR_FILE : DEFAULT_CERT_FILE); + final String inputFile = inputFileSpec.value(options); + final int keysize = options.has(keysizeSpec) ? keysizeSpec.value(options) : DEFAULT_KEY_SIZE; + if (csrOnly) { + Collection certificateInformations = getCertificateInformationList(terminal, inputFile); + generateAndWriteCsrs(outputFile, certificateInformations, keysize); + } else { + final String dn = options.has(caDnSpec) ? caDnSpec.value(options) : AUTO_GEN_CA_DN; + final boolean prompt = options.has(caPasswordSpec); + final char[] keyPass = options.hasArgument(caPasswordSpec) ? caPasswordSpec.value(options).toCharArray() : null; + final int days = options.hasArgument(daysSpec) ? daysSpec.value(options) : DEFAULT_DAYS; + final char[] p12Password; + if (options.hasArgument(p12Spec)) { + p12Password = p12Spec.value(options).toCharArray(); + } else if (options.has(p12Spec)) { + p12Password = new char[0]; + } else { + p12Password = null; + } + CAInfo caInfo = getCAInfo(terminal, dn, caCertPathSpec.value(options), caKeyPathSpec.value(options), keyPass, prompt, env, + keysize, days); + Collection certificateInformations = getCertificateInformationList(terminal, inputFile); + generateAndWriteSignedCertificates(outputFile, certificateInformations, caInfo, keysize, days, p12Password); + } + printConclusion(terminal, csrOnly, outputFile); + } + + @Override + protected void printAdditionalHelp(Terminal terminal) { + terminal.println("Simplifies the generation of certificate signing requests and signed"); + terminal.println("certificates. The tool runs interactively unless the 'in' and 'out' parameters"); + terminal.println("are specified. In the interactive mode, the tool will prompt for required"); + terminal.println("values that have not been provided through the use of command line options."); + terminal.println(""); + } + + /** + * Checks for output file in the user specified options or prompts the user for the output file + * + * @param terminal terminal to communicate with a user + * @param outputPath user specified output file, may be {@code null} + * @return a {@link Path} to the output file + */ + static Path getOutputFile(Terminal terminal, String outputPath, String defaultFilename) throws IOException { + Path file; + if (outputPath != null) { + file = resolvePath(outputPath); + } else { + file = resolvePath(defaultFilename); + String input = terminal.readText("Please enter the desired output file [" + file + "]: "); + if (input.isEmpty() == false) { + file = resolvePath(input); + } + } + return file.toAbsolutePath(); + } + + @SuppressForbidden(reason = "resolve paths against CWD for a CLI tool") + private static Path resolvePath(String pathStr) { + return PathUtils.get(pathStr).normalize(); + } + + /** + * This method handles the collection of information about each instance that is necessary to generate a certificate. The user may + * be prompted or the information can be gathered from a file + * @param terminal the terminal to use for user interaction + * @param inputFile an optional file that will be used to load the instance information + * @return a {@link Collection} of {@link CertificateInformation} that represents each instance + */ + static Collection getCertificateInformationList(Terminal terminal, String inputFile) + throws Exception { + if (inputFile != null) { + return parseAndValidateFile(terminal, resolvePath(inputFile).toAbsolutePath()); + } + Map map = new HashMap<>(); + boolean done = false; + while (done == false) { + String name = terminal.readText("Enter instance name: "); + if (name.isEmpty() == false) { + final boolean isNameValidFilename = Name.isValidFilename(name); + String filename = terminal.readText("Enter name for directories and files " + (isNameValidFilename ? "[" + name + "]" : "") + + ": " ); + if (filename.isEmpty() && isNameValidFilename) { + filename = name; + } + String ipAddresses = terminal.readText("Enter IP Addresses for instance (comma-separated if more than one) []: "); + String dnsNames = terminal.readText("Enter DNS names for instance (comma-separated if more than one) []: "); + List ipList = Arrays.asList(Strings.splitStringByCommaToArray(ipAddresses)); + List dnsList = Arrays.asList(Strings.splitStringByCommaToArray(dnsNames)); + List commonNames = null; + + CertificateInformation information = new CertificateInformation(name, filename, ipList, dnsList, commonNames); + List validationErrors = information.validate(); + if (validationErrors.isEmpty()) { + if (map.containsKey(name)) { + terminal.println("Overwriting previously defined instance information [" + name + "]"); + } + map.put(name, information); + } else { + for (String validationError : validationErrors) { + terminal.println(validationError); + } + terminal.println("Skipping entry as invalid values were found"); + } + } else { + terminal.println("A name must be provided"); + } + + String exit = terminal.readText("Would you like to specify another instance? Press 'y' to continue entering instance " + + "information: "); + if ("y".equals(exit) == false) { + done = true; + } + } + return map.values(); + } + + static Collection parseAndValidateFile(Terminal terminal, Path file) throws Exception { + final Collection config = parseFile(file); + boolean hasError = false; + for (CertificateInformation certInfo : config) { + final List errors = certInfo.validate(); + if (errors.size() > 0) { + hasError = true; + terminal.println(Terminal.Verbosity.SILENT, "Configuration for instance " + certInfo.name.originalName + + " has invalid details"); + for (String message : errors) { + terminal.println(Terminal.Verbosity.SILENT, " * " + message); + } + terminal.println(""); + } + } + if (hasError) { + throw new UserException(ExitCodes.CONFIG, "File " + file + " contains invalid configuration details (see messages above)"); + } + return config; + } + + /** + * Parses the input file to retrieve the certificate information + * @param file the file to parse + * @return a collection of certificate information + */ + static Collection parseFile(Path file) throws Exception { + try (Reader reader = Files.newBufferedReader(file)) { + // EMPTY is safe here because we never use namedObject + XContentParser xContentParser = XContentType.YAML.xContent().createParser(NamedXContentRegistry.EMPTY, reader); + return InputFileParser.PARSER.parse(xContentParser, new ArrayList<>(), null); + } + } + + /** + * Generates certificate signing requests and writes them out to the specified file in zip format + * @param outputFile the file to write the output to. This file must not already exist + * @param certInfo the details to use in the certificate signing requests + */ + static void generateAndWriteCsrs(Path outputFile, Collection certInfo, int keysize) throws Exception { + fullyWriteFile(outputFile, (outputStream, pemWriter) -> { + for (CertificateInformation certificateInformation : certInfo) { + KeyPair keyPair = CertUtils.generateKeyPair(keysize); + GeneralNames sanList = getSubjectAlternativeNamesValue(certificateInformation.ipAddresses, certificateInformation.dnsNames, + certificateInformation.commonNames); + PKCS10CertificationRequest csr = CertUtils.generateCSR(keyPair, certificateInformation.name.x500Principal, sanList); + + final String dirName = certificateInformation.name.filename + "/"; + ZipEntry zipEntry = new ZipEntry(dirName); + assert zipEntry.isDirectory(); + outputStream.putNextEntry(zipEntry); + + // write csr + outputStream.putNextEntry(new ZipEntry(dirName + certificateInformation.name.filename + ".csr")); + pemWriter.writeObject(csr); + pemWriter.flush(); + outputStream.closeEntry(); + + // write private key + outputStream.putNextEntry(new ZipEntry(dirName + certificateInformation.name.filename + ".key")); + pemWriter.writeObject(keyPair.getPrivate()); + pemWriter.flush(); + outputStream.closeEntry(); + } + }); + } + + /** + * Returns the CA certificate and private key that will be used to sign certificates. These may be specified by the user or + * automatically generated + * + * @param terminal the terminal to use for prompting the user + * @param dn the distinguished name to use for the CA + * @param caCertPath the path to the CA certificate or {@code null} if not provided + * @param caKeyPath the path to the CA private key or {@code null} if not provided + * @param prompt whether we should prompt the user for a password + * @param keyPass the password to the private key. If not present and the key is encrypted the user will be prompted + * @param env the environment for this tool to resolve files with + * @param keysize the size of the key in bits + * @param days the number of days that the certificate should be valid for + * @return CA cert and private key + */ + static CAInfo getCAInfo(Terminal terminal, String dn, String caCertPath, String caKeyPath, char[] keyPass, boolean prompt, + Environment env, int keysize, int days) throws Exception { + if (caCertPath != null) { + assert caKeyPath != null; + final String resolvedCaCertPath = resolvePath(caCertPath).toAbsolutePath().toString(); + Certificate[] certificates = CertUtils.readCertificates(Collections.singletonList(resolvedCaCertPath), env); + if (certificates.length != 1) { + throw new IllegalArgumentException("expected a single certificate in file [" + caCertPath + "] but found [" + + certificates.length + "]"); + } + Certificate caCert = certificates[0]; + PrivateKey privateKey = readPrivateKey(caKeyPath, keyPass, terminal, prompt); + return new CAInfo((X509Certificate) caCert, privateKey); + } + + // generate the CA keys and cert + X500Principal x500Principal = new X500Principal(dn); + KeyPair keyPair = CertUtils.generateKeyPair(keysize); + Certificate caCert = CertUtils.generateCACertificate(x500Principal, keyPair, days); + final char[] password; + if (prompt) { + password = terminal.readSecret("Enter password for CA private key: "); + } else { + password = keyPass; + } + return new CAInfo((X509Certificate) caCert, keyPair.getPrivate(), true, password); + } + + /** + * Generates signed certificates in PEM format stored in a zip file + * @param outputFile the file that the certificates will be written to. This file must not exist + * @param certificateInformations details for creation of the certificates + * @param caInfo the CA information to sign the certificates with + * @param keysize the size of the key in bits + * @param days the number of days that the certificate should be valid for + */ + static void generateAndWriteSignedCertificates(Path outputFile, Collection certificateInformations, + CAInfo caInfo, int keysize, int days, char[] pkcs12Password) throws Exception { + fullyWriteFile(outputFile, (outputStream, pemWriter) -> { + // write out the CA info first if it was generated + writeCAInfoIfGenerated(outputStream, pemWriter, caInfo); + + for (CertificateInformation certificateInformation : certificateInformations) { + KeyPair keyPair = CertUtils.generateKeyPair(keysize); + Certificate certificate = CertUtils.generateSignedCertificate(certificateInformation.name.x500Principal, + getSubjectAlternativeNamesValue(certificateInformation.ipAddresses, certificateInformation.dnsNames, + certificateInformation.commonNames), + keyPair, caInfo.caCert, caInfo.privateKey, days); + + final String dirName = certificateInformation.name.filename + "/"; + ZipEntry zipEntry = new ZipEntry(dirName); + assert zipEntry.isDirectory(); + outputStream.putNextEntry(zipEntry); + + // write cert + final String entryBase = dirName + certificateInformation.name.filename; + outputStream.putNextEntry(new ZipEntry(entryBase + ".crt")); + pemWriter.writeObject(certificate); + pemWriter.flush(); + outputStream.closeEntry(); + + // write private key + outputStream.putNextEntry(new ZipEntry(entryBase + ".key")); + pemWriter.writeObject(keyPair.getPrivate()); + pemWriter.flush(); + outputStream.closeEntry(); + + if (pkcs12Password != null) { + final KeyStore pkcs12 = KeyStore.getInstance("PKCS12"); + pkcs12.load(null); + pkcs12.setKeyEntry(certificateInformation.name.originalName, keyPair.getPrivate(), pkcs12Password, + new Certificate[]{certificate}); + + outputStream.putNextEntry(new ZipEntry(entryBase + ".p12")); + pkcs12.store(outputStream, pkcs12Password); + outputStream.closeEntry(); + } + } + }); + } + + /** + * This method handles the deletion of a file in the case of a partial write + * @param file the file that is being written to + * @param writer writes the contents of the file + */ + private static void fullyWriteFile(Path file, Writer writer) throws Exception { + boolean success = false; + try (OutputStream outputStream = Files.newOutputStream(file, StandardOpenOption.CREATE_NEW); + ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream, StandardCharsets.UTF_8); + JcaPEMWriter pemWriter = new JcaPEMWriter(new OutputStreamWriter(zipOutputStream, StandardCharsets.UTF_8))) { + writer.write(zipOutputStream, pemWriter); + + // set permissions to 600 + PosixFileAttributeView view = Files.getFileAttributeView(file, PosixFileAttributeView.class); + if (view != null) { + view.setPermissions(Sets.newHashSet(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); + } + + success = true; + } finally { + if (success == false) { + Files.deleteIfExists(file); + } + } + } + + /** + * This method handles writing out the certificate authority cert and private key if the certificate authority was generated by + * this invocation of the tool + * @param outputStream the output stream to write to + * @param pemWriter the writer for PEM objects + * @param info the certificate authority information + */ + private static void writeCAInfoIfGenerated(ZipOutputStream outputStream, JcaPEMWriter pemWriter, CAInfo info) throws Exception { + if (info.generated) { + final String caDirName = "ca/"; + ZipEntry zipEntry = new ZipEntry(caDirName); + assert zipEntry.isDirectory(); + outputStream.putNextEntry(zipEntry); + outputStream.putNextEntry(new ZipEntry(caDirName + "ca.crt")); + pemWriter.writeObject(info.caCert); + pemWriter.flush(); + outputStream.closeEntry(); + outputStream.putNextEntry(new ZipEntry(caDirName + "ca.key")); + if (info.password != null && info.password.length > 0) { + try { + PEMEncryptor encryptor = new JcePEMEncryptorBuilder("DES-EDE3-CBC").setProvider(CertUtils.BC_PROV).build(info.password); + pemWriter.writeObject(info.privateKey, encryptor); + } finally { + // we can safely nuke the password chars now + Arrays.fill(info.password, (char) 0); + } + } else { + pemWriter.writeObject(info.privateKey); + } + pemWriter.flush(); + outputStream.closeEntry(); + } + } + + private static void printIntro(Terminal terminal, boolean csr) { + terminal.println("******************************************************************************"); + terminal.println("Note: The 'certgen' tool has been deprecated in favour of the 'certutil' tool."); + terminal.println(" This command will be removed in a future release of X-Pack."); + terminal.println("******************************************************************************"); + terminal.println(""); + + terminal.println("This tool assists you in the generation of X.509 certificates and certificate"); + terminal.println("signing requests for use with SSL in the Elastic stack. Depending on the command"); + terminal.println("line option specified, you may be prompted for the following:"); + terminal.println(""); + terminal.println("* The path to the output file"); + if (csr) { + terminal.println(" * The output file is a zip file containing the certificate signing requests"); + terminal.println(" and private keys for each instance."); + } else { + terminal.println(" * The output file is a zip file containing the signed certificates and"); + terminal.println(" private keys for each instance. If a Certificate Authority was generated,"); + terminal.println(" the certificate and private key will also be included in the output file."); + } + terminal.println("* Information about each instance"); + terminal.println(" * An instance is any piece of the Elastic Stack that requires a SSL certificate."); + terminal.println(" Depending on your configuration, Elasticsearch, Logstash, Kibana, and Beats"); + terminal.println(" may all require a certificate and private key."); + terminal.println(" * The minimum required value for each instance is a name. This can simply be the"); + terminal.println(" hostname, which will be used as the Common Name of the certificate. A full"); + terminal.println(" distinguished name may also be used."); + terminal.println(" * A filename value may be required for each instance. This is necessary when the"); + terminal.println(" name would result in an invalid file or directory name. The name provided here"); + terminal.println(" is used as the directory name (within the zip) and the prefix for the key and"); + terminal.println(" certificate files. The filename is required if you are prompted and the name"); + terminal.println(" is not displayed in the prompt."); + terminal.println(" * IP addresses and DNS names are optional. Multiple values can be specified as a"); + terminal.println(" comma separated string. If no IP addresses or DNS names are provided, you may"); + terminal.println(" disable hostname verification in your SSL configuration."); + + if (csr == false) { + terminal.println("* Certificate Authority private key password"); + terminal.println(" * The password may be left empty if desired."); + } + terminal.println(""); + terminal.println("Let's get started..."); + terminal.println(""); + } + + private static void printConclusion(Terminal terminal, boolean csr, Path outputFile) { + if (csr) { + terminal.println("Certificate signing requests written to " + outputFile); + terminal.println(""); + terminal.println("This file should be properly secured as it contains the private keys for all"); + terminal.println("instances."); + terminal.println(""); + terminal.println("After unzipping the file, there will be a directory for each instance containing"); + terminal.println("the certificate signing request and the private key. Provide the certificate"); + terminal.println("signing requests to your certificate authority. Once you have received the"); + terminal.println("signed certificate, copy the signed certificate, key, and CA certificate to the"); + terminal.println("configuration directory of the Elastic product that they will be used for and"); + terminal.println("follow the SSL configuration instructions in the product guide."); + } else { + terminal.println("Certificates written to " + outputFile); + terminal.println(""); + terminal.println("This file should be properly secured as it contains the private keys for all"); + terminal.println("instances and the certificate authority."); + terminal.println(""); + terminal.println("After unzipping the file, there will be a directory for each instance containing"); + terminal.println("the certificate and private key. Copy the certificate, key, and CA certificate"); + terminal.println("to the configuration directory of the Elastic product that they will be used for"); + terminal.println("and follow the SSL configuration instructions in the product guide."); + terminal.println(""); + terminal.println("For client applications, you may only need to copy the CA certificate and"); + terminal.println("configure the client to trust this certificate."); + } + } + + /** + * Helper method to read a private key and support prompting of user for a key. To avoid passwords being placed as an argument we + * can prompt the user for their password if we encounter an encrypted key. + * @param path the path to the private key + * @param password the password provided by the user or {@code null} + * @param terminal the terminal to use for user interaction + * @param prompt whether to prompt the user or not + * @return the {@link PrivateKey} that was read from the file + */ + private static PrivateKey readPrivateKey(String path, char[] password, Terminal terminal, boolean prompt) + throws Exception { + AtomicReference passwordReference = new AtomicReference<>(password); + try (Reader reader = Files.newBufferedReader(resolvePath(path), StandardCharsets.UTF_8)) { + return CertUtils.readPrivateKey(reader, () -> { + if (password != null || prompt == false) { + return password; + } + char[] promptedValue = terminal.readSecret("Enter password for CA private key: "); + passwordReference.set(promptedValue); + return promptedValue; + }); + } finally { + if (passwordReference.get() != null) { + Arrays.fill(passwordReference.get(), (char) 0); + } + } + } + + private static GeneralNames getSubjectAlternativeNamesValue(List ipAddresses, List dnsNames, List commonNames) { + Set generalNameList = new HashSet<>(); + for (String ip : ipAddresses) { + generalNameList.add(new GeneralName(GeneralName.iPAddress, ip)); + } + + for (String dns : dnsNames) { + generalNameList.add(new GeneralName(GeneralName.dNSName, dns)); + } + + for (String cn : commonNames) { + generalNameList.add(CertUtils.createCommonName(cn)); + } + + if (generalNameList.isEmpty()) { + return null; + } + return new GeneralNames(generalNameList.toArray(new GeneralName[0])); + } + + static class CertificateInformation { + final Name name; + final List ipAddresses; + final List dnsNames; + final List commonNames; + + CertificateInformation(String name, String filename, List ipAddresses, List dnsNames, List commonNames) { + this.name = Name.fromUserProvidedName(name, filename); + this.ipAddresses = ipAddresses == null ? Collections.emptyList() : ipAddresses; + this.dnsNames = dnsNames == null ? Collections.emptyList() : dnsNames; + this.commonNames = commonNames == null ? Collections.emptyList() : commonNames; + } + + List validate() { + List errors = new ArrayList<>(); + if (name.error != null) { + errors.add(name.error); + } + for (String ip : ipAddresses) { + if (InetAddresses.isInetAddress(ip) == false) { + errors.add("[" + ip + "] is not a valid IP address"); + } + } + for (String dnsName : dnsNames) { + if (DERIA5String.isIA5String(dnsName) == false) { + errors.add("[" + dnsName + "] is not a valid DNS name"); + } + } + return errors; + } + } + + static class Name { + + final String originalName; + final X500Principal x500Principal; + final String filename; + final String error; + + private Name(String name, X500Principal x500Principal, String filename, String error) { + this.originalName = name; + this.x500Principal = x500Principal; + this.filename = filename; + this.error = error; + } + + static Name fromUserProvidedName(String name, String filename) { + if ("ca".equals(name)) { + return new Name(name, null, null, "[ca] may not be used as an instance name"); + } + + final X500Principal principal; + try { + if (name.contains("=")) { + principal = new X500Principal(name); + } else { + principal = new X500Principal("CN=" + name); + } + } catch (IllegalArgumentException e) { + String error = "[" + name + "] could not be converted to a valid DN\n" + e.getMessage() + "\n" + + ExceptionsHelper.stackTrace(e); + return new Name(name, null, null, error); + } + + boolean validFilename = isValidFilename(filename); + if (validFilename == false) { + return new Name(name, principal, null, "[" + filename + "] is not a valid filename"); + } + return new Name(name, principal, resolvePath(filename).toString(), null); + } + + static boolean isValidFilename(String name) { + return ALLOWED_FILENAME_CHAR_PATTERN.matcher(name).matches() + && ALLOWED_FILENAME_CHAR_PATTERN.matcher(resolvePath(name).toString()).matches() + && name.startsWith(".") == false; + } + + @Override + public String toString() { + return getClass().getSimpleName() + + "{original=[" + originalName + "] principal=[" + x500Principal + + "] file=[" + filename + "] err=[" + error + "]}"; + } + } + + static class CAInfo { + final X509Certificate caCert; + final PrivateKey privateKey; + final boolean generated; + final char[] password; + + CAInfo(X509Certificate caCert, PrivateKey privateKey) { + this(caCert, privateKey, false, null); + } + + CAInfo(X509Certificate caCert, PrivateKey privateKey, boolean generated, char[] password) { + this.caCert = caCert; + this.privateKey = privateKey; + this.generated = generated; + this.password = password; + } + } + + private interface Writer { + void write(ZipOutputStream zipOutputStream, JcaPEMWriter pemWriter) throws Exception; + } +} diff --git a/plugin/src/main/java/org/elasticsearch/xpack/ssl/CertificateTool.java b/plugin/src/main/java/org/elasticsearch/xpack/ssl/CertificateTool.java index 9b6917098c1..cfd9f6b4d88 100644 --- a/plugin/src/main/java/org/elasticsearch/xpack/ssl/CertificateTool.java +++ b/plugin/src/main/java/org/elasticsearch/xpack/ssl/CertificateTool.java @@ -10,12 +10,15 @@ import java.io.IOException; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.Reader; +import java.nio.CharBuffer; +import java.nio.charset.CharsetEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.nio.file.attribute.PosixFileAttributeView; import java.nio.file.attribute.PosixFilePermission; +import java.security.Key; import java.security.KeyPair; import java.security.KeyStore; import java.security.PrivateKey; @@ -31,13 +34,17 @@ import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; -import joptsimple.ArgumentAcceptingOptionSpec; +import joptsimple.OptionParser; import joptsimple.OptionSet; import joptsimple.OptionSpec; +import joptsimple.OptionSpecBuilder; import org.bouncycastle.asn1.DERIA5String; import org.bouncycastle.asn1.x509.GeneralName; import org.bouncycastle.asn1.x509.GeneralNames; @@ -48,9 +55,12 @@ import org.bouncycastle.pkcs.PKCS10CertificationRequest; import org.elasticsearch.ExceptionsHelper; import org.elasticsearch.cli.EnvironmentAwareCommand; import org.elasticsearch.cli.ExitCodes; +import org.elasticsearch.cli.MultiCommand; import org.elasticsearch.cli.Terminal; import org.elasticsearch.cli.Terminal.Verbosity; import org.elasticsearch.cli.UserException; +import org.elasticsearch.common.CheckedConsumer; +import org.elasticsearch.common.CheckedFunction; import org.elasticsearch.common.ParseField; import org.elasticsearch.common.Strings; import org.elasticsearch.common.SuppressForbidden; @@ -67,12 +77,22 @@ import org.elasticsearch.env.Environment; /** * CLI tool to make generation of certificates or certificate requests easier for users */ -public class CertificateTool extends EnvironmentAwareCommand { +public class CertificateTool extends MultiCommand { private static final String AUTO_GEN_CA_DN = "CN=Elastic Certificate Tool Autogenerated CA"; private static final String DESCRIPTION = "Simplifies certificate creation for use with the Elastic Stack"; - private static final String DEFAULT_CSR_FILE = "csr-bundle.zip"; - private static final String DEFAULT_CERT_FILE = "certificate-bundle.zip"; + private static final String DEFAULT_CSR_ZIP = "csr-bundle.zip"; + private static final String DEFAULT_CERT_ZIP = "certificate-bundle.zip"; + private static final String DEFAULT_CA_ZIP = "elastic-stack-ca.zip"; + private static final String DEFAULT_CA_P12 = "elastic-stack-ca.p12"; + + static final String DEFAULT_CERT_NAME = "instance"; + + /** + * Used to test whether passwords are ASCII (which PKCS/PBE requires) + */ + private static final CharsetEncoder ASCII_ENCODER = StandardCharsets.US_ASCII.newEncoder(); + private static final int DEFAULT_DAYS = 3 * 365; private static final int FILE_EXTENSION_LENGTH = 4; static final int MAX_FILENAME_LENGTH = 255 - FILE_EXTENSION_LENGTH; @@ -106,170 +126,757 @@ public class CertificateTool extends EnvironmentAwareCommand { } } - private final OptionSpec outputPathSpec; - private final OptionSpec csrSpec; - private final OptionSpec caCertPathSpec; - private final OptionSpec caKeyPathSpec; - private final OptionSpec caPasswordSpec; - private final OptionSpec caDnSpec; - private final OptionSpec keysizeSpec; - private final OptionSpec inputFileSpec; - private final OptionSpec daysSpec; - private final ArgumentAcceptingOptionSpec p12Spec; - - CertificateTool() { - super(DESCRIPTION); - outputPathSpec = parser.accepts("out", "path of the zip file that the output should be written to") - .withRequiredArg(); - csrSpec = parser.accepts("csr", "only generate certificate signing requests"); - caCertPathSpec = parser.accepts("cert", "path to an existing ca certificate").availableUnless(csrSpec).withRequiredArg(); - caKeyPathSpec = parser.accepts("key", "path to an existing ca private key") - .availableIf(caCertPathSpec) - .requiredIf(caCertPathSpec) - .withRequiredArg(); - caPasswordSpec = parser.accepts("pass", "password for an existing ca private key or the generated ca private key") - .availableUnless(csrSpec) - .withOptionalArg(); - caDnSpec = parser.accepts("dn", "distinguished name to use for the generated ca. defaults to " + AUTO_GEN_CA_DN) - .availableUnless(caCertPathSpec) - .availableUnless(csrSpec) - .withRequiredArg(); - keysizeSpec = parser.accepts("keysize", "size in bits of RSA keys").withRequiredArg().ofType(Integer.class); - inputFileSpec = parser.accepts("in", "file containing details of the instances in yaml format").withRequiredArg(); - daysSpec = parser.accepts("days", "number of days that the generated certificates are valid") - .availableUnless(csrSpec) - .withRequiredArg() - .ofType(Integer.class); - p12Spec = parser.accepts("p12", "output a p12 (PKCS#12) version for each certificate/key pair, with optional password") - .availableUnless(csrSpec) - .withOptionalArg(); - } public static void main(String[] args) throws Exception { new CertificateTool().main(args, Terminal.DEFAULT); } - @Override - protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception { - final boolean csrOnly = options.has(csrSpec); - printIntro(terminal, csrOnly); - final Path outputFile = getOutputFile(terminal, outputPathSpec.value(options), csrOnly ? DEFAULT_CSR_FILE : DEFAULT_CERT_FILE); - final String inputFile = inputFileSpec.value(options); - final int keysize = options.has(keysizeSpec) ? keysizeSpec.value(options) : DEFAULT_KEY_SIZE; - if (csrOnly) { - Collection certificateInformations = getCertificateInformationList(terminal, inputFile); - generateAndWriteCsrs(outputFile, certificateInformations, keysize); - } else { - final String dn = options.has(caDnSpec) ? caDnSpec.value(options) : AUTO_GEN_CA_DN; - final boolean prompt = options.has(caPasswordSpec); - final char[] keyPass = options.hasArgument(caPasswordSpec) ? caPasswordSpec.value(options).toCharArray() : null; - final int days = options.hasArgument(daysSpec) ? daysSpec.value(options) : DEFAULT_DAYS; - final char[] p12Password; - if (options.hasArgument(p12Spec)) { - p12Password = p12Spec.value(options).toCharArray(); - } else if (options.has(p12Spec)) { - p12Password = new char[0]; + CertificateTool() { + super(DESCRIPTION); + subcommands.put("csr", new SigningRequestCommand()); + subcommands.put("cert", new GenerateCertificateCommand()); + subcommands.put("ca", new CertificateAuthorityCommand()); + } + + + static final String INTRO_TEXT = "This tool assists you in the generation of X.509 certificates and certificate\n" + + "signing requests for use with SSL/TLS in the Elastic stack."; + + static final String INSTANCE_EXPLANATION = + " * An instance is any piece of the Elastic Stack that requires a SSL certificate.\n" + + " Depending on your configuration, Elasticsearch, Logstash, Kibana, and Beats\n" + + " may all require a certificate and private key.\n" + + " * The minimum required value for each instance is a name. This can simply be the\n" + + " hostname, which will be used as the Common Name of the certificate. A full\n" + + " distinguished name may also be used.\n" + + " * A filename value may be required for each instance. This is necessary when the\n" + + " name would result in an invalid file or directory name. The name provided here\n" + + " is used as the directory name (within the zip) and the prefix for the key and\n" + + " certificate files. The filename is required if you are prompted and the name\n" + + " is not displayed in the prompt.\n" + + " * IP addresses and DNS names are optional. Multiple values can be specified as a\n" + + " comma separated string. If no IP addresses or DNS names are provided, you may\n" + + " disable hostname verification in your SSL configuration."; + + static final String CA_EXPLANATION = + " * All certificates generated by this tool will be signed by a certificate authority (CA).\n" + + " * The tool can automatically generate a new CA for you, or you can provide your own with the\n" + + " -ca or -ca-cert command line options."; + + + abstract static class CertificateCommand extends EnvironmentAwareCommand { + // Common option for multiple commands. + // Not every command uses every option, but where they are common we want to keep them consistent + final OptionSpec outputPathSpec; + final OptionSpec outputPasswordSpec; + final OptionSpec keysizeSpec; + + OptionSpec pemFormatSpec; + OptionSpec daysSpec; + + OptionSpec caPkcs12PathSpec; + OptionSpec caCertPathSpec; + OptionSpec caKeyPathSpec; + OptionSpec caPasswordSpec; + OptionSpec caDnSpec; + OptionSpec keepCaKeySpec; + + OptionSpec multipleNodes; + OptionSpec nameSpec; + OptionSpec dnsNamesSpec; + OptionSpec ipAddressesSpec; + + OptionSpec inputFileSpec; + + CertificateCommand(String description) { + super(description); + outputPathSpec = parser.accepts("out", "path to the output file that should be produced").withRequiredArg(); + outputPasswordSpec = parser.accepts("pass", "password for generated private keys").withOptionalArg(); + keysizeSpec = parser.accepts("keysize", "size in bits of RSA keys").withRequiredArg().ofType(Integer.class); + } + + final void acceptCertificateGenerationOptions() { + pemFormatSpec = parser.accepts("pem", "output certificates and keys in PEM format instead of PKCS#12"); + daysSpec = parser.accepts("days", "number of days that the generated certificates are valid") + .withRequiredArg().ofType(Integer.class); + } + + final void acceptsCertificateAuthority() { + caPkcs12PathSpec = parser.accepts("ca", "path to an existing ca key pair (in PKCS#12 format)").withRequiredArg(); + caCertPathSpec = parser.accepts("ca-cert", "path to an existing ca certificate") + .availableUnless(caPkcs12PathSpec) + .withRequiredArg(); + caKeyPathSpec = parser.accepts("ca-key", "path to an existing ca private key") + .availableIf(caCertPathSpec) + .requiredIf(caCertPathSpec) + .withRequiredArg(); + + keepCaKeySpec = parser.accepts("keep-ca-key", "retain the CA private key for future use") + .availableUnless(caPkcs12PathSpec) + .availableUnless(caCertPathSpec); + + caPasswordSpec = parser.accepts("ca-pass", "password for an existing ca private key or the generated ca private key") + .withOptionalArg(); + + acceptsCertificateAuthorityName(); + } + + void acceptsCertificateAuthorityName() { + OptionSpecBuilder builder = parser.accepts("ca-dn", + "distinguished name to use for the generated ca. defaults to " + AUTO_GEN_CA_DN); + if (caPkcs12PathSpec != null) { + builder = builder.availableUnless(caPkcs12PathSpec); + } + if (caCertPathSpec != null) { + builder = builder.availableUnless(caCertPathSpec); + } + caDnSpec = builder.withRequiredArg(); + } + + final void acceptInstanceDetails() { + multipleNodes = parser.accepts("multiple", "generate files for multiple instances"); + nameSpec = parser.accepts("name", "name of the generated certificate").availableUnless(multipleNodes).withRequiredArg(); + dnsNamesSpec = parser.accepts("dns", "comma separated DNS names").availableUnless(multipleNodes).withRequiredArg(); + ipAddressesSpec = parser.accepts("ip", "comma separated IP addresses").availableUnless(multipleNodes).withRequiredArg(); + } + + final void acceptInputFile() { + inputFileSpec = parser.accepts("in", "file containing details of the instances in yaml format").withRequiredArg(); + } + + // For testing + OptionParser getParser() { + return parser; + } + + /** + * Checks for output file in the user specified options or prompts the user for the output file. + * The resulting path is stored in the {@code config} parameter. + */ + Path resolveOutputPath(Terminal terminal, OptionSet options, String defaultFilename) throws IOException { + return resolveOutputPath(terminal, outputPathSpec.value(options), defaultFilename); + } + + static Path resolveOutputPath(Terminal terminal, String userOption, String defaultFilename) { + Path file; + if (userOption != null) { + file = CertificateTool.resolvePath(userOption); } else { - p12Password = null; + file = CertificateTool.resolvePath(defaultFilename); + String input = terminal.readText("Please enter the desired output file [" + file + "]: "); + if (input.isEmpty() == false) { + file = CertificateTool.resolvePath(input); + } } - CAInfo caInfo = getCAInfo(terminal, dn, caCertPathSpec.value(options), caKeyPathSpec.value(options), keyPass, prompt, env, - keysize, days); - Collection certificateInformations = getCertificateInformationList(terminal, inputFile); - generateAndWriteSignedCertificates(outputFile, certificateInformations, caInfo, keysize, days, p12Password); + return file.toAbsolutePath(); } - printConclusion(terminal, csrOnly, outputFile); - } - @Override - protected void printAdditionalHelp(Terminal terminal) { - terminal.println("Simplifies the generation of certificate signing requests and signed"); - terminal.println("certificates. The tool runs interactively unless the 'in' and 'out' parameters"); - terminal.println("are specified. In the interactive mode, the tool will prompt for required"); - terminal.println("values that have not been provided through the use of command line options."); - terminal.println(""); - } - - /** - * Checks for output file in the user specified options or prompts the user for the output file - * - * @param terminal terminal to communicate with a user - * @param outputPath user specified output file, may be {@code null} - * @return a {@link Path} to the output file - */ - static Path getOutputFile(Terminal terminal, String outputPath, String defaultFilename) throws IOException { - Path file; - if (outputPath != null) { - file = resolvePath(outputPath); - } else { - file = resolvePath(defaultFilename); - String input = terminal.readText("Please enter the desired output file [" + file + "]: "); - if (input.isEmpty() == false) { - file = resolvePath(input); + final int getKeySize(OptionSet options) { + if (options.has(keysizeSpec)) { + return keysizeSpec.value(options); + } else { + return DEFAULT_KEY_SIZE; + } + } + + final int getDays(OptionSet options) { + if (options.has(daysSpec)) { + return daysSpec.value(options); + } else { + return DEFAULT_DAYS; + } + } + + boolean keepCaKey(OptionSet options) { + return options.has(keepCaKeySpec); + } + + boolean usePemFormat(OptionSet options) { + return options.has(pemFormatSpec); + } + + boolean useOutputPassword(OptionSet options) { + return options.has(outputPasswordSpec); + } + + char[] getOutputPassword(OptionSet options) { + return getChars(outputPasswordSpec.value(options)); + } + + protected Path resolvePath(OptionSet options, OptionSpec spec) { + final String value = spec.value(options); + if (Strings.isNullOrEmpty(value)) { + return null; + } + return CertificateTool.resolvePath(value); + } + + /** + * Returns the CA certificate and private key that will be used to sign certificates. These may be specified by the user or + * automatically generated + * + * @return CA cert and private key + */ + CAInfo getCAInfo(Terminal terminal, OptionSet options, Environment env) throws Exception { + if (options.has(caPkcs12PathSpec)) { + return loadPkcs12CA(terminal, options, env); + } else if (options.has(caCertPathSpec)) { + return loadPemCA(terminal, options, env); + } else { + return generateCA(terminal, options); + } + } + + private CAInfo loadPkcs12CA(Terminal terminal, OptionSet options, Environment env) throws Exception { + Path path = resolvePath(options, caPkcs12PathSpec); + char[] passwordOption = getChars(caPasswordSpec.value(options)); + + Map keys = withPassword("CA (" + path + ")", passwordOption, + terminal, password -> CertUtils.readPkcs12KeyPairs(path, password, a -> password, env)); + + if (keys.size() != 1) { + throw new IllegalArgumentException("expected a single key in file [" + path.toAbsolutePath() + "] but found [" + + keys.size() + "]"); + } + final Map.Entry pair = keys.entrySet().iterator().next(); + return new CAInfo((X509Certificate) pair.getKey(), (PrivateKey) pair.getValue()); + } + + private CAInfo loadPemCA(Terminal terminal, OptionSet options, Environment env) throws Exception { + if (options.hasArgument(caKeyPathSpec) == false) { + throw new UserException(ExitCodes.USAGE, "Option " + caCertPathSpec + " also requires " + caKeyPathSpec); + } + Path cert = resolvePath(options, caCertPathSpec); + Path key = resolvePath(options, caKeyPathSpec); + String password = caPasswordSpec.value(options); + + final String resolvedCaCertPath = cert.toAbsolutePath().toString(); + Certificate[] certificates = CertUtils.readCertificates(Collections.singletonList(resolvedCaCertPath), env); + if (certificates.length != 1) { + throw new IllegalArgumentException("expected a single certificate in file [" + resolvedCaCertPath + "] but found [" + + certificates.length + "]"); + } + X509Certificate caCert = (X509Certificate) certificates[0]; + PrivateKey privateKey = readPrivateKey(key, getChars(password), terminal); + return new CAInfo(caCert, privateKey); + } + + CAInfo generateCA(Terminal terminal, OptionSet options) throws Exception { + String dn = caDnSpec.value(options); + if (Strings.isNullOrEmpty(dn)) { + dn = AUTO_GEN_CA_DN; + } + X500Principal x500Principal = new X500Principal(dn); + KeyPair keyPair = CertUtils.generateKeyPair(getKeySize(options)); + X509Certificate caCert = CertUtils.generateCACertificate(x500Principal, keyPair, getDays(options)); + + if (options.hasArgument(caPasswordSpec)) { + char[] password = getChars(caPasswordSpec.value(options)); + return new CAInfo(caCert, keyPair.getPrivate(), true, password); + } + if (options.has(caPasswordSpec)) { + return withPassword("CA Private key", null, terminal, p -> new CAInfo(caCert, keyPair.getPrivate(), true, p.clone())); + } + return new CAInfo(caCert, keyPair.getPrivate(), true, null); + } + + /** + * This method handles the collection of information about each instance that is necessary to generate a certificate. The user may + * be prompted or the information can be gathered from a file + * + * @return a {@link Collection} of {@link CertificateInformation} that represents each instance + */ + Collection getCertificateInformationList(Terminal terminal, OptionSet options) + throws Exception { + final Path input = resolvePath(options, inputFileSpec); + if (input != null) { + return parseAndValidateFile(terminal, input.toAbsolutePath()); + } + if (options.has(multipleNodes)) { + return readMultipleCertificateInformation(terminal); + } else { + final Function> splitByComma = v -> Arrays.stream(Strings.splitStringByCommaToArray(v)); + final List dns = dnsNamesSpec.values(options).stream().flatMap(splitByComma).collect(Collectors.toList()); + final List ip = ipAddressesSpec.values(options).stream().flatMap(splitByComma).collect(Collectors.toList()); + final List cn = null; + final String name = getCertificateName(options); + final String fileName; + if (Name.isValidFilename(name)) { + fileName = name; + } else { + fileName = requestFileName(terminal, name); + } + CertificateInformation information = new CertificateInformation(name, fileName, ip, dns, cn); + List validationErrors = information.validate(); + if (validationErrors.isEmpty()) { + return Collections.singleton(information); + } else { + validationErrors.forEach(terminal::println); + return Collections.emptyList(); + } + } + } + + protected String getCertificateName(OptionSet options) { + return options.has(nameSpec) ? nameSpec.value(options) : DEFAULT_CERT_NAME; + } + + static Collection readMultipleCertificateInformation(Terminal terminal) { + Map map = new HashMap<>(); + boolean done = false; + while (done == false) { + String name = terminal.readText("Enter instance name: "); + if (name.isEmpty() == false) { + String filename = requestFileName(terminal, name); + String ipAddresses = terminal.readText("Enter IP Addresses for instance (comma-separated if more than one) []: "); + String dnsNames = terminal.readText("Enter DNS names for instance (comma-separated if more than one) []: "); + List ipList = Arrays.asList(Strings.splitStringByCommaToArray(ipAddresses)); + List dnsList = Arrays.asList(Strings.splitStringByCommaToArray(dnsNames)); + List commonNames = null; + + CertificateInformation information = new CertificateInformation(name, filename, ipList, dnsList, commonNames); + List validationErrors = information.validate(); + if (validationErrors.isEmpty()) { + if (map.containsKey(name)) { + terminal.println("Overwriting previously defined instance information [" + name + "]"); + } + map.put(name, information); + } else { + for (String validationError : validationErrors) { + terminal.println(validationError); + } + terminal.println("Skipping entry as invalid values were found"); + } + } else { + terminal.println("A name must be provided"); + } + + String exit = terminal.readText("Would you like to specify another instance? Press 'y' to continue entering instance " + + "information: "); + if ("y".equals(exit) == false) { + done = true; + } + } + return map.values(); + } + + private static String requestFileName(Terminal terminal, String certName) { + final boolean isNameValidFilename = Name.isValidFilename(certName); + while (true) { + String filename = terminal.readText("Enter name for directories and files of " + certName + + (isNameValidFilename ? " [" + certName + "]" : "") + ": "); + if (filename.isEmpty() && isNameValidFilename) { + return certName; + } + if (Name.isValidFilename(filename)) { + return filename; + } else { + terminal.println(Terminal.Verbosity.SILENT, "'" + filename + "' is not a valid filename"); + continue; + } + } + } + + /** + * This method handles writing out the certificate authority in PEM format to a zip file. + * + * @param outputStream the output stream to write to + * @param pemWriter the writer for PEM objects + * @param info the certificate authority information + * @param includeKey if true, write the CA key in PEM format + */ + static void writeCAInfo(ZipOutputStream outputStream, JcaPEMWriter pemWriter, CAInfo info, boolean includeKey) + throws Exception { + final String caDirName = createCaDirectory(outputStream); + outputStream.putNextEntry(new ZipEntry(caDirName + "ca.crt")); + pemWriter.writeObject(info.certAndKey.cert); + pemWriter.flush(); + outputStream.closeEntry(); + if (includeKey) { + outputStream.putNextEntry(new ZipEntry(caDirName + "ca.key")); + if (info.password != null && info.password.length > 0) { + try { + PEMEncryptor encryptor = getEncrypter(info.password); + pemWriter.writeObject(info.certAndKey.key, encryptor); + } finally { + // we can safely nuke the password chars now + Arrays.fill(info.password, (char) 0); + } + } else { + pemWriter.writeObject(info.certAndKey.key); + } + pemWriter.flush(); + outputStream.closeEntry(); + } + } + + /** + * This method handles writing out the certificate authority in PKCS#12 format to a zip file. + * + * @param outputStream the output stream to write to + * @param info the certificate authority information + * @param terminal used to prompt for a password (if not already supplied) + */ + static void writeCAInfo(ZipOutputStream outputStream, CAInfo info, Terminal terminal) throws Exception { + final String dirName = createCaDirectory(outputStream); + final String fileName = dirName + "ca.p12"; + outputStream.putNextEntry(new ZipEntry(fileName)); + withPassword("Generated CA", info.password, terminal, caPassword -> { + writePkcs12(fileName, outputStream, "ca", info.certAndKey, null, caPassword, null); + return null; + }); + outputStream.closeEntry(); + } + + private static String createCaDirectory(ZipOutputStream outputStream) throws IOException { + final String caDirName = "ca/"; + ZipEntry zipEntry = new ZipEntry(caDirName); + assert zipEntry.isDirectory(); + outputStream.putNextEntry(zipEntry); + return caDirName; + } + + static void writePkcs12(String fileName, OutputStream output, String alias, CertificateAndKey pair, X509Certificate caCert, + char[] password, Terminal terminal) throws Exception { + final KeyStore pkcs12 = KeyStore.getInstance("PKCS12"); + pkcs12.load(null); + withPassword(fileName, password, terminal, p12Password -> { + if (isAscii(p12Password)) { + pkcs12.setKeyEntry(alias, pair.key, p12Password, new Certificate[] { pair.cert }); + if (caCert != null) { + pkcs12.setCertificateEntry("ca", caCert); + } + pkcs12.store(output, p12Password); + return null; + } else { + throw new UserException(ExitCodes.CONFIG, "PKCS#12 passwords must be plain ASCII"); + } + }); + } + } + + static class SigningRequestCommand extends CertificateCommand { + + SigningRequestCommand() { + super("generate certificate signing requests"); + acceptInstanceDetails(); + acceptInputFile(); + } + + @Override + protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception { + terminal.println(INTRO_TEXT); + terminal.println(""); + terminal.println("The 'csr' mode generates certificate signing requests that can be sent to"); + terminal.println("a trusted certificate authority"); + terminal.println(" * By default, this generates a single CSR for a single instance."); + terminal.println(" * You can use the '-multiple' option to generate CSRs for multiple" ); + terminal.println(" instances, each with their own private key."); + terminal.println(" * The '-in' option allows for the CSR generation to be automated"); + terminal.println(" by describing the details of each instance in a YAML file"); + terminal.println(""); + terminal.println(INSTANCE_EXPLANATION); + terminal.println(""); + terminal.println("The 'csr' mode produces a single zip file which contains the certificate"); + terminal.println("signing requests and private keys for each instance."); + terminal.println(" * Each certificate signing request is provided as a standard PEM encoding of a PKCS#10 CSR."); + terminal.println(" * Each key is provided as a PEM encoding of an RSA private key"); + terminal.println(""); + + final Path output = resolveOutputPath(terminal, options, DEFAULT_CSR_ZIP); + final int keySize = getKeySize(options); + Collection certificateInformations = getCertificateInformationList(terminal, options); + generateAndWriteCsrs(output, keySize, certificateInformations); + + terminal.println(""); + terminal.println("Certificate signing requests have been written to " + output); + terminal.println(""); + terminal.println("This file should be properly secured as it contains the private keys for all"); + terminal.println("instances."); + terminal.println(""); + terminal.println("After unzipping the file, there will be a directory for each instance containing"); + terminal.println("the certificate signing request and the private key. Provide the certificate"); + terminal.println("signing requests to your certificate authority. Once you have received the"); + terminal.println("signed certificate, copy the signed certificate, key, and CA certificate to the"); + terminal.println("configuration directory of the Elastic product that they will be used for and"); + terminal.println("follow the SSL configuration instructions in the product guide."); + } + + /** + * Generates certificate signing requests and writes them out to the specified file in zip format + * + * @param certInfo the details to use in the certificate signing requests + */ + void generateAndWriteCsrs(Path output, int keySize, Collection certInfo) throws Exception { + fullyWriteZipFile(output, (outputStream, pemWriter) -> { + for (CertificateInformation certificateInformation : certInfo) { + KeyPair keyPair = CertUtils.generateKeyPair(keySize); + GeneralNames sanList = getSubjectAlternativeNamesValue(certificateInformation.ipAddresses, + certificateInformation.dnsNames, certificateInformation.commonNames); + PKCS10CertificationRequest csr = CertUtils.generateCSR(keyPair, certificateInformation.name.x500Principal, sanList); + + final String dirName = certificateInformation.name.filename + "/"; + ZipEntry zipEntry = new ZipEntry(dirName); + assert zipEntry.isDirectory(); + outputStream.putNextEntry(zipEntry); + + // write csr + outputStream.putNextEntry(new ZipEntry(dirName + certificateInformation.name.filename + ".csr")); + pemWriter.writeObject(csr); + pemWriter.flush(); + outputStream.closeEntry(); + + // write private key + outputStream.putNextEntry(new ZipEntry(dirName + certificateInformation.name.filename + ".key")); + pemWriter.writeObject(keyPair.getPrivate()); + pemWriter.flush(); + outputStream.closeEntry(); + } + }); + } + } + + static class GenerateCertificateCommand extends CertificateCommand { + + GenerateCertificateCommand() { + super("generate X.509 certificates and keys"); + acceptCertificateGenerationOptions(); + acceptInstanceDetails(); + acceptsCertificateAuthority(); + acceptInputFile(); + } + + @Override + protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception { + terminal.println(INTRO_TEXT); + terminal.println(""); + terminal.println("The 'cert' mode generates X.509 certificate and private keys."); + terminal.println(" * By default, this generates a single certificate and key for use"); + terminal.println(" on a single instance."); + terminal.println(" * The '-multiple' option will prompt you to enter details for multiple"); + terminal.println(" instances and will generate a certificate and key for each one"); + terminal.println(" * The '-in' option allows for the certificate generation to be automated by describing"); + terminal.println(" the details of each instance in a YAML file"); + terminal.println(""); + terminal.println(INSTANCE_EXPLANATION); + terminal.println(""); + terminal.println(CA_EXPLANATION); + terminal.println(""); + terminal.println("By default the 'cert' mode produces a single PKCS#12 output file which holds:"); + terminal.println(" * The instance certificate"); + terminal.println(" * The private key for the instance certificate"); + terminal.println(" * The CA certificate"); + terminal.println(""); + terminal.println("If you elect to generate PEM format certificates (the -pem option), then the output will"); + terminal.println("be a zip file containing individual files for the instance certificate, the key and the CA certificate"); + terminal.println(""); + terminal.println("If you elect to generate multiple instances certificates, the output will be a zip file"); + terminal.println("containing all the generated certificates"); + terminal.println(""); + + CAInfo caInfo = getCAInfo(terminal, options, env); + Collection certInfo = getCertificateInformationList(terminal, options); + final boolean keepCaKey = keepCaKey(options); + final boolean usePemFormat = usePemFormat(options); + final boolean writeZipFile = certInfo.size() > 1 || keepCaKey || usePemFormat; + + final String outputName; + if (writeZipFile) { + outputName = DEFAULT_CERT_ZIP; + } else if (options.has(nameSpec)) { + outputName = nameSpec.value(options) + ".p12"; + } else { + outputName = "elastic-certificates.p12"; + } + final Path output = resolveOutputPath(terminal, options, outputName); + + generateAndWriteSignedCertificates(output, writeZipFile, options, certInfo, caInfo, terminal); + + terminal.println(""); + terminal.println("Certificates written to " + output); + terminal.println(""); + if (certInfo.size() > 1) { + terminal.println(Terminal.Verbosity.NORMAL, "This file should be properly secured as it contains the private keys for "); + terminal.print(Terminal.Verbosity.NORMAL, "all instances"); + } else { + terminal.println(Terminal.Verbosity.NORMAL, "This file should be properly secured as it contains the private key for "); + terminal.print(Terminal.Verbosity.NORMAL, "your instance"); + } + if (caInfo.generated && keepCaKey) { + terminal.println(Terminal.Verbosity.NORMAL, " and for the certificate authority."); + } else { + terminal.println(Terminal.Verbosity.NORMAL, "."); + } + terminal.println(""); + final String filesDescription; + if (writeZipFile) { + terminal.println("After unzipping the file, there will be a directory for each instance."); + if (usePemFormat) { + terminal.println("Each instance has a certificate and private key."); + filesDescription = "the certificate, key, and CA certificate"; + } else { + terminal.println("Each instance has a single PKCS#12 (.p12) file containing the instance"); + terminal.println("certificate, instance private key and the CA certificate"); + filesDescription = "this '.p12' file"; + } + } else { + terminal.println("This file is a self contained file and can be copied and used 'as is'"); + filesDescription = "this '.p12' file"; + } + terminal.println("For each Elastic product that you wish to configure, you should copy"); + terminal.println(filesDescription + " to the relevant configuration directory"); + terminal.println("and then follow the SSL configuration instructions in the product guide."); + terminal.println(""); + if (usePemFormat || caInfo.generated == false) { + terminal.println("For client applications, you may only need to copy the CA certificate and"); + terminal.println("configure the client to trust this certificate."); + } + } + + /** + * Generates signed certificates in either PKCS#12 format or PEM format, wrapped in a zip file if necessary. + * + * @param output the output file (either zip, or PKCS#12) + * @param writeZipFile if true, output a zip file, otherwise output a single PKCS#12 file + * @param options the current command line options + * @param certs the certificates to write to the file + * @param caInfo the CA information to sign the certificates with + * @param terminal the terminal to use if prompting for passwords + */ + void generateAndWriteSignedCertificates(Path output, boolean writeZipFile, OptionSet options, + Collection certs, CAInfo caInfo, Terminal terminal) + throws Exception { + + checkDirectory(output, terminal); + + final int keySize = getKeySize(options); + final int days = getDays(options); + final char[] outputPassword = super.getOutputPassword(options); + if (writeZipFile) { + final boolean usePem = usePemFormat(options); + final boolean usePassword = super.useOutputPassword(options); + fullyWriteZipFile(output, (outputStream, pemWriter) -> { + // write out the CA info first if it was generated + if (caInfo.generated) { + final boolean writeCAKey = keepCaKey(options); + if (usePem) { + writeCAInfo(outputStream, pemWriter, caInfo, writeCAKey); + } else if (writeCAKey) { + writeCAInfo(outputStream, caInfo, terminal); + } + } + + for (CertificateInformation certificateInformation : certs) { + CertificateAndKey pair = generateCertificateAndKey(certificateInformation, caInfo, keySize, days); + + final String dirName = certificateInformation.name.filename + "/"; + ZipEntry zipEntry = new ZipEntry(dirName); + assert zipEntry.isDirectory(); + outputStream.putNextEntry(zipEntry); + + final String entryBase = dirName + certificateInformation.name.filename; + + if (usePem) { + // write cert + outputStream.putNextEntry(new ZipEntry(entryBase + ".crt")); + pemWriter.writeObject(pair.cert); + pemWriter.flush(); + outputStream.closeEntry(); + + // write private key + final String keyFileName = entryBase + ".key"; + outputStream.putNextEntry(new ZipEntry(keyFileName)); + if (usePassword) { + withPassword(keyFileName, outputPassword, terminal, password -> { + pemWriter.writeObject(pair.key, getEncrypter(password)); + return null; + }); + } else { + pemWriter.writeObject(pair.key); + } + pemWriter.flush(); + outputStream.closeEntry(); + } else { + final String fileName = entryBase + ".p12"; + outputStream.putNextEntry(new ZipEntry(fileName)); + writePkcs12(fileName, outputStream, certificateInformation.name.originalName, pair, caInfo.certAndKey.cert, + outputPassword, terminal); + outputStream.closeEntry(); + } + } + }); + } else { + assert certs.size() == 1; + CertificateInformation certificateInformation = certs.iterator().next(); + CertificateAndKey pair = generateCertificateAndKey(certificateInformation, caInfo, keySize, days); + fullyWriteFile(output, stream -> writePkcs12(output.getFileName().toString(), stream, + certificateInformation.name.originalName, pair, caInfo.certAndKey.cert, outputPassword, terminal)); + } + } + + private CertificateAndKey generateCertificateAndKey(CertificateInformation certificateInformation, CAInfo caInfo, + int keySize, int days) throws Exception { + KeyPair keyPair = CertUtils.generateKeyPair(keySize); + Certificate certificate = CertUtils.generateSignedCertificate(certificateInformation.name.x500Principal, + getSubjectAlternativeNamesValue(certificateInformation.ipAddresses, certificateInformation.dnsNames, + certificateInformation.commonNames), + keyPair, caInfo.certAndKey.cert, caInfo.certAndKey.key, days); + return new CertificateAndKey((X509Certificate) certificate, keyPair.getPrivate()); + } + + } + + static class CertificateAuthorityCommand extends CertificateCommand { + + CertificateAuthorityCommand() { + super("generate a new local certificate authority"); + acceptCertificateGenerationOptions(); + acceptsCertificateAuthorityName(); + super.caPasswordSpec = super.outputPasswordSpec; + } + + @Override + protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception { + terminal.println(INTRO_TEXT); + terminal.println(""); + terminal.println("The 'ca' mode generates a new 'certificate authority'"); + terminal.println("This will create a new X.509 certificate and private key that can be used"); + terminal.println("to sign certificate when running in 'cert' mode."); + terminal.println(""); + terminal.println("Use the 'ca-dn' option if you wish to configure the 'distinguished name'"); + terminal.println("of the certificate authority"); + terminal.println(""); + terminal.println("By default the 'ca' mode produces a single PKCS#12 output file which holds:"); + terminal.println(" * The CA certificate"); + terminal.println(" * The CA's private key"); + terminal.println(""); + terminal.println("If you elect to generate PEM format certificates (the -pem option), then the output will"); + terminal.println("be a zip file containing individual files for the CA certificate and private key"); + terminal.println(""); + + CAInfo caInfo = generateCA(terminal, options); + final boolean writeZipFile = usePemFormat(options); + final Path output = resolveOutputPath(terminal, options, writeZipFile ? DEFAULT_CA_ZIP : DEFAULT_CA_P12); + writeCertificateAuthority(output, caInfo, writeZipFile, terminal); + } + + private void writeCertificateAuthority(Path output, CAInfo caInfo, boolean writePemZip, Terminal terminal) throws Exception { + checkDirectory(output, terminal); + if (writePemZip) { + fullyWriteZipFile(output, (outputStream, pemWriter) -> writeCAInfo(outputStream, pemWriter, caInfo, true)); + } else { + final String fileName = output.getFileName().toString(); + fullyWriteFile(output, outputStream -> + writePkcs12(fileName, outputStream, "ca", caInfo.certAndKey, null, caInfo.password, terminal)); } } - return file.toAbsolutePath(); } @SuppressForbidden(reason = "resolve paths against CWD for a CLI tool") - private static Path resolvePath(String pathStr) { + static Path resolvePath(String pathStr) { return PathUtils.get(pathStr).normalize(); } - /** - * This method handles the collection of information about each instance that is necessary to generate a certificate. The user may - * be prompted or the information can be gathered from a file - * @param terminal the terminal to use for user interaction - * @param inputFile an optional file that will be used to load the instance information - * @return a {@link Collection} of {@link CertificateInformation} that represents each instance - */ - static Collection getCertificateInformationList(Terminal terminal, String inputFile) - throws Exception { - if (inputFile != null) { - return parseAndValidateFile(terminal, resolvePath(inputFile).toAbsolutePath()); - } - Map map = new HashMap<>(); - boolean done = false; - while (done == false) { - String name = terminal.readText("Enter instance name: "); - if (name.isEmpty() == false) { - final boolean isNameValidFilename = Name.isValidFilename(name); - String filename = terminal.readText("Enter name for directories and files " + (isNameValidFilename ? "[" + name + "]" : "") - + ": " ); - if (filename.isEmpty() && isNameValidFilename) { - filename = name; - } - String ipAddresses = terminal.readText("Enter IP Addresses for instance (comma-separated if more than one) []: "); - String dnsNames = terminal.readText("Enter DNS names for instance (comma-separated if more than one) []: "); - List ipList = Arrays.asList(Strings.splitStringByCommaToArray(ipAddresses)); - List dnsList = Arrays.asList(Strings.splitStringByCommaToArray(dnsNames)); - List commonNames = null; - - CertificateInformation information = new CertificateInformation(name, filename, ipList, dnsList, commonNames); - List validationErrors = information.validate(); - if (validationErrors.isEmpty()) { - if (map.containsKey(name)) { - terminal.println("Overwriting previously defined instance information [" + name + "]"); - } - map.put(name, information); - } else { - for (String validationError : validationErrors) { - terminal.println(validationError); - } - terminal.println("Skipping entry as invalid values were found"); - } - } else { - terminal.println("A name must be provided"); - } - - String exit = terminal.readText("Would you like to specify another instance? Press 'y' to continue entering instance " + - "information: "); - if ("y".equals(exit) == false) { - done = true; - } - } - return map.values(); - } - static Collection parseAndValidateFile(Terminal terminal, Path file) throws Exception { final Collection config = parseFile(file); boolean hasError = false; @@ -292,6 +899,7 @@ public class CertificateTool extends EnvironmentAwareCommand { /** * Parses the input file to retrieve the certificate information + * * @param file the file to parse * @return a collection of certificate information */ @@ -303,146 +911,79 @@ public class CertificateTool extends EnvironmentAwareCommand { } } - /** - * Generates certificate signing requests and writes them out to the specified file in zip format - * @param outputFile the file to write the output to. This file must not already exist - * @param certInfo the details to use in the certificate signing requests - */ - static void generateAndWriteCsrs(Path outputFile, Collection certInfo, int keysize) throws Exception { - fullyWriteFile(outputFile, (outputStream, pemWriter) -> { - for (CertificateInformation certificateInformation : certInfo) { - KeyPair keyPair = CertUtils.generateKeyPair(keysize); - GeneralNames sanList = getSubjectAlternativeNamesValue(certificateInformation.ipAddresses, certificateInformation.dnsNames, - certificateInformation.commonNames); - PKCS10CertificationRequest csr = CertUtils.generateCSR(keyPair, certificateInformation.name.x500Principal, sanList); - - final String dirName = certificateInformation.name.filename + "/"; - ZipEntry zipEntry = new ZipEntry(dirName); - assert zipEntry.isDirectory(); - outputStream.putNextEntry(zipEntry); - - // write csr - outputStream.putNextEntry(new ZipEntry(dirName + certificateInformation.name.filename + ".csr")); - pemWriter.writeObject(csr); - pemWriter.flush(); - outputStream.closeEntry(); - - // write private key - outputStream.putNextEntry(new ZipEntry(dirName + certificateInformation.name.filename + ".key")); - pemWriter.writeObject(keyPair.getPrivate()); - pemWriter.flush(); - outputStream.closeEntry(); - } - }); + private static PEMEncryptor getEncrypter(char[] password) { + return new JcePEMEncryptorBuilder("DES-EDE3-CBC").setProvider(CertUtils.BC_PROV).build(password); } - /** - * Returns the CA certificate and private key that will be used to sign certificates. These may be specified by the user or - * automatically generated - * - * @param terminal the terminal to use for prompting the user - * @param dn the distinguished name to use for the CA - * @param caCertPath the path to the CA certificate or {@code null} if not provided - * @param caKeyPath the path to the CA private key or {@code null} if not provided - * @param prompt whether we should prompt the user for a password - * @param keyPass the password to the private key. If not present and the key is encrypted the user will be prompted - * @param env the environment for this tool to resolve files with - * @param keysize the size of the key in bits - * @param days the number of days that the certificate should be valid for - * @return CA cert and private key - */ - static CAInfo getCAInfo(Terminal terminal, String dn, String caCertPath, String caKeyPath, char[] keyPass, boolean prompt, - Environment env, int keysize, int days) throws Exception { - if (caCertPath != null) { - assert caKeyPath != null; - final String resolvedCaCertPath = resolvePath(caCertPath).toAbsolutePath().toString(); - Certificate[] certificates = CertUtils.readCertificates(Collections.singletonList(resolvedCaCertPath), env); - if (certificates.length != 1) { - throw new IllegalArgumentException("expected a single certificate in file [" + caCertPath + "] but found [" + - certificates.length + "]"); + private static T withPassword(String description, char[] password, Terminal terminal, + CheckedFunction body) throws E { + if (password == null) { + char[] promptedValue = terminal.readSecret("Enter password for " + description + " : "); + try { + return body.apply(promptedValue); + } finally { + Arrays.fill(promptedValue, (char) 0); } - Certificate caCert = certificates[0]; - PrivateKey privateKey = readPrivateKey(caKeyPath, keyPass, terminal, prompt); - return new CAInfo((X509Certificate) caCert, privateKey); - } - - // generate the CA keys and cert - X500Principal x500Principal = new X500Principal(dn); - KeyPair keyPair = CertUtils.generateKeyPair(keysize); - Certificate caCert = CertUtils.generateCACertificate(x500Principal, keyPair, days); - final char[] password; - if (prompt) { - password = terminal.readSecret("Enter password for CA private key: "); } else { - password = keyPass; + return body.apply(password); } - return new CAInfo((X509Certificate) caCert, keyPair.getPrivate(), true, password); - } - - /** - * Generates signed certificates in PEM format stored in a zip file - * @param outputFile the file that the certificates will be written to. This file must not exist - * @param certificateInformations details for creation of the certificates - * @param caInfo the CA information to sign the certificates with - * @param keysize the size of the key in bits - * @param days the number of days that the certificate should be valid for - */ - static void generateAndWriteSignedCertificates(Path outputFile, Collection certificateInformations, - CAInfo caInfo, int keysize, int days, char[] pkcs12Password) throws Exception { - fullyWriteFile(outputFile, (outputStream, pemWriter) -> { - // write out the CA info first if it was generated - writeCAInfoIfGenerated(outputStream, pemWriter, caInfo); - - for (CertificateInformation certificateInformation : certificateInformations) { - KeyPair keyPair = CertUtils.generateKeyPair(keysize); - Certificate certificate = CertUtils.generateSignedCertificate(certificateInformation.name.x500Principal, - getSubjectAlternativeNamesValue(certificateInformation.ipAddresses, certificateInformation.dnsNames, - certificateInformation.commonNames), - keyPair, caInfo.caCert, caInfo.privateKey, days); - - final String dirName = certificateInformation.name.filename + "/"; - ZipEntry zipEntry = new ZipEntry(dirName); - assert zipEntry.isDirectory(); - outputStream.putNextEntry(zipEntry); - - // write cert - final String entryBase = dirName + certificateInformation.name.filename; - outputStream.putNextEntry(new ZipEntry(entryBase + ".crt")); - pemWriter.writeObject(certificate); - pemWriter.flush(); - outputStream.closeEntry(); - - // write private key - outputStream.putNextEntry(new ZipEntry(entryBase + ".key")); - pemWriter.writeObject(keyPair.getPrivate()); - pemWriter.flush(); - outputStream.closeEntry(); - - if (pkcs12Password != null) { - final KeyStore pkcs12 = KeyStore.getInstance("PKCS12"); - pkcs12.load(null); - pkcs12.setKeyEntry(certificateInformation.name.originalName, keyPair.getPrivate(), pkcs12Password, - new Certificate[]{certificate}); - - outputStream.putNextEntry(new ZipEntry(entryBase + ".p12")); - pkcs12.store(outputStream, pkcs12Password); - outputStream.closeEntry(); - } - } - }); } /** * This method handles the deletion of a file in the case of a partial write - * @param file the file that is being written to + * + * @param file the file that is being written to * @param writer writes the contents of the file */ - private static void fullyWriteFile(Path file, Writer writer) throws Exception { + private static void fullyWriteZipFile(Path file, Writer writer) throws Exception { + fullyWriteFile(file, outputStream -> { + try (ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream, StandardCharsets.UTF_8); + JcaPEMWriter pemWriter = new JcaPEMWriter(new OutputStreamWriter(zipOutputStream, StandardCharsets.UTF_8))) { + writer.write(zipOutputStream, pemWriter); + } + }); + } + + /** + * Checks whether the parent directories of {@code path} exist, and offers to create them if needed. + */ + private static void checkDirectory(Path path, Terminal terminal) throws UserException { + final Path parent = path.getParent(); + if (Files.isDirectory(parent)) { + return; + } + if (Files.exists(parent)) { + terminal.println(Terminal.Verbosity.SILENT, "Path " + parent + " exists, but is not a directory. Cannot write to " + path); + throw new UserException(ExitCodes.CANT_CREATE, "Cannot write to " + path); + } + if (terminal.promptYesNo("Directory " + parent + " does not exist. Do you want to create it?", true)) { + try { + Files.createDirectories(parent); + } catch (IOException e) { + throw new UserException(ExitCodes.CANT_CREATE, "Cannot create directory " + parent, e); + } + } else { + throw new UserException(ExitCodes.CANT_CREATE, "Directory " + parent + " does not exist"); + } + + } + + /** + * This method handles the deletion of a file in the case of a partial write + * + * @param file the file that is being written to + * @param writer writes the contents of the file + */ + private static void fullyWriteFile(Path file, CheckedConsumer writer) throws Exception { + assert file != null; + assert writer != null; + boolean success = false; - try (OutputStream outputStream = Files.newOutputStream(file, StandardOpenOption.CREATE_NEW); - ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream, StandardCharsets.UTF_8); - JcaPEMWriter pemWriter = new JcaPEMWriter(new OutputStreamWriter(zipOutputStream, StandardCharsets.UTF_8))) { - writer.write(zipOutputStream, pemWriter); + if (Files.exists(file)) { + throw new UserException(ExitCodes.IO_ERROR, "Output file '" + file + "' already exists"); + } + try (OutputStream outputStream = Files.newOutputStream(file, StandardOpenOption.CREATE_NEW)) { + writer.accept(outputStream); // set permissions to 600 PosixFileAttributeView view = Files.getFileAttributeView(file, PosixFileAttributeView.class); @@ -458,126 +999,24 @@ public class CertificateTool extends EnvironmentAwareCommand { } } - /** - * This method handles writing out the certificate authority cert and private key if the certificate authority was generated by - * this invocation of the tool - * @param outputStream the output stream to write to - * @param pemWriter the writer for PEM objects - * @param info the certificate authority information - */ - private static void writeCAInfoIfGenerated(ZipOutputStream outputStream, JcaPEMWriter pemWriter, CAInfo info) throws Exception { - if (info.generated) { - final String caDirName = "ca/"; - ZipEntry zipEntry = new ZipEntry(caDirName); - assert zipEntry.isDirectory(); - outputStream.putNextEntry(zipEntry); - outputStream.putNextEntry(new ZipEntry(caDirName + "ca.crt")); - pemWriter.writeObject(info.caCert); - pemWriter.flush(); - outputStream.closeEntry(); - outputStream.putNextEntry(new ZipEntry(caDirName + "ca.key")); - if (info.password != null && info.password.length > 0) { - try { - PEMEncryptor encryptor = new JcePEMEncryptorBuilder("DES-EDE3-CBC").setProvider(CertUtils.BC_PROV).build(info.password); - pemWriter.writeObject(info.privateKey, encryptor); - } finally { - // we can safely nuke the password chars now - Arrays.fill(info.password, (char) 0); - } - } else { - pemWriter.writeObject(info.privateKey); - } - pemWriter.flush(); - outputStream.closeEntry(); - } - } - - private static void printIntro(Terminal terminal, boolean csr) { - terminal.println("This tool assists you in the generation of X.509 certificates and certificate"); - terminal.println("signing requests for use with SSL in the Elastic stack. Depending on the command"); - terminal.println("line option specified, you may be prompted for the following:"); - terminal.println(""); - terminal.println("* The path to the output file"); - if (csr) { - terminal.println(" * The output file is a zip file containing the certificate signing requests"); - terminal.println(" and private keys for each instance."); - } else { - terminal.println(" * The output file is a zip file containing the signed certificates and"); - terminal.println(" private keys for each instance. If a Certificate Authority was generated,"); - terminal.println(" the certificate and private key will also be included in the output file."); - } - terminal.println("* Information about each instance"); - terminal.println(" * An instance is any piece of the Elastic Stack that requires a SSL certificate."); - terminal.println(" Depending on your configuration, Elasticsearch, Logstash, Kibana, and Beats"); - terminal.println(" may all require a certificate and private key."); - terminal.println(" * The minimum required value for each instance is a name. This can simply be the"); - terminal.println(" hostname, which will be used as the Common Name of the certificate. A full"); - terminal.println(" distinguished name may also be used."); - terminal.println(" * A filename value may be required for each instance. This is necessary when the"); - terminal.println(" name would result in an invalid file or directory name. The name provided here"); - terminal.println(" is used as the directory name (within the zip) and the prefix for the key and"); - terminal.println(" certificate files. The filename is required if you are prompted and the name"); - terminal.println(" is not displayed in the prompt."); - terminal.println(" * IP addresses and DNS names are optional. Multiple values can be specified as a"); - terminal.println(" comma separated string. If no IP addresses or DNS names are provided, you may"); - terminal.println(" disable hostname verification in your SSL configuration."); - - if (csr == false) { - terminal.println("* Certificate Authority private key password"); - terminal.println(" * The password may be left empty if desired."); - } - terminal.println(""); - terminal.println("Let's get started..."); - terminal.println(""); - } - - private static void printConclusion(Terminal terminal, boolean csr, Path outputFile) { - if (csr) { - terminal.println("Certificate signing requests written to " + outputFile); - terminal.println(""); - terminal.println("This file should be properly secured as it contains the private keys for all"); - terminal.println("instances."); - terminal.println(""); - terminal.println("After unzipping the file, there will be a directory for each instance containing"); - terminal.println("the certificate signing request and the private key. Provide the certificate"); - terminal.println("signing requests to your certificate authority. Once you have received the"); - terminal.println("signed certificate, copy the signed certificate, key, and CA certificate to the"); - terminal.println("configuration directory of the Elastic product that they will be used for and"); - terminal.println("follow the SSL configuration instructions in the product guide."); - } else { - terminal.println("Certificates written to " + outputFile); - terminal.println(""); - terminal.println("This file should be properly secured as it contains the private keys for all"); - terminal.println("instances and the certificate authority."); - terminal.println(""); - terminal.println("After unzipping the file, there will be a directory for each instance containing"); - terminal.println("the certificate and private key. Copy the certificate, key, and CA certificate"); - terminal.println("to the configuration directory of the Elastic product that they will be used for"); - terminal.println("and follow the SSL configuration instructions in the product guide."); - terminal.println(""); - terminal.println("For client applications, you may only need to copy the CA certificate and"); - terminal.println("configure the client to trust this certificate."); - } - } - /** * Helper method to read a private key and support prompting of user for a key. To avoid passwords being placed as an argument we * can prompt the user for their password if we encounter an encrypted key. - * @param path the path to the private key + * + * @param path the path to the private key * @param password the password provided by the user or {@code null} * @param terminal the terminal to use for user interaction - * @param prompt whether to prompt the user or not * @return the {@link PrivateKey} that was read from the file */ - private static PrivateKey readPrivateKey(String path, char[] password, Terminal terminal, boolean prompt) - throws Exception { + private static PrivateKey readPrivateKey(Path path, char[] password, Terminal terminal) + throws Exception { AtomicReference passwordReference = new AtomicReference<>(password); - try (Reader reader = Files.newBufferedReader(resolvePath(path), StandardCharsets.UTF_8)) { + try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { return CertUtils.readPrivateKey(reader, () -> { - if (password != null || prompt == false) { + if (password != null) { return password; } - char[] promptedValue = terminal.readSecret("Enter password for CA private key: "); + char[] promptedValue = terminal.readSecret("Enter password for CA private key (" + path.getFileName() + ") : "); passwordReference.set(promptedValue); return promptedValue; }); @@ -608,6 +1047,15 @@ public class CertificateTool extends EnvironmentAwareCommand { return new GeneralNames(generalNameList.toArray(new GeneralName[0])); } + private static boolean isAscii(char[] str) { + return ASCII_ENCODER.canEncode(CharBuffer.wrap(str)); + } + + private static char[] getChars(String password) { + return password == null ? null : password.toCharArray(); + } + + static class CertificateInformation { final Name name; final List ipAddresses; @@ -658,6 +1106,9 @@ public class CertificateTool extends EnvironmentAwareCommand { if ("ca".equals(name)) { return new Name(name, null, null, "[ca] may not be used as an instance name"); } + if (name == null) { + return new Name("", null, null, "instance name may not be null"); + } final X500Principal principal; try { @@ -693,9 +1144,18 @@ public class CertificateTool extends EnvironmentAwareCommand { } } + static class CertificateAndKey { + final X509Certificate cert; + final PrivateKey key; + + CertificateAndKey(X509Certificate cert, PrivateKey key) { + this.cert = cert; + this.key = key; + } + } + static class CAInfo { - final X509Certificate caCert; - final PrivateKey privateKey; + final CertificateAndKey certAndKey; final boolean generated; final char[] password; @@ -704,8 +1164,7 @@ public class CertificateTool extends EnvironmentAwareCommand { } CAInfo(X509Certificate caCert, PrivateKey privateKey, boolean generated, char[] password) { - this.caCert = caCert; - this.privateKey = privateKey; + this.certAndKey = new CertificateAndKey(caCert, privateKey); this.generated = generated; this.password = password; } @@ -714,4 +1173,5 @@ public class CertificateTool extends EnvironmentAwareCommand { private interface Writer { void write(ZipOutputStream zipOutputStream, JcaPEMWriter pemWriter) throws Exception; } + } diff --git a/plugin/src/test/java/org/elasticsearch/test/TestMatchers.java b/plugin/src/test/java/org/elasticsearch/test/TestMatchers.java index 13b199d75be..2390385fa1e 100644 --- a/plugin/src/test/java/org/elasticsearch/test/TestMatchers.java +++ b/plugin/src/test/java/org/elasticsearch/test/TestMatchers.java @@ -16,7 +16,7 @@ import org.hamcrest.Matchers; public class TestMatchers extends Matchers { public static Matcher pathExists(Path path, LinkOption... options) { - return new CustomMatcher("Path " + path + " doesn't exist") { + return new CustomMatcher("Path " + path + " exists") { @Override public boolean matches(Object item) { return Files.exists(path, options); diff --git a/plugin/src/test/java/org/elasticsearch/xpack/ssl/CertificateGenerateToolTests.java b/plugin/src/test/java/org/elasticsearch/xpack/ssl/CertificateGenerateToolTests.java new file mode 100644 index 00000000000..893a0a202b9 --- /dev/null +++ b/plugin/src/test/java/org/elasticsearch/xpack/ssl/CertificateGenerateToolTests.java @@ -0,0 +1,535 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.ssl; + +import javax.security.auth.x500.X500Principal; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.net.InetAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.security.Key; +import java.security.KeyPair; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.interfaces.RSAKey; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import com.google.common.jimfs.Configuration; +import com.google.common.jimfs.Jimfs; +import org.apache.lucene.util.IOUtils; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.ASN1Sequence; +import org.bouncycastle.asn1.ASN1String; +import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.pkcs.Attribute; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.Extensions; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.openssl.PEMEncryptedKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.elasticsearch.cli.MockTerminal; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.SuppressForbidden; +import org.elasticsearch.common.io.PathUtils; +import org.elasticsearch.common.network.NetworkAddress; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.env.Environment; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.test.SecuritySettingsSource; +import org.elasticsearch.xpack.ssl.CertificateGenerateTool.CAInfo; +import org.elasticsearch.xpack.ssl.CertificateGenerateTool.CertificateInformation; +import org.elasticsearch.xpack.ssl.CertificateGenerateTool.Name; +import org.hamcrest.Matchers; +import org.junit.After; + +import static org.elasticsearch.test.TestMatchers.pathExists; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; + +/** + * Unit tests for the tool used to simplify SSL certificate generation + */ +public class CertificateGenerateToolTests extends ESTestCase { + + private FileSystem jimfs; + + private Path initTempDir() throws Exception { + Configuration conf = Configuration.unix().toBuilder().setAttributeViews("posix").build(); + jimfs = Jimfs.newFileSystem(conf); + Path tempDir = jimfs.getPath("temp"); + IOUtils.rm(tempDir); + Files.createDirectories(tempDir); + return tempDir; + } + + @After + public void tearDown() throws Exception { + IOUtils.close(jimfs); + super.tearDown(); + } + + public void testOutputDirectory() throws Exception { + Path outputDir = createTempDir(); + Path outputFile = outputDir.resolve("certs.zip"); + MockTerminal terminal = new MockTerminal(); + + // test with a user provided dir + Path resolvedOutputFile = CertificateGenerateTool.getOutputFile(terminal, outputFile.toString(), null); + assertEquals(outputFile, resolvedOutputFile); + assertTrue(terminal.getOutput().isEmpty()); + + // test without a user provided directory + Path userPromptedOutputFile = outputDir.resolve("csr"); + assertFalse(Files.exists(userPromptedOutputFile)); + terminal.addTextInput(userPromptedOutputFile.toString()); + resolvedOutputFile = CertificateGenerateTool.getOutputFile(terminal, null, "out.zip"); + assertEquals(userPromptedOutputFile, resolvedOutputFile); + assertTrue(terminal.getOutput().isEmpty()); + + // test with empty user input + String defaultFilename = randomAlphaOfLengthBetween(1, 10); + Path expectedDefaultPath = resolvePath(defaultFilename); + terminal.addTextInput(""); + resolvedOutputFile = CertificateGenerateTool.getOutputFile(terminal, null, defaultFilename); + assertEquals(expectedDefaultPath, resolvedOutputFile); + assertTrue(terminal.getOutput().isEmpty()); + } + + public void testPromptingForInstanceInformation() throws Exception { + final int numberOfInstances = scaledRandomIntBetween(1, 12); + Map> instanceInput = new HashMap<>(numberOfInstances); + for (int i = 0; i < numberOfInstances; i++) { + final String name; + while (true) { + String randomName = getValidRandomInstanceName(); + if (instanceInput.containsKey(randomName) == false) { + name = randomName; + break; + } + } + Map instanceInfo = new HashMap<>(); + instanceInput.put(name, instanceInfo); + instanceInfo.put("ip", randomFrom("127.0.0.1", "::1", "192.168.1.1,::1", "")); + instanceInfo.put("dns", randomFrom("localhost", "localhost.localdomain", "localhost,myhost", "")); + logger.info("instance [{}] name [{}] [{}]", i, name, instanceInfo); + } + + int count = 0; + MockTerminal terminal = new MockTerminal(); + for (Entry> entry : instanceInput.entrySet()) { + terminal.addTextInput(entry.getKey()); + terminal.addTextInput(""); + terminal.addTextInput(entry.getValue().get("ip")); + terminal.addTextInput(entry.getValue().get("dns")); + count++; + if (count == numberOfInstances) { + terminal.addTextInput("n"); + } else { + terminal.addTextInput("y"); + } + } + + Collection certInfos = CertificateGenerateTool.getCertificateInformationList(terminal, null); + logger.info("certificate tool output:\n{}", terminal.getOutput()); + assertEquals(numberOfInstances, certInfos.size()); + for (CertificateInformation certInfo : certInfos) { + String name = certInfo.name.originalName; + Map instanceInfo = instanceInput.get(name); + assertNotNull("did not find map for " + name, instanceInfo); + List expectedIps = Arrays.asList(Strings.commaDelimitedListToStringArray(instanceInfo.get("ip"))); + List expectedDns = Arrays.asList(Strings.commaDelimitedListToStringArray(instanceInfo.get("dns"))); + assertEquals(expectedIps, certInfo.ipAddresses); + assertEquals(expectedDns, certInfo.dnsNames); + instanceInput.remove(name); + } + assertEquals(0, instanceInput.size()); + final String output = terminal.getOutput(); + assertTrue("Output: " + output, output.isEmpty()); + } + + public void testParsingFile() throws Exception { + Path tempDir = initTempDir(); + Path instanceFile = writeInstancesTo(tempDir.resolve("instances.yml")); + Collection certInfos = CertificateGenerateTool.parseFile(instanceFile); + assertEquals(4, certInfos.size()); + + Map certInfosMap = + certInfos.stream().collect(Collectors.toMap((c) -> c.name.originalName, Function.identity())); + CertificateInformation certInfo = certInfosMap.get("node1"); + assertEquals(Collections.singletonList("127.0.0.1"), certInfo.ipAddresses); + assertEquals(Collections.singletonList("localhost"), certInfo.dnsNames); + assertEquals(Collections.emptyList(), certInfo.commonNames); + assertEquals("node1", certInfo.name.filename); + + certInfo = certInfosMap.get("node2"); + assertEquals(Collections.singletonList("::1"), certInfo.ipAddresses); + assertEquals(Collections.emptyList(), certInfo.dnsNames); + assertEquals(Collections.singletonList("node2.elasticsearch"), certInfo.commonNames); + assertEquals("node2", certInfo.name.filename); + + certInfo = certInfosMap.get("node3"); + assertEquals(Collections.emptyList(), certInfo.ipAddresses); + assertEquals(Collections.emptyList(), certInfo.dnsNames); + assertEquals(Collections.emptyList(), certInfo.commonNames); + assertEquals("node3", certInfo.name.filename); + + certInfo = certInfosMap.get("CN=different value"); + assertEquals(Collections.emptyList(), certInfo.ipAddresses); + assertEquals(Collections.singletonList("node4.mydomain.com"), certInfo.dnsNames); + assertEquals(Collections.emptyList(), certInfo.commonNames); + assertEquals("different file", certInfo.name.filename); + } + + public void testGeneratingCsr() throws Exception { + Path tempDir = initTempDir(); + Path outputFile = tempDir.resolve("out.zip"); + Path instanceFile = writeInstancesTo(tempDir.resolve("instances.yml")); + Collection certInfos = CertificateGenerateTool.parseFile(instanceFile); + assertEquals(4, certInfos.size()); + + assertFalse(Files.exists(outputFile)); + CertificateGenerateTool.generateAndWriteCsrs(outputFile, certInfos, randomFrom(1024, 2048)); + assertTrue(Files.exists(outputFile)); + + Set perms = Files.getPosixFilePermissions(outputFile); + assertTrue(perms.toString(), perms.contains(PosixFilePermission.OWNER_READ)); + assertTrue(perms.toString(), perms.contains(PosixFilePermission.OWNER_WRITE)); + assertEquals(perms.toString(), 2, perms.size()); + + FileSystem fileSystem = FileSystems.newFileSystem(new URI("jar:" + outputFile.toUri()), Collections.emptyMap()); + Path zipRoot = fileSystem.getPath("/"); + + assertFalse(Files.exists(zipRoot.resolve("ca"))); + for (CertificateInformation certInfo : certInfos) { + String filename = certInfo.name.filename; + assertTrue(Files.exists(zipRoot.resolve(filename))); + final Path csr = zipRoot.resolve(filename + "/" + filename + ".csr"); + assertTrue(Files.exists(csr)); + assertTrue(Files.exists(zipRoot.resolve(filename + "/" + filename + ".key"))); + PKCS10CertificationRequest request = readCertificateRequest(csr); + assertEquals(certInfo.name.x500Principal.getName(), request.getSubject().toString()); + Attribute[] extensionsReq = request.getAttributes(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest); + if (certInfo.ipAddresses.size() > 0 || certInfo.dnsNames.size() > 0) { + assertEquals(1, extensionsReq.length); + Extensions extensions = Extensions.getInstance(extensionsReq[0].getAttributeValues()[0]); + GeneralNames subjAltNames = GeneralNames.fromExtensions(extensions, Extension.subjectAlternativeName); + assertSubjAltNames(subjAltNames, certInfo); + } else { + assertEquals(0, extensionsReq.length); + } + } + } + + public void testGeneratingSignedCertificates() throws Exception { + Path tempDir = initTempDir(); + Path outputFile = tempDir.resolve("out.zip"); + Path instanceFile = writeInstancesTo(tempDir.resolve("instances.yml")); + Collection certInfos = CertificateGenerateTool.parseFile(instanceFile); + assertEquals(4, certInfos.size()); + + final int keysize = randomFrom(1024, 2048); + final int days = randomIntBetween(1, 1024); + KeyPair keyPair = CertUtils.generateKeyPair(keysize); + X509Certificate caCert = CertUtils.generateCACertificate(new X500Principal("CN=test ca"), keyPair, days); + + final boolean generatedCa = randomBoolean(); + final char[] keyPassword = randomBoolean() ? SecuritySettingsSource.TEST_PASSWORD.toCharArray() : null; + final char[] pkcs12Password = randomBoolean() ? randomAlphaOfLengthBetween(1, 12).toCharArray() : null; + assertFalse(Files.exists(outputFile)); + CAInfo caInfo = new CAInfo(caCert, keyPair.getPrivate(), generatedCa, keyPassword); + CertificateGenerateTool.generateAndWriteSignedCertificates(outputFile, certInfos, caInfo, keysize, days, pkcs12Password); + assertTrue(Files.exists(outputFile)); + + Set perms = Files.getPosixFilePermissions(outputFile); + assertTrue(perms.toString(), perms.contains(PosixFilePermission.OWNER_READ)); + assertTrue(perms.toString(), perms.contains(PosixFilePermission.OWNER_WRITE)); + assertEquals(perms.toString(), 2, perms.size()); + + FileSystem fileSystem = FileSystems.newFileSystem(new URI("jar:" + outputFile.toUri()), Collections.emptyMap()); + Path zipRoot = fileSystem.getPath("/"); + + if (generatedCa) { + assertTrue(Files.exists(zipRoot.resolve("ca"))); + assertTrue(Files.exists(zipRoot.resolve("ca").resolve("ca.crt"))); + assertTrue(Files.exists(zipRoot.resolve("ca").resolve("ca.key"))); + // check the CA cert + try (Reader reader = Files.newBufferedReader(zipRoot.resolve("ca").resolve("ca.crt"))) { + X509Certificate parsedCaCert = readX509Certificate(reader); + assertThat(parsedCaCert.getSubjectX500Principal().getName(), containsString("test ca")); + assertEquals(caCert, parsedCaCert); + long daysBetween = ChronoUnit.DAYS.between(caCert.getNotBefore().toInstant(), caCert.getNotAfter().toInstant()); + assertEquals(days, (int) daysBetween); + } + + // check the CA key + if (keyPassword != null) { + try (Reader reader = Files.newBufferedReader(zipRoot.resolve("ca").resolve("ca.key"))) { + PEMParser pemParser = new PEMParser(reader); + Object parsed = pemParser.readObject(); + assertThat(parsed, instanceOf(PEMEncryptedKeyPair.class)); + char[] zeroChars = new char[keyPassword.length]; + Arrays.fill(zeroChars, (char) 0); + assertArrayEquals(zeroChars, keyPassword); + } + } + + try (Reader reader = Files.newBufferedReader(zipRoot.resolve("ca").resolve("ca.key"))) { + PrivateKey privateKey = CertUtils.readPrivateKey(reader, () -> keyPassword != null ? + SecuritySettingsSource.TEST_PASSWORD.toCharArray() : null); + assertEquals(caInfo.privateKey, privateKey); + } + } else { + assertFalse(Files.exists(zipRoot.resolve("ca"))); + } + + for (CertificateInformation certInfo : certInfos) { + String filename = certInfo.name.filename; + assertTrue(Files.exists(zipRoot.resolve(filename))); + final Path cert = zipRoot.resolve(filename + "/" + filename + ".crt"); + assertTrue(Files.exists(cert)); + assertTrue(Files.exists(zipRoot.resolve(filename + "/" + filename + ".key"))); + final Path p12 = zipRoot.resolve(filename + "/" + filename + ".p12"); + try (Reader reader = Files.newBufferedReader(cert)) { + X509Certificate certificate = readX509Certificate(reader); + assertEquals(certInfo.name.x500Principal.toString(), certificate.getSubjectX500Principal().getName()); + final int sanCount = certInfo.ipAddresses.size() + certInfo.dnsNames.size() + certInfo.commonNames.size(); + if (sanCount == 0) { + assertNull(certificate.getSubjectAlternativeNames()); + } else { + X509CertificateHolder x509CertHolder = new X509CertificateHolder(certificate.getEncoded()); + GeneralNames subjAltNames = + GeneralNames.fromExtensions(x509CertHolder.getExtensions(), Extension.subjectAlternativeName); + assertSubjAltNames(subjAltNames, certInfo); + } + if (pkcs12Password != null) { + assertThat(p12, pathExists(p12)); + try (InputStream in = Files.newInputStream(p12)) { + final KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(in, pkcs12Password); + final Certificate p12Certificate = ks.getCertificate(certInfo.name.originalName); + assertThat("Certificate " + certInfo.name, p12Certificate, notNullValue()); + assertThat(p12Certificate, equalTo(certificate)); + final Key key = ks.getKey(certInfo.name.originalName, pkcs12Password); + assertThat(key, notNullValue()); + } + } else { + assertThat(p12, not(pathExists(p12))); + } + } + } + } + + public void testGetCAInfo() throws Exception { + Environment env = new Environment(Settings.builder().put("path.home", createTempDir()).build()); + Path testNodeCertPath = getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.crt"); + Path testNodeKeyPath = getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.pem"); + final boolean passwordPrompt = randomBoolean(); + MockTerminal terminal = new MockTerminal(); + if (passwordPrompt) { + terminal.addSecretInput("testnode"); + } + + final int days = randomIntBetween(1, 1024); + CAInfo caInfo = CertificateGenerateTool.getCAInfo(terminal, "CN=foo", testNodeCertPath.toString(), testNodeKeyPath.toString(), + passwordPrompt ? null : "testnode".toCharArray(), passwordPrompt, env, randomFrom(1024, 2048), days); + assertTrue(terminal.getOutput().isEmpty()); + assertEquals(caInfo.caCert.getSubjectX500Principal().getName(), + "CN=Elasticsearch Test Node,OU=elasticsearch,O=org"); + assertThat(caInfo.privateKey.getAlgorithm(), containsString("RSA")); + assertEquals(2048, ((RSAKey) caInfo.privateKey).getModulus().bitLength()); + assertFalse(caInfo.generated); + long daysBetween = ChronoUnit.DAYS.between(caInfo.caCert.getNotBefore().toInstant(), caInfo.caCert.getNotAfter().toInstant()); + assertEquals(1460L, daysBetween); + + // test generation + final boolean passwordProtected = randomBoolean(); + final char[] password; + if (passwordPrompt && passwordProtected) { + password = null; + terminal.addSecretInput("testnode"); + } else { + password = "testnode".toCharArray(); + } + final int keysize = randomFrom(1024, 2048); + caInfo = CertificateGenerateTool.getCAInfo(terminal, "CN=foo bar", null, null, password, passwordProtected && passwordPrompt, env, + keysize, days); + assertTrue(terminal.getOutput().isEmpty()); + assertThat(caInfo.caCert, instanceOf(X509Certificate.class)); + assertEquals(caInfo.caCert.getSubjectX500Principal().getName(), "CN=foo bar"); + assertThat(caInfo.privateKey.getAlgorithm(), containsString("RSA")); + assertTrue(caInfo.generated); + assertEquals(keysize, ((RSAKey) caInfo.privateKey).getModulus().bitLength()); + daysBetween = ChronoUnit.DAYS.between(caInfo.caCert.getNotBefore().toInstant(), caInfo.caCert.getNotAfter().toInstant()); + assertEquals(days, (int) daysBetween); + } + + public void testNameValues() throws Exception { + // good name + Name name = Name.fromUserProvidedName("my instance", "my instance"); + assertEquals("my instance", name.originalName); + assertNull(name.error); + assertEquals("CN=my instance", name.x500Principal.getName()); + assertEquals("my instance", name.filename); + + // too long + String userProvidedName = randomAlphaOfLength(CertificateGenerateTool.MAX_FILENAME_LENGTH + 1); + name = Name.fromUserProvidedName(userProvidedName, userProvidedName); + assertEquals(userProvidedName, name.originalName); + assertThat(name.error, containsString("valid filename")); + + // too short + name = Name.fromUserProvidedName("", ""); + assertEquals("", name.originalName); + assertThat(name.error, containsString("valid filename")); + assertEquals("CN=", name.x500Principal.getName()); + assertNull(name.filename); + + // invalid characters only + userProvidedName = "<>|<>*|?\"\\"; + name = Name.fromUserProvidedName(userProvidedName, userProvidedName); + assertEquals(userProvidedName, name.originalName); + assertThat(name.error, containsString("valid DN")); + assertNull(name.x500Principal); + assertNull(name.filename); + + // invalid for file but DN ok + userProvidedName = "*"; + name = Name.fromUserProvidedName(userProvidedName, userProvidedName); + assertEquals(userProvidedName, name.originalName); + assertThat(name.error, containsString("valid filename")); + assertEquals("CN=" + userProvidedName, name.x500Principal.getName()); + assertNull(name.filename); + + // invalid with valid chars for filename + userProvidedName = "*.mydomain.com"; + name = Name.fromUserProvidedName(userProvidedName, userProvidedName); + assertEquals(userProvidedName, name.originalName); + assertThat(name.error, containsString("valid filename")); + assertEquals("CN=" + userProvidedName, name.x500Principal.getName()); + + // valid but could create hidden file/dir so it is not allowed + userProvidedName = ".mydomain.com"; + name = Name.fromUserProvidedName(userProvidedName, userProvidedName); + assertEquals(userProvidedName, name.originalName); + assertThat(name.error, containsString("valid filename")); + assertEquals("CN=" + userProvidedName, name.x500Principal.getName()); + } + + private PKCS10CertificationRequest readCertificateRequest(Path path) throws Exception { + try (Reader reader = Files.newBufferedReader(path); + PEMParser pemParser = new PEMParser(reader)) { + Object object = pemParser.readObject(); + assertThat(object, instanceOf(PKCS10CertificationRequest.class)); + return (PKCS10CertificationRequest) object; + } + } + + private X509Certificate readX509Certificate(Reader reader) throws Exception { + List list = new ArrayList<>(1); + CertUtils.readCertificates(reader, list, CertificateFactory.getInstance("X.509")); + assertEquals(1, list.size()); + assertThat(list.get(0), instanceOf(X509Certificate.class)); + return (X509Certificate) list.get(0); + } + + private void assertSubjAltNames(GeneralNames subjAltNames, CertificateInformation certInfo) throws Exception { + final int expectedCount = certInfo.ipAddresses.size() + certInfo.dnsNames.size() + certInfo.commonNames.size(); + assertEquals(expectedCount, subjAltNames.getNames().length); + Collections.sort(certInfo.dnsNames); + Collections.sort(certInfo.ipAddresses); + for (GeneralName generalName : subjAltNames.getNames()) { + if (generalName.getTagNo() == GeneralName.dNSName) { + String dns = ((ASN1String) generalName.getName()).getString(); + assertTrue(certInfo.dnsNames.stream().anyMatch(dns::equals)); + } else if (generalName.getTagNo() == GeneralName.iPAddress) { + byte[] ipBytes = DEROctetString.getInstance(generalName.getName()).getOctets(); + String ip = NetworkAddress.format(InetAddress.getByAddress(ipBytes)); + assertTrue(certInfo.ipAddresses.stream().anyMatch(ip::equals)); + } else if (generalName.getTagNo() == GeneralName.otherName) { + ASN1Sequence seq = ASN1Sequence.getInstance(generalName.getName()); + assertThat(seq.size(), equalTo(2)); + assertThat(seq.getObjectAt(0), instanceOf(ASN1ObjectIdentifier.class)); + assertThat(seq.getObjectAt(0).toString(), equalTo(CertUtils.CN_OID)); + assertThat(seq.getObjectAt(1), instanceOf(ASN1String.class)); + assertThat(seq.getObjectAt(1).toString(), Matchers.isIn(certInfo.commonNames)); + } else { + fail("unknown general name with tag " + generalName.getTagNo()); + } + } + } + + /** + * Gets a random name that is valid for certificate generation. There are some cases where the random value could match one of the + * reserved names like ca, so this method allows us to avoid these issues. + */ + private String getValidRandomInstanceName() { + String name; + boolean valid; + do { + name = randomAlphaOfLengthBetween(1, 32); + valid = Name.fromUserProvidedName(name, name).error == null; + } while (valid == false); + return name; + } + + /** + * Writes the description of instances to a given {@link Path} + */ + private Path writeInstancesTo(Path path) throws IOException { + Iterable instances = Arrays.asList( + "instances:", + " - name: \"node1\"", + " ip:", + " - \"127.0.0.1\"", + " dns: \"localhost\"", + " - name: \"node2\"", + " filename: \"node2\"", + " ip: \"::1\"", + " cn:", + " - \"node2.elasticsearch\"", + " - name: \"node3\"", + " filename: \"node3\"", + " - name: \"CN=different value\"", + " filename: \"different file\"", + " dns:", + " - \"node4.mydomain.com\""); + + return Files.write(path, instances, StandardCharsets.UTF_8); + } + + @SuppressForbidden(reason = "resolve paths against CWD for a CLI tool") + private static Path resolvePath(String path) { + return PathUtils.get(path).toAbsolutePath(); + } +} diff --git a/plugin/src/test/java/org/elasticsearch/xpack/ssl/CertificateToolTests.java b/plugin/src/test/java/org/elasticsearch/xpack/ssl/CertificateToolTests.java index 93305e7b1e7..0d9157f4983 100644 --- a/plugin/src/test/java/org/elasticsearch/xpack/ssl/CertificateToolTests.java +++ b/plugin/src/test/java/org/elasticsearch/xpack/ssl/CertificateToolTests.java @@ -5,9 +5,12 @@ */ package org.elasticsearch.xpack.ssl; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509ExtendedKeyManager; +import javax.net.ssl.X509ExtendedTrustManager; import javax.security.auth.x500.X500Principal; import java.io.IOException; -import java.io.InputStream; import java.io.Reader; import java.net.InetAddress; import java.net.URI; @@ -20,6 +23,7 @@ import java.nio.file.attribute.PosixFilePermission; import java.security.Key; import java.security.KeyPair; import java.security.KeyStore; +import java.security.Principal; import java.security.PrivateKey; import java.security.cert.Certificate; import java.security.cert.CertificateFactory; @@ -40,6 +44,8 @@ import java.util.stream.Collectors; import com.google.common.jimfs.Configuration; import com.google.common.jimfs.Jimfs; +import joptsimple.OptionSet; +import joptsimple.OptionSpec; import org.apache.lucene.util.IOUtils; import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.bouncycastle.asn1.ASN1Sequence; @@ -63,21 +69,26 @@ import org.elasticsearch.common.SuppressForbidden; import org.elasticsearch.common.io.PathUtils; import org.elasticsearch.common.network.NetworkAddress; import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.common.util.CollectionUtils; import org.elasticsearch.env.Environment; import org.elasticsearch.test.ESTestCase; import org.elasticsearch.test.SecuritySettingsSource; import org.elasticsearch.xpack.ssl.CertificateTool.CAInfo; +import org.elasticsearch.xpack.ssl.CertificateTool.CertificateAuthorityCommand; +import org.elasticsearch.xpack.ssl.CertificateTool.CertificateCommand; import org.elasticsearch.xpack.ssl.CertificateTool.CertificateInformation; +import org.elasticsearch.xpack.ssl.CertificateTool.GenerateCertificateCommand; import org.elasticsearch.xpack.ssl.CertificateTool.Name; import org.hamcrest.Matchers; import org.junit.After; import static org.elasticsearch.test.TestMatchers.pathExists; +import static org.hamcrest.Matchers.arrayWithSize; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.not; -import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; /** * Unit tests for the tool used to simplify SSL certificate generation @@ -106,16 +117,16 @@ public class CertificateToolTests extends ESTestCase { Path outputFile = outputDir.resolve("certs.zip"); MockTerminal terminal = new MockTerminal(); - // test with a user provided dir - Path resolvedOutputFile = CertificateTool.getOutputFile(terminal, outputFile.toString(), null); + // test with a user provided file + Path resolvedOutputFile = CertificateCommand.resolveOutputPath(terminal, outputFile.toString(), "something"); assertEquals(outputFile, resolvedOutputFile); assertTrue(terminal.getOutput().isEmpty()); - // test without a user provided directory + // test without a user provided file, with user input (prompted) Path userPromptedOutputFile = outputDir.resolve("csr"); assertFalse(Files.exists(userPromptedOutputFile)); terminal.addTextInput(userPromptedOutputFile.toString()); - resolvedOutputFile = CertificateTool.getOutputFile(terminal, null, "out.zip"); + resolvedOutputFile = CertificateCommand.resolveOutputPath(terminal, (String) null, "default.zip"); assertEquals(userPromptedOutputFile, resolvedOutputFile); assertTrue(terminal.getOutput().isEmpty()); @@ -123,7 +134,7 @@ public class CertificateToolTests extends ESTestCase { String defaultFilename = randomAlphaOfLengthBetween(1, 10); Path expectedDefaultPath = resolvePath(defaultFilename); terminal.addTextInput(""); - resolvedOutputFile = CertificateTool.getOutputFile(terminal, null, defaultFilename); + resolvedOutputFile = CertificateCommand.resolveOutputPath(terminal, (String) null, defaultFilename); assertEquals(expectedDefaultPath, resolvedOutputFile); assertTrue(terminal.getOutput().isEmpty()); } @@ -162,7 +173,7 @@ public class CertificateToolTests extends ESTestCase { } } - Collection certInfos = CertificateTool.getCertificateInformationList(terminal, null); + Collection certInfos = CertificateCommand.readMultipleCertificateInformation(terminal); logger.info("certificate tool output:\n{}", terminal.getOutput()); assertEquals(numberOfInstances, certInfos.size()); for (CertificateInformation certInfo : certInfos) { @@ -233,7 +244,9 @@ public class CertificateToolTests extends ESTestCase { assertEquals(4, certInfos.size()); assertFalse(Files.exists(outputFile)); - CertificateTool.generateAndWriteCsrs(outputFile, certInfos, randomFrom(1024, 2048)); + int keySize = randomFrom(1024, 2048); + + new CertificateTool.SigningRequestCommand().generateAndWriteCsrs(outputFile, keySize, certInfos); assertTrue(Files.exists(outputFile)); Set perms = Files.getPosixFilePermissions(outputFile); @@ -265,24 +278,37 @@ public class CertificateToolTests extends ESTestCase { } } - public void testGeneratingSignedCertificates() throws Exception { + public void testGeneratingSignedPemCertificates() throws Exception { Path tempDir = initTempDir(); Path outputFile = tempDir.resolve("out.zip"); Path instanceFile = writeInstancesTo(tempDir.resolve("instances.yml")); Collection certInfos = CertificateTool.parseFile(instanceFile); assertEquals(4, certInfos.size()); - final int keysize = randomFrom(1024, 2048); - final int days = randomIntBetween(1, 1024); - KeyPair keyPair = CertUtils.generateKeyPair(keysize); + int keySize = randomFrom(1024, 2048); + int days = randomIntBetween(1, 1024); + + KeyPair keyPair = CertUtils.generateKeyPair(keySize); X509Certificate caCert = CertUtils.generateCACertificate(new X500Principal("CN=test ca"), keyPair, days); final boolean generatedCa = randomBoolean(); - final char[] keyPassword = randomBoolean() ? SecuritySettingsSource.TEST_PASSWORD.toCharArray() : null; - final char[] pkcs12Password = randomBoolean() ? randomAlphaOfLengthBetween(1, 12).toCharArray() : null; + final boolean keepCaKey = generatedCa && randomBoolean(); + final String keyPassword = randomBoolean() ? SecuritySettingsSource.TEST_PASSWORD : null; + assertFalse(Files.exists(outputFile)); - CAInfo caInfo = new CAInfo(caCert, keyPair.getPrivate(), generatedCa, keyPassword); - CertificateTool.generateAndWriteSignedCertificates(outputFile, certInfos, caInfo, keysize, days, pkcs12Password); + CAInfo caInfo = new CAInfo(caCert, keyPair.getPrivate(), generatedCa, keyPassword == null ? null : keyPassword.toCharArray()); + final GenerateCertificateCommand command = new GenerateCertificateCommand(); + List args = CollectionUtils.arrayAsArrayList("-keysize", String.valueOf(keySize), "-days", String.valueOf(days), "-pem"); + if (keyPassword != null) { + args.add("-pass"); + args.add(keyPassword); + } + if (keepCaKey) { + args.add("-keep-ca-key"); + } + final OptionSet options = command.getParser().parse(Strings.toStringArray(args)); + + command.generateAndWriteSignedCertificates(outputFile, true, options, certInfos, caInfo, null); assertTrue(Files.exists(outputFile)); Set perms = Files.getPosixFilePermissions(outputFile); @@ -296,32 +322,33 @@ public class CertificateToolTests extends ESTestCase { if (generatedCa) { assertTrue(Files.exists(zipRoot.resolve("ca"))); assertTrue(Files.exists(zipRoot.resolve("ca").resolve("ca.crt"))); - assertTrue(Files.exists(zipRoot.resolve("ca").resolve("ca.key"))); // check the CA cert try (Reader reader = Files.newBufferedReader(zipRoot.resolve("ca").resolve("ca.crt"))) { X509Certificate parsedCaCert = readX509Certificate(reader); assertThat(parsedCaCert.getSubjectX500Principal().getName(), containsString("test ca")); assertEquals(caCert, parsedCaCert); - long daysBetween = ChronoUnit.DAYS.between(caCert.getNotBefore().toInstant(), caCert.getNotAfter().toInstant()); + long daysBetween = getDurationInDays(caCert); assertEquals(days, (int) daysBetween); } - // check the CA key - if (keyPassword != null) { - try (Reader reader = Files.newBufferedReader(zipRoot.resolve("ca").resolve("ca.key"))) { - PEMParser pemParser = new PEMParser(reader); - Object parsed = pemParser.readObject(); - assertThat(parsed, instanceOf(PEMEncryptedKeyPair.class)); - char[] zeroChars = new char[keyPassword.length]; - Arrays.fill(zeroChars, (char) 0); - assertArrayEquals(zeroChars, keyPassword); + if (keepCaKey) { + assertTrue(Files.exists(zipRoot.resolve("ca").resolve("ca.key"))); + // check the CA key + if (keyPassword != null) { + try (Reader reader = Files.newBufferedReader(zipRoot.resolve("ca").resolve("ca.key"))) { + PEMParser pemParser = new PEMParser(reader); + Object parsed = pemParser.readObject(); + assertThat(parsed, instanceOf(PEMEncryptedKeyPair.class)); + char[] zeroChars = new char[caInfo.password.length]; + Arrays.fill(zeroChars, (char) 0); + assertArrayEquals(zeroChars, caInfo.password); + } } - } - try (Reader reader = Files.newBufferedReader(zipRoot.resolve("ca").resolve("ca.key"))) { - PrivateKey privateKey = CertUtils.readPrivateKey(reader, () -> keyPassword != null ? - SecuritySettingsSource.TEST_PASSWORD.toCharArray() : null); - assertEquals(caInfo.privateKey, privateKey); + try (Reader reader = Files.newBufferedReader(zipRoot.resolve("ca").resolve("ca.key"))) { + PrivateKey privateKey = CertUtils.readPrivateKey(reader, () -> keyPassword != null ? keyPassword.toCharArray() : null); + assertEquals(caInfo.certAndKey.key, privateKey); + } } } else { assertFalse(Files.exists(zipRoot.resolve("ca"))); @@ -346,20 +373,7 @@ public class CertificateToolTests extends ESTestCase { GeneralNames.fromExtensions(x509CertHolder.getExtensions(), Extension.subjectAlternativeName); assertSubjAltNames(subjAltNames, certInfo); } - if (pkcs12Password != null) { - assertThat(p12, pathExists(p12)); - try (InputStream in = Files.newInputStream(p12)) { - final KeyStore ks = KeyStore.getInstance("PKCS12"); - ks.load(in, pkcs12Password); - final Certificate p12Certificate = ks.getCertificate(certInfo.name.originalName); - assertThat("Certificate " + certInfo.name, p12Certificate, notNullValue()); - assertThat(p12Certificate, equalTo(certificate)); - final Key key = ks.getKey(certInfo.name.originalName, pkcs12Password); - assertThat(key, notNullValue()); - } - } else { - assertThat(p12, not(pathExists(p12))); - } + assertThat(p12, not(pathExists(p12))); } } } @@ -374,38 +388,64 @@ public class CertificateToolTests extends ESTestCase { terminal.addSecretInput("testnode"); } + final int keySize = randomFrom(1024, 2048); final int days = randomIntBetween(1, 1024); - CAInfo caInfo = CertificateTool.getCAInfo(terminal, "CN=foo", testNodeCertPath.toString(), testNodeKeyPath.toString(), - passwordPrompt ? null : "testnode".toCharArray(), passwordPrompt, env, randomFrom(1024, 2048), days); + String caPassword = passwordPrompt ? null : "testnode"; + + List args = CollectionUtils.arrayAsArrayList( + "-keysize", String.valueOf(keySize), + "-days", String.valueOf(days), + "-pem", + "-ca-cert", testNodeCertPath.toString(), + "-ca-key", testNodeKeyPath.toString()); + + args.add("-ca-pass"); + if (caPassword != null) { + args.add(caPassword); + } + + final GenerateCertificateCommand command = new GenerateCertificateCommand(); + + OptionSet options = command.getParser().parse(Strings.toStringArray(args)); + CAInfo caInfo = command.getCAInfo(terminal, options, env); + assertTrue(terminal.getOutput().isEmpty()); - assertEquals(caInfo.caCert.getSubjectX500Principal().getName(), - "CN=Elasticsearch Test Node,OU=elasticsearch,O=org"); - assertThat(caInfo.privateKey.getAlgorithm(), containsString("RSA")); - assertEquals(2048, ((RSAKey) caInfo.privateKey).getModulus().bitLength()); + CertificateTool.CertificateAndKey caCK = caInfo.certAndKey; + assertEquals(caCK.cert.getSubjectX500Principal().getName(), "CN=Elasticsearch Test Node,OU=elasticsearch,O=org"); + assertThat(caCK.key.getAlgorithm(), containsString("RSA")); + assertEquals(2048, ((RSAKey) caCK.key).getModulus().bitLength()); assertFalse(caInfo.generated); - long daysBetween = ChronoUnit.DAYS.between(caInfo.caCert.getNotBefore().toInstant(), caInfo.caCert.getNotAfter().toInstant()); + long daysBetween = getDurationInDays(caCK.cert); assertEquals(1460L, daysBetween); // test generation + args = CollectionUtils.arrayAsArrayList( + "-keysize", String.valueOf(keySize), + "-days", String.valueOf(days), + "-pem", + "-ca-dn", "CN=foo bar"); + final boolean passwordProtected = randomBoolean(); - final char[] password; - if (passwordPrompt && passwordProtected) { - password = null; - terminal.addSecretInput("testnode"); - } else { - password = "testnode".toCharArray(); + if (passwordProtected) { + args.add("-ca-pass"); + if (passwordPrompt) { + terminal.addSecretInput("testnode"); + } else { + args.add(caPassword); + } } - final int keysize = randomFrom(1024, 2048); - caInfo = CertificateTool.getCAInfo(terminal, "CN=foo bar", null, null, password, passwordProtected && passwordPrompt, env, - keysize, days); + + options = command.getParser().parse(Strings.toStringArray(args)); + caInfo = command.getCAInfo(terminal, options, env); + caCK = caInfo.certAndKey; + assertTrue(terminal.getOutput().isEmpty()); - assertThat(caInfo.caCert, instanceOf(X509Certificate.class)); - assertEquals(caInfo.caCert.getSubjectX500Principal().getName(), "CN=foo bar"); - assertThat(caInfo.privateKey.getAlgorithm(), containsString("RSA")); + assertThat(caCK.cert, instanceOf(X509Certificate.class)); + assertEquals(caCK.cert.getSubjectX500Principal().getName(), "CN=foo bar"); + assertThat(caCK.key.getAlgorithm(), containsString("RSA")); assertTrue(caInfo.generated); - assertEquals(keysize, ((RSAKey) caInfo.privateKey).getModulus().bitLength()); - daysBetween = ChronoUnit.DAYS.between(caInfo.caCert.getNotBefore().toInstant(), caInfo.caCert.getNotAfter().toInstant()); - assertEquals(days, (int) daysBetween); + assertEquals(keySize, getKeySize(caCK.key)); + assertEquals(days, getDurationInDays(caCK.cert)); } public void testNameValues() throws Exception { @@ -416,6 +456,13 @@ public class CertificateToolTests extends ESTestCase { assertEquals("CN=my instance", name.x500Principal.getName()); assertEquals("my instance", name.filename); + // null + name = Name.fromUserProvidedName(null, ""); + assertEquals("", name.originalName); + assertThat(name.error, containsString("null")); + assertNull(name.x500Principal); + assertNull(name.filename); + // too long String userProvidedName = randomAlphaOfLength(CertificateTool.MAX_FILENAME_LENGTH + 1); name = Name.fromUserProvidedName(userProvidedName, userProvidedName); @@ -426,7 +473,7 @@ public class CertificateToolTests extends ESTestCase { name = Name.fromUserProvidedName("", ""); assertEquals("", name.originalName); assertThat(name.error, containsString("valid filename")); - assertEquals("CN=", name.x500Principal.getName()); + assertEquals("CN=", String.valueOf(name.x500Principal)); assertNull(name.filename); // invalid characters only @@ -460,6 +507,260 @@ public class CertificateToolTests extends ESTestCase { assertEquals("CN=" + userProvidedName, name.x500Principal.getName()); } + /** + * A multi-stage test that: + * - Create a new CA + * - Uses that CA to create 2 node certificates + * - Creates a 3rd node certificate using an auto-generated CA + * - Checks that the first 2 node certificates trust one another + * - Checks that the 3rd node certificate is _not_ trusted + * - Checks that all 3 certificates have the right values based on the command line options provided during generation + */ + public void testCreateCaAndMultipleInstances() throws Exception { + final Path tempDir = initTempDir(); + + final Terminal terminal = new MockTerminal(); + Environment env = new Environment(Settings.builder().put("path.home", tempDir).build()); + + final Path caFile = tempDir.resolve("ca.p12"); + final Path node1File = tempDir.resolve("node1.p12").toAbsolutePath(); + final Path node2File = tempDir.resolve("node2.p12").toAbsolutePath(); + final Path node3File = tempDir.resolve("node3.p12").toAbsolutePath(); + + final int caKeySize = randomIntBetween(4, 8) * 512; + final int node1KeySize = randomIntBetween(2, 6) * 512; + final int node2KeySize = randomIntBetween(2, 6) * 512; + final int node3KeySize = randomIntBetween(1, 4) * 512; + + final int days = randomIntBetween(7, 1500); + + final String caPassword = randomAlphaOfLengthBetween(4, 16); + final String node1Password = randomAlphaOfLengthBetween(4, 16); + final String node2Password = randomAlphaOfLengthBetween(4, 16); + final String node3Password = randomAlphaOfLengthBetween(4, 16); + + final String node1Ip = "200.181." + randomIntBetween(1, 250) + "." + randomIntBetween(1, 250); + final String node2Ip = "200.182." + randomIntBetween(1, 250) + "." + randomIntBetween(1, 250); + final String node3Ip = "200.183." + randomIntBetween(1, 250) + "." + randomIntBetween(1, 250); + + final CertificateAuthorityCommand caCommand = new CertificateAuthorityCommand() { + @Override + Path resolveOutputPath(Terminal terminal, OptionSet options, String defaultFilename) throws IOException { + // Needed to work within the security manager + return caFile; + } + }; + final OptionSet caOptions = caCommand.getParser().parse( + "-ca-dn", "CN=My ElasticSearch Cluster", + "-pass", caPassword, + "-out", caFile.toString(), + "-keysize", String.valueOf(caKeySize), + "-days", String.valueOf(days) + ); + caCommand.execute(terminal, caOptions, env); + + assertThat(caFile, pathExists(caFile)); + + final GenerateCertificateCommand gen1Command = new PathAwareGenerateCertificateCommand(caFile, node1File); + final OptionSet gen1Options = gen1Command.getParser().parse( + "-ca", "", + "-ca-pass", caPassword, + "-pass", node1Password, + "-out", "", + "-keysize", String.valueOf(node1KeySize), + "-days", String.valueOf(days), + "-dns", "node01.cluster1.es.internal.corp.net", + "-ip", node1Ip, + "-name", "node01"); + gen1Command.execute(terminal, gen1Options, env); + + assertThat(node1File, pathExists(node1File)); + + final GenerateCertificateCommand gen2Command = new PathAwareGenerateCertificateCommand(caFile, node2File); + final OptionSet gen2Options = gen2Command.getParser().parse( + "-ca", "", + "-ca-pass", caPassword, + "-pass", node2Password, + "-out", "", + "-keysize", String.valueOf(node2KeySize), + "-days", String.valueOf(days), + "-dns", "node02.cluster1.es.internal.corp.net", + "-ip", node2Ip, + "-name", "node02"); + gen2Command.execute(terminal, gen2Options, env); + + assertThat(node2File, pathExists(node2File)); + + // Node 3 uses an auto generated CA, and therefore should not be trusted by the other nodes. + final GenerateCertificateCommand gen3Command = new PathAwareGenerateCertificateCommand(null, node3File); + final OptionSet gen3Options = gen3Command.getParser().parse( + "-ca-dn", "CN=My ElasticSearch Cluster 2", + "-pass", node3Password, + "-out", "", + "-keysize", String.valueOf(node3KeySize), + "-days", String.valueOf(days), + "-dns", "node03.cluster2.es.internal.corp.net", + "-ip", node3Ip); + gen3Command.execute(terminal, gen3Options, env); + + assertThat(node3File, pathExists(node3File)); + + final KeyStore node1KeyStore = CertUtils.readKeyStore(node1File, "PKCS12", node1Password.toCharArray()); + final KeyStore node2KeyStore = CertUtils.readKeyStore(node2File, "PKCS12", node2Password.toCharArray()); + final KeyStore node3KeyStore = CertUtils.readKeyStore(node3File, "PKCS12", node3Password.toCharArray()); + + checkTrust(node1KeyStore, node1Password.toCharArray(), node1KeyStore, true); + checkTrust(node1KeyStore, node1Password.toCharArray(), node2KeyStore, true); + checkTrust(node2KeyStore, node2Password.toCharArray(), node2KeyStore, true); + checkTrust(node2KeyStore, node2Password.toCharArray(), node1KeyStore, true); + checkTrust(node1KeyStore, node1Password.toCharArray(), node3KeyStore, false); + checkTrust(node3KeyStore, node3Password.toCharArray(), node2KeyStore, false); + checkTrust(node3KeyStore, node3Password.toCharArray(), node3KeyStore, true); + + final Certificate node1Cert = node1KeyStore.getCertificate("node01"); + assertThat(node1Cert, instanceOf(X509Certificate.class)); + assertSubjAltNames(node1Cert, node1Ip, "node01.cluster1.es.internal.corp.net"); + assertThat(getDurationInDays((X509Certificate) node1Cert), equalTo(days)); + final Key node1Key = node1KeyStore.getKey("node01", node1Password.toCharArray()); + assertThat(getKeySize(node1Key), equalTo(node1KeySize)); + + final Certificate node2Cert = node2KeyStore.getCertificate("node02"); + assertThat(node2Cert, instanceOf(X509Certificate.class)); + assertSubjAltNames(node2Cert, node2Ip, "node02.cluster1.es.internal.corp.net"); + assertThat(getDurationInDays((X509Certificate) node2Cert), equalTo(days)); + final Key node2Key = node2KeyStore.getKey("node02", node2Password.toCharArray()); + assertThat(getKeySize(node2Key), equalTo(node2KeySize)); + + final Certificate node3Cert = node3KeyStore.getCertificate(CertificateTool.DEFAULT_CERT_NAME); + assertThat(node3Cert, instanceOf(X509Certificate.class)); + assertSubjAltNames(node3Cert, node3Ip, "node03.cluster2.es.internal.corp.net"); + assertThat(getDurationInDays((X509Certificate) node3Cert), equalTo(days)); + final Key node3Key = node3KeyStore.getKey(CertificateTool.DEFAULT_CERT_NAME, node3Password.toCharArray()); + assertThat(getKeySize(node3Key), equalTo(node3KeySize)); + } + + + /** + * A multi-stage test that: + * - Creates a ZIP of a PKCS12 cert, with an auto-generated CA + * - Uses the generate CA to create a PEM certificate + * - Checks that the PKCS12 certificate and the PEM certificate trust one another + */ + public void testTrustBetweenPEMandPKCS12() throws Exception { + final Path tempDir = initTempDir(); + + final MockTerminal terminal = new MockTerminal(); + Environment env = new Environment(Settings.builder().put("path.home", tempDir).build()); + + final Path pkcs12Zip = tempDir.resolve("p12.zip"); + final Path pemZip = tempDir.resolve("pem.zip"); + + final int keySize = randomIntBetween(4, 8) * 512; + final int days = randomIntBetween(500, 1500); + + final String caPassword = randomAlphaOfLengthBetween(4, 16); + final String node1Password = randomAlphaOfLengthBetween(4, 16); + + final GenerateCertificateCommand gen1Command = new PathAwareGenerateCertificateCommand(null, pkcs12Zip); + final OptionSet gen1Options = gen1Command.getParser().parse( + "-keep-ca-key", + "-out", "", + "-keysize", String.valueOf(keySize), + "-days", String.valueOf(days), + "-dns", "node01.cluster1.es.internal.corp.net", + "-name", "node01" + ); + + terminal.addSecretInput(caPassword); + terminal.addSecretInput(node1Password); + gen1Command.execute(terminal, gen1Options, env); + + assertThat(pkcs12Zip, pathExists(pkcs12Zip)); + + FileSystem zip1FS = FileSystems.newFileSystem(new URI("jar:" + pkcs12Zip.toUri()), Collections.emptyMap()); + Path zip1Root = zip1FS.getPath("/"); + + final Path caP12 = zip1Root.resolve("ca/ca.p12"); + assertThat(caP12, pathExists(caP12)); + + final Path node1P12 = zip1Root.resolve("node01/node01.p12"); + assertThat(node1P12, pathExists(node1P12)); + + final GenerateCertificateCommand gen2Command = new PathAwareGenerateCertificateCommand(caP12, pemZip); + final OptionSet gen2Options = gen2Command.getParser().parse( + "-ca", "", + "-out", "", + "-keysize", String.valueOf(keySize), + "-days", String.valueOf(days), + "-dns", "node02.cluster1.es.internal.corp.net", + "-name", "node02", + "-pem" + ); + + terminal.addSecretInput(caPassword); + gen2Command.execute(terminal, gen2Options, env); + + assertThat(pemZip, pathExists(pemZip)); + + FileSystem zip2FS = FileSystems.newFileSystem(new URI("jar:" + pemZip.toUri()), Collections.emptyMap()); + Path zip2Root = zip2FS.getPath("/"); + + final Path ca2 = zip2Root.resolve("ca/ca.p12"); + assertThat(ca2, not(pathExists(ca2))); + + final Path node2Cert = zip2Root.resolve("node02/node02.crt"); + assertThat(node2Cert, pathExists(node2Cert)); + final Path node2Key = zip2Root.resolve("node02/node02.key"); + assertThat(node2Key, pathExists(node2Key)); + + final KeyStore node1KeyStore = CertUtils.readKeyStore(node1P12, "PKCS12", node1Password.toCharArray()); + final KeyStore node1TrustStore = node1KeyStore; + + final KeyStore node2KeyStore = CertUtils.getKeyStoreFromPEM(node2Cert, node2Key, new char[0]); + final KeyStore node2TrustStore = CertUtils.readKeyStore(caP12, "PKCS12", caPassword.toCharArray()); + + checkTrust(node1KeyStore, node1Password.toCharArray(), node2TrustStore, true); + checkTrust(node2KeyStore, new char[0], node1TrustStore, true); + } + + private int getKeySize(Key node1Key) { + assertThat(node1Key, instanceOf(RSAKey.class)); + return ((RSAKey) node1Key).getModulus().bitLength(); + } + + private int getDurationInDays(X509Certificate cert) { + return (int) ChronoUnit.DAYS.between(cert.getNotBefore().toInstant(), cert.getNotAfter().toInstant()); + } + + private void assertSubjAltNames(Certificate certificate, String ip, String dns) throws Exception { + final X509CertificateHolder holder = new X509CertificateHolder(certificate.getEncoded()); + final GeneralNames names = GeneralNames.fromExtensions(holder.getExtensions(), Extension.subjectAlternativeName); + final CertificateInformation certInfo = new CertificateInformation("n", "n", Collections.singletonList(ip), + Collections.singletonList(dns), Collections.emptyList()); + assertSubjAltNames(names, certInfo); + } + + /** + * Checks whether there are keys in {@code keyStore} that are trusted by {@code trustStore}. + */ + private void checkTrust(KeyStore keyStore, char[] keyPassword, KeyStore trustStore, boolean trust) throws Exception { + final X509ExtendedKeyManager keyManager = CertUtils.keyManager(keyStore, keyPassword, KeyManagerFactory.getDefaultAlgorithm()); + final X509ExtendedTrustManager trustManager = CertUtils.trustManager(trustStore, TrustManagerFactory.getDefaultAlgorithm()); + + final X509Certificate[] node1CertificateIssuers = trustManager.getAcceptedIssuers(); + final Principal[] trustedPrincipals = new Principal[node1CertificateIssuers.length]; + for (int i = 0; i < node1CertificateIssuers.length; i++) { + trustedPrincipals[i] = node1CertificateIssuers[i].getIssuerX500Principal(); + } + final String[] keyAliases = keyManager.getClientAliases("RSA", trustedPrincipals); + if (trust) { + assertThat(keyAliases, arrayWithSize(1)); + trustManager.checkClientTrusted(keyManager.getCertificateChain(keyAliases[0]), "RSA"); + } else { + assertThat(keyAliases, nullValue()); + } + } + private PKCS10CertificationRequest readCertificateRequest(Path path) throws Exception { try (Reader reader = Files.newBufferedReader(path); PEMParser pemParser = new PEMParser(reader)) { @@ -557,4 +858,31 @@ public class CertificateToolTests extends ESTestCase { private static Path resolvePath(String path) { return PathUtils.get(path).toAbsolutePath(); } + + /** + * Converting jimfs Paths into strings and back to paths doesn't work with the security manager. + * This class works around that by sticking with the original path objects + */ + private static class PathAwareGenerateCertificateCommand extends GenerateCertificateCommand { + private final Path caFile; + private final Path outFile; + + PathAwareGenerateCertificateCommand(Path caFile, Path outFile) { + this.caFile = caFile; + this.outFile = outFile; + } + + @Override + protected Path resolvePath(OptionSet options, OptionSpec spec) { + if (spec.options().contains("ca")) { + return caFile; + } + return super.resolvePath(options, spec); + } + + @Override + Path resolveOutputPath(Terminal terminal, OptionSet options, String defaultFilename) throws IOException { + return outFile; + } + } }