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 30b75aadf5fbb..bba67d6809abe 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 @@ -120,8 +120,20 @@ public static List readCertificates(InputStream input) throws Certi * 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 0000000000000..a18e1bab94123 --- /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 452805c859838..c1a290f509a58 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 @@ 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 82dda5ffa3998..116f089d70b46 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' +} + if (BuildParams.inFipsJvm) { test.enabled = false testingConventions.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 bc2b27df58047..876fcfbf992fd 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 @@ private static X509Certificate generateSignedCertificate(X500Principal principal 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 e3a0f4e7112c4..4ae1f313ce152 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 static void main(String[] args) throws Exception { subcommands.put("csr", new SigningRequestCommand()); subcommands.put("cert", new GenerateCertificateCommand()); subcommands.put("ca", new CertificateAuthorityCommand()); + subcommands.put("http", new HttpCertificateCommand()); } @@ -920,7 +921,7 @@ static Collection parseFile(Path file) throws Exception } } - 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 @@ private static PrivateKey readPrivateKey(Path path, char[] password, Terminal te } } - 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 @@ private static GeneralNames getSubjectAlternativeNamesValue(List ipAddre 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 0000000000000..604482ffb6f77 --- /dev/null +++ b/x-pack/plugin/security/cli/src/main/java/org/elasticsearch/xpack/security/cli/HttpCertificateCommand.java @@ -0,0 +1,1201 @@ +/* + * 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.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.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; + +/** + * 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 = CertificateTool.getSubjectAlternativeNamesValue(cert.ipNames, cert.dnsNames, List.of()); + 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, Map.ofEntries( + Map.entry("CSR", csrFile), + Map.entry("KEY", keyFile), + Map.entry("CERT", certName), + Map.entry("YML", ymlFile), + Map.entry("PASSWORD", hasPassword ? "*" : ""))); + 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, Map.ofEntries( + Map.entry("P12", p12Name), + Map.entry("YML", ymlFile), + Map.entry("PASSWORD", hasPassword ? "*" : ""))); + 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, Map.of( + "P12", "ca.p12", + "DN", ca.certAndKey.cert.getSubjectX500Principal().getName(), + "PASSWORD", ca.password == null || ca.password.length == 0 ? "" : "*" + ))); + 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, Map.ofEntries( + Map.entry("CA_CERT_NAME", caCertName), + Map.entry("CA_CERT", caCert), + Map.entry("YML", ymlFile) + )); + + // 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); + PrintWriter writer = new PrintWriter(entry, false, StandardCharsets.UTF_8)) { + 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() + .or(() -> 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(List.of(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 0000000000000..dcd9c7c189811 --- /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 0000000000000..d6e1fce20275a --- /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 0000000000000..06f435116e3a0 --- /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 0000000000000..9ce1624430ad8 --- /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 0000000000000..7658f56b47ddc --- /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 0000000000000..28d308b10a661 --- /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 0000000000000..78a92782bdd1d --- /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 0000000000000..891c4257666c1 --- /dev/null +++ b/x-pack/plugin/security/cli/src/test/java/org/elasticsearch/xpack/security/cli/HttpCertificateCommandTests.java @@ -0,0 +1,781 @@ +/* + * 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.core.internal.io.IOUtils; +import org.elasticsearch.env.Environment; +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.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 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 = Files.readString(esReadmePath); + + final Path ymlPath = zipRoot.resolve("elasticsearch/sample-elasticsearch.yml"); + assertThat(ymlPath, isRegularFile()); + final String yml = Files.readString(ymlPath); + + // 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, List.of("Certificate Signing Request"), List.of(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 = Files.readString(readmePath); + + final Path ymlPath = zipRoot.resolve("elasticsearch/sample-elasticsearch.yml"); + assertThat(ymlPath, isRegularFile()); + final String yml = Files.readString(ymlPath); + + 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, List.of("2. elasticsearch-ca.pem"), + List.of(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, List.of(), List.of()); + 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 = Files.readString(readmePath); + + final Path ymlPath = zipRoot.resolve("elasticsearch/" + certNames[i] + "/sample-elasticsearch.yml"); + assertThat(ymlPath, isRegularFile()); + final String yml = Files.readString(ymlPath); + + final Tuple certAndKey = readCertificateAndKey(p12Path, password.toCharArray()); + + // Verify the Cert was built correctly + verifyCertificate(certAndKey.v1(), certNames[i], certYears, List.of(hostNames[i]), List.of()); + 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, List.of("2. elasticsearch-ca.pem"), + List.of(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", Map.of()), is("abc\n")); + assertThat(copy.apply("${not_a_var}\n", Map.of()), is("${not_a_var}\n")); + assertThat(copy.apply("${var}\n", Map.of("var", "xyz")), is("xyz\n")); + assertThat(copy.apply("#if not\nbody\n#endif\n", Map.of()), is("")); + assertThat(copy.apply("#if blank\nbody\n#endif\n", Map.of("blank", "")), is("")); + assertThat(copy.apply("#if yes\nbody\n#endif\n", Map.of("yes", "true")), is("body\n")); + assertThat(copy.apply("#if yes\ntrue\n#else\nfalse\n#endif\n", Map.of("yes", "*")), is("true\n")); + assertThat(copy.apply("#if blank\ntrue\n#else\nfalse\n#endif\n", Map.of("blank", "")), is("false\n")); + assertThat(copy.apply("#if var\n--> ${var} <--\n#else\n(${var})\n#endif\n", Map.of("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 = Files.readString(kibanaReadmePath); + + final Path kibanaYmlPath = zipRoot.resolve("kibana/sample-kibana.yml"); + assertThat(kibanaYmlPath, isRegularFile()); + final String kibanaYml = Files.readString(kibanaYmlPath); + + 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(List.of(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()); + } + } + + /** + * 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.toUnmodifiableMap(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 0000000000000..52974071dcfa8 --- /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.List; + +@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(List.of(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 0000000000000..111cf4d2af582 --- /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 0000000000000..08ae669ce9908 --- /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 0000000000000..02114990b417f Binary files /dev/null and b/x-pack/plugin/security/cli/src/test/resources/org/elasticsearch/xpack/security/cli/ca.p12 differ