From 2bb7b53e419a1ebdb2f5bf529f48812aabc26260 Mon Sep 17 00:00:00 2001 From: Tim Vernum Date: Tue, 14 Jan 2020 21:24:21 +1100 Subject: [PATCH] Add certutil http command (#50952) This adds a new "http" sub-command to the certutil CLI tool. The http command generates certificates/CSRs for use on the http interface of an elasticsearch node/cluster. It is designed to be a guided tool that provides explanations and sugestions for each of the configuration options. The generated zip file output includes extensive "readme" documentation and sample configuration files for core Elastic products. Backport of: #49827 --- .../xpack/core/ssl/CertParsingUtils.java | 16 +- .../org/elasticsearch/test/FileMatchers.java | 90 ++ .../org/elasticsearch/test/TestMatchers.java | 4 + x-pack/plugin/security/cli/build.gradle | 5 + .../xpack/security/cli/CertGenUtils.java | 8 + .../xpack/security/cli/CertificateTool.java | 7 +- .../security/cli/HttpCertificateCommand.java | 1207 +++++++++++++++++ .../cli/certutil-http/ca-readme-p12.txt | 33 + .../cli/certutil-http/es-readme-csr.txt | 56 + .../cli/certutil-http/es-readme-p12.txt | 38 + .../cli/certutil-http/es-sample-csr.yml | 32 + .../cli/certutil-http/es-sample-p12.yml | 22 + .../cli/certutil-http/kibana-readme.txt | 62 + .../cli/certutil-http/kibana-sample.yml | 25 + .../cli/HttpCertificateCommandTests.java | 792 +++++++++++ .../xpack/security/cli/PemToKeystore.java | 52 + .../elasticsearch/xpack/security/cli/ca.crt | 20 + .../elasticsearch/xpack/security/cli/ca.key | 30 + .../elasticsearch/xpack/security/cli/ca.p12 | Bin 0 -> 2529 bytes 19 files changed, 2494 insertions(+), 5 deletions(-) create mode 100644 x-pack/plugin/core/src/test/java/org/elasticsearch/test/FileMatchers.java create mode 100644 x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/HttpCertificateCommand.java create mode 100644 x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/ca-readme-p12.txt create mode 100644 x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/es-readme-csr.txt create mode 100644 x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/es-readme-p12.txt create mode 100644 x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/es-sample-csr.yml create mode 100644 x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/es-sample-p12.yml create mode 100644 x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/kibana-readme.txt create mode 100644 x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/kibana-sample.yml create mode 100644 x-pack/plugin/security/cli/src/test/java/org/elasticsearch/xpack/security/cli/HttpCertificateCommandTests.java create mode 100644 x-pack/plugin/security/cli/src/test/java/org/elasticsearch/xpack/security/cli/PemToKeystore.java create mode 100644 x-pack/plugin/security/cli/src/test/resources/org/elasticsearch/xpack/security/cli/ca.crt create mode 100644 x-pack/plugin/security/cli/src/test/resources/org/elasticsearch/xpack/security/cli/ca.key create mode 100644 x-pack/plugin/security/cli/src/test/resources/org/elasticsearch/xpack/security/cli/ca.p12 diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/CertParsingUtils.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/CertParsingUtils.java index 90f63aa496c..fb89316abb2 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/CertParsingUtils.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/CertParsingUtils.java @@ -130,8 +130,20 @@ public class CertParsingUtils { * return the password for that key. If it returns {@code null}, then the key-pair for that alias is not read. */ public static Map readPkcs12KeyPairs(Path path, char[] password, Function keyPassword) - throws CertificateException, NoSuchAlgorithmException, KeyStoreException, IOException, UnrecoverableKeyException { - final KeyStore store = readKeyStore(path, "PKCS12", password); + throws CertificateException, NoSuchAlgorithmException, KeyStoreException, IOException, UnrecoverableKeyException { + return readKeyPairsFromKeystore(path, "PKCS12", password, keyPassword); + } + + public static Map readKeyPairsFromKeystore(Path path, String storeType, char[] password, + Function keyPassword) + throws IOException, KeyStoreException, CertificateException, NoSuchAlgorithmException, UnrecoverableKeyException { + + final KeyStore store = readKeyStore(path, storeType, password); + return readKeyPairsFromKeystore(store, keyPassword); + } + + static Map readKeyPairsFromKeystore(KeyStore store, Function keyPassword) + throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException { final Enumeration enumeration = store.aliases(); final Map map = new HashMap<>(store.size()); while (enumeration.hasMoreElements()) { diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/test/FileMatchers.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/test/FileMatchers.java new file mode 100644 index 00000000000..c1229b0a79a --- /dev/null +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/test/FileMatchers.java @@ -0,0 +1,90 @@ +/* + * 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.test; + +import org.hamcrest.CustomMatcher; +import org.hamcrest.Description; +import org.hamcrest.Matcher; + +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; + +public class FileMatchers { + public static Matcher pathExists(LinkOption... options) { + return new CustomMatcher("Path exists") { + @Override + public boolean matches(Object item) { + if (item instanceof Path) { + Path path = (Path) item; + return Files.exists(path, options); + } else { + return false; + } + + } + }; + } + + public static Matcher isDirectory(LinkOption... options) { + return new FileTypeMatcher("directory", options) { + @Override + protected boolean matchPath(Path path) { + return Files.isDirectory(path, options); + } + }; + } + + public static Matcher isRegularFile(LinkOption... options) { + return new FileTypeMatcher("regular file", options) { + @Override + protected boolean matchPath(Path path) { + return Files.isRegularFile(path, options); + } + }; + } + + private abstract static class FileTypeMatcher extends CustomMatcher { + private final LinkOption[] options; + + FileTypeMatcher(String typeName, LinkOption... options) { + super("Path is " + typeName); + this.options = options; + } + + @Override + public boolean matches(Object item) { + if (item instanceof Path) { + Path path = (Path) item; + return matchPath(path); + } else { + return false; + } + } + + protected abstract boolean matchPath(Path path); + + @Override + public void describeMismatch(Object item, Description description) { + super.describeMismatch(item, description); + if (item instanceof Path) { + Path path = (Path) item; + if (Files.exists(path, options) == false) { + description.appendText(" (file not found)"); + } else if (Files.isDirectory(path, options)) { + description.appendText(" (directory)"); + } else if (Files.isSymbolicLink(path)) { + description.appendText(" (symlink)"); + } else if (Files.isRegularFile(path, options)) { + description.appendText(" (regular file)"); + } else { + description.appendText(" (unknown file type)"); + } + } + } + } +} diff --git a/x-pack/plugin/core/src/test/java/org/elasticsearch/test/TestMatchers.java b/x-pack/plugin/core/src/test/java/org/elasticsearch/test/TestMatchers.java index b5aed4eae2f..f40b50fd835 100644 --- a/x-pack/plugin/core/src/test/java/org/elasticsearch/test/TestMatchers.java +++ b/x-pack/plugin/core/src/test/java/org/elasticsearch/test/TestMatchers.java @@ -20,6 +20,10 @@ import java.util.regex.Pattern; public class TestMatchers extends Matchers { + /** + * @deprecated Use {@link FileMatchers#pathExists} + */ + @Deprecated public static Matcher pathExists(Path path, LinkOption... options) { return new CustomMatcher("Path " + path + " exists") { @Override diff --git a/x-pack/plugin/security/cli/build.gradle b/x-pack/plugin/security/cli/build.gradle index 8957e98b1e3..7ed302fdf36 100644 --- a/x-pack/plugin/security/cli/build.gradle +++ b/x-pack/plugin/security/cli/build.gradle @@ -19,6 +19,11 @@ dependencyLicenses { mapping from: /bc.*/, to: 'bouncycastle' } +forbiddenPatterns { + exclude '**/*.p12' + exclude '**/*.jks' +} + rootProject.globalInfo.ready { if (BuildParams.inFipsJvm) { test.enabled = false diff --git a/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/CertGenUtils.java b/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/CertGenUtils.java index bc2b27df580..876fcfbf992 100644 --- a/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/CertGenUtils.java +++ b/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/CertGenUtils.java @@ -157,6 +157,14 @@ public class CertGenUtils { throw new IllegalArgumentException("the certificate must be valid for at least one day"); } final ZonedDateTime notAfter = notBefore.plusDays(days); + return generateSignedCertificate(principal, subjectAltNames, keyPair, caCert, caPrivKey, isCa, notBefore, notAfter, + signatureAlgorithm); + } + + public static X509Certificate generateSignedCertificate(X500Principal principal, GeneralNames subjectAltNames, KeyPair keyPair, + X509Certificate caCert, PrivateKey caPrivKey, boolean isCa, + ZonedDateTime notBefore, ZonedDateTime notAfter, String signatureAlgorithm) + throws NoSuchAlgorithmException, CertIOException, OperatorCreationException, CertificateException { final BigInteger serial = CertGenUtils.getSerial(); JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils(); diff --git a/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/CertificateTool.java b/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/CertificateTool.java index e3a0f4e7112..4ae1f313ce1 100644 --- a/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/CertificateTool.java +++ b/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/CertificateTool.java @@ -142,6 +142,7 @@ public class CertificateTool extends LoggingAwareMultiCommand { subcommands.put("csr", new SigningRequestCommand()); subcommands.put("cert", new GenerateCertificateCommand()); subcommands.put("ca", new CertificateAuthorityCommand()); + subcommands.put("http", new HttpCertificateCommand()); } @@ -920,7 +921,7 @@ public class CertificateTool extends LoggingAwareMultiCommand { } } - private static PEMEncryptor getEncrypter(char[] password) { + static PEMEncryptor getEncrypter(char[] password) { return new JcePEMEncryptorBuilder("DES-EDE3-CBC").setProvider(BC_PROV).build(password); } @@ -1036,7 +1037,7 @@ public class CertificateTool extends LoggingAwareMultiCommand { } } - private static GeneralNames getSubjectAlternativeNamesValue(List ipAddresses, List dnsNames, List commonNames) { + static GeneralNames getSubjectAlternativeNamesValue(List ipAddresses, List dnsNames, List commonNames) { Set generalNameList = new HashSet<>(); for (String ip : ipAddresses) { generalNameList.add(new GeneralName(GeneralName.iPAddress, ip)); @@ -1056,7 +1057,7 @@ public class CertificateTool extends LoggingAwareMultiCommand { return new GeneralNames(generalNameList.toArray(new GeneralName[0])); } - private static boolean isAscii(char[] str) { + static boolean isAscii(char[] str) { return ASCII_ENCODER.canEncode(CharBuffer.wrap(str)); } diff --git a/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/HttpCertificateCommand.java b/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/HttpCertificateCommand.java new file mode 100644 index 00000000000..10de695be65 --- /dev/null +++ b/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/HttpCertificateCommand.java @@ -0,0 +1,1207 @@ +/* + * 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.security.cli; + +import joptsimple.OptionParser; +import joptsimple.OptionSet; +import org.bouncycastle.asn1.DERIA5String; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.cert.CertIOException; +import org.bouncycastle.openssl.jcajce.JcaMiscPEMGenerator; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.OperatorException; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.bouncycastle.util.io.pem.PemObjectGenerator; +import org.elasticsearch.ElasticsearchException; +import org.elasticsearch.Version; +import org.elasticsearch.cli.EnvironmentAwareCommand; +import org.elasticsearch.cli.ExitCodes; +import org.elasticsearch.cli.SuppressForbidden; +import org.elasticsearch.cli.Terminal; +import org.elasticsearch.cli.UserException; +import org.elasticsearch.common.Strings; +import org.elasticsearch.common.collect.MapBuilder; +import org.elasticsearch.common.io.PathUtils; +import org.elasticsearch.common.io.Streams; +import org.elasticsearch.common.network.InetAddresses; +import org.elasticsearch.common.util.set.Sets; +import org.elasticsearch.env.Environment; +import org.elasticsearch.xpack.core.ssl.CertParsingUtils; +import org.elasticsearch.xpack.core.ssl.PemUtils; + +import javax.security.auth.x500.X500Principal; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.io.UncheckedIOException; +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.GeneralSecurityException; +import java.security.Key; +import java.security.KeyPair; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.Period; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.elasticsearch.xpack.security.cli.CertGenUtils.generateSignedCertificate; +import static org.elasticsearch.xpack.security.cli.CertificateTool.getSubjectAlternativeNamesValue; + +/** + * This command is the "elasticsearch-certutil http" command. It provides a guided process for creating + * certificates or CSRs for the Rest (http/s) interface of Elasticsearch and configuring other stack products + * to trust this certificate. + */ +class HttpCertificateCommand extends EnvironmentAwareCommand { + + static final int DEFAULT_CERT_KEY_SIZE = 2048; + static final Period DEFAULT_CERT_VALIDITY = Period.ofYears(5); + + static final X500Principal DEFAULT_CA_NAME = new X500Principal("CN=Elasticsearch HTTP CA"); + static final int DEFAULT_CA_KEY_SIZE = DEFAULT_CERT_KEY_SIZE; + static final Period DEFAULT_CA_VALIDITY = DEFAULT_CERT_VALIDITY; + + private static final String ES_README_CSR = "es-readme-csr.txt"; + private static final String ES_YML_CSR = "es-sample-csr.yml"; + private static final String ES_README_P12 = "es-readme-p12.txt"; + private static final String ES_YML_P12 = "es-sample-p12.yml"; + private static final String CA_README_P12 = "ca-readme-p12.txt"; + private static final String KIBANA_README = "kibana-readme.txt"; + private static final String KIBANA_YML = "kibana-sample.yml"; + + /** + * Magic bytes for a non-empty PKCS#12 file + */ + private static final byte[] MAGIC_BYTES1_PKCS12 = new byte[] { (byte) 0x30, (byte) 0x82 }; + /** + * Magic bytes for an empty PKCS#12 file + */ + private static final byte[] MAGIC_BYTES2_PKCS12 = new byte[] { (byte) 0x30, (byte) 0x56 }; + /** + * Magic bytes for a JKS keystore + */ + private static final byte[] MAGIC_BYTES_JKS = new byte[] { (byte) 0xFE, (byte) 0xED }; + + enum FileType { + PKCS12, + JKS, + PEM_CERT, + PEM_KEY, + PEM_CERT_CHAIN, + UNRECOGNIZED; + } + + private class CertOptions { + final String name; + final X500Principal subject; + final List dnsNames; + final List ipNames; + final int keySize; + final Period validity; + + private CertOptions(String name, X500Principal subject, List dnsNames, List ipNames, int keySize, Period validity) { + this.name = name; + this.subject = subject; + this.dnsNames = dnsNames; + this.ipNames = ipNames; + this.keySize = keySize; + this.validity = validity; + } + } + + HttpCertificateCommand() { + super("generate a new certificate (or certificate request) for the Elasticsearch HTTP interface"); + } + + @Override + protected void execute(Terminal terminal, OptionSet options, Environment env) throws Exception { + printHeader("Elasticsearch HTTP Certificate Utility", terminal); + + terminal.println("The 'http' command guides you through the process of generating certificates"); + terminal.println("for use on the HTTP (Rest) interface for Elasticsearch."); + terminal.println(""); + terminal.println("This tool will ask you a number of questions in order to generate the right"); + terminal.println("set of files for your needs."); + + final CertificateTool.CAInfo caInfo; + final Period validity; + final boolean csr = askCertSigningRequest(terminal); + if (csr) { + caInfo = null; + validity = null; + } else { + final boolean existingCa = askExistingCertificateAuthority(terminal); + if (existingCa) { + caInfo = findExistingCA(terminal, env); + } else { + caInfo = createNewCA(terminal); + } + terminal.println(Terminal.Verbosity.VERBOSE, "Using the following CA:"); + terminal.println(Terminal.Verbosity.VERBOSE, "\tSubject: " + caInfo.certAndKey.cert.getSubjectX500Principal()); + terminal.println(Terminal.Verbosity.VERBOSE, "\tIssuer: " + caInfo.certAndKey.cert.getIssuerX500Principal()); + terminal.println(Terminal.Verbosity.VERBOSE, "\tSerial: " + caInfo.certAndKey.cert.getSerialNumber()); + terminal.println(Terminal.Verbosity.VERBOSE, "\tExpiry: " + caInfo.certAndKey.cert.getNotAfter()); + terminal.println(Terminal.Verbosity.VERBOSE, "\tSignature Algorithm: " + caInfo.certAndKey.cert.getSigAlgName()); + + validity = getCertificateValidityPeriod(terminal); + } + + final boolean multipleCertificates = askMultipleCertificates(terminal); + final List certificates = new ArrayList<>(); + + String nodeDescription = multipleCertificates ? "node #1" : "your nodes"; + while (true) { + final CertOptions cert = getCertificateConfiguration(terminal, multipleCertificates, nodeDescription, validity, csr); + terminal.println(Terminal.Verbosity.VERBOSE, "Generating the following " + (csr ? "CSR" : "Certificate") + ":"); + terminal.println(Terminal.Verbosity.VERBOSE, "\tName: " + cert.name); + terminal.println(Terminal.Verbosity.VERBOSE, "\tSubject: " + cert.subject); + terminal.println(Terminal.Verbosity.VERBOSE, "\tDNS Names: " + Strings.collectionToCommaDelimitedString(cert.dnsNames)); + terminal.println(Terminal.Verbosity.VERBOSE, "\tIP Names: " + Strings.collectionToCommaDelimitedString(cert.ipNames)); + terminal.println(Terminal.Verbosity.VERBOSE, "\tKey Size: " + cert.keySize); + terminal.println(Terminal.Verbosity.VERBOSE, "\tValidity: " + toString(cert.validity)); + certificates.add(cert); + + if (multipleCertificates && terminal.promptYesNo("Generate additional certificates?", true)) { + nodeDescription = "node #" + (certificates.size() + 1); + } else { + break; + } + } + + printHeader("What password do you want for your private key(s)?", terminal); + char[] password; + if (csr) { + terminal.println("Your private key(s) will be stored as a PEM formatted file."); + terminal.println("We recommend that you protect your private keys with a password"); + terminal.println(""); + terminal.println("If you do not wish to use a password, simply press at the prompt below."); + password = readPassword(terminal, "Provide a password for the private key: ", true); + } else { + terminal.println("Your private key(s) will be stored in a PKCS#12 keystore file named \"http.p12\"."); + terminal.println("This type of keystore is always password protected, but it is possible to use a"); + terminal.println("blank password."); + terminal.println(""); + terminal.println("If you wish to use a blank password, simply press at the prompt below."); + password = readPassword(terminal, "Provide a password for the \"http.p12\" file: ", true); + } + + printHeader("Where should we save the generated files?", terminal); + if (csr) { + terminal.println("A number of files will be generated including your private key(s),"); + terminal.println("certificate request(s), and sample configuration options for Elastic Stack products."); + } else { + terminal.println("A number of files will be generated including your private key(s),"); + terminal.println("public certificate(s), and sample configuration options for Elastic Stack products."); + } + terminal.println(""); + terminal.println("These files will be included in a single zip archive."); + terminal.println(""); + Path output = resolvePath("elasticsearch-ssl-http.zip"); + output = tryReadInput(terminal, "What filename should be used for the output zip file?", output, this::resolvePath); + + writeZip(output, password, caInfo, certificates, env); + terminal.println(""); + terminal.println("Zip file written to " + output); + } + + /** + * Resolve a filename as a Path (suppressing forbidden APIs). + * Protected so tests can map String path-names to real path objects + */ + @SuppressForbidden(reason = "CLI tool resolves files against working directory") + protected Path resolvePath(String name) { + return PathUtils.get(name).normalize().toAbsolutePath(); + } + + private void writeZip(Path file, char[] password, CertificateTool.CAInfo caInfo, List certificates, + Environment env) throws UserException { + if (Files.exists(file)) { + throw new UserException(ExitCodes.IO_ERROR, "Output file '" + file + "' already exists"); + } + + boolean success = false; + try { + try (OutputStream fileStream = Files.newOutputStream(file, StandardOpenOption.CREATE_NEW); + ZipOutputStream zipStream = new ZipOutputStream(fileStream, StandardCharsets.UTF_8)) { + + createZipDirectory(zipStream, "elasticsearch"); + if (certificates.size() == 1) { + writeCertificateAndKeyDetails(zipStream, "elasticsearch", certificates.get(0), caInfo, password, env); + } else { + for (CertOptions cert : certificates) { + final String dirName = "elasticsearch/" + cert.name; + createZipDirectory(zipStream, dirName); + writeCertificateAndKeyDetails(zipStream, dirName, cert, caInfo, password, env); + } + } + + if (caInfo != null && caInfo.generated) { + createZipDirectory(zipStream, "ca"); + writeCertificateAuthority(zipStream, "ca", caInfo, env); + } + + createZipDirectory(zipStream, "kibana"); + writeKibanaInfo(zipStream, "kibana", caInfo, env); + + /* TODO + createZipDirectory(zipStream, "beats"); + writeBeatsInfo(zipStream, "beats", caInfo); + + createZipDirectory(zipStream, "logstash"); + writeLogstashInfo(zipStream, "logstash", caInfo); + + createZipDirectory(zipStream, "lang-clients"); + writeLangClientInfo(zipStream, "lang-clients", caInfo); + + createZipDirectory(zipStream, "other"); + writeMiscellaneousInfo(zipStream, "other", caInfo); + */ + + // 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); + } + } + } catch (IOException e) { + throw new ElasticsearchException("Failed to write ZIP file '" + file + "'", e); + } + } + + private void createZipDirectory(ZipOutputStream zip, String name) throws IOException { + ZipEntry entry = new ZipEntry(name + "/"); + assert entry.isDirectory(); + zip.putNextEntry(entry); + } + + private void writeCertificateAndKeyDetails(ZipOutputStream zip, String dirName, CertOptions cert, CertificateTool.CAInfo ca, + char[] password, Environment env) { + // TODO : Should we add support for configuring PKI in ES? + try { + final KeyPair keyPair = CertGenUtils.generateKeyPair(cert.keySize); + final GeneralNames sanList = getSubjectAlternativeNamesValue(cert.ipNames, cert.dnsNames, Collections.emptyList()); + final boolean hasPassword = password != null && password.length > 0; + // TODO Add info to the READMEs so that the user could regenerate these certs if needed. + // (i.e. show them the certutil cert command that they would need). + if (ca == null) { + // No local CA, generate a CSR instead + final PKCS10CertificationRequest csr = CertGenUtils.generateCSR(keyPair, cert.subject, sanList); + final String csrFile = "http-" + cert.name + ".csr"; + final String keyFile = "http-" + cert.name + ".key"; + final String certName = "http-" + cert.name + ".crt"; + final String ymlFile = "sample-elasticsearch.yml"; + final Map substitutions = buildSubstitutions(env, MapBuilder.newMapBuilder() + .put("CSR", csrFile) + .put("KEY", keyFile) + .put("CERT", certName) + .put("YML", ymlFile) + .put("PASSWORD", hasPassword ? "*" : "") + .immutableMap()); + writeTextFile(zip, dirName + "/README.txt", ES_README_CSR, substitutions); + writePemEntry(zip, dirName + "/" + csrFile, new JcaMiscPEMGenerator(csr)); + writePemEntry(zip, dirName + "/" + keyFile, generator(keyPair.getPrivate(), password)); + writeTextFile(zip, dirName + "/" + ymlFile, ES_YML_CSR, substitutions); + } else { + final ZonedDateTime notBefore = ZonedDateTime.now(ZoneOffset.UTC); + final ZonedDateTime notAfter = notBefore.plus(cert.validity); + Certificate certificate = CertGenUtils.generateSignedCertificate(cert.subject, sanList, keyPair, ca.certAndKey.cert, + ca.certAndKey.key, false, notBefore, notAfter, null); + + final String p12Name = "http.p12"; + final String ymlFile = "sample-elasticsearch.yml"; + final Map substitutions = buildSubstitutions(env, MapBuilder.newMapBuilder() + .put("P12", p12Name) + .put("YML", ymlFile) + .put("PASSWORD", hasPassword ? "*" : "") + .immutableMap()); + writeTextFile(zip, dirName + "/README.txt", ES_README_P12, substitutions); + writeKeyStore(zip, dirName + "/" + p12Name, certificate, keyPair.getPrivate(), password, ca.certAndKey.cert); + writeTextFile(zip, dirName + "/" + ymlFile, ES_YML_P12, substitutions); + } + } catch (OperatorException | IOException | GeneralSecurityException e) { + throw new ElasticsearchException("Failed to write certificate to ZIP file", e); + } + } + + private void writeCertificateAuthority(ZipOutputStream zip, String dirName, CertificateTool.CAInfo ca, Environment env) { + assert ca != null; + assert ca.generated; + + try { + writeTextFile(zip, dirName + "/README.txt", CA_README_P12, + buildSubstitutions(env, MapBuilder.newMapBuilder() + .put("P12", "ca.p12") + .put("DN", ca.certAndKey.cert.getSubjectX500Principal().getName()) + .put("PASSWORD", ca.password == null || ca.password.length == 0 ? "" : "*") + .immutableMap())); + final KeyStore pkcs12 = KeyStore.getInstance("PKCS12"); + pkcs12.load(null); + pkcs12.setKeyEntry("ca", ca.certAndKey.key, ca.password, new Certificate[] { ca.certAndKey.cert }); + try (ZipEntryStream entry = new ZipEntryStream(zip, dirName + "/ca.p12")) { + pkcs12.store(entry, ca.password); + } + } catch (KeyStoreException | IOException | CertificateException | NoSuchAlgorithmException e) { + throw new ElasticsearchException("Failed to write CA to ZIP file", e); + } + } + + private void writeKibanaInfo(ZipOutputStream zip, String dirName, CertificateTool.CAInfo ca, Environment env) { + final String caCertName = "elasticsearch-ca.pem"; + final String caCert = ca == null ? "" : caCertName; + final String ymlFile = "sample-kibana.yml"; + + final Map substitutions = buildSubstitutions(env, MapBuilder.newMapBuilder() + .put("CA_CERT_NAME", caCertName) + .put("CA_CERT", caCert) + .put("YML", ymlFile) + .immutableMap()); + + // TODO : Should we add support for client certs from Kibana to ES? + + try { + writeTextFile(zip, dirName + "/README.txt", KIBANA_README, substitutions); + if (ca != null) { + writePemEntry(zip, dirName + "/" + caCert, new JcaMiscPEMGenerator(ca.certAndKey.cert)); + } + writeTextFile(zip, dirName + "/" + ymlFile, KIBANA_YML, substitutions); + } catch (IOException e) { + throw new ElasticsearchException("Failed to write Kibana details ZIP file", e); + } + } + + /** + * Loads {@code resource} from the classpath, performs variable substitution on it, and then writes it to {@code writer}. + */ + private void writeTextFile(ZipOutputStream zip, String outputName, String resource, Map substitutions) { + try (InputStream stream = getClass().getResourceAsStream("certutil-http/" + resource); + ZipEntryStream entry = new ZipEntryStream(zip, outputName); + OutputStreamWriter osw = new OutputStreamWriter(entry, StandardCharsets.UTF_8); + PrintWriter writer = new PrintWriter(osw, false)) { + if (stream == null) { + throw new IllegalStateException("Cannot find internal resource " + resource); + } + copyWithSubstitutions(stream, writer, substitutions); + writer.flush(); + } catch (IOException e) { + throw new UncheckedIOException("Cannot add resource " + resource + " to zip file", e); + } + } + + /** + * Copies the input stream to the writer, while performing variable substitutions. + * The variable substitution processing supports 2 constructs + *
    + *
  1. + * For each key in @{code substitutions}, any sequence of ${key} in the input is replaced with the + * substitution value. + *
  2. + *
  3. + * Any line in the input that has the form #if KEY causes the following block to be output + * if-only-if KEY exists with a non-empty value in {@code substitutions}. + * A block is terminated with {@code #endif}. Lines with {@code #else} are also supported. Nested blocks are not supported. + *
  4. + *
+ */ + static void copyWithSubstitutions(InputStream stream, PrintWriter writer, Map substitutions) throws IOException { + boolean skip = false; + for (String line : Streams.readAllLines(stream)) { + for (Map.Entry subst : substitutions.entrySet()) { + line = line.replace("${" + subst.getKey() + "}", subst.getValue()); + } + if (line.startsWith("#if ")) { + final String key = line.substring(4).trim(); + skip = Strings.isNullOrEmpty(substitutions.get(key)); + continue; + } else if (line.equals("#else")) { + skip = !skip; + continue; + } else if (line.equals("#endif")) { + skip = false; + continue; + } else if (skip) { + continue; + } + writer.println(line); + } + } + + private Map buildSubstitutions(Environment env, Map entries) { + final Map map = new HashMap<>(entries.size() + 4); + ZonedDateTime now = ZonedDateTime.now().withNano(0); + map.put("DATE", now.format(DateTimeFormatter.ISO_LOCAL_DATE)); + map.put("TIME", now.format(DateTimeFormatter.ISO_OFFSET_TIME)); + map.put("VERSION", Version.CURRENT.toString()); + map.put("CONF_DIR", env.configFile().toAbsolutePath().toString()); + map.putAll(entries); + return map; + } + + private void writeKeyStore(ZipOutputStream zip, String name, Certificate certificate, PrivateKey key, char[] password, + X509Certificate caCert) throws IOException, GeneralSecurityException { + final KeyStore pkcs12 = KeyStore.getInstance("PKCS12"); + pkcs12.load(null); + pkcs12.setKeyEntry("http", key, password, new Certificate[] { certificate }); + if (caCert != null) { + pkcs12.setCertificateEntry("ca", caCert); + } + try (ZipEntryStream entry = new ZipEntryStream(zip, name)) { + pkcs12.store(entry, password); + } + } + + private void writePemEntry(ZipOutputStream zip, String name, PemObjectGenerator generator) throws IOException { + try (ZipEntryStream entry = new ZipEntryStream(zip, name); + JcaPEMWriter pem = new JcaPEMWriter(new OutputStreamWriter(entry, StandardCharsets.UTF_8))) { + pem.writeObject(generator); + pem.flush(); + } + } + + private JcaMiscPEMGenerator generator(PrivateKey privateKey, char[] password) throws IOException { + if (password == null || password.length == 0) { + return new JcaMiscPEMGenerator(privateKey); + } + return new JcaMiscPEMGenerator(privateKey, CertificateTool.getEncrypter(password)); + } + + private Period getCertificateValidityPeriod(Terminal terminal) { + printHeader("How long should your certificates be valid?", terminal); + terminal.println("Every certificate has an expiry date. When the expiry date is reached clients"); + terminal.println("will stop trusting your certificate and TLS connections will fail."); + terminal.println(""); + terminal.println("Best practice suggests that you should either:"); + terminal.println("(a) set this to a short duration (90 - 120 days) and have automatic processes"); + terminal.println("to generate a new certificate before the old one expires, or"); + terminal.println("(b) set it to a longer duration (3 - 5 years) and then perform a manual update"); + terminal.println("a few months before it expires."); + terminal.println(""); + terminal.println("You may enter the validity period in years (e.g. 3Y), months (e.g. 18M), or days (e.g. 90D)"); + terminal.println(""); + + return readPeriodInput(terminal, "For how long should your certificate be valid?", DEFAULT_CERT_VALIDITY, 60); + } + + private boolean askMultipleCertificates(Terminal terminal) { + printHeader("Do you wish to generate one certificate per node?", terminal); + terminal.println("If you have multiple nodes in your cluster, then you may choose to generate a"); + terminal.println("separate certificate for each of these nodes. Each certificate will have its"); + terminal.println("own private key, and will be issued for a specific hostname or IP address."); + terminal.println(""); + terminal.println("Alternatively, you may wish to generate a single certificate that is valid"); + terminal.println("across all the hostnames or addresses in your cluster."); + terminal.println(""); + terminal.println("If all of your nodes will be accessed through a single domain"); + terminal.println("(e.g. node01.es.example.com, node02.es.example.com, etc) then you may find it"); + terminal.println("simpler to generate one certificate with a wildcard hostname (*.es.example.com)"); + terminal.println("and use that across all of your nodes."); + terminal.println(""); + terminal.println("However, if you do not have a common domain name, and you expect to add"); + terminal.println("additional nodes to your cluster in the future, then you should generate a"); + terminal.println("certificate per node so that you can more easily generate new certificates when"); + terminal.println("you provision new nodes."); + terminal.println(""); + return terminal.promptYesNo("Generate a certificate per node?", false); + } + + private CertOptions getCertificateConfiguration(Terminal terminal, boolean multipleCertificates, String nodeDescription, + Period validity, boolean csr) { + + String certName = null; + if (multipleCertificates) { + printHeader("What is the name of " + nodeDescription + "?", terminal); + terminal.println("This name will be used as part of the certificate file name, and as a"); + terminal.println("descriptive name within the certificate."); + terminal.println(""); + terminal.println("You can use any descriptive name that you like, but we recommend using the name"); + terminal.println("of the Elasticsearch node."); + terminal.println(""); + certName = terminal.readText(nodeDescription + " name: "); + nodeDescription = certName; + } + + printHeader("Which hostnames will be used to connect to " + nodeDescription + "?", terminal); + terminal.println("These hostnames will be added as \"DNS\" names in the \"Subject Alternative Name\""); + terminal.println("(SAN) field in your certificate."); + terminal.println(""); + terminal.println("You should list every hostname and variant that people will use to connect to"); + terminal.println("your cluster over http."); + terminal.println("Do not list IP addresses here, you will be asked to enter them later."); + terminal.println(""); + terminal.println("If you wish to use a wildcard certificate (for example *.es.example.com) you"); + terminal.println("can enter that here."); + + final List dnsNames = new ArrayList<>(); + while (true) { + terminal.println(""); + terminal.println("Enter all the hostnames that you need, one per line." ); + terminal.println("When you are done, press once more to move on to the next step."); + terminal.println(""); + + dnsNames.addAll(readMultiLineInput(terminal, this::validateHostname)); + if (dnsNames.isEmpty()) { + terminal.println(Terminal.Verbosity.SILENT, "You did not enter any hostnames."); + terminal.println("Clients are likely to encounter TLS hostname verification errors if they"); + terminal.println("connect to your cluster using a DNS name."); + } else { + terminal.println(Terminal.Verbosity.SILENT, "You entered the following hostnames."); + terminal.println(Terminal.Verbosity.SILENT, ""); + dnsNames.forEach(s -> terminal.println(Terminal.Verbosity.SILENT, " - " + s)); + } + terminal.println(""); + if (terminal.promptYesNo("Is this correct", true)) { + break; + } else { + dnsNames.clear(); + } + } + + printHeader("Which IP addresses will be used to connect to " + nodeDescription + "?", terminal); + terminal.println("If your clients will ever connect to your nodes by numeric IP address, then you"); + terminal.println("can list these as valid IP \"Subject Alternative Name\" (SAN) fields in your"); + terminal.println("certificate."); + terminal.println(""); + terminal.println("If you do not have fixed IP addresses, or not wish to support direct IP access"); + terminal.println("to your cluster then you can just press to skip this step."); + + final List ipNames = new ArrayList<>(); + while (true) { + terminal.println(""); + terminal.println("Enter all the IP addresses that you need, one per line."); + terminal.println("When you are done, press once more to move on to the next step."); + terminal.println(""); + + ipNames.addAll(readMultiLineInput(terminal, this::validateIpAddress)); + if (ipNames.isEmpty()) { + terminal.println(Terminal.Verbosity.SILENT, "You did not enter any IP addresses."); + } else { + terminal.println(Terminal.Verbosity.SILENT, "You entered the following IP addresses."); + terminal.println(Terminal.Verbosity.SILENT, ""); + ipNames.forEach(s -> terminal.println(Terminal.Verbosity.SILENT, " - " + s)); + } + terminal.println(""); + if (terminal.promptYesNo("Is this correct", true)) { + break; + } else { + ipNames.clear(); + } + } + + printHeader("Other certificate options", terminal); + terminal.println("The generated certificate will have the following additional configuration"); + terminal.println("values. These values have been selected based on a combination of the"); + terminal.println("information you have provided above and secure defaults. You should not need to"); + terminal.println("change these values unless you have specific requirements."); + terminal.println(""); + + if (certName == null) { + certName = dnsNames.stream().filter(n -> n.indexOf('*') == -1).findFirst() + .orElseGet(() -> dnsNames.stream().map(s -> s.replace("*.", "")).findFirst() + .orElse("elasticsearch")); + } + X500Principal dn = buildDistinguishedName(certName); + int keySize = DEFAULT_CERT_KEY_SIZE; + while (true) { + terminal.println(Terminal.Verbosity.SILENT, "Key Name: " + certName); + terminal.println(Terminal.Verbosity.SILENT, "Subject DN: " + dn); + terminal.println(Terminal.Verbosity.SILENT, "Key Size: " + keySize); + terminal.println(Terminal.Verbosity.SILENT, ""); + if (terminal.promptYesNo("Do you wish to change any of these options?", false) == false) { + break; + } + + printHeader("What should your key be named?", terminal); + if (csr) { + terminal.println("This will be included in the name of the files that are generated"); + } else { + terminal.println("This will be the entry name in the PKCS#12 keystore that is generated"); + } + terminal.println("It is helpful to have a meaningful name for this key"); + terminal.println(""); + certName = tryReadInput(terminal, "Key Name", certName, Function.identity()); + + printHeader("What subject DN should be used for your certificate?", terminal); + terminal.println("This will be visible to clients."); + terminal.println("It is helpful to have a meaningful name for each certificate"); + terminal.println(""); + dn = tryReadInput(terminal, "Subject DN", dn, name -> { + try { + if (name.contains("=")) { + return new X500Principal(name); + } else { + return new X500Principal("CN=" + name); + } + } catch (IllegalArgumentException e) { + terminal.println(Terminal.Verbosity.SILENT, "'" + name + "' is not a valid DN (" + e.getMessage() + ")"); + return null; + } + }); + + printHeader("What key size should your certificate have?", terminal); + terminal.println("The RSA private key for your certificate has a fixed 'key size' (in bits)."); + terminal.println("Larger key sizes are generally more secure, but are also slower."); + terminal.println(""); + terminal.println("We recommend that you use one of 2048, 3072 or 4096 bits for your key."); + + keySize = readKeySize(terminal, keySize); + terminal.println(""); + } + + return new CertOptions(certName, dn, dnsNames, ipNames, keySize, validity); + } + + private String validateHostname(String name) { + if (DERIA5String.isIA5String(name)) { + return null; + } else { + return name + " is not a valid DNS name"; + } + } + + private String validateIpAddress(String ip) { + if (InetAddresses.isInetAddress(ip)) { + return null; + } else { + return ip + " is not a valid IP address"; + } + } + + private X500Principal buildDistinguishedName(String name) { + return new X500Principal("CN=" + name.replace(".", ",DC=")); + } + + private List readMultiLineInput(Terminal terminal, Function validator) { + final List lines = new ArrayList<>(); + while (true) { + String input = terminal.readText(""); + if (Strings.isEmpty(input)) { + break; + } else { + final String error = validator.apply(input); + if (error == null) { + lines.add(input); + } else { + terminal.println("Error: " + error); + } + } + } + return lines; + } + + + private boolean askCertSigningRequest(Terminal terminal) { + printHeader("Do you wish to generate a Certificate Signing Request (CSR)?", terminal); + + terminal.println("A CSR is used when you want your certificate to be created by an existing"); + terminal.println("Certificate Authority (CA) that you do not control (that is, you don't have"); + terminal.println("access to the keys for that CA). "); + terminal.println(""); + terminal.println("If you are in a corporate environment with a central security team, then you"); + terminal.println("may have an existing Corporate CA that can generate your certificate for you."); + terminal.println("Infrastructure within your organisation may already be configured to trust this"); + terminal.println("CA, so it may be easier for clients to connect to Elasticsearch if you use a"); + terminal.println("CSR and send that request to the team that controls your CA."); + terminal.println(""); + terminal.println("If you choose not to generate a CSR, this tool will generate a new certificate"); + terminal.println("for you. That certificate will be signed by a CA under your control. This is a"); + terminal.println("quick and easy way to secure your cluster with TLS, but you will need to"); + terminal.println("configure all your clients to trust that custom CA."); + + terminal.println(""); + return terminal.promptYesNo("Generate a CSR?", false); + } + + private CertificateTool.CAInfo findExistingCA(Terminal terminal, Environment env) throws UserException { + printHeader("What is the path to your CA?", terminal); + + terminal.println("Please enter the full pathname to the Certificate Authority that you wish to"); + terminal.println("use for signing your new http certificate. This can be in PKCS#12 (.p12), JKS"); + terminal.println("(.jks) or PEM (.crt, .key, .pem) format."); + + final Path caPath = requestPath("CA Path: ", terminal, env, true); + final FileType fileType = guessFileType(caPath, terminal); + switch (fileType) { + + case PKCS12: + case JKS: + terminal.println(Terminal.Verbosity.VERBOSE, "CA file " + caPath + " appears to be a " + fileType + " keystore"); + return readKeystoreCA(caPath, fileType, terminal); + + case PEM_KEY: + printHeader("What is the path to your CA certificate?", terminal); + terminal.println(caPath + " appears to be a PEM formatted private key file."); + terminal.println("In order to use it for signing we also need access to the certificate"); + terminal.println("that corresponds to that key."); + terminal.println(""); + final Path caCertPath = requestPath("CA Certificate: ", terminal, env, true); + return readPemCA(caCertPath, caPath, terminal); + + case PEM_CERT: + printHeader("What is the path to your CA key?", terminal); + terminal.println(caPath + " appears to be a PEM formatted certificate file."); + terminal.println("In order to use it for signing we also need access to the private key"); + terminal.println("that corresponds to that certificate."); + terminal.println(""); + final Path caKeyPath = requestPath("CA Key: ", terminal, env, true); + return readPemCA(caPath, caKeyPath, terminal); + + case PEM_CERT_CHAIN: + terminal.println(Terminal.Verbosity.SILENT, "The file at " + caPath + " contains multiple certificates."); + terminal.println("That type of file typically represents a certificate-chain"); + terminal.println("This tool requires a single certificate for the CA"); + throw new UserException(ExitCodes.DATA_ERROR, caPath + ": Unsupported file type (certificate chain)"); + + + case UNRECOGNIZED: + default: + terminal.println(Terminal.Verbosity.SILENT, "The file at " + caPath + " isn't a file type that this tool recognises."); + terminal.println("Please try again with a CA in PKCS#12, JKS or PEM format"); + throw new UserException(ExitCodes.DATA_ERROR, caPath + ": Unrecognized file type"); + } + } + + private CertificateTool.CAInfo createNewCA(Terminal terminal) { + terminal.println("A new Certificate Authority will be generated for you"); + + printHeader("CA Generation Options", terminal); + terminal.println("The generated certificate authority will have the following configuration values."); + terminal.println("These values have been selected based on secure defaults."); + terminal.println("You should not need to change these values unless you have specific requirements."); + terminal.println(""); + + X500Principal dn = DEFAULT_CA_NAME; + Period validity = DEFAULT_CA_VALIDITY; + int keySize = DEFAULT_CA_KEY_SIZE; + while (true) { + terminal.println(Terminal.Verbosity.SILENT, "Subject DN: " + dn); + terminal.println(Terminal.Verbosity.SILENT, "Validity: " + toString(validity)); + terminal.println(Terminal.Verbosity.SILENT, "Key Size: " + keySize); + terminal.println(Terminal.Verbosity.SILENT, ""); + if (terminal.promptYesNo("Do you wish to change any of these options?", false) == false) { + break; + } + + printHeader("What should your CA be named?", terminal); + terminal.println("Every client that connects to your Elasticsearch cluster will need to trust"); + terminal.println("this custom Certificate Authority."); + terminal.println("It is helpful to have a meaningful name for this CA"); + terminal.println(""); + dn = tryReadInput(terminal, "CA Name", dn, name -> { + try { + if (name.contains("=")) { + return new X500Principal(name); + } else { + return new X500Principal("CN=" + name); + } + } catch (IllegalArgumentException e) { + terminal.println(Terminal.Verbosity.SILENT, "'" + name + "' is not a valid CA name (" + e.getMessage() + ")"); + return null; + } + }); + + printHeader("How long should your CA be valid?", terminal); + terminal.println("Every certificate has an expiry date. When the expiry date is reached, clients"); + terminal.println("will stop trusting your Certificate Authority and TLS connections will fail."); + terminal.println(""); + terminal.println("We recommend that you set this to a long duration (3 - 5 years) and then perform a"); + terminal.println("manual update a few months before it expires."); + terminal.println("You may enter the validity period in years (e.g. 3Y), months (e.g. 18M), or days (e.g. 90D)"); + + validity = readPeriodInput(terminal, "CA Validity", validity, 90); + + printHeader("What key size should your CA have?", terminal); + terminal.println("The RSA private key for your Certificate Authority has a fixed 'key size' (in bits)."); + terminal.println("Larger key sizes are generally more secure, but are also slower."); + terminal.println(""); + terminal.println("We recommend that you use one of 2048, 3072 or 4096 bits for your key."); + + keySize = readKeySize(terminal, keySize); + terminal.println(""); + } + + try { + final KeyPair keyPair = CertGenUtils.generateKeyPair(keySize); + final ZonedDateTime notBefore = ZonedDateTime.now(ZoneOffset.UTC); + final ZonedDateTime notAfter = notBefore.plus(validity); + X509Certificate caCert = generateSignedCertificate(dn, null, keyPair, null, null, true, notBefore, notAfter, null); + + printHeader("CA password", terminal); + terminal.println("We recommend that you protect your CA private key with a strong password."); + terminal.println("If your key does not have a password (or the password can be easily guessed)"); + terminal.println("then anyone who gets a copy of the key file will be able to generate new certificates"); + terminal.println("and impersonate your Elasticsearch cluster."); + terminal.println(""); + terminal.println("IT IS IMPORTANT THAT YOU REMEMBER THIS PASSWORD AND KEEP IT SECURE"); + terminal.println(""); + final char[] password = readPassword(terminal, "CA password: ", true); + return new CertificateTool.CAInfo(caCert, keyPair.getPrivate(), true, password.length == 0 ? null : password); + } catch (GeneralSecurityException | CertIOException | OperatorCreationException e) { + throw new IllegalArgumentException("Cannot generate CA key pair", e); + } + } + + /** + * Read input from the terminal as a {@link Period}. + * Package protected for testing purposes. + */ + Period readPeriodInput(Terminal terminal, String prompt, Period defaultValue, int recommendedMinimumDays) { + Period period = tryReadInput(terminal, prompt, defaultValue, input -> { + String periodInput = input.replaceAll("[,\\s]", ""); + if (input.charAt(0) != 'P') { + periodInput = "P" + periodInput; + } + try { + final Period parsed = Period.parse(periodInput); + final long approxDays = 30 * parsed.toTotalMonths() + parsed.getDays(); + if (approxDays < recommendedMinimumDays) { + terminal.println("The period '" + toString(parsed) + "' is less than the recommended period"); + if (terminal.promptYesNo("Are you sure?", false) == false) { + return null; + } + } + return parsed; + } catch (DateTimeParseException e) { + terminal.println("Sorry, I do not understand '" + input + "' (" + e.getMessage() + ")"); + return null; + } + }); + return period; + } + + private Integer readKeySize(Terminal terminal, int keySize) { + return tryReadInput(terminal, "Key Size", keySize, input -> { + try { + final int size = Integer.parseInt(input); + if (size < 1024) { + terminal.println("Keys must be at least 1024 bits"); + return null; + } + if (size > 8192) { + terminal.println("Keys cannot be larger than 8192 bits"); + return null; + } + if (size % 1024 != 0) { + terminal.println("The key size should be a multiple of 1024 bits"); + return null; + } + return size; + } catch (NumberFormatException e) { + terminal.println("The key size must be a positive integer"); + return null; + } + }); + } + + private char[] readPassword(Terminal terminal, String prompt, boolean confirm) { + while (true) { + final char[] password = terminal.readSecret(prompt + " [ for none]"); + if (password.length == 0) { + return password; + } + if (CertificateTool.isAscii(password)) { + if (confirm) { + final char[] again = terminal.readSecret("Repeat password to confirm: "); + if (Arrays.equals(password, again) == false) { + terminal.println("Passwords do not match"); + continue; + } + } + return password; + } else { + terminal.println(Terminal.Verbosity.SILENT, "Passwords must be plain ASCII"); + } + } + } + + private CertificateTool.CAInfo readKeystoreCA(Path ksPath, FileType fileType, Terminal terminal) throws UserException { + final String storeType = fileType == FileType.PKCS12 ? "PKCS12" : "jks"; + terminal.println("Reading a " + storeType + " keystore requires a password."); + terminal.println("It is possible for the keystore's password to be blank,"); + terminal.println("in which case you can simply press at the prompt"); + final char[] password = terminal.readSecret("Password for " + ksPath.getFileName() + ":"); + try { + final Map keys = CertParsingUtils.readKeyPairsFromKeystore(ksPath, storeType, password, alias -> password); + + if (keys.size() != 1) { + if (keys.isEmpty()) { + terminal.println(Terminal.Verbosity.SILENT, "The keystore at " + ksPath + " does not contain any keys "); + } else { + terminal.println(Terminal.Verbosity.SILENT, "The keystore at " + ksPath + " contains " + keys.size() + " keys,"); + terminal.println(Terminal.Verbosity.SILENT, "but this command requires a keystore with a single key"); + } + terminal.println("Please try again with a keystore that contains exactly 1 private key entry"); + throw new UserException(ExitCodes.DATA_ERROR, "The CA keystore " + ksPath + " contains " + keys.size() + " keys"); + } + final Map.Entry pair = keys.entrySet().iterator().next(); + return new CertificateTool.CAInfo((X509Certificate) pair.getKey(), (PrivateKey) pair.getValue()); + } catch (IOException | GeneralSecurityException e) { + throw new ElasticsearchException("Failed to read keystore " + ksPath, e); + } + } + + private CertificateTool.CAInfo readPemCA(Path certPath, Path keyPath, Terminal terminal) throws UserException { + final X509Certificate cert = readCertificate(certPath, terminal); + final PrivateKey key = readPrivateKey(keyPath, terminal); + return new CertificateTool.CAInfo(cert, key); + } + + private X509Certificate readCertificate(Path path, Terminal terminal) throws UserException { + try { + final X509Certificate[] certificates = CertParsingUtils.readX509Certificates(Collections.singletonList(path)); + switch (certificates.length) { + case 0: + terminal.errorPrintln("Could not read any certificates from " + path); + throw new UserException(ExitCodes.DATA_ERROR, path + ": No certificates found"); + case 1: + return certificates[0]; + default: + terminal.errorPrintln("Read [" + certificates.length + "] certificates from " + path + " but expected 1"); + throw new UserException(ExitCodes.DATA_ERROR, path + ": Multiple certificates found"); + } + } catch (CertificateException | IOException e) { + throw new ElasticsearchException("Failed to read certificates from " + path, e); + } + } + + private PrivateKey readPrivateKey(Path path, Terminal terminal) { + try { + return PemUtils.readPrivateKey(path, () -> { + terminal.println(""); + terminal.println("The PEM key stored in " + path + " requires a password."); + terminal.println(""); + return terminal.readSecret("Password for " + path.getFileName() + ":"); + }); + } catch (IOException e) { + throw new ElasticsearchException("Failed to read private key from " + path, e); + } + } + + + private boolean askExistingCertificateAuthority(Terminal terminal) { + printHeader("Do you have an existing Certificate Authority (CA) key-pair that you wish to use to sign your certificate?", terminal); + terminal.println("If you have an existing CA certificate and key, then you can use that CA to"); + terminal.println("sign your new http certificate. This allows you to use the same CA across"); + terminal.println("multiple Elasticsearch clusters which can make it easier to configure clients,"); + terminal.println("and may be easier for you to manage."); + terminal.println(""); + terminal.println("If you do not have an existing CA, one will be generated for you."); + terminal.println(""); + + return terminal.promptYesNo("Use an existing CA?", false); + } + + private T tryReadInput(Terminal terminal, String prompt, T defaultValue, Function parser) { + final String defaultStr = defaultValue instanceof Period ? toString((Period) defaultValue) : String.valueOf(defaultValue); + while (true) { + final String input = terminal.readText(prompt + " [" + defaultStr + "] "); + if (Strings.isEmpty(input)) { + return defaultValue; + } + T parsed = parser.apply(input); + if (parsed != null) { + return parsed; + } + } + } + + static String toString(Period period) { + if (period == null) { + return "N/A"; + } + if (period.isZero()) { + return "0d"; + } + List parts = new ArrayList<>(3); + if (period.getYears() != 0) { + parts.add(period.getYears() + "y"); + } + if (period.getMonths() != 0) { + parts.add(period.getMonths() + "m"); + } + if (period.getDays() != 0) { + parts.add(period.getDays() + "d"); + } + return Strings.collectionToCommaDelimitedString(parts); + } + + private Path requestPath(String prompt, Terminal terminal, Environment env, boolean requireExisting) { + for (; ; ) { + final String input = terminal.readText(prompt); + final Path path = env.configFile().resolve(input).toAbsolutePath(); + + if (path.getFileName() == null) { + terminal.println(Terminal.Verbosity.SILENT, input + " is not a valid file"); + continue; + } + if (requireExisting == false || Files.isReadable(path)) { + return path; + } + + if (Files.notExists(path)) { + terminal.println(Terminal.Verbosity.SILENT, "The file " + path + " does not exist"); + } else { + terminal.println(Terminal.Verbosity.SILENT, "The file " + path + " cannot be read"); + } + } + } + + static FileType guessFileType(Path path, Terminal terminal) { + // trust the extension for some file-types rather than inspecting the contents + // we don't rely on filename for PEM files because + // (a) users have a tendency to get things mixed up (e.g. naming something "key.crt") + // (b) we need to distinguish between Certs & Keys, so a ".pem" file is ambiguous + final String fileName = path == null ? "" : path.getFileName().toString().toLowerCase(Locale.ROOT); + if (fileName.endsWith(".p12") || fileName.endsWith(".pfx") || fileName.endsWith(".pkcs12")) { + return FileType.PKCS12; + } + if (fileName.endsWith(".jks")) { + return FileType.JKS; + } + // Sniff the file. We could just try loading them, but then we need to catch a variety of exceptions + // and guess what they mean. For example, loading a PKCS#12 needs a password, so we would need to + // distinguish between a "wrong/missing password" exception and a "not a PKCS#12 file" exception. + try (InputStream in = Files.newInputStream(path)) { + byte[] leadingBytes = new byte[2]; + final int read = in.read(leadingBytes); + if (read < leadingBytes.length) { + // No supported file type has less than 2 bytes + return FileType.UNRECOGNIZED; + } + if (Arrays.equals(leadingBytes, MAGIC_BYTES1_PKCS12) || Arrays.equals(leadingBytes, MAGIC_BYTES2_PKCS12)) { + return FileType.PKCS12; + } + if (Arrays.equals(leadingBytes, MAGIC_BYTES_JKS)) { + return FileType.JKS; + } + } catch (IOException e) { + terminal.errorPrintln("Failed to read from file " + path); + terminal.errorPrintln(e.toString()); + return FileType.UNRECOGNIZED; + } + // Probably a PEM file, but we need to know what type of object(s) it holds + try (Stream lines = Files.lines(path, StandardCharsets.UTF_8)) { + final List types = lines.filter(s -> s.startsWith("-----BEGIN")).map(s -> { + if (s.contains("BEGIN CERTIFICATE")) { + return FileType.PEM_CERT; + } else if (s.contains("PRIVATE KEY")) { + return FileType.PEM_KEY; + } else { + return null; + } + }).filter(ft -> ft != null).collect(Collectors.toList()); + switch (types.size()) { + case 0: + // Not a PEM + return FileType.UNRECOGNIZED; + case 1: + return types.get(0); + default: + if (types.contains(FileType.PEM_KEY)) { + // A Key and something else. Could be a cert + key pair, but we don't support that + terminal.errorPrintln("Cannot determine a type for the PEM file " + path + " because it contains: [" + + Strings.collectionToCommaDelimitedString(types) + "]"); + } else { + // Multiple certificates = chain + return FileType.PEM_CERT_CHAIN; + } + } + } catch (UncheckedIOException | IOException e) { + terminal.errorPrintln("Cannot determine the file type for " + path); + terminal.errorPrintln(e.toString()); + return FileType.UNRECOGNIZED; + } + return FileType.UNRECOGNIZED; + } + + private void printHeader(String text, Terminal terminal) { + terminal.println(""); + terminal.println(Terminal.Verbosity.SILENT, "## " + text); + terminal.println(""); + } + + /** + * The standard zip output stream cannot be wrapped safely in another stream, because its close method closes the + * zip file, not just the entry. + * This class handles close correctly for a single entry + */ + private class ZipEntryStream extends OutputStream { + + private final ZipOutputStream zip; + + ZipEntryStream(ZipOutputStream zip, String name) throws IOException { + this(zip, new ZipEntry(name)); + } + + ZipEntryStream(ZipOutputStream zip, ZipEntry entry) throws IOException { + this.zip = zip; + assert entry.isDirectory() == false; + zip.putNextEntry(entry); + } + + @Override + public void write(int b) throws IOException { + zip.write(b); + } + + @Override + public void write(byte[] b) throws IOException { + zip.write(b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + zip.write(b, off, len); + } + + @Override + public void flush() throws IOException { + zip.flush(); + } + + @Override + public void close() throws IOException { + zip.closeEntry(); + } + } + + // For testing + OptionParser getParser() { + return parser; + } +} diff --git a/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/ca-readme-p12.txt b/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/ca-readme-p12.txt new file mode 100644 index 00000000000..dcd9c7c1898 --- /dev/null +++ b/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/ca-readme-p12.txt @@ -0,0 +1,33 @@ +There are two files in this directory: + +1. This README file +2. ${P12} + +## ${P12} + +The "${P12}" file is a PKCS#12 format keystore. +It contains a copy of the certificate and private key for your Certificate Authority. + +You should keep this file secure, and should not provide it to anyone else. + +The sole purpose for this keystore is to generate new certificates if you add additional nodes to your Elasticsearch cluster, or need to +update the server names (hostnames or IP addresses) of your nodes. + +This keystore is not required in order to operate any Elastic product or client. +We recommended that you keep the file somewhere safe, and do not deploy it to your production servers. + +#if PASSWORD +Your keystore is protected by a password. +Your password has not been stored anywhere - it is your responsibility to keep it safe. +#else +Your keystore has a blank password. +It is important that you protect this file - if someone else gains access to your private key they can impersonate your Elasticsearch node. +#endif + + +If you wish to create additional certificates for the nodes in your cluster you can provide this keystore to the "elasticsearch-certutil" +utility as shown in the example below: + + elasticsearch-certutil cert --ca ${P12} --dns "hostname.of.your.node" --pass + +See the elasticsearch-certutil documentation for additional options. diff --git a/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/es-readme-csr.txt b/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/es-readme-csr.txt new file mode 100644 index 00000000000..d6e1fce2027 --- /dev/null +++ b/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/es-readme-csr.txt @@ -0,0 +1,56 @@ +There are four files in this directory: + +1. This README file +2. ${CSR} +3. ${KEY} +4. ${YML} + +## ${CSR} + +The "${CSR}" file is a Certificate Signing Request. +You should provide a copy this file to a Certificate Authority ("CA"), and they will provide you with a signed Certificate. + +In many large organisations there is a central security team that operates an internal Certificate Authority that can generate your +certificate for you. Alternatively, it may be possible to have a your certificate generated by a commercial Certificate Authority. + +In either case, you need to provide the ${CSR} file to the certificate authority, and they will provide you with your signed certificate. +For the purposes of this document, we assume that when they send you your certificate, you will save it as a file named "${CERT}". + +The certificate authority might also provide you with a copy of their signing certificate. If they do, you should keep a copy of that +certificate, as you may need it when configuring clients such as Kibana. + +## ${KEY} + +The "${KEY}" file is your private key. +You should keep this file secure, and should not provide it to anyone else (not even the CA). + +Once you have a copy of your certificate (from the CA), you will configure your Elasticsearch nodes to use the certificate +and this private key. +You will need to copy both of those files to your elasticsearch configuration directory. + +#if PASSWORD +Your private key is protected by a passphrase. +Your password has not been stored anywhere - it is your responsibility to keep it safe. + +When you configure elasticsearch to enable SSL (but not before then), you will need to provide the key's password as a secure +configuration setting in Elasticsearch so that it can decrypt your private key. + +The command for this is: + + elasticsearch-keystore add "xpack.security.http.ssl.secure_key_passphrase" + +#else +Your private key is not password protected. +It is important that you protect this file - if someone else gains access to your private key they can impersonate your Elasticsearch node. +#endif + +## ${YML} + +This is a sample configuration for Elasticsearch to enable SSL on the http interface. +You can use this sample to update the "elasticsearch.yml" configuration file in your config directory. +The location of this directory can vary depending on how you installed Elasticsearch, but based on your system it appears that your config +directory is ${CONF_DIR} + +You will not be able to configure Elasticsearch until the Certificate Authority processes your CSR and provides you with a copy of your +certificate. When you have a copy of the certificate you should copy it and the private key ("${KEY}") to the config directory. +The sample config assumes that the certificate is named "${CERT}". diff --git a/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/es-readme-p12.txt b/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/es-readme-p12.txt new file mode 100644 index 00000000000..06f435116e3 --- /dev/null +++ b/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/es-readme-p12.txt @@ -0,0 +1,38 @@ +There are three files in this directory: + +1. This README file +2. ${P12} +3. ${YML} + +## ${P12} + +The "${P12}" file is a PKCS#12 format keystore. +It contains a copy of your certificate and the associated private key. +You should keep this file secure, and should not provide it to anyone else. + +You will need to copy this file to your elasticsearch configuration directory. + +#if PASSWORD +Your keystore is protected by a password. +Your password has not been stored anywhere - it is your responsibility to keep it safe. + +When you configure elasticsearch to enable SSL (but not before then), you will need to provide the keystore's password as a secure +configuration setting in Elasticsearch so that it can access your private key. + +The command for this is: + + elasticsearch-keystore add "xpack.security.http.ssl.keystore.secure_password" + +#else +Your keystore has a blank password. +It is important that you protect this file - if someone else gains access to your private key they can impersonate your Elasticsearch node. +#endif + +## ${YML} + +This is a sample configuration for Elasticsearch to enable SSL on the http interface. +You can use this sample to update the "elasticsearch.yml" configuration file in your config directory. +The location of this directory can vary depending on how you installed Elasticsearch, but based on your system it appears that your config +directory is ${CONF_DIR} + +This sample configuration assumes that you have copied your ${P12} file directly into the config directory without renaming it. \ No newline at end of file diff --git a/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/es-sample-csr.yml b/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/es-sample-csr.yml new file mode 100644 index 00000000000..9ce1624430a --- /dev/null +++ b/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/es-sample-csr.yml @@ -0,0 +1,32 @@ +# +# SAMPLE ELASTICSEARCH CONFIGURATION FOR ENABLING SSL ON THE HTTP INTERFACE +# +# This is a sample configuration snippet for Elasticsearch that enables and configures SSL for the HTTP (Rest) interface +# +# This was automatically generated at: ${DATE} ${TIME} +# This configuration was intended for Elasticsearch version ${VERSION} +# +# You should review these settings, and then update the main configuration file at +# ${CONF_DIR}/elasticsearch.yml +# + +# This turns on SSL for the HTTP (Rest) interface +xpack.security.http.ssl.enabled: true + +# This configures the certificate to use. +# This certificate will be generated by your Certificate Authority, based on the CSR that you sent to them. +xpack.security.http.ssl.certificate: "${CERT}" + +# This configures the private key for your certificate. +#if PASSWORD +# Because your private key is encrypted, you will also need to add the passphrase to the Elasticsearch keystore +# elasticsearch-keystore add "xpack.security.http.ssl.secure_key_passphrase" +#endif +xpack.security.http.ssl.key: "${KEY}" + +# If your Certificate Authorities provides you with a copy of their certificate you can configure it here. +# This is not strictly necessary, but can make it easier when running other elasticsearch utilities such as the "setup-passwords" tool. +# +#xpack.security.http.ssl.certificate_authorities: [ "ca.crt" ] +# + diff --git a/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/es-sample-p12.yml b/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/es-sample-p12.yml new file mode 100644 index 00000000000..7658f56b47d --- /dev/null +++ b/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/es-sample-p12.yml @@ -0,0 +1,22 @@ +# +# SAMPLE ELASTICSEARCH CONFIGURATION FOR ENABLING SSL ON THE HTTP INTERFACE +# +# This is a sample configuration snippet for Elasticsearch that enables and configures SSL for the HTTP (Rest) interface +# +# This was automatically generated at: ${DATE} ${TIME} +# This configuration was intended for Elasticsearch version ${VERSION} +# +# You should review these settings, and then update the main configuration file at +# ${CONF_DIR}/elasticsearch.yml +# + +# This turns on SSL for the HTTP (Rest) interface +xpack.security.http.ssl.enabled: true + +# This configures the keystore to use for SSL on HTTP +#if PASSWORD +# Because your keystore has a password, you will also need to add the password to the Elasticsearch keystore +# elasticsearch-keystore add "xpack.security.http.ssl.keystore.secure_password" +#endif +xpack.security.http.ssl.keystore.path: "${P12}" + diff --git a/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/kibana-readme.txt b/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/kibana-readme.txt new file mode 100644 index 00000000000..28d308b10a6 --- /dev/null +++ b/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/kibana-readme.txt @@ -0,0 +1,62 @@ +#if CA_CERT +There are three files in this directory: + +1. This README file +2. ${CA_CERT} +3. ${YML} + +#else +There are two files in this directory: + +1. This README file +2. ${YML} + +#endif +#if CA_CERT +## ${CA_CERT} + +The "${CA_CERT}" file is a PEM format X.509 Certificate for the Elasticsearch Certificate Authority. + +You need to configure Kibana to trust this certificate as an issuing CA for TLS connections to your Elasticsearch cluster. +The "${YML}" file, and the instructions below, explain how to do this. + +#else +Because your Elasticsearch certificates are being generated by an external CA (via a Certificate Signing Request), this directory does not +contain a copy of the CA's issuing certificate (we don't know where you will send your CSRs and who will sign them). + +If you are using a public (commercial) CA then it is likely that Kibana will already be configured to trust this CA and you will not need +to do any special configuration. + +However, if you are using a CA that is specific to your organisation, then you will need to configure Kibana to trust that CA. +When your CA issues your certificate, you should ask them for a copy of their certificate chain in PEM format. + +The "${YML}" file, and the instructions below, explain what to do this with this file. + +#endif +## ${YML} + +This is a sample configuration for Kibana to enable SSL for connections to Elasticsearch. +You can use this sample to update the "kibana.yml" configuration file in your Kibana config directory. + +------------------------------------------------------------------------------------------------- +NOTE: + You also need to update the URLs in your "elasticsearch.hosts" setting to use the "https" URL. + e.g. If your kibana.yml file currently has + + elasticsearch.hosts: [ "http://localhost:9200" ] + + then you should change this to: + + elasticsearch.hosts: [ "https://localhost:9200" ] + +------------------------------------------------------------------------------------------------- + +#if CA_CERT +The sample configuration assumes that you have copied the "${CA_CERT}" file directly into the Kibana config +directory without renaming it. +#else +The sample configuration assumes that you have a file named "${CA_CERT_NAME}" which contains your CA's certificate +chain, and have copied that file into the Kibana config directory. +#endif + + diff --git a/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/kibana-sample.yml b/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/kibana-sample.yml new file mode 100644 index 00000000000..78a92782bdd --- /dev/null +++ b/x-pack/plugin/security/cli/src/main/resources/org/elasticsearch/xpack/security/cli/certutil-http/kibana-sample.yml @@ -0,0 +1,25 @@ +# +# SAMPLE KIBANA CONFIGURATION FOR ENABLING SSL TO ELASTICSEARCH +# +# This is a sample configuration snippet for Kibana that configures SSL for connections to Elasticsearch +# +# This was automatically generated at: ${DATE} ${TIME} +# This configuration was intended for version ${VERSION} +# +# You should review these settings, and then update the main kibana.yml configuration file. +# +#------------------------------------------------------------------------------------------------- +# You also need to update the URLs in your "elasticsearch.hosts" setting to use the "https" URL. +# e.g. If your kibana.yml file currently has +# +# elasticsearch.hosts: [ "http://localhost:9200" ] +# +# then you should change this to: +# +# elasticsearch.hosts: [ "https://localhost:9200" ] +# +#------------------------------------------------------------------------------------------------- + +# This configures Kibana to trust a specific Certificate Authority for connections to Elasticsearch +elasticsearch.ssl.certificateAuthorities: [ "config/${CA_CERT_NAME}" ] + diff --git a/x-pack/plugin/security/cli/src/test/java/org/elasticsearch/xpack/security/cli/HttpCertificateCommandTests.java b/x-pack/plugin/security/cli/src/test/java/org/elasticsearch/xpack/security/cli/HttpCertificateCommandTests.java new file mode 100644 index 00000000000..284a5dbb481 --- /dev/null +++ b/x-pack/plugin/security/cli/src/test/java/org/elasticsearch/xpack/security/cli/HttpCertificateCommandTests.java @@ -0,0 +1,792 @@ +/* + * 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.security.cli; + +import com.google.common.jimfs.Configuration; +import com.google.common.jimfs.Jimfs; +import joptsimple.OptionSet; +import org.bouncycastle.asn1.DERIA5String; +import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.DERSequence; +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.pkcs.PKCS10CertificationRequest; +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequest; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemReader; +import org.elasticsearch.cli.MockTerminal; +import org.elasticsearch.common.CheckedBiFunction; +import org.elasticsearch.common.CheckedFunction; +import org.elasticsearch.common.collect.Tuple; +import org.elasticsearch.common.network.NetworkAddress; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.core.internal.io.IOUtils; +import org.elasticsearch.env.Environment; +import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.core.ssl.CertParsingUtils; +import org.elasticsearch.xpack.core.ssl.PemUtils; +import org.elasticsearch.xpack.security.cli.HttpCertificateCommand.FileType; +import org.hamcrest.Matchers; +import org.junit.Before; +import org.junit.BeforeClass; + +import javax.security.auth.x500.X500Principal; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.InetAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.UnknownHostException; +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.security.GeneralSecurityException; +import java.security.Key; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.security.interfaces.RSAKey; +import java.time.Instant; +import java.time.Period; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static java.util.Collections.singletonMap; +import static org.elasticsearch.test.FileMatchers.isDirectory; +import static org.elasticsearch.test.FileMatchers.isRegularFile; +import static org.elasticsearch.test.FileMatchers.pathExists; +import static org.elasticsearch.xpack.security.cli.HttpCertificateCommand.guessFileType; +import static org.hamcrest.Matchers.arrayWithSize; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.in; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.oneOf; + +public class HttpCertificateCommandTests extends ESTestCase { + private static final String CA_PASSWORD = "ca-password"; + private FileSystem jimfs; + private Path testRoot; + + @Before + public void createTestDir() throws Exception { + Configuration conf = Configuration.unix().toBuilder().setAttributeViews("posix").build(); + jimfs = Jimfs.newFileSystem(conf); + testRoot = jimfs.getPath(getClass().getSimpleName() + "-" + getTestName()); + IOUtils.rm(testRoot); + Files.createDirectories(testRoot); + } + + @BeforeClass + public static void muteInFips() { + assumeFalse("Can't run in a FIPS JVM", inFipsJvm()); + } + + public void testGenerateSingleCertificateSigningRequest() throws Exception { + final Path outFile = testRoot.resolve("csr.zip").toAbsolutePath(); + + final List hostNames = randomHostNames(); + final List ipAddresses = randomIpAddresses(); + final String certificateName = hostNames.get(0); + + final HttpCertificateCommand command = new PathAwareHttpCertificateCommand(outFile); + + final MockTerminal terminal = new MockTerminal(); + + terminal.addTextInput("y"); // generate CSR + + terminal.addTextInput(randomBoolean() ? "n" : ""); // cert-per-node + + // enter hostnames + hostNames.forEach(terminal::addTextInput); + terminal.addTextInput(""); // end-of-hosts + terminal.addTextInput(randomBoolean() ? "y" : ""); // yes, correct + + // enter ip names + ipAddresses.forEach(terminal::addTextInput); + terminal.addTextInput(""); // end-of-ips + terminal.addTextInput(randomBoolean() ? "y" : ""); // yes, correct + + terminal.addTextInput(randomBoolean() ? "n" : ""); // don't change advanced settings + + final String password = randomPassword(); + terminal.addSecretInput(password); + terminal.addSecretInput(password); // confirm + + terminal.addTextInput(outFile.toString()); + + final Environment env = newEnvironment(); + final OptionSet options = command.getParser().parse(new String[0]); + command.execute(terminal, options, env); + + Path zipRoot = getZipRoot(outFile); + + assertThat(zipRoot.resolve("elasticsearch"), isDirectory()); + + final Path csrPath = zipRoot.resolve("elasticsearch/http-" + certificateName + ".csr"); + final PKCS10CertificationRequest csr = readPemObject(csrPath, "CERTIFICATE REQUEST", PKCS10CertificationRequest::new); + + final Path keyPath = zipRoot.resolve("elasticsearch/http-" + certificateName + ".key"); + final AtomicBoolean wasEncrypted = new AtomicBoolean(false); + final PrivateKey privateKey = PemUtils.readPrivateKey(keyPath, () -> { + wasEncrypted.set(true); + return password.toCharArray(); + }); + assertTrue("Password should have been required to decrypted key", wasEncrypted.get()); + + final Path esReadmePath = zipRoot.resolve("elasticsearch/README.txt"); + assertThat(esReadmePath, isRegularFile()); + final String esReadme = new String(Files.readAllBytes(esReadmePath), StandardCharsets.UTF_8); + + final Path ymlPath = zipRoot.resolve("elasticsearch/sample-elasticsearch.yml"); + assertThat(ymlPath, isRegularFile()); + final String yml = new String(Files.readAllBytes(ymlPath), StandardCharsets.UTF_8); + + // Verify the CSR was built correctly + verifyCertificationRequest(csr, certificateName, hostNames, ipAddresses); + + // Verify the key + assertMatchingPair(getPublicKey(csr), privateKey); + + final String crtName = keyPath.getFileName().toString().replace(".csr", ".crt"); + + // Verify the README + assertThat(esReadme, containsString(csrPath.getFileName().toString())); + assertThat(esReadme, containsString(crtName)); + assertThat(esReadme, containsString(keyPath.getFileName().toString())); + assertThat(esReadme, containsString(ymlPath.getFileName().toString())); + assertThat(esReadme, not(containsString(password))); + + // Verify the yml + assertThat(yml, not(containsString(csrPath.getFileName().toString()))); + assertThat(yml, containsString(crtName)); + assertThat(yml, containsString(keyPath.getFileName().toString())); + assertThat(yml, not(containsString(password))); + + // Should not be a CA directory in CSR mode + assertThat(zipRoot.resolve("ca"), not(pathExists())); + + // No CA in CSR mode + verifyKibanaDirectory(zipRoot, + false, + Collections.singletonList("Certificate Signing Request"), + Arrays.asList(password, csrPath.getFileName().toString())); + } + + public void testGenerateSingleCertificateWithExistingCA() throws Exception { + final Path outFile = testRoot.resolve("certs.zip").toAbsolutePath(); + + final List hostNames = randomHostNames(); + final List ipAddresses = randomIpAddresses(); + final String certificateName = hostNames.get(0); + + final Path caCertPath = getDataPath("ca.crt"); + assertThat(caCertPath, isRegularFile()); + final Path caKeyPath = getDataPath("ca.key"); + assertThat(caKeyPath, isRegularFile()); + final String caPassword = CA_PASSWORD; + + final int years = randomIntBetween(1, 8); + + final HttpCertificateCommand command = new PathAwareHttpCertificateCommand(outFile); + + final MockTerminal terminal = new MockTerminal(); + + terminal.addTextInput(randomBoolean() ? "n" : ""); // don't generate CSR + terminal.addTextInput("y"); // existing CA + + // randomise between cert+key, key+cert, PKCS12 : the tool is smart enough to handle any of those. + switch (randomFrom(FileType.PEM_CERT, FileType.PEM_KEY, FileType.PKCS12)) { + case PEM_CERT: + terminal.addTextInput(caCertPath.toAbsolutePath().toString()); + terminal.addTextInput(caKeyPath.toAbsolutePath().toString()); + break; + case PEM_KEY: + terminal.addTextInput(caKeyPath.toAbsolutePath().toString()); + terminal.addTextInput(caCertPath.toAbsolutePath().toString()); + break; + case PKCS12: + terminal.addTextInput(getDataPath("ca.p12").toAbsolutePath().toString()); + break; + } + terminal.addSecretInput(caPassword); + + terminal.addTextInput(years + "y"); // validity period + + terminal.addTextInput(randomBoolean() ? "n" : ""); // don't use cert-per-node + + // enter hostnames + hostNames.forEach(terminal::addTextInput); + terminal.addTextInput(""); // end-of-hosts + terminal.addTextInput(randomBoolean() ? "y" : ""); // yes, correct + + // enter ip names + ipAddresses.forEach(terminal::addTextInput); + terminal.addTextInput(""); // end-of-ips + terminal.addTextInput(randomBoolean() ? "y" : ""); // yes, correct + + terminal.addTextInput(randomBoolean() ? "n" : ""); // don't change advanced settings + + final String password = randomPassword(); + terminal.addSecretInput(password); + terminal.addSecretInput(password); // confirm + + terminal.addTextInput(outFile.toString()); + + final Environment env = newEnvironment(); + final OptionSet options = command.getParser().parse(new String[0]); + command.execute(terminal, options, env); + + Path zipRoot = getZipRoot(outFile); + + assertThat(zipRoot.resolve("elasticsearch"), isDirectory()); + + final Path p12Path = zipRoot.resolve("elasticsearch/http.p12"); + + final Path readmePath = zipRoot.resolve("elasticsearch/README.txt"); + assertThat(readmePath, isRegularFile()); + final String readme = new String(Files.readAllBytes(readmePath), StandardCharsets.UTF_8); + + final Path ymlPath = zipRoot.resolve("elasticsearch/sample-elasticsearch.yml"); + assertThat(ymlPath, isRegularFile()); + final String yml = new String(Files.readAllBytes(ymlPath), StandardCharsets.UTF_8); + + final Tuple certAndKey = readCertificateAndKey(p12Path, password.toCharArray()); + + // Verify the Cert was built correctly + verifyCertificate(certAndKey.v1(), certificateName, years, hostNames, ipAddresses); + assertThat(getRSAKeySize(certAndKey.v1().getPublicKey()), is(HttpCertificateCommand.DEFAULT_CERT_KEY_SIZE)); + assertThat(getRSAKeySize(certAndKey.v2()), is(HttpCertificateCommand.DEFAULT_CERT_KEY_SIZE)); + + final X509Certificate caCert = readPemCertificate(caCertPath); + verifyChain(certAndKey.v1(), caCert); + + // Verify the README + assertThat(readme, containsString(p12Path.getFileName().toString())); + assertThat(readme, containsString(ymlPath.getFileName().toString())); + assertThat(readme, not(containsString(password))); + assertThat(readme, not(containsString(caPassword))); + + // Verify the yml + assertThat(yml, containsString(p12Path.getFileName().toString())); + assertThat(yml, not(containsString(password))); + assertThat(yml, not(containsString(caPassword))); + + // Should not be a CA directory when using an existing CA. + assertThat(zipRoot.resolve("ca"), not(pathExists())); + + verifyKibanaDirectory(zipRoot, true, Collections.singletonList("2. elasticsearch-ca.pem"), + Arrays.asList(password, caPassword, caKeyPath.getFileName().toString())); + } + + public void testGenerateMultipleCertificateWithNewCA() throws Exception { + final Path outFile = testRoot.resolve("certs.zip").toAbsolutePath(); + + final int numberCerts = randomIntBetween(3, 6); + final String[] certNames = new String[numberCerts]; + final String[] hostNames = new String[numberCerts]; + for (int i = 0; i < numberCerts; i++) { + certNames[i] = randomAlphaOfLengthBetween(6, 12); + hostNames[i] = randomAlphaOfLengthBetween(4, 8); + } + + final HttpCertificateCommand command = new PathAwareHttpCertificateCommand(outFile); + + final MockTerminal terminal = new MockTerminal(); + + terminal.addTextInput(randomBoolean() ? "n" : ""); // don't generate CSR + terminal.addTextInput(randomBoolean() ? "n" : ""); // no existing CA + + final String caDN; + final int caYears; + final int caKeySize; + // randomise whether to change CA defaults. + if (randomBoolean()) { + terminal.addTextInput("y"); // Change defaults + caDN = "CN=" + randomAlphaOfLengthBetween(3, 8); + caYears = randomIntBetween(1, 3); + caKeySize = randomFrom(2048, 3072, 4096); + terminal.addTextInput(caDN); + terminal.addTextInput(caYears + "y"); + terminal.addTextInput(Integer.toString(caKeySize)); + terminal.addTextInput("n"); // Don't change values + } else { + terminal.addTextInput(randomBoolean() ? "n" : ""); // Don't change defaults + caDN = HttpCertificateCommand.DEFAULT_CA_NAME.toString(); + caYears = HttpCertificateCommand.DEFAULT_CA_VALIDITY.getYears(); + caKeySize = HttpCertificateCommand.DEFAULT_CA_KEY_SIZE; + } + + final String caPassword = randomPassword(); + terminal.addSecretInput(caPassword); + terminal.addSecretInput(caPassword); // confirm + + final int certYears = randomIntBetween(1, 8); + terminal.addTextInput(certYears + "y"); // node cert validity period + + terminal.addTextInput("y"); // cert-per-node + + for (int i = 0; i < numberCerts; i++) { + if (i != 0) { + terminal.addTextInput(randomBoolean() ? "y" : ""); // another cert + } + + // certificate / node name + terminal.addTextInput(certNames[i]); + + // enter hostname + terminal.addTextInput(hostNames[i]); // end-of-hosts + terminal.addTextInput(""); // end-of-hosts + terminal.addTextInput(randomBoolean() ? "y" : ""); // yes, correct + + // no ip + terminal.addTextInput(""); // end-of-ip + terminal.addTextInput(randomBoolean() ? "y" : ""); // yes, correct + + terminal.addTextInput(randomBoolean() ? "n" : ""); // don't change advanced settings + } + terminal.addTextInput("n"); // no more certs + + + final String password = randomPassword(); + terminal.addSecretInput(password); + terminal.addSecretInput(password); // confirm + + terminal.addTextInput(outFile.toString()); + + final Environment env = newEnvironment(); + final OptionSet options = command.getParser().parse(new String[0]); + command.execute(terminal, options, env); + + Path zipRoot = getZipRoot(outFile); + + // Should have a CA directory with the generated CA. + assertThat(zipRoot.resolve("ca"), isDirectory()); + final Path caPath = zipRoot.resolve("ca/ca.p12"); + final Tuple caCertKey = readCertificateAndKey(caPath, caPassword.toCharArray()); + verifyCertificate(caCertKey.v1(), caDN.replaceFirst("CN=", ""), caYears, Collections.emptyList(), Collections.emptyList()); + assertThat(getRSAKeySize(caCertKey.v1().getPublicKey()), is(caKeySize)); + assertThat(getRSAKeySize(caCertKey.v2()), is(caKeySize)); + + assertThat(zipRoot.resolve("elasticsearch"), isDirectory()); + + for (int i = 0; i < numberCerts; i++) { + assertThat(zipRoot.resolve("elasticsearch/" + certNames[i]), isDirectory()); + final Path p12Path = zipRoot.resolve("elasticsearch/" + certNames[i] + "/http.p12"); + assertThat(p12Path, isRegularFile()); + + final Path readmePath = zipRoot.resolve("elasticsearch/" + certNames[i] + "/README.txt"); + assertThat(readmePath, isRegularFile()); + final String readme = new String(Files.readAllBytes(readmePath), StandardCharsets.UTF_8); + + final Path ymlPath = zipRoot.resolve("elasticsearch/" + certNames[i] + "/sample-elasticsearch.yml"); + assertThat(ymlPath, isRegularFile()); + final String yml = new String(Files.readAllBytes(ymlPath), StandardCharsets.UTF_8); + + final Tuple certAndKey = readCertificateAndKey(p12Path, password.toCharArray()); + + // Verify the Cert was built correctly + verifyCertificate(certAndKey.v1(), certNames[i], certYears, Collections.singletonList(hostNames[i]), Collections.emptyList()); + verifyChain(certAndKey.v1(), caCertKey.v1()); + assertThat(getRSAKeySize(certAndKey.v1().getPublicKey()), is(HttpCertificateCommand.DEFAULT_CERT_KEY_SIZE)); + assertThat(getRSAKeySize(certAndKey.v2()), is(HttpCertificateCommand.DEFAULT_CERT_KEY_SIZE)); + + // Verify the README + assertThat(readme, containsString(p12Path.getFileName().toString())); + assertThat(readme, containsString(ymlPath.getFileName().toString())); + assertThat(readme, not(containsString(password))); + assertThat(readme, not(containsString(caPassword))); + + // Verify the yml + assertThat(yml, containsString(p12Path.getFileName().toString())); + assertThat(yml, not(containsString(password))); + assertThat(yml, not(containsString(caPassword))); + } + + verifyKibanaDirectory(zipRoot, true, Collections.singletonList("2. elasticsearch-ca.pem"), + Arrays.asList(password, caPassword, caPath.getFileName().toString())); + } + + public void testParsingValidityPeriod() throws Exception { + final HttpCertificateCommand command = new HttpCertificateCommand(); + final MockTerminal terminal = new MockTerminal(); + + terminal.addTextInput("2y"); + assertThat(command.readPeriodInput(terminal, "", null, 1), is(Period.ofYears(2))); + + terminal.addTextInput("18m"); + assertThat(command.readPeriodInput(terminal, "", null, 1), is(Period.ofMonths(18))); + + terminal.addTextInput("90d"); + assertThat(command.readPeriodInput(terminal, "", null, 1), is(Period.ofDays(90))); + + terminal.addTextInput("1y, 6m"); + assertThat(command.readPeriodInput(terminal, "", null, 1), is(Period.ofYears(1).withMonths(6))); + + // Test: Re-prompt on bad input. + terminal.addTextInput("2m & 4d"); + terminal.addTextInput("2m 4d"); + assertThat(command.readPeriodInput(terminal, "", null, 1), is(Period.ofMonths(2).withDays(4))); + + terminal.addTextInput("1y, 6m"); + assertThat(command.readPeriodInput(terminal, "", null, 1), is(Period.ofYears(1).withMonths(6))); + + // Test: Accept default value + final Period p = Period.of(randomIntBetween(1, 5), randomIntBetween(0, 11), randomIntBetween(0, 30)); + terminal.addTextInput(""); + assertThat(command.readPeriodInput(terminal, "", p, 1), is(p)); + + final int y = randomIntBetween(1, 5); + final int m = randomIntBetween(1, 11); + final int d = randomIntBetween(1, 30); + terminal.addTextInput(y + "y " + m + "m " + d + "d"); + assertThat(command.readPeriodInput(terminal, "", null, 1), is(Period.of(y, m, d))); + + // Test: Minimum Days + final int shortDays = randomIntBetween(1, 20); + + terminal.addTextInput(shortDays + "d"); + terminal.addTextInput("y"); // I'm sure + assertThat(command.readPeriodInput(terminal, "", null, 21), is(Period.ofDays(shortDays))); + + terminal.addTextInput(shortDays + "d"); + terminal.addTextInput("n"); // I'm not sure + terminal.addTextInput("30d"); + assertThat(command.readPeriodInput(terminal, "", null, 21), is(Period.ofDays(30))); + + terminal.addTextInput("2m"); + terminal.addTextInput("n"); // I'm not sure + terminal.addTextInput("2y"); + assertThat(command.readPeriodInput(terminal, "", null, 90), is(Period.ofYears(2))); + } + + public void testValidityPeriodToString() throws Exception { + assertThat(HttpCertificateCommand.toString(Period.ofYears(2)), is("2y")); + assertThat(HttpCertificateCommand.toString(Period.ofMonths(5)), is("5m")); + assertThat(HttpCertificateCommand.toString(Period.ofDays(60)), is("60d")); + assertThat(HttpCertificateCommand.toString(Period.ZERO), is("0d")); + assertThat(HttpCertificateCommand.toString(null), is("N/A")); + + final int y = randomIntBetween(1, 5); + final int m = randomIntBetween(1, 11); + final int d = randomIntBetween(1, 30); + assertThat(HttpCertificateCommand.toString(Period.of(y, m, d)), is(y + "y," + m + "m," + d + "d")); + } + + public void testGuessFileType() throws Exception { + MockTerminal terminal = new MockTerminal(); + + final Path caCert = getDataPath("ca.crt"); + final Path caKey = getDataPath("ca.key"); + assertThat(guessFileType(caCert, terminal), is(FileType.PEM_CERT)); + assertThat(guessFileType(caKey, terminal), is(FileType.PEM_KEY)); + + final Path certChain = testRoot.resolve("ca.pem"); + try (OutputStream out = Files.newOutputStream(certChain)) { + Files.copy(getDataPath("testnode.crt"), out); + Files.copy(caCert, out); + } + assertThat(guessFileType(certChain, terminal), is(FileType.PEM_CERT_CHAIN)); + + final Path tmpP12 = testRoot.resolve("tmp.p12"); + assertThat(guessFileType(tmpP12, terminal), is(FileType.PKCS12)); + final Path tmpJks = testRoot.resolve("tmp.jks"); + assertThat(guessFileType(tmpJks, terminal), is(FileType.JKS)); + + final Path tmpKeystore = testRoot.resolve("tmp.keystore"); + writeDummyKeystore(tmpKeystore, "PKCS12"); + assertThat(guessFileType(tmpKeystore, terminal), is(FileType.PKCS12)); + writeDummyKeystore(tmpKeystore, "jks"); + assertThat(guessFileType(tmpKeystore, terminal), is(FileType.JKS)); + } + + public void testTextFileSubstitutions() throws Exception { + CheckedBiFunction, String, Exception> copy = (source, subs) -> { + try (InputStream in = new ByteArrayInputStream(source.getBytes(StandardCharsets.UTF_8)); + StringWriter out = new StringWriter(); + PrintWriter writer = new PrintWriter(out)) { + HttpCertificateCommand.copyWithSubstitutions(in, writer, subs); + return out.toString(); + } + }; + assertThat(copy.apply("abc\n", Collections.emptyMap()), is("abc\n")); + assertThat(copy.apply("${not_a_var}\n", Collections.emptyMap()), is("${not_a_var}\n")); + assertThat(copy.apply("${var}\n", singletonMap("var", "xyz")), is("xyz\n")); + assertThat(copy.apply("#if not\nbody\n#endif\n", Collections.emptyMap()), is("")); + assertThat(copy.apply("#if blank\nbody\n#endif\n", singletonMap("blank", "")), is("")); + assertThat(copy.apply("#if yes\nbody\n#endif\n", singletonMap("yes", "true")), is("body\n")); + assertThat(copy.apply("#if yes\ntrue\n#else\nfalse\n#endif\n", singletonMap("yes", "*")), is("true\n")); + assertThat(copy.apply("#if blank\ntrue\n#else\nfalse\n#endif\n", singletonMap("blank", "")), is("false\n")); + assertThat(copy.apply("#if var\n--> ${var} <--\n#else\n(${var})\n#endif\n", singletonMap("var", "foo")), is("--> foo <--\n")); + } + + private Path getZipRoot(Path outFile) throws IOException, URISyntaxException { + assertThat(outFile, isRegularFile()); + + FileSystem fileSystem = FileSystems.newFileSystem(new URI("jar:" + outFile.toUri()), Collections.emptyMap()); + return fileSystem.getPath("/"); + } + + private List randomIpAddresses() throws UnknownHostException { + final int ipCount = randomIntBetween(0, 3); + final List ipAddresses = new ArrayList<>(ipCount); + for (int i = 0; i < ipCount; i++) { + String ip = randomIpAddress(); + ipAddresses.add(ip); + } + return ipAddresses; + } + + private String randomIpAddress() throws UnknownHostException { + return formatIpAddress(randomByteArrayOfLength(4)); + } + + private String formatIpAddress(byte[] addr) throws UnknownHostException { + return NetworkAddress.format(InetAddress.getByAddress(addr)); + } + + private List randomHostNames() { + final int hostCount = randomIntBetween(1, 5); + final List hostNames = new ArrayList<>(hostCount); + for (int i = 0; i < hostCount; i++) { + String host = String.join(".", randomArray(1, 4, String[]::new, () -> randomAlphaOfLengthBetween(3, 8))); + if (i > 0 && randomBoolean()) { + host = "*." + host; + } + hostNames.add(host); + } + return hostNames; + } + + private String randomPassword() { + // We want to assert that this password doesn't end up in any output files, so we need to make sure we + // don't randomly generate a real word. + return randomAlphaOfLength(4) + randomFrom('~', '*', '%', '$', '|') + randomAlphaOfLength(4); + } + + private void verifyCertificationRequest(PKCS10CertificationRequest csr, String certificateName, List hostNames, + List ipAddresses) throws IOException { + // We rebuild the DN from the encoding because BC uses openSSL style toString, but we use LDAP style. + assertThat(new X500Principal(csr.getSubject().getEncoded()).toString(), is("CN=" + certificateName.replaceAll("\\.", ", DC="))); + final Attribute[] extensionAttributes = csr.getAttributes(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest); + assertThat(extensionAttributes, arrayWithSize(1)); + assertThat(extensionAttributes[0].getAttributeValues(), arrayWithSize(1)); + assertThat(extensionAttributes[0].getAttributeValues()[0], instanceOf(DERSequence.class)); + + // We register 1 extension - the subject alternative names + final Extensions extensions = Extensions.getInstance(extensionAttributes[0].getAttributeValues()[0]); + assertThat(extensions, notNullValue()); + final GeneralNames names = GeneralNames.fromExtensions(extensions, Extension.subjectAlternativeName); + assertThat(names.getNames(), arrayWithSize(hostNames.size() + ipAddresses.size())); + for (GeneralName name : names.getNames()) { + assertThat(name.getTagNo(), oneOf(GeneralName.dNSName, GeneralName.iPAddress)); + if (name.getTagNo() == GeneralName.dNSName) { + final String dns = DERIA5String.getInstance(name.getName()).getString(); + assertThat(dns, in(hostNames)); + } else if (name.getTagNo() == GeneralName.iPAddress) { + final String ip = formatIpAddress(DEROctetString.getInstance(name.getName()).getOctets()); + assertThat(ip, in(ipAddresses)); + } + } + } + + private void verifyCertificate(X509Certificate cert, String certificateName, int years, + List hostNames, List ipAddresses) throws CertificateParsingException { + assertThat(cert.getSubjectX500Principal().toString(), is("CN=" + certificateName.replaceAll("\\.", ", DC="))); + final Collection> san = cert.getSubjectAlternativeNames(); + final int expectedSanEntries = hostNames.size() + ipAddresses.size(); + if (expectedSanEntries > 0) { + assertThat(san, hasSize(expectedSanEntries)); + for (List name : san) { + assertThat(name, hasSize(2)); + assertThat(name.get(0), Matchers.instanceOf(Integer.class)); + assertThat(name.get(1), Matchers.instanceOf(String.class)); + final Integer tag = (Integer) name.get(0); + final String value = (String) name.get(1); + assertThat(tag, oneOf(GeneralName.dNSName, GeneralName.iPAddress)); + if (tag.intValue() == GeneralName.dNSName) { + assertThat(value, in(hostNames)); + } else if (tag.intValue() == GeneralName.iPAddress) { + assertThat(value, in(ipAddresses)); + } + } + } else if (san != null) { + assertThat(san, hasSize(0)); + } + + // We don't know exactly when the certificate was generated, but it should have been in the last 10 minutes + long now = System.currentTimeMillis(); + long nowMinus10Minutes = now - TimeUnit.MINUTES.toMillis(10); + assertThat(cert.getNotBefore().getTime(), Matchers.lessThanOrEqualTo(now)); + assertThat(cert.getNotBefore().getTime(), Matchers.greaterThanOrEqualTo(nowMinus10Minutes)); + + final ZonedDateTime expiry = Instant.ofEpochMilli(cert.getNotBefore().getTime()).atZone(ZoneOffset.UTC).plusYears(years); + assertThat(cert.getNotAfter().getTime(), is(expiry.toInstant().toEpochMilli())); + } + + private void verifyChain(X509Certificate... chain) throws GeneralSecurityException { + for (int i = 1; i < chain.length; i++) { + assertThat(chain[i - 1].getIssuerX500Principal(), is(chain[i].getSubjectX500Principal())); + chain[i - 1].verify(chain[i].getPublicKey()); + } + final X509Certificate root = chain[chain.length - 1]; + assertThat(root.getIssuerX500Principal(), is(root.getSubjectX500Principal())); + } + + /** + * Checks that a public + private key are a matching pair. + */ + private void assertMatchingPair(PublicKey publicKey, PrivateKey privateKey) throws GeneralSecurityException { + final byte[] bytes = randomByteArrayOfLength(128); + final Signature rsa = Signature.getInstance("SHA512withRSA"); + + rsa.initSign(privateKey); + rsa.update(bytes); + final byte[] signature = rsa.sign(); + + rsa.initVerify(publicKey); + rsa.update(bytes); + assertTrue("PublicKey and PrivateKey are not a matching pair", rsa.verify(signature)); + } + + private void verifyKibanaDirectory(Path zipRoot, boolean expectCAFile, Iterable readmeShouldContain, + Iterable shouldNotContain) throws IOException { + assertThat(zipRoot.resolve("kibana"), isDirectory()); + if (expectCAFile) { + assertThat(zipRoot.resolve("kibana/elasticsearch-ca.pem"), isRegularFile()); + } else { + assertThat(zipRoot.resolve("kibana/elasticsearch-ca.pem"), not(pathExists())); + } + + final Path kibanaReadmePath = zipRoot.resolve("kibana/README.txt"); + assertThat(kibanaReadmePath, isRegularFile()); + final String kibanaReadme = new String(Files.readAllBytes(kibanaReadmePath), StandardCharsets.UTF_8); + + final Path kibanaYmlPath = zipRoot.resolve("kibana/sample-kibana.yml"); + assertThat(kibanaYmlPath, isRegularFile()); + final String kibanaYml = new String(Files.readAllBytes(kibanaYmlPath), StandardCharsets.UTF_8); + + assertThat(kibanaReadme, containsString(kibanaYmlPath.getFileName().toString())); + assertThat(kibanaReadme, containsString("elasticsearch.hosts")); + assertThat(kibanaReadme, containsString("https://")); + assertThat(kibanaReadme, containsString("elasticsearch-ca.pem")); + readmeShouldContain.forEach(s -> assertThat(kibanaReadme, containsString(s))); + shouldNotContain.forEach(s -> assertThat(kibanaReadme, not(containsString(s)))); + + assertThat(kibanaYml, containsString("elasticsearch.ssl.certificateAuthorities: [ \"config/elasticsearch-ca.pem\" ]")); + assertThat(kibanaYml, containsString("https://")); + shouldNotContain.forEach(s -> assertThat(kibanaYml, not(containsString(s)))); + } + + private PublicKey getPublicKey(PKCS10CertificationRequest pkcs) throws GeneralSecurityException { + return new JcaPKCS10CertificationRequest(pkcs).getPublicKey(); + } + + private int getRSAKeySize(Key key) { + assertThat(key, instanceOf(RSAKey.class)); + final RSAKey rsa = (RSAKey) key; + return rsa.getModulus().bitLength(); + } + + private Tuple readCertificateAndKey(Path pkcs12, + char[] password) throws IOException, GeneralSecurityException { + + final Map entries = CertParsingUtils.readPkcs12KeyPairs(pkcs12, password, alias -> password); + assertThat(entries.entrySet(), Matchers.hasSize(1)); + + Certificate cert = entries.keySet().iterator().next(); + Key key = entries.get(cert); + + assertThat(cert, instanceOf(X509Certificate.class)); + assertThat(key, instanceOf(PrivateKey.class)); + assertMatchingPair(cert.getPublicKey(), (PrivateKey) key); + return new Tuple<>((X509Certificate) cert, (PrivateKey) key); + } + + private X509Certificate readPemCertificate(Path caCertPath) throws CertificateException, IOException { + final Certificate[] certificates = CertParsingUtils.readCertificates(Collections.singletonList(caCertPath)); + assertThat(certificates, arrayWithSize(1)); + final Certificate cert = certificates[0]; + assertThat(cert, instanceOf(X509Certificate.class)); + return (X509Certificate) cert; + } + + private T readPemObject(Path path, String expectedType, + CheckedFunction factory) throws IOException { + assertThat(path, isRegularFile()); + final PemReader csrReader = new PemReader(Files.newBufferedReader(path)); + final PemObject csrPem = csrReader.readPemObject(); + assertThat(csrPem.getType(), is(expectedType)); + return factory.apply(csrPem.getContent()); + } + + private void writeDummyKeystore(Path path, String type) throws GeneralSecurityException, IOException { + Files.deleteIfExists(path); + KeyStore ks = KeyStore.getInstance(type); + ks.load(null); + if (randomBoolean()) { + final X509Certificate cert = readPemCertificate(getDataPath("ca.crt")); + ks.setCertificateEntry(randomAlphaOfLength(4), cert); + } + try (OutputStream out = Files.newOutputStream(path)) { + ks.store(out, randomAlphaOfLength(8).toCharArray()); + } + } + + private Environment newEnvironment() { + return TestEnvironment.newEnvironment(super.buildEnvSettings(Settings.EMPTY)); + } + + /** + * A special version of {@link HttpCertificateCommand} that can resolve input strings back to JIMFS paths + */ + private class PathAwareHttpCertificateCommand extends HttpCertificateCommand { + + final Map paths; + + PathAwareHttpCertificateCommand(Path... configuredPaths) { + paths = Stream.of(configuredPaths).collect(Collectors.toMap(Path::toString, Function.identity())); + } + + @Override + protected Path resolvePath(String name) { + return Optional.ofNullable(this.paths.get(name)).orElseGet(() -> super.resolvePath(name)); + } + } + +} diff --git a/x-pack/plugin/security/cli/src/test/java/org/elasticsearch/xpack/security/cli/PemToKeystore.java b/x-pack/plugin/security/cli/src/test/java/org/elasticsearch/xpack/security/cli/PemToKeystore.java new file mode 100644 index 00000000000..5a946ae3d05 --- /dev/null +++ b/x-pack/plugin/security/cli/src/test/java/org/elasticsearch/xpack/security/cli/PemToKeystore.java @@ -0,0 +1,52 @@ +/* + * 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.security.cli; + +import org.elasticsearch.cli.SuppressForbidden; +import org.elasticsearch.xpack.core.ssl.CertParsingUtils; +import org.elasticsearch.xpack.core.ssl.PemUtils; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.util.Collections; + +@SuppressForbidden(reason = "CLI utility for testing only") +public class PemToKeystore { + + public static void main(String[] args) throws IOException, GeneralSecurityException { + if (args.length != 5) { + System.out.println("Usage: " + PemToKeystore.class.getName() + " "); + return; + } + Path keystorePath = Paths.get(args[0]).toAbsolutePath(); + String keystoreType = args[1]; + Path certPath = Paths.get(args[2]).toAbsolutePath(); + Path keyPath = Paths.get(args[3]).toAbsolutePath(); + char[] password = args[4].toCharArray(); + + final Certificate[] certificates = CertParsingUtils.readCertificates(Collections.singletonList(certPath)); + if (certificates.length == 0) { + throw new IllegalArgumentException("No certificates found in " + certPath); + } + final PrivateKey key = PemUtils.readPrivateKey(keyPath, () -> password); + + KeyStore keyStore = KeyStore.getInstance(keystoreType); + keyStore.load(null); + keyStore.setKeyEntry("key", key, password, certificates); + try (OutputStream out = Files.newOutputStream(keystorePath)) { + keyStore.store(out, password); + } + } + +} diff --git a/x-pack/plugin/security/cli/src/test/resources/org/elasticsearch/xpack/security/cli/ca.crt b/x-pack/plugin/security/cli/src/test/resources/org/elasticsearch/xpack/security/cli/ca.crt new file mode 100644 index 00000000000..111cf4d2af5 --- /dev/null +++ b/x-pack/plugin/security/cli/src/test/resources/org/elasticsearch/xpack/security/cli/ca.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDSTCCAjGgAwIBAgIUe3y1qDBsjh2w16BBfPQjg5bAgjYwDQYJKoZIhvcNAQEL +BQAwNDEyMDAGA1UEAxMpRWxhc3RpYyBDZXJ0aWZpY2F0ZSBUb29sIEF1dG9nZW5l +cmF0ZWQgQ0EwHhcNMTkxMjAxMTEzNTUwWhcNMzMwODA5MTEzNTUwWjA0MTIwMAYD +VQQDEylFbGFzdGljIENlcnRpZmljYXRlIFRvb2wgQXV0b2dlbmVyYXRlZCBDQTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJdB/0UGumX8QsWAnhnadnza +HsE0WMB50j6uHgqNh/QieIw7iQGmhbwG2V+O7263j74+YOUcrjvEuR3el1+cjJIU +SP0Zl9wV2cWdltW3N/GhvU4QVnJS13w146yB3JEQROsD/hdtGP6vBGjzpjIcmKPa +pSOqJEzG113CYX260FQK86o/9kAk07kce4sx8RW+Xda/e2eLF5siIH7/7eju9OiF +RvQC1bABj0UpccuWwJWjIr93v5egTmQFHuX/Tlq44hhCKFa+0xh+LxdiAlbaeUGG +e3sd1I20veMJAOTftGCOx6Psatcw0P2+FGsliQh8MIMwkcBwkxauuUNvWZpAd+UC +AwEAAaNTMFEwHQYDVR0OBBYEFB1nLSbpN2TgSed4DBuhpwvC1CNqMB8GA1UdIwQY +MBaAFB1nLSbpN2TgSed4DBuhpwvC1CNqMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADggEBABPMW7mpIi8VyA2QwwBnUo8a3OdHdospwoHm5GIbxpOHILq/ +LWosNiJL/gFDVldt6nubIz/rkVqHjUf5H9DeMtlKdPCYJVZ1Cu9peEG3qPVhnTf6 +G6qwt7a6/a1/AwWIc+2EaC9t6VgqVN+Dbn4dH6uI0m+ZwfsTAVCCn4tQYf9/QcRw +YHyl/C8nlWP0YwH0NDYug+J+MRewTpU+BYZPswH99HG954ZVylK00ZlQbeD6hj2w +T/P8sHl9U2vkRiGeLDhP2ygI4glXFNU5VJQGqv2GWxo9XTHCkAjGovzU8D1wYdfX +dWXUwN+qtcVdX3Ur/MowjzRumc6uWZjqEm12Vu4= +-----END CERTIFICATE----- diff --git a/x-pack/plugin/security/cli/src/test/resources/org/elasticsearch/xpack/security/cli/ca.key b/x-pack/plugin/security/cli/src/test/resources/org/elasticsearch/xpack/security/cli/ca.key new file mode 100644 index 00000000000..08ae669ce99 --- /dev/null +++ b/x-pack/plugin/security/cli/src/test/resources/org/elasticsearch/xpack/security/cli/ca.key @@ -0,0 +1,30 @@ +-----BEGIN RSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: DES-EDE3-CBC,A4FFEDABB4598123 + +Dx7CtxcKyx2clKjaj/c66anbFznGXnueMkWk4FLh9nYiSQmiTqzaE/ajSzH1Fdxm +1/xBvbfFsUT6bdiGkxPEdGpgBCLnsebgjbQWv3lB0wtquhQkTfd8HoyShInnsPAb +Hu5DSHkjvjIABl1MUyHiaZVskBEj/vYKsMil2GinrpWUxwOqg0HKyrKlzb/I7Gph +Hc1NuzbRobtmQgi/JLVnOeVEIJFt3ekVQnEYQxM5+ZUsP85M6WoPOa7q8soGGLnO +OFZ20kihb20/5xaA9SUadpYWFLSwZQYN2471MXj1uWz7mEJjei81mIjdTOem486P +uIqNY1BBHzljjTq+r2mO/RKer5PRR+pbI+cNkRyESQZqitOHWWWPwXSo3K3RhhDK +gaSOSMBLv2qoYjoswafIISIMvSbcnYzNa+p1T/U62Q95STMV1ch2Ulv+20xo4nVG +3Mkr6oESB7MOcRm9XwPYZAb60MbaaFRUOagoId0AM7efLYTIpT6GXbnS0K6PPf2z +cP/LKDh3pOgzjRIAN18+nZBY7D3r6fejWsBonMPlzgEX2hBPjmOLIBgpgO3/Kg2q ++PuSE+F53fPu3t5mxsEdPtM9yJTxfughvrNCxvaxfmajmZfHaMpta1Q2H9iEhv99 +L4nG1UtMJa9MMBPlTsJnkunvLcGQ8KfUMBHtlHwTwd5bP7vSs5aNGJKrdlOoKk3v +O5DGbpfw/UIw2t+2dnqwc1epkYvMJbFc7S9hYMYwJZ1BC3zHxRvBJTJ6LbCxulWC +SLUy/TZVsHSmRNftUJA48ioDSkA/inMziLmb/aqmWfvojiNmSJy/GkPJKyv1C9IK +zPqE+7noy32Cf9hztu933YBBNWPPz9Xh8WC4AluQY9Lg2H8NjBjFadL0Re0QzdBF +ZXEXT5otDthKqZpD5aRQGoleQcTYlIeJkODSgH+Ti7LvuiNJToG0iREyQRXpcsdj +iVBP3jYe4nurHRnozQIfIF0BzArSRi0aRi9PHnregS4gkLtbyKx27T61bB47TYXk +oIPm6qV7wWmVAklBz4+s3UXsTfyiqckdNxDDO+IyGEnEjpml/XePAy52hmGoQ9uI +BCAst7JC0VuKcnad9u/2BL3WN+tyhNQ1zA3OcuNLiMT3mgAghadQq2hBiO2y2cT7 +b9OZLYwA4zLEzacIvo/0X1XtjiRANgZoUaMluyF5yVgnk9X9MmixBOT0pENV8GYx +WbN0xDZPPigynnQTapnLgzOzci/MQZzuWfh1wvnkiKL7y8TXGtl6AvMtYX85yrUE +Fakpleb8clKbSX2RQYlS/7+muO68e/m+svKaIS6ZupAlmu5rhlDsZAK6if+AEPpz +C4AGsV7R9aDn+TZ+Zt+cxd7s+L8rexoMthblCprv3PwCSZ75Q52iLZeajfMhcI9A +KWEra9QFT8kvIX2yuYFItuc9NL8s15zqNcaeUMyiw6gL28yBd7aQLbMj/zADOQGg +qsb5QypRsxV/neh63I7PIQfIsFOJhM3+h9xAFK48nQzc39S7b2SMYdKPOfmEOFLi +ln/q63+Bobl5EotOxb9gsQ0nWmKpQqFHsMzYQSwcJg+gGeBXy6RwIw== +-----END RSA PRIVATE KEY----- diff --git a/x-pack/plugin/security/cli/src/test/resources/org/elasticsearch/xpack/security/cli/ca.p12 b/x-pack/plugin/security/cli/src/test/resources/org/elasticsearch/xpack/security/cli/ca.p12 new file mode 100644 index 0000000000000000000000000000000000000000..02114990b417f2e161be0f080ba5091daec07d90 GIT binary patch literal 2529 zcmY+EXFMB<8pe}ILhKQn+A-TCVwGy`5~?~;v_=WF$5E;^LX1{x&lstqQEFDv8a-Ac z#H`w*NKxY!RYd7^@9*Aoe&@sU<^R0z^YM?u!)8E0CKMi)356-en#S&N0GWX#JS+~3 zhee;@2oxUT@{bB~9gK%qonVWTm_ph9_r=Z%1d;IILlhpohf;*H{vV%=i-O@%!e;bi z!sG1pf6d4jSN__<@Il!@On?q6Fdj@Xzags@d6qD`<)X(V+y7Wr);^yUuP_m2-e9IQ zan_{%C~mr~a&v*!tPmNvdZ#VyXKU)k4wjWNW5G~O*zVZ;+(7q@Hm@L`B!gPftPY9H z!0efcoH|dPgn*sO68DuQxB)4A`A9vIWzVRbofhrTmR?>;F|8F$tNk&nsO-d)a`-`~ za2*n*PP@>vMNktJ5YH$#>xnxamlHfz_=ANO#Ybmcd+@_y)cTzX0Kh)wcCh64bg=w& z@x5N3e1?69Xj9r6lV73Hyd!o&#n(pVSB%{YZzq#e-#D*+d2$giL?wX~ zyI^m0v6+*!WFRtED)*cdC9m*I9%MvG9Z?T<%?$D&g@>17Dm-_Fy~X>xJWOp`v=Sw* z9aKD_npI{aiO~q`FY5K1ZGwUNT3ay0PNaKy8CI-b-itWghf6f8+Ycz>I?v!zN>QQ4D%`4bVXoSX&<5zWV zFjbFbgh;=fZv3KBOclb0#l(^X`CE_jn!Fpk}2smLUHHC|Bb zeBBgH_l=MPTyxiw8V9qPmY5B8zW>Z)6n=@)IDD%Pvz2_#xS`Sn8STv5;ir3aO6aY* zxY1iXK}o4cO4%O~o{K>4@cZHjSKU}n{Z&!l+H3U&SBeSU+*+?AM=Gmv~Z%%|nXPev5nQYyaHs-bLzo(moFiQgyC{A4N>wi*mc4~~=d(a}`cEuiqxRDs6-kmAz2jEhGQcE; zeD#)T8(HHgncR8*xu>OL=W??j#QjZ8CMo$_^me$?XaA?hFl(~xIKkDl6Q=8>8Mei1 z_85uoFCV&q3Xuy5g@^J`HCm=S790OmG*po)tWHTnwX-&8aU|OY6`&y>s|=txZtc1l zeJi}Gc+VWLh1ir^M6Om(44y|DJ=9%NS;fj)aNXPHg?a5t4Txprk5=fXY zVbpph7U7`z+0P(f<5EAe$Vk8uiY#pAxnbQ_Vf*&e^1;a9xK8?9k4cchy*K{3sUi+; zF&^loI+=nkZ#X? z{Q2IqAF?qYNpDh0?%Aj^YB|MO7u9oG-tF}nK9qQXt+p)Iy2dlyu$D#PHGY^FLx-TX zQT%_Y5(Y=JoPq*80j_{hl+@pt9}NfdUia{Jl|-v+s$(!(7%dHq8u~<=@V}0jp(iGV zoFE_w2teVP7=Jgwf4CL;AGiJ%vD0L0Ino!=RgR1*^H-nD+}-}KTO;vIA8bc^SR^F) zEPA`mW;sY)NjMlf1zPf!x<8XGq(xae%n>zPw=qhV;a#JXSg-3Men^m>2ZkAwvHW^j9buRfcGa9qXj<>jT6id&q!BiT z;TU)*d>CP^{czuojbep$_9GnOk+>g_De=1gpIYHZDZsHajwv~w)%LsWqzII9<34^x zc44GEtj@{3u=~!>Ti@|V_AY4!^xzK%VzFF?53qeL-svVjBmP^rWdzJOgzSH|?+mr2 z^T+^15y|1v8e&H|ZG;_MwxE^_$NU~~e8F*bF8@NSxRIG9hFSoA0~$}3>-1IIh7#=$ zf^H9YzMDFKJU_047tX-+yXic@=kJ~Fq7j@+&T6Sy+zGJ38O&I(4mGYKuL(4lKN3gF zPdeBJ%=+!uYM*}DbZl--@@1Xyk_!VaT@NCC*ImZx8&|cMxK-SnD8h4^;huEwC2pi< zk$Z=R_;^i+S7-x^K4PG+ze6bE#ui)=hlfY49xQ3+75FC9+^Bh<~E13K8Q+{wy!tdUdi0{8}QUqwATOK{HGT(Asqec+4yP6{$7M*sIVUC z>$|}`+4Gm^fT%f2!p1&RajSI+Zj7;=@x9Nm1?l{HxM?>pudH|H-jhDyt18OKjpIC< z{wmPsWDeQTgrUf6mMfAEW<0&?OLSX9N*6|4c|kpJnxsu9KJn^E!IpKEwY{}8XuSNh z%TqFz{muKDu8r#+&ANU$iP{)Jo+im_w||W0{ww~AsxxBEUcN}WeHK*u!K0cGDDIoW z@>%+#=*Qg01oEWzWE8*%{(C#Lef1WCzir>ZSo!8RrC}D`0Gs0A)s%;&CJp9&IVMHD z54!I#Vu!0L7Cr^+7_Cf+_HLFz;nX3|2`d$aWD%$6Iezt$%A_TpC8B|@*DpSZm#=6X zH>P|S@giihkaa9ZPM@c@rB!dyFf}{Ek-NlBT)4G)BoEiTITl