From 347e5653c3e704fe1d3585917f76d7cc41ebf153 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 7 Jun 2024 10:08:26 +0200 Subject: [PATCH] CSR and certificate exchange refactored (#19545) * Datanode keystore implementation * Bind keystore check, remove legacy private key storage * added license * code cleanup, better cert signed verification * added changelog * code cleanup * CSR and certificate exchange refactored * removed jetbrains annotation * removed jetbrains annotation --- .../DataNodeConfigurationPeriodical.java | 39 +-- .../org/graylog/datanode/commands/Server.java | 5 +- .../datanode/DatanodeProvisioningIT.java | 10 + .../cert/storage/CertChainMongoStorage.java | 97 ------- .../certutil/csr/storage/CsrMongoStorage.java | 67 ----- ...ylogCertificateProvisioningPeriodical.java | 65 ++--- .../certificates/CertificateExchange.java | 39 +++ .../certificates/CertificateExchangeImpl.java | 250 ++++++++++++++++++ .../certificates/CertificateExchangeType.java | 22 ++ .../CertificateSigningRequest.java} | 15 +- .../DataNodeProvisioningBindings.java | 4 + .../storage/CertChainMongoStorageTest.java | 80 ------ .../CertificateExchangeImplTest.java | 123 +++++++++ 13 files changed, 492 insertions(+), 324 deletions(-) delete mode 100644 graylog2-server/src/main/java/org/graylog/security/certutil/cert/storage/CertChainMongoStorage.java delete mode 100644 graylog2-server/src/main/java/org/graylog/security/certutil/csr/storage/CsrMongoStorage.java create mode 100644 graylog2-server/src/main/java/org/graylog2/cluster/certificates/CertificateExchange.java create mode 100644 graylog2-server/src/main/java/org/graylog2/cluster/certificates/CertificateExchangeImpl.java create mode 100644 graylog2-server/src/main/java/org/graylog2/cluster/certificates/CertificateExchangeType.java rename graylog2-server/src/main/java/org/{graylog/security/certutil/cert/storage/CertChainStorage.java => graylog2/cluster/certificates/CertificateSigningRequest.java} (54%) delete mode 100644 graylog2-server/src/test/java/org/graylog/security/certutil/cert/storage/CertChainMongoStorageTest.java create mode 100644 graylog2-server/src/test/java/org/graylog2/cluster/certificates/CertificateExchangeImplTest.java diff --git a/data-node/src/main/java/org/graylog/datanode/bootstrap/preflight/DataNodeConfigurationPeriodical.java b/data-node/src/main/java/org/graylog/datanode/bootstrap/preflight/DataNodeConfigurationPeriodical.java index c3c53145a2..25fa70e5aa 100644 --- a/data-node/src/main/java/org/graylog/datanode/bootstrap/preflight/DataNodeConfigurationPeriodical.java +++ b/data-node/src/main/java/org/graylog/datanode/bootstrap/preflight/DataNodeConfigurationPeriodical.java @@ -19,16 +19,14 @@ package org.graylog.datanode.bootstrap.preflight; import com.google.common.collect.ImmutableList; import jakarta.inject.Inject; import jakarta.inject.Singleton; -import org.bouncycastle.operator.OperatorException; import org.bouncycastle.pkcs.PKCS10CertificationRequest; import org.graylog.datanode.configuration.DatanodeKeystore; import org.graylog.datanode.configuration.DatanodeKeystoreException; import org.graylog.security.certutil.cert.CertificateChain; -import org.graylog.security.certutil.cert.storage.CertChainMongoStorage; -import org.graylog.security.certutil.cert.storage.CertChainStorage; import org.graylog.security.certutil.csr.exceptions.CSRGenerationException; -import org.graylog.security.certutil.csr.storage.CsrMongoStorage; import org.graylog2.cluster.NodeNotFoundException; +import org.graylog2.cluster.certificates.CertificateExchange; +import org.graylog2.cluster.certificates.CertificateSigningRequest; import org.graylog2.cluster.nodes.DataNodeDto; import org.graylog2.cluster.nodes.NodeService; import org.graylog2.cluster.preflight.DataNodeProvisioningConfig; @@ -54,23 +52,20 @@ public class DataNodeConfigurationPeriodical extends Periodical { private final DataNodeProvisioningService dataNodeProvisioningService; private final NodeService nodeService; private final NodeId nodeId; - private final CsrMongoStorage csrStorage; - private final CertChainStorage certMongoStorage; + private final CertificateExchange certificateExchange; private final DatanodeKeystore datanodeKeystore; @Inject public DataNodeConfigurationPeriodical(final DataNodeProvisioningService dataNodeProvisioningService, final NodeService nodeService, final NodeId nodeId, - final CsrMongoStorage csrStorage, - final CertChainMongoStorage certMongoStorage, + CertificateExchange certificateExchange, DatanodeKeystore datanodeKeystore) { this.dataNodeProvisioningService = dataNodeProvisioningService; this.nodeService = nodeService; this.nodeId = nodeId; - this.csrStorage = csrStorage; - this.certMongoStorage = certMongoStorage; + this.certificateExchange = certificateExchange; this.datanodeKeystore = datanodeKeystore; } @@ -88,28 +83,18 @@ public class DataNodeConfigurationPeriodical extends Periodical { if (state == null) { return; } + + // always check if there are any certificates that we can accept + certificateExchange.pollCertificate(nodeId.getNodeId(), this::processCertificateChain); + switch (state) { case CONFIGURED -> writeCsr(c); - case SIGNED -> readSignedCertificate(c); case STARTUP_TRIGGER -> dataNodeProvisioningService.changeState(nodeId.getNodeId(), DataNodeProvisioningConfig.State.STARTUP_REQUESTED); } }); } - private void readSignedCertificate(DataNodeProvisioningConfig cfg) { - if (cfg.certificate() == null) { - LOG.error("Config entry in signed state, but no certificate data present in Mongo"); - } else { - try { - certMongoStorage.readCertChain(nodeId.getNodeId()) - .ifPresent(this::processCertificateChain); - } catch (Exception ex) { - LOG.error("Config entry in signed state, but wrong certificate data present in Mongo"); - } - } - } - private void processCertificateChain(CertificateChain certificateChain) { try { datanodeKeystore.replaceCertificatesInKeystore(certificateChain); @@ -127,11 +112,9 @@ public class DataNodeConfigurationPeriodical extends Periodical { .addAll(determineAltNames()) .build(); final PKCS10CertificationRequest csr = datanodeKeystore.createCertificateSigningRequest(node.getHostname(), altNames); - csrStorage.writeCsr(csr, nodeId.getNodeId()); + certificateExchange.requestCertificate(new CertificateSigningRequest(nodeId.getNodeId(), csr)); LOG.info("created CSR for this node"); - } catch (CSRGenerationException | IOException | NodeNotFoundException | OperatorException | - DatanodeKeystoreException - ex) { + } catch (CSRGenerationException | IOException | NodeNotFoundException | DatanodeKeystoreException ex) { LOG.error("error generating a CSR: " + ex.getMessage(), ex); dataNodeProvisioningService.save(cfg.asError(ex.getMessage())); } diff --git a/data-node/src/main/java/org/graylog/datanode/commands/Server.java b/data-node/src/main/java/org/graylog/datanode/commands/Server.java index 042e207640..b44b8b7128 100644 --- a/data-node/src/main/java/org/graylog/datanode/commands/Server.java +++ b/data-node/src/main/java/org/graylog/datanode/commands/Server.java @@ -25,6 +25,7 @@ import com.google.inject.Key; import com.google.inject.Module; import com.google.inject.spi.Message; import com.mongodb.MongoException; +import jakarta.inject.Inject; import org.graylog.datanode.Configuration; import org.graylog.datanode.bindings.ConfigurationModule; import org.graylog.datanode.bindings.PeriodicalBindings; @@ -34,9 +35,7 @@ import org.graylog.datanode.bootstrap.ServerBootstrap; import org.graylog.datanode.configuration.S3RepositoryConfiguration; import org.graylog.datanode.rest.RestBindings; import org.graylog.datanode.shutdown.GracefulShutdown; -import org.graylog.plugins.map.config.GeoIpProcessorConfig; import org.graylog2.bindings.MongoDBModule; -import org.graylog2.bindings.PasswordAlgorithmBindings; import org.graylog2.cluster.nodes.DataNodeDto; import org.graylog2.cluster.nodes.DataNodeStatus; import org.graylog2.cluster.nodes.NodeService; @@ -53,8 +52,6 @@ import org.graylog2.shared.system.activities.ActivityWriter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import jakarta.inject.Inject; - import java.util.Arrays; import java.util.Collection; import java.util.List; diff --git a/full-backend-tests/src/test/java/org/graylog/datanode/DatanodeProvisioningIT.java b/full-backend-tests/src/test/java/org/graylog/datanode/DatanodeProvisioningIT.java index d785ae2db2..a1fb89d775 100644 --- a/full-backend-tests/src/test/java/org/graylog/datanode/DatanodeProvisioningIT.java +++ b/full-backend-tests/src/test/java/org/graylog/datanode/DatanodeProvisioningIT.java @@ -18,7 +18,9 @@ package org.graylog.datanode; import com.fasterxml.jackson.annotation.JsonProperty; import com.github.joschi.jadconfig.util.Duration; +import com.github.rholder.retry.Attempt; import com.github.rholder.retry.RetryException; +import com.github.rholder.retry.RetryListener; import com.github.rholder.retry.RetryerBuilder; import com.github.rholder.retry.StopStrategies; import com.github.rholder.retry.WaitStrategies; @@ -130,6 +132,14 @@ public class DatanodeProvisioningIT { connectedDatanodes = RetryerBuilder.>newBuilder() .withWaitStrategy(WaitStrategies.fixedWait(1, TimeUnit.SECONDS)) .withStopStrategy(StopStrategies.stopAfterAttempt(60)) + .withRetryListener(new RetryListener() { + @Override + public void onRetry(Attempt attempt) { + if(attempt.hasResult()) { + log.info(String.valueOf(attempt.getResult())); + } + } + }) .retryIfResult(list -> list.isEmpty() || !list.stream().allMatch(node -> node.status().equals(DataNodeProvisioningConfig.State.CONNECTED.name()) && node.dataNodeStatus().equals(DataNodeStatus.AVAILABLE.name()) diff --git a/graylog2-server/src/main/java/org/graylog/security/certutil/cert/storage/CertChainMongoStorage.java b/graylog2-server/src/main/java/org/graylog/security/certutil/cert/storage/CertChainMongoStorage.java deleted file mode 100644 index 441e9e44af..0000000000 --- a/graylog2-server/src/main/java/org/graylog/security/certutil/cert/storage/CertChainMongoStorage.java +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright (C) 2020 Graylog, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Server Side Public License, version 1, - * as published by MongoDB, Inc. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * Server Side Public License for more details. - * - * You should have received a copy of the Server Side Public License - * along with this program. If not, see - * . - */ -package org.graylog.security.certutil.cert.storage; - -import org.bouncycastle.cert.X509CertificateHolder; -import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; -import org.bouncycastle.openssl.PEMParser; -import org.bouncycastle.openssl.jcajce.JcaPEMWriter; -import org.bouncycastle.operator.OperatorCreationException; -import org.graylog.security.certutil.cert.CertificateChain; -import org.graylog2.cluster.preflight.DataNodeProvisioningService; - -import jakarta.inject.Inject; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.Reader; -import java.io.StringReader; -import java.io.StringWriter; -import java.security.GeneralSecurityException; -import java.security.cert.Certificate; -import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; -import java.util.LinkedList; -import java.util.List; -import java.util.Optional; - -public class CertChainMongoStorage implements CertChainStorage { - - private DataNodeProvisioningService mongoService; - - @Inject - public CertChainMongoStorage(final DataNodeProvisioningService mongoService) { - this.mongoService = mongoService; - } - - @Override - public void writeCertChain(final CertificateChain certChain, - final String nodeId) throws IOException, OperatorCreationException { - var writer = new StringWriter(); - try (JcaPEMWriter jcaPEMWriter = new JcaPEMWriter(writer)) { - for (Certificate cert : certChain.toCertificateChainArray()) { - jcaPEMWriter.writeObject(cert); - } - - } - mongoService.writeCert(nodeId, writer.toString()); - } - - @Override - public Optional readCertChain(final String nodeId) throws IOException, GeneralSecurityException { - final Optional certAsString = mongoService.readCert(nodeId); - - if (certAsString.isPresent()) { - Reader pemReader = new BufferedReader(new StringReader(certAsString.get())); - PEMParser pemParser = new PEMParser(pemReader); - List caCerts = new LinkedList<>(); - X509Certificate signedCert = readSingleCert(pemParser); - - if (signedCert != null) { - X509Certificate caCert = readSingleCert(pemParser); - while (caCert != null) { - caCerts.add(caCert); - caCert = readSingleCert(pemParser); - } - return Optional.of(new CertificateChain(signedCert, caCerts)); - } - } - return Optional.empty(); - } - - private X509Certificate readSingleCert(PEMParser pemParser) throws IOException, CertificateException { - var parsedObj = pemParser.readObject(); - if (parsedObj instanceof X509Certificate) { - return (X509Certificate) parsedObj; - } else if (parsedObj instanceof X509CertificateHolder) { - return new JcaX509CertificateConverter().getCertificate( - (X509CertificateHolder) parsedObj - ); - } - return null; - } -} diff --git a/graylog2-server/src/main/java/org/graylog/security/certutil/csr/storage/CsrMongoStorage.java b/graylog2-server/src/main/java/org/graylog/security/certutil/csr/storage/CsrMongoStorage.java deleted file mode 100644 index 9914edebb5..0000000000 --- a/graylog2-server/src/main/java/org/graylog/security/certutil/csr/storage/CsrMongoStorage.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (C) 2020 Graylog, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Server Side Public License, version 1, - * as published by MongoDB, Inc. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * Server Side Public License for more details. - * - * You should have received a copy of the Server Side Public License - * along with this program. If not, see - * . - */ -package org.graylog.security.certutil.csr.storage; - -import org.bouncycastle.openssl.PEMParser; -import org.bouncycastle.openssl.jcajce.JcaPEMWriter; -import org.bouncycastle.operator.OperatorException; -import org.bouncycastle.pkcs.PKCS10CertificationRequest; -import org.graylog2.cluster.preflight.DataNodeProvisioningService; - -import jakarta.inject.Inject; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.StringReader; -import java.io.StringWriter; -import java.util.Optional; - -public class CsrMongoStorage { - - private DataNodeProvisioningService mongoService; - - @Inject - public CsrMongoStorage(final DataNodeProvisioningService mongoService) { - this.mongoService = mongoService; - } - - public void writeCsr(PKCS10CertificationRequest csr, String nodeId) throws IOException, OperatorException { - try (StringWriter writer = new StringWriter()) { - try (JcaPEMWriter jcaPEMWriter = new JcaPEMWriter(writer)) { - jcaPEMWriter.writeObject(csr); - } - mongoService.writeCsr(nodeId, writer.toString()); - } - } - - - public Optional readCsr(String nodeId) throws IOException { - final var nodeCsr = mongoService.getPreflightConfigFor(nodeId) - .flatMap(cfg -> Optional.ofNullable(cfg.csr())); - if (nodeCsr.isEmpty()) { - return Optional.empty(); - } - final var csr = nodeCsr.get(); - final var pemReader = new BufferedReader(new StringReader(csr)); - final var pemParser = new PEMParser(pemReader); - final var parsedObj = pemParser.readObject(); - if (parsedObj instanceof PKCS10CertificationRequest) { - return Optional.of((PKCS10CertificationRequest) parsedObj); - } - return Optional.empty(); - } -} diff --git a/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/GraylogCertificateProvisioningPeriodical.java b/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/GraylogCertificateProvisioningPeriodical.java index 64216f9824..001d065f7b 100644 --- a/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/GraylogCertificateProvisioningPeriodical.java +++ b/graylog2-server/src/main/java/org/graylog2/bootstrap/preflight/GraylogCertificateProvisioningPeriodical.java @@ -35,11 +35,10 @@ import org.graylog.security.certutil.CaConfiguration; import org.graylog.security.certutil.CaService; import org.graylog.security.certutil.ca.exceptions.KeyStoreStorageException; import org.graylog.security.certutil.cert.CertificateChain; -import org.graylog.security.certutil.cert.storage.CertChainMongoStorage; -import org.graylog.security.certutil.cert.storage.CertChainStorage; import org.graylog.security.certutil.csr.CsrSigner; -import org.graylog.security.certutil.csr.storage.CsrMongoStorage; import org.graylog2.Configuration; +import org.graylog2.cluster.certificates.CertificateExchange; +import org.graylog2.cluster.certificates.CertificateSigningRequest; import org.graylog2.cluster.nodes.DataNodeDto; import org.graylog2.cluster.nodes.NodeService; import org.graylog2.cluster.preflight.DataNodeProvisioningConfig; @@ -57,13 +56,13 @@ import org.slf4j.LoggerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; +import java.io.IOException; import java.security.KeyManagementException; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.SecureRandom; -import java.security.UnrecoverableKeyException; import java.security.cert.X509Certificate; import java.time.Duration; import java.util.List; @@ -91,8 +90,6 @@ public class GraylogCertificateProvisioningPeriodical extends Periodical { private final NodeService nodeService; private final CaConfiguration configuration; - private final CsrMongoStorage csrStorage; - private final CertChainStorage certMongoStorage; private final CaService caService; private final CsrSigner csrSigner; private final ClusterConfigService clusterConfigService; @@ -102,10 +99,10 @@ public class GraylogCertificateProvisioningPeriodical extends Periodical { private final NotificationService notificationService; private final ExecutorService executor; + private final CertificateExchange certificateExchange; + @Inject public GraylogCertificateProvisioningPeriodical(final DataNodeProvisioningService dataNodeProvisioningService, - final CsrMongoStorage csrStorage, - final CertChainMongoStorage certMongoStorage, final CaService caService, final Configuration configuration, final NodeService nodeService, @@ -115,10 +112,8 @@ public class GraylogCertificateProvisioningPeriodical extends Periodical { final IndexerJwtAuthTokenProvider indexerJwtAuthTokenProvider, final PreflightConfigService preflightConfigService, final NotificationService notificationService, - final CustomCAX509TrustManager trustManager) { + final CustomCAX509TrustManager trustManager, CertificateExchange certificateExchange) { this.dataNodeProvisioningService = dataNodeProvisioningService; - this.csrStorage = csrStorage; - this.certMongoStorage = certMongoStorage; this.caService = caService; this.passwordSecret = passwordSecret; this.configuration = configuration; @@ -127,6 +122,7 @@ public class GraylogCertificateProvisioningPeriodical extends Periodical { this.clusterConfigService = clusterConfigService; this.preflightConfigService = preflightConfigService; this.notificationService = notificationService; + this.certificateExchange = certificateExchange; this.executor = Executors.newFixedThreadPool(THREADPOOL_THREADS, new ThreadFactoryBuilder().setNameFormat("provisioning-connectivity-check-task").build()); this.okHttpClient = Suppliers.memoize(() -> buildConnectivityCheckOkHttpClient(trustManager, indexerJwtAuthTokenProvider)); } @@ -210,29 +206,13 @@ public class GraylogCertificateProvisioningPeriodical extends Periodical { } } - final var caKeystore = optKey.get(); - final var nodesWithCSR = nodesByState.getOrDefault(DataNodeProvisioningConfig.State.CSR, List.of()); - final var hasNodesWithCSR = !nodesWithCSR.isEmpty(); - if (hasNodesWithCSR) { - var caPrivateKey = (PrivateKey) caKeystore.getKey(CA_KEY_ALIAS, password); - var caCertificate = (X509Certificate) caKeystore.getCertificate(CA_KEY_ALIAS); - nodesWithCSR.forEach(c -> { - try { - var csr = csrStorage.readCsr(c.nodeId()); - if (csr.isEmpty()) { - LOG.error("Node in CSR state, but no CSR present : " + c.nodeId()); - dataNodeProvisioningService.save(c.asError("Node in CSR state, but no CSR present")); - } else { - var cert = csrSigner.sign(caPrivateKey, caCertificate, csr.get(), renewalPolicy); - final List caCertificates = List.of(caCertificate); - certMongoStorage.writeCertChain(new CertificateChain(cert, caCertificates), c.nodeId()); - } - } catch (Exception e) { - LOG.error("Could not sign CSR: " + e.getMessage(), e); - dataNodeProvisioningService.save(c.asError(e.getMessage())); - } - }); - } + optKey.ifPresent(caKeystore -> { + try { + certificateExchange.signPendingCertificateRequests(request -> signCertificate(request, caKeystore, password, renewalPolicy)); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); nodesByState.getOrDefault(DataNodeProvisioningConfig.State.STARTUP_REQUESTED, List.of()) .forEach(c -> { @@ -240,8 +220,20 @@ public class GraylogCertificateProvisioningPeriodical extends Periodical { executor.submit(() -> checkConnectivity(c)); }); } - } catch (KeyStoreException | NoSuchAlgorithmException | UnrecoverableKeyException | - KeyStoreStorageException e) { + } catch (KeyStoreException | NoSuchAlgorithmException | KeyStoreStorageException e) { + throw new RuntimeException(e); + } + } + + @Nonnull + private CertificateChain signCertificate(CertificateSigningRequest request, KeyStore caKeystore, char[] password, RenewalPolicy renewalPolicy) { + try { + var caPrivateKey = (PrivateKey) caKeystore.getKey(CA_KEY_ALIAS, password); + var caCertificate = (X509Certificate) caKeystore.getCertificate(CA_KEY_ALIAS); + var cert = csrSigner.sign(caPrivateKey, caCertificate, request.request(), renewalPolicy); + final List caCertificates = List.of(caCertificate); + return new CertificateChain(cert, caCertificates); + } catch (Exception e) { throw new RuntimeException(e); } } @@ -345,6 +337,7 @@ public class GraylogCertificateProvisioningPeriodical extends Periodical { * This record serves as a DTO for retry logic. We can't use the original Response, as we are having problems * closing the response between repeats and failure recoveries. Rather close the response ASAP and provide * only necessary information to the retryer. + * * @param success Could we connect to the datanode URL? * @param message What was the error message if not? */ diff --git a/graylog2-server/src/main/java/org/graylog2/cluster/certificates/CertificateExchange.java b/graylog2-server/src/main/java/org/graylog2/cluster/certificates/CertificateExchange.java new file mode 100644 index 0000000000..108690843c --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog2/cluster/certificates/CertificateExchange.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.cluster.certificates; + +import org.graylog.security.certutil.cert.CertificateChain; + +import java.io.IOException; +import java.util.function.Consumer; +import java.util.function.Function; + +public interface CertificateExchange { + + void requestCertificate(CertificateSigningRequest request) throws IOException; + + /** + * Take every pending CSR and transform it to a valid certificate chain by using the signing function + */ + void signPendingCertificateRequests(Function signingFunction) throws IOException; + + /** + * If the consumer fails to consume existing certificate, the certificate won't be removed and the attempt will + * be repeated during the next poll call. + */ + void pollCertificate(String nodeId, Consumer chainConsumer); +} diff --git a/graylog2-server/src/main/java/org/graylog2/cluster/certificates/CertificateExchangeImpl.java b/graylog2-server/src/main/java/org/graylog2/cluster/certificates/CertificateExchangeImpl.java new file mode 100644 index 0000000000..7fcb516dd1 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog2/cluster/certificates/CertificateExchangeImpl.java @@ -0,0 +1,250 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.cluster.certificates; + +import com.mongodb.client.FindIterable; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.IndexOptions; +import com.mongodb.client.model.UpdateOptions; +import com.mongodb.client.result.UpdateResult; +import jakarta.annotation.Nonnull; +import jakarta.inject.Inject; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.bson.Document; +import org.bson.conversions.Bson; +import org.graylog.security.certutil.cert.CertificateChain; +import org.graylog2.database.MongoConnection; +import org.graylog2.security.encryption.EncryptedValue; +import org.graylog2.security.encryption.EncryptedValueService; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.io.StringWriter; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.LinkedList; +import java.util.List; +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; + +import static com.mongodb.client.model.Filters.eq; +import static com.mongodb.client.model.Indexes.ascending; +import static com.mongodb.client.model.Updates.combine; +import static com.mongodb.client.model.Updates.set; + +public class CertificateExchangeImpl implements CertificateExchange { + + private static final String ENCRYPTED_VALUE_SUBFIELD = "encrypted_value"; + private static final String SALT_SUBFIELD = "salt"; + + private static final String COLLECTION_NAME = "certificate_exchange"; + public static final String FIELD_NODE_ID = "node_id"; + public static final String FIELD_ENTRY_TYPE = "type"; + public static final String FIELD_ENCRYPTED_VALUE = "value"; + + private final MongoDatabase mongoDatabase; + private final EncryptedValueService encryptionService; + + @Inject + public CertificateExchangeImpl(final MongoConnection mongoConnection, EncryptedValueService encryptionService) { + this.mongoDatabase = mongoConnection.getMongoDatabase(); + this.encryptionService = encryptionService; + createIndex(this.mongoDatabase); + } + + private static void createIndex(MongoDatabase mongoDatabase) { + final MongoCollection dbCollection = mongoDatabase.getCollection(COLLECTION_NAME); + dbCollection.createIndex(ascending(FIELD_NODE_ID, FIELD_ENTRY_TYPE), new IndexOptions().unique(true)); + } + + private void writeCertificate(String nodeId, CertificateChain certificateChain) throws IOException { + writeToDatabase(nodeId, toString(certificateChain), CertificateExchangeType.CERT_CHAIN); + } + + private void writeToDatabase(String nodeId, String serializedValue, CertificateExchangeType type) { + MongoCollection dbCollection = mongoDatabase.getCollection(COLLECTION_NAME); + final EncryptedValue encrypted = encryptionService.encrypt(serializedValue); + final UpdateResult result = dbCollection.updateOne( + Filters.and( + eq(FIELD_NODE_ID, nodeId), + eq(FIELD_ENTRY_TYPE, type.name()) + ), + combine( + set(FIELD_NODE_ID, nodeId), + set(FIELD_ENCRYPTED_VALUE + "." + ENCRYPTED_VALUE_SUBFIELD, encrypted.value()), + set(FIELD_ENCRYPTED_VALUE + "." + SALT_SUBFIELD, encrypted.salt()) + ), + new UpdateOptions().upsert(true) + ); + final boolean updated = result.getModifiedCount() > 0 || result.getUpsertedId() != null; + if (!updated) { + throw new RuntimeException("Failed to write certificateChain"); + } + } + + @Override + public void signPendingCertificateRequests(Function signingFunction) throws IOException { + Optional req = getNextCertificateSigningRequest(); + while (req.isPresent()) { + final CertificateSigningRequest csr = req.get(); + writeCertificate(csr.nodeId(), signingFunction.apply(csr)); + removeCertificateSigningRequest(csr.nodeId()); + req = getNextCertificateSigningRequest(); + } + } + + @Override + public void requestCertificate(CertificateSigningRequest request) throws IOException { + writeToDatabase(request.nodeId(), toString(request.request()), CertificateExchangeType.CSR); + } + + @Override + public void pollCertificate(String nodeId, Consumer chainConsumer) { + readEntry( + Filters.and( + eq(FIELD_NODE_ID, nodeId), + eq(FIELD_ENTRY_TYPE, CertificateExchangeType.CERT_CHAIN.name())), (document, decryptedValue) -> parseCertificateChain(decryptedValue)) + .ifPresent(certificateChain -> { + chainConsumer.accept(certificateChain); + removeCertificateChain(nodeId); + }); + } + + @Nonnull + private Optional readEntry(Bson filter, BiFunction parser) { + MongoCollection dbCollection = mongoDatabase.getCollection(COLLECTION_NAME); + final FindIterable objects = dbCollection.find( + filter + ); + final Document document = objects.first(); + + if (document != null) { + final Document encryptedCertificateDocument = document.get(FIELD_ENCRYPTED_VALUE, Document.class); + if (encryptedCertificateDocument != null) { + final EncryptedValue encryptedCertificate = EncryptedValue.builder() + .value(encryptedCertificateDocument.getString(ENCRYPTED_VALUE_SUBFIELD)) + .salt(encryptedCertificateDocument.getString(SALT_SUBFIELD)) + .isDeleteValue(false) + .isKeepValue(false) + .build(); + + final String decryptedValue = encryptionService.decrypt(encryptedCertificate); + return Optional.ofNullable(parser.apply(document, decryptedValue)); + } + } + return Optional.empty(); + } + + private Optional getNextCertificateSigningRequest() { + return readEntry(eq(FIELD_ENTRY_TYPE, CertificateExchangeType.CSR.name()), (document, decryptedValue) -> new CertificateSigningRequest(document.getString(FIELD_NODE_ID), parseCSR(decryptedValue))); + } + + private boolean removeCertificateChain(String nodeId) { + return removeEntry(nodeId, CertificateExchangeType.CERT_CHAIN); + } + + private boolean removeCertificateSigningRequest(String nodeId) { + return removeEntry(nodeId, CertificateExchangeType.CSR); + } + + private boolean removeEntry(String nodeId, CertificateExchangeType type) { + MongoCollection dbCollection = mongoDatabase.getCollection(COLLECTION_NAME); + var result = dbCollection.deleteOne( + Filters.and( + eq(FIELD_NODE_ID, nodeId), + eq(FIELD_ENTRY_TYPE, type.name()) + ) + ); + return result.getDeletedCount() > 0; + } + + + private static String toString(PKCS10CertificationRequest csr) throws IOException { + StringWriter writer = new StringWriter(); + try (JcaPEMWriter jcaPEMWriter = new JcaPEMWriter(writer)) { + jcaPEMWriter.writeObject(csr); + } + return writer.toString(); + } + + private static String toString(CertificateChain certChain) throws IOException { + StringWriter writer = new StringWriter(); + try (JcaPEMWriter jcaPEMWriter = new JcaPEMWriter(writer)) { + for (Certificate c : certChain.toCertificateChainArray()) { + jcaPEMWriter.writeObject(c); + } + } + return writer.toString(); + } + + public CertificateChain parseCertificateChain(String certificate) { + try { + Reader pemReader = new BufferedReader(new StringReader(certificate)); + PEMParser pemParser = new PEMParser(pemReader); + List caCerts = new LinkedList<>(); + X509Certificate signedCert = readSingleCert(pemParser); + + X509Certificate caCert = readSingleCert(pemParser); + while (caCert != null) { + caCerts.add(caCert); + caCert = readSingleCert(pemParser); + } + return new CertificateChain(signedCert, caCerts); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private X509Certificate readSingleCert(PEMParser pemParser) throws IOException, CertificateException { + var parsedObj = pemParser.readObject(); + if (parsedObj == null) { + return null; + } + if (parsedObj instanceof X509Certificate) { + return (X509Certificate) parsedObj; + } else if (parsedObj instanceof X509CertificateHolder) { + return new JcaX509CertificateConverter().getCertificate( + (X509CertificateHolder) parsedObj + ); + } else { + throw new IllegalArgumentException("Cannot read certificate from PEMParser. Containing object is of unexpected type " + parsedObj.getClass()); + } + } + + private PKCS10CertificationRequest parseCSR(String csr) { + try { + final var pemReader = new BufferedReader(new StringReader(csr)); + final var pemParser = new PEMParser(pemReader); + final var parsedObj = pemParser.readObject(); + return (PKCS10CertificationRequest) parsedObj; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + +} diff --git a/graylog2-server/src/main/java/org/graylog2/cluster/certificates/CertificateExchangeType.java b/graylog2-server/src/main/java/org/graylog2/cluster/certificates/CertificateExchangeType.java new file mode 100644 index 0000000000..2bf5cfac55 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog2/cluster/certificates/CertificateExchangeType.java @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.cluster.certificates; + +public enum CertificateExchangeType { + CSR, + CERT_CHAIN +} diff --git a/graylog2-server/src/main/java/org/graylog/security/certutil/cert/storage/CertChainStorage.java b/graylog2-server/src/main/java/org/graylog2/cluster/certificates/CertificateSigningRequest.java similarity index 54% rename from graylog2-server/src/main/java/org/graylog/security/certutil/cert/storage/CertChainStorage.java rename to graylog2-server/src/main/java/org/graylog2/cluster/certificates/CertificateSigningRequest.java index 26ba54687c..e18d389d54 100644 --- a/graylog2-server/src/main/java/org/graylog/security/certutil/cert/storage/CertChainStorage.java +++ b/graylog2-server/src/main/java/org/graylog2/cluster/certificates/CertificateSigningRequest.java @@ -14,18 +14,9 @@ * along with this program. If not, see * . */ -package org.graylog.security.certutil.cert.storage; +package org.graylog2.cluster.certificates; -import org.bouncycastle.operator.OperatorCreationException; -import org.graylog.security.certutil.cert.CertificateChain; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; -import java.io.IOException; -import java.security.GeneralSecurityException; -import java.util.Optional; - -public interface CertChainStorage { - void writeCertChain(CertificateChain certChain, String nodeId) - throws IOException, OperatorCreationException; - - Optional readCertChain(String nodeId) throws IOException, GeneralSecurityException; +public record CertificateSigningRequest(String nodeId, PKCS10CertificationRequest request) { } diff --git a/graylog2-server/src/main/java/org/graylog2/cluster/preflight/DataNodeProvisioningBindings.java b/graylog2-server/src/main/java/org/graylog2/cluster/preflight/DataNodeProvisioningBindings.java index 37b8792786..1d399e98ec 100644 --- a/graylog2-server/src/main/java/org/graylog2/cluster/preflight/DataNodeProvisioningBindings.java +++ b/graylog2-server/src/main/java/org/graylog2/cluster/preflight/DataNodeProvisioningBindings.java @@ -18,6 +18,8 @@ package org.graylog2.cluster.preflight; import com.google.inject.AbstractModule; import com.google.inject.name.Names; +import org.graylog2.cluster.certificates.CertificateExchange; +import org.graylog2.cluster.certificates.CertificateExchangeImpl; public class DataNodeProvisioningBindings extends AbstractModule { @@ -28,5 +30,7 @@ public class DataNodeProvisioningBindings extends AbstractModule { // this is the generic dependency used by callers bind(DataNodeProvisioningService.class).to(DataNodeProvisioningBusEvents.class); + + bind(CertificateExchange.class).to(CertificateExchangeImpl.class); } } diff --git a/graylog2-server/src/test/java/org/graylog/security/certutil/cert/storage/CertChainMongoStorageTest.java b/graylog2-server/src/test/java/org/graylog/security/certutil/cert/storage/CertChainMongoStorageTest.java deleted file mode 100644 index 62c1034aaf..0000000000 --- a/graylog2-server/src/test/java/org/graylog/security/certutil/cert/storage/CertChainMongoStorageTest.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright (C) 2020 Graylog, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the Server Side Public License, version 1, - * as published by MongoDB, Inc. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * Server Side Public License for more details. - * - * You should have received a copy of the Server Side Public License - * along with this program. If not, see - * . - */ -package org.graylog.security.certutil.cert.storage; - -import org.graylog.security.certutil.CertConstants; -import org.graylog.security.certutil.cert.CertificateChain; -import org.graylog.testing.mongodb.MongoDBInstance; -import org.graylog2.bindings.providers.MongoJackObjectMapperProvider; -import org.graylog2.cluster.preflight.DataNodeProvisioningConfig; -import org.graylog2.cluster.preflight.DataNodeProvisioningService; -import org.graylog2.cluster.preflight.DataNodeProvisioningServiceImpl; -import org.graylog2.shared.bindings.providers.ObjectMapperProvider; -import org.junit.Rule; -import org.junit.Test; -import org.mockito.junit.MockitoJUnit; -import org.mockito.junit.MockitoRule; - -import java.io.FileInputStream; -import java.security.KeyStore; -import java.security.cert.Certificate; -import java.security.cert.X509Certificate; -import java.util.Arrays; -import java.util.Optional; -import java.util.stream.Collectors; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.Assert.assertEquals; - -public class CertChainMongoStorageTest { - - @Rule - public final MongoDBInstance mongodb = MongoDBInstance.createForClass(); - @Rule - public final MockitoRule mockitoRule = MockitoJUnit.rule(); - - @Test - public void testChainStorageSaveAndRetrieve() throws Exception { - final String nodeId = "test-node-id"; - final DataNodeProvisioningService mongoService = new DataNodeProvisioningServiceImpl( - new MongoJackObjectMapperProvider(new ObjectMapperProvider().get()), - mongodb.mongoConnection() - ); - CertChainMongoStorage toTest = new CertChainMongoStorage(mongoService); - mongoService.save(DataNodeProvisioningConfig.builder().nodeId(nodeId).state(DataNodeProvisioningConfig.State.UNCONFIGURED).build()); - - KeyStore testKeystore = KeyStore.getInstance(CertConstants.PKCS12); - testKeystore.load(new FileInputStream("src/test/resources/org/graylog/security/certutil/keystore/storage/sample_certificate_keystore.p12"), "password".toCharArray()); - final Certificate[] testFileCertChain = testKeystore.getCertificateChain("datanode"); - CertificateChain certificateChain = new CertificateChain((X509Certificate) testFileCertChain[0], - Arrays.stream(testFileCertChain) - .skip(1) - .map(c -> (X509Certificate) c) - .collect(Collectors.toList())); - - toTest.writeCertChain(certificateChain, nodeId); - final Optional retrievedCertChain = toTest.readCertChain(nodeId); - assertThat(retrievedCertChain) - .isPresent() - .contains(certificateChain); - - assertThat(retrievedCertChain.get().signedCertificate()) - .satisfies(sc -> assertEquals("CN=localhost", sc.getSubjectX500Principal().getName())); - - } - -} diff --git a/graylog2-server/src/test/java/org/graylog2/cluster/certificates/CertificateExchangeImplTest.java b/graylog2-server/src/test/java/org/graylog2/cluster/certificates/CertificateExchangeImplTest.java new file mode 100644 index 0000000000..47f3ef3e64 --- /dev/null +++ b/graylog2-server/src/test/java/org/graylog2/cluster/certificates/CertificateExchangeImplTest.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +package org.graylog2.cluster.certificates; + +import org.apache.commons.lang3.RandomStringUtils; +import org.assertj.core.api.Assertions; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.graylog.security.certutil.CertConstants; +import org.graylog.security.certutil.CertRequest; +import org.graylog.security.certutil.CertificateGenerator; +import org.graylog.security.certutil.KeyPair; +import org.graylog.security.certutil.cert.CertificateChain; +import org.graylog.security.certutil.csr.CsrGenerator; +import org.graylog.security.certutil.csr.CsrSigner; +import org.graylog.security.certutil.csr.InMemoryKeystoreInformation; +import org.graylog.security.certutil.csr.exceptions.CSRGenerationException; +import org.graylog.testing.mongodb.MongoDBInstance; +import org.graylog2.security.encryption.EncryptedValueService; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +public class CertificateExchangeImplTest { + + public static final String FIRST_NODE_ID = "5ca1ab1e-0000-4000-a000-000000000000"; + public static final String SECOND_NODE_ID = "5ca1ab1e-0000-4000-a000-111111111111"; + @Rule + public final MongoDBInstance mongodb = MongoDBInstance.createForClass(); + private CertificateExchange certificateExchange; + + private final String encryptionPassword = RandomStringUtils.randomAlphabetic(20); + private final char[] keystorePassword = RandomStringUtils.randomAlphabetic(20).toCharArray(); + private KeyStore keystore; + + private KeyPair ca; + + @Before + public void setUp() throws Exception { + EncryptedValueService encryptedValueService = new EncryptedValueService(encryptionPassword); + certificateExchange = new CertificateExchangeImpl(mongodb.mongoConnection(), encryptedValueService); + this.keystore = generateNodeKeypair().toKeystore(CertConstants.DATANODE_KEY_ALIAS, keystorePassword); + this.ca = generateCa(); + } + + @Test + public void testLifecycle() throws Exception { + // create and persist one CSR + certificateExchange.requestCertificate(new CertificateSigningRequest(FIRST_NODE_ID, createCertificateSigningRequest("my-datanode-host"))); + certificateExchange.requestCertificate(new CertificateSigningRequest(SECOND_NODE_ID, createCertificateSigningRequest("my-second-node"))); + + // trigger all certificates processing. Every CSR will be replaced by a certificate chain + certificateExchange.signPendingCertificateRequests(this::signCertificate); + + // now let's verify that we have actually replaced the CSR with a certificate and we'll be able to poll one cert chain + AtomicReference firstNodeChainRef = new AtomicReference<>(); + certificateExchange.pollCertificate(FIRST_NODE_ID, firstNodeChainRef::set); + verifyCertChain(firstNodeChainRef, "my-datanode-host"); + + AtomicReference secondNodeChainRef = new AtomicReference<>(); + certificateExchange.pollCertificate(SECOND_NODE_ID, secondNodeChainRef::set); + verifyCertChain(secondNodeChainRef, "my-second-node"); + } + + private void verifyCertChain(AtomicReference ref, String hostname) { + Assertions.assertThat(ref.get()).isNotNull(); + final CertificateChain chain = ref.get(); + final X509Certificate cert = chain.signedCertificate(); + Assertions.assertThat(cert.getSubjectX500Principal().toString()).isEqualTo("CN=" + hostname); + Assertions.assertThat(chain.caCertificates()).isNotEmpty(); + } + + private CertificateChain signCertificate(CertificateSigningRequest csr) { + try { + final CsrSigner signer = new CsrSigner(); + final X509Certificate cert = signer.sign(ca.privateKey(), ca.certificate(), csr.request(), 30); + return new CertificateChain(cert, List.of(ca.certificate())); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private PKCS10CertificationRequest createCertificateSigningRequest(String hostname) throws CSRGenerationException { + final InMemoryKeystoreInformation keystore = new InMemoryKeystoreInformation(this.keystore, keystorePassword); + return CsrGenerator.generateCSR(keystore, CertConstants.DATANODE_KEY_ALIAS, hostname, Collections.emptyList()); + } + + private static KeyPair generateNodeKeypair() throws Exception { + final CertRequest certRequest = CertRequest.selfSigned(CertConstants.DATANODE_KEY_ALIAS) + .isCA(false) + .validity(Duration.ofDays(99 * 365)); + + return CertificateGenerator.generate(certRequest); + } + + private static KeyPair generateCa() throws Exception { + final CertRequest certRequest = CertRequest.selfSigned(CertConstants.CA_KEY_ALIAS) + .isCA(true) + .validity(Duration.ofDays(99 * 365)); + + return CertificateGenerator.generate(certRequest); + } +}