Java tutorial
/* * 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.core.ssl; 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; import org.bouncycastle.jce.provider.BouncyCastleProvider; 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.LoggingAwareMultiCommand; 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; 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.LoggingDeprecationHandler; 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; 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.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; 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.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; /** * CLI tool to make generation of certificates or certificate requests easier for users */ public class CertificateTool extends LoggingAwareMultiCommand { 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_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"; private static final BouncyCastleProvider BC_PROV = new BouncyCastleProvider(); 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; 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 CertificateToolParser { private static final ObjectParser<List<CertificateInformation>, 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<CertificateInformation, Void> instanceParser = new ConstructingObjectParser<>( "instances", a -> new CertificateInformation((String) a[0], (String) (a[1] == null ? a[0] : a[1]), (List<String>) a[2], (List<String>) a[3], (List<String>) 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")); } } public static void main(String[] args) throws Exception { new CertificateTool().main(args, Terminal.DEFAULT); } 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<String> outputPathSpec; final OptionSpec<String> outputPasswordSpec; final OptionSpec<Integer> keysizeSpec; OptionSpec<Void> pemFormatSpec; OptionSpec<Integer> daysSpec; OptionSpec<String> caPkcs12PathSpec; OptionSpec<String> caCertPathSpec; OptionSpec<String> caKeyPathSpec; OptionSpec<String> caPasswordSpec; OptionSpec<String> caDnSpec; OptionSpec<Void> keepCaKeySpec; OptionSpec<Void> multipleNodesSpec; OptionSpec<String> nameSpec; OptionSpec<String> dnsNamesSpec; OptionSpec<String> ipAddressesSpec; OptionSpec<String> 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() { multipleNodesSpec = parser.accepts("multiple", "generate files for multiple instances"); nameSpec = parser.accepts("name", "name of the generated certificate") .availableUnless(multipleNodesSpec).withRequiredArg(); dnsNamesSpec = parser.accepts("dns", "comma separated DNS names").availableUnless(multipleNodesSpec) .withRequiredArg(); ipAddressesSpec = parser.accepts("ip", "comma separated IP addresses") .availableUnless(multipleNodesSpec).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 { file = CertificateTool.resolvePath(defaultFilename); String input = terminal.readText("Please enter the desired output file [" + file + "]: "); if (input.isEmpty() == false) { file = CertificateTool.resolvePath(input); } } return file.toAbsolutePath(); } 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<String> 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<Certificate, Key> keys = withPassword("CA (" + path + ")", passwordOption, terminal, password -> CertParsingUtils.readPkcs12KeyPairs(path, password, a -> password)); if (keys.size() != 1) { throw new IllegalArgumentException("expected a single key in file [" + path.toAbsolutePath() + "] but found [" + keys.size() + "]"); } final Map.Entry<Certificate, Key> 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 = CertParsingUtils .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 = CertGenUtils.generateKeyPair(getKeySize(options)); X509Certificate caCert = CertGenUtils.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<CertificateInformation> getCertificateInformationList(Terminal terminal, OptionSet options) throws Exception { final Path input = resolvePath(options, inputFileSpec); if (input != null) { return parseAndValidateFile(terminal, input.toAbsolutePath()); } if (options.has(multipleNodesSpec)) { return readMultipleCertificateInformation(terminal); } else { final Function<String, Stream<? extends String>> splitByComma = v -> Arrays .stream(Strings.splitStringByCommaToArray(v)); final List<String> dns = dnsNamesSpec.values(options).stream().flatMap(splitByComma) .collect(Collectors.toList()); final List<String> ip = ipAddressesSpec.values(options).stream().flatMap(splitByComma) .collect(Collectors.toList()); final List<String> 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<String> 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<CertificateInformation> readMultipleCertificateInformation(Terminal terminal) { Map<String, CertificateInformation> 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<String> ipList = Arrays.asList(Strings.splitStringByCommaToArray(ipAddresses)); List<String> dnsList = Arrays.asList(Strings.splitStringByCommaToArray(dnsNames)); List<String> commonNames = null; CertificateInformation information = new CertificateInformation(name, filename, ipList, dnsList, commonNames); List<String> 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<CertificateInformation> 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<CertificateInformation> certInfo) throws Exception { fullyWriteZipFile(output, (outputStream, pemWriter) -> { for (CertificateInformation certificateInformation : certInfo) { KeyPair keyPair = CertGenUtils.generateKeyPair(keySize); GeneralNames sanList = getSubjectAlternativeNamesValue(certificateInformation.ipAddresses, certificateInformation.dnsNames, certificateInformation.commonNames); PKCS10CertificationRequest csr = CertGenUtils.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 specify any of the following options:"); terminal.println(" * -pem (PEM formatted output)"); terminal.println(" * -keep-ca-key (retain generated CA key)"); terminal.println(" * -multiple (generate multiple certificates)"); terminal.println(" * -in (generate certificates from an input file)"); terminal.println("then the output will be be a zip file containing individual certificate/key files"); terminal.println(""); CAInfo caInfo = getCAInfo(terminal, options, env); Collection<CertificateInformation> certInfo = getCertificateInformationList(terminal, options); final boolean keepCaKey = keepCaKey(options); final boolean usePemFormat = usePemFormat(options); final boolean writeZipFile = options.has(multipleNodesSpec) || options.has(inputFileSpec) || 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<CertificateInformation> 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 = CertGenUtils.generateKeyPair(keySize); Certificate certificate = CertGenUtils.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)); } } } @SuppressForbidden(reason = "resolve paths against CWD for a CLI tool") static Path resolvePath(String pathStr) { return PathUtils.get(pathStr).normalize(); } static Collection<CertificateInformation> parseAndValidateFile(Terminal terminal, Path file) throws Exception { final Collection<CertificateInformation> config = parseFile(file); boolean hasError = false; for (CertificateInformation certInfo : config) { final List<String> errors = certInfo.validate(); if (errors.size() > 0) { hasError = true; terminal.println(Verbosity.SILENT, "Configuration for instance " + certInfo.name.originalName + " has invalid details"); for (String message : errors) { terminal.println(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<CertificateInformation> 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, LoggingDeprecationHandler.INSTANCE, reader); return CertificateToolParser.PARSER.parse(xContentParser, new ArrayList<>(), null); } } private static PEMEncryptor getEncrypter(char[] password) { return new JcePEMEncryptorBuilder("DES-EDE3-CBC").setProvider(BC_PROV).build(password); } private static <T, E extends Exception> T withPassword(String description, char[] password, Terminal terminal, CheckedFunction<char[], T, E> 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); } } else { return body.apply(password); } } /** * 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 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<OutputStream, Exception> writer) throws Exception { assert file != null; assert writer != null; boolean success = false; 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); if (view != null) { view.setPermissions( Sets.newHashSet(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); } success = true; } finally { if (success == false) { Files.deleteIfExists(file); } } } /** * 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 * @return the {@link PrivateKey} that was read from the file */ private static PrivateKey readPrivateKey(Path path, char[] password, Terminal terminal) throws Exception { AtomicReference<char[]> passwordReference = new AtomicReference<>(password); try { return PemUtils.readPrivateKey(path, () -> { if (password != null) { return password; } char[] promptedValue = terminal .readSecret("Enter password for CA private key (" + path.getFileName() + ") : "); passwordReference.set(promptedValue); return promptedValue; }); } finally { if (passwordReference.get() != null) { Arrays.fill(passwordReference.get(), (char) 0); } } } private static GeneralNames getSubjectAlternativeNamesValue(List<String> ipAddresses, List<String> dnsNames, List<String> commonNames) { Set<GeneralName> 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(CertGenUtils.createCommonName(cn)); } if (generalNameList.isEmpty()) { return null; } 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<String> ipAddresses; final List<String> dnsNames; final List<String> commonNames; CertificateInformation(String name, String filename, List<String> ipAddresses, List<String> dnsNames, List<String> 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<String> validate() { List<String> 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"); } if (name == null) { return new Name("", null, null, "instance name may not be null"); } 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 CertificateAndKey { final X509Certificate cert; final PrivateKey key; CertificateAndKey(X509Certificate cert, PrivateKey key) { this.cert = cert; this.key = key; } } static class CAInfo { final CertificateAndKey certAndKey; 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.certAndKey = new CertificateAndKey(caCert, privateKey); this.generated = generated; this.password = password; } } private interface Writer { void write(ZipOutputStream zipOutputStream, JcaPEMWriter pemWriter) throws Exception; } }