diff --git a/graylog2-server/src/main/java/org/graylog/collectors/CollectorCaCache.java b/graylog2-server/src/main/java/org/graylog/collectors/CollectorCaCache.java new file mode 100644 index 000000000000..c640e0e293e8 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog/collectors/CollectorCaCache.java @@ -0,0 +1,155 @@ +/* + * 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.collectors; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.eventbus.EventBus; +import com.google.common.eventbus.Subscribe; +import com.google.common.util.concurrent.AbstractIdleService; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.graylog.collectors.events.CollectorCaConfigUpdated; +import org.graylog.security.pki.CertificateEntry; +import org.graylog.security.pki.CertificateService; +import org.graylog.security.pki.PemUtils; +import org.graylog2.security.encryption.EncryptedValueService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.function.Supplier; + +import static org.graylog2.shared.utilities.StringUtils.requireNonBlank; + +/** + * Provides a CA cache that caches {@link CertificateEntry} instances based on their expiration date. + */ +@Singleton +public class CollectorCaCache extends AbstractIdleService { + private static final Logger LOG = LoggerFactory.getLogger(CollectorCaCache.class); + + private static final String SERVER_KEY = "_static_:server"; + private static final String SIGNING_KEY = "_static_:signing"; + private static final String CA_KEY = "_static_:ca"; + + private final CollectorCaService caService; + private final CertificateService certificateService; + private final EncryptedValueService encryptedValueService; + private final EventBus eventBus; + private final Cache cache; + + public record CacheEntry(PrivateKey privateKey, X509Certificate cert, String fingerprint) { + } + + @Inject + public CollectorCaCache(CollectorCaService caService, + CertificateService certificateService, + EncryptedValueService encryptedValueService, + EventBus eventBus, + Clock clock) { + this.caService = caService; + this.certificateService = certificateService; + this.encryptedValueService = encryptedValueService; + this.eventBus = eventBus; + this.cache = Caffeine.newBuilder() + .expireAfter(Expiry.creating((key, value) -> + Duration.between(Instant.now(clock), value.cert().getNotAfter().toInstant()))) + .initialCapacity(3) + .build(); + } + + /** + * Get entry by certificate Subject Key Identifier. + * + * @param ski the cert Subject Key Identifier value + * @return the cache entry or an empty optional + */ + public Optional getBySubjectKeyIdentifier(String ski) { + requireNonBlank(ski, "Subject Key Identifier can't be blank"); + + return Optional.ofNullable(cache.get(ski, key -> getCacheEntry( + () -> certificateService.findBySubjectKeyIdentifier(ski).orElse(null) + ).orElse(null))); + } + + /** + * Get the server entry. + * + * @return the server entry + */ + public CacheEntry getServer() { + return cache.get(SERVER_KEY, key -> getCacheEntry(caService::getOtlpServerCert).orElseThrow(() -> new IllegalStateException("Server certificate not found"))); + } + + /** + * Get the signing entry. + * + * @return the signing entry + */ + public CacheEntry getSigning() { + return cache.get(SIGNING_KEY, key -> getCacheEntry(caService::getSigningCert).orElseThrow(() -> new IllegalStateException("Signing certificate not found"))); + } + + /** + * Get the CA entry. + * + * @return the CA entry + */ + public CacheEntry getCa() { + return cache.get(CA_KEY, key -> getCacheEntry(caService::getCaCert).orElseThrow(() -> new IllegalStateException("CA certificate not found"))); + } + + private Optional getCacheEntry(Supplier certSupplier) { + try { + final var certEntry = certSupplier.get(); + if (certEntry == null) { + return Optional.empty(); + } + final var cert = PemUtils.parseCertificate(certEntry.certificate()); + final var privateKey = PemUtils.parsePrivateKey(encryptedValueService.decrypt(certEntry.privateKey())); + LOG.debug("Loaded cert <{}>", certEntry.fingerprint()); + return Optional.of(new CacheEntry(privateKey, cert, certEntry.fingerprint())); + } catch (Exception e) { + LOG.error("Couldn't load certificate", e); + throw new RuntimeException(e); + } + } + + @Override + protected void startUp() throws Exception { + eventBus.register(this); + } + + @Override + protected void shutDown() throws Exception { + eventBus.unregister(this); + } + + @Subscribe + @VisibleForTesting + void handleCollectorsConfigEvent(CollectorCaConfigUpdated ignored) { + cache.invalidateAll(); + } +} diff --git a/graylog2-server/src/main/java/org/graylog/collectors/CollectorCaKeyManager.java b/graylog2-server/src/main/java/org/graylog/collectors/CollectorCaKeyManager.java new file mode 100644 index 000000000000..9489a1553dbd --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog/collectors/CollectorCaKeyManager.java @@ -0,0 +1,107 @@ +/* + * 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.collectors; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Set; + +import javax.net.ssl.SSLEngine; +import javax.net.ssl.X509ExtendedKeyManager; +import java.net.Socket; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; + +/** + * Custom key manager that dynamically retrieves the server and signing certificates. This behavior is required + * for certificate renewal. + *

+ * Extends {@link X509ExtendedKeyManager} rather than implementing {@link javax.net.ssl.X509KeyManager} because + * Netty uses {@link javax.net.ssl.SSLEngine}-based handshakes. The JDK wraps a plain {@code X509KeyManager} in + * an adapter that adds endpoint identification checks; extending the "Extended" variant avoids that wrapper. + */ +@Singleton +public class CollectorCaKeyManager extends X509ExtendedKeyManager { + private static final Logger LOG = LoggerFactory.getLogger(CollectorCaKeyManager.class); + private static final String ALIAS = "server"; + private static final Set ED25519_KEY_TYPES = Set.of("EdDSA", "Ed25519"); + + private final CollectorCaCache caCache; + + @Inject + public CollectorCaKeyManager(CollectorCaCache caCache) { + this.caCache = caCache; + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + if (ED25519_KEY_TYPES.contains(keyType)) { + LOG.debug("Returning <{}> as the server alias for key type <{}>", ALIAS, keyType); + return ALIAS; + } + LOG.debug("Returning null for key type <{}>", keyType); + return null; + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + if (ALIAS.equals(alias)) { + final var serverEntry = caCache.getServer(); + final var signingEntry = caCache.getSigning(); + LOG.debug("Returning certificate chain for alias <{}>: server-cert={} signing-cert={}", + alias, serverEntry.fingerprint(), signingEntry.fingerprint()); + return new X509Certificate[]{serverEntry.cert(), signingEntry.cert()}; + } + LOG.debug("Returning null certificate chain for alias <{}>", alias); + return null; + } + + @Override + public PrivateKey getPrivateKey(String alias) { + if (ALIAS.equals(alias)) { + final var serverEntry = caCache.getServer(); + LOG.debug("Returning private key for server certificate <{}>", serverEntry.fingerprint()); + return serverEntry.privateKey(); + } + LOG.debug("Returning null private key for alias <{}>", alias); + return null; + } + + @Override + public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) { + return chooseServerAlias(keyType, issuers, null); + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + return null; + } + + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + return null; + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + return null; + } +} diff --git a/graylog2-server/src/main/java/org/graylog/collectors/CollectorCaService.java b/graylog2-server/src/main/java/org/graylog/collectors/CollectorCaService.java index 9675b6e190e7..52ba4028f757 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/CollectorCaService.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/CollectorCaService.java @@ -16,24 +16,22 @@ */ package org.graylog.collectors; -import io.netty.handler.ssl.ClientAuth; -import io.netty.handler.ssl.SslContextBuilder; -import io.netty.handler.ssl.SslProvider; +import com.google.common.annotations.VisibleForTesting; import jakarta.inject.Inject; import jakarta.inject.Singleton; import org.bouncycastle.asn1.x509.KeyPurposeId; import org.bouncycastle.asn1.x509.KeyUsage; import org.graylog.security.pki.Algorithm; +import org.graylog.security.pki.CertificateBuilder; import org.graylog.security.pki.CertificateEntry; import org.graylog.security.pki.CertificateService; -import org.graylog.security.pki.PemUtils; import org.graylog2.plugin.cluster.ClusterIdService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.security.PrivateKey; -import java.security.cert.X509Certificate; +import java.time.Clock; import java.time.Duration; +import java.time.Instant; import java.util.List; import static org.apache.commons.lang3.StringUtils.isNotBlank; @@ -58,6 +56,9 @@ public class CollectorCaService { static final Duration SIGNING_CERT_VALIDITY = Duration.ofDays(5 * 365); static final Duration OTLP_SERVER_CERT_VALIDITY = Duration.ofDays(2 * 365); + // Renew when less than 20% of the certificate's lifetime remains + static final double RENEWAL_THRESHOLD_RATIO = 0.2; + static final String CA_CERT_CN = "Collectors CA"; static final String SIGNING_CERT_CN = "Collectors Signing"; static final String OTLP_SERVER_CERT_CN = "Collectors OTLP Server"; @@ -65,14 +66,17 @@ public class CollectorCaService { private final CertificateService certificateService; private final ClusterIdService clusterIdService; private final CollectorsConfigService collectorsConfigService; + private final Clock clock; @Inject public CollectorCaService(CertificateService certificateService, ClusterIdService clusterIdService, - CollectorsConfigService collectorsConfigService) { + CollectorsConfigService collectorsConfigService, + Clock clock) { this.certificateService = certificateService; this.clusterIdService = clusterIdService; this.collectorsConfigService = collectorsConfigService; + this.clock = clock; } private CollectorsConfig ensureConfig() { @@ -110,43 +114,6 @@ public CertificateEntry getOtlpServerCert() { return certificateService.findById(ensureConfig().otlpServerCertId()).orElseThrow(this::caNotInitializedError); } - /** - * Creates a new {@link SslContextBuilder} configured for the OTLP server endpoint. - *

- * The builder is configured with: - *

    - *
  • The OTLP server certificate and private key for server identity
  • - *
  • Client authentication required (mTLS)
  • - *
  • The signing cert as the trust anchor for validating client certificates
  • - *
- * - * @return a configured SslContextBuilder ready to be built - */ - public SslContextBuilder newServerSslContextBuilder() { - final var hierarchy = loadHierarchy(); - final var otlpServerCert = hierarchy.otlpServerCert(); - final var signingCert = hierarchy.signingCert(); - - try { - final PrivateKey key = PemUtils.parsePrivateKey(certificateService.encryptedValueService().decrypt(otlpServerCert.privateKey())); - - final X509Certificate signingCertPem = PemUtils.parseCertificate(signingCert.certificate()); - final X509Certificate serverCertPem = PemUtils.parseCertificate(otlpServerCert.certificate()); - final X509Certificate trustedCert = PemUtils.parseCertificate(signingCert.certificate()); - - // The Collector only has access to the CA cert, so we need to have the intermediate signing cert - // in the key cert chain. - return SslContextBuilder.forServer(key, serverCertPem, signingCertPem) - // JDK provider required: BoringSSL (OPENSSL) can load Ed25519 keys but cannot - // complete TLS handshakes — its cipher suite negotiation doesn't recognize Ed25519. - .sslProvider(SslProvider.JDK) - .clientAuth(ClientAuth.REQUIRE) - .trustManager(trustedCert); - } catch (Exception e) { - throw new RuntimeException("Failed to create OTLP server SSL context", e); - } - } - /** * Loads the existing Collector CA hierarchy. * @@ -166,6 +133,14 @@ public boolean isCaInitialized() { return maybeConfig.isPresent() && isNotBlank(maybeConfig.get().caCertId()); } + /** + * Checks if the signing and OTLP server certificates need renewal and renews them if necessary. + *

+ * The signing cert is renewed when less than {@link #RENEWAL_THRESHOLD_RATIO} of its lifetime remains. + * When the signing cert is renewed, the OTLP server cert is always re-issued (cascading renewal) + * because it is signed by the signing cert. The OTLP server cert is also independently checked + * and renewed if it approaches expiry on its own. + */ public void renewCertificates() { if (!isCaInitialized()) { LOG.debug("CA not initialized - skipping renewal"); @@ -173,7 +148,50 @@ public void renewCertificates() { } final var hierarchy = loadHierarchy(); - // TODO: Continue + final var now = Instant.now(clock); + + try { + final var builder = certificateService.builder(); + + if (needsRenewal(hierarchy.signingCert(), now)) { + final var curSigningCert = hierarchy.signingCert(); + + LOG.info("Renewing signing certificate <{}> (expires {})", curSigningCert.fingerprint(), curSigningCert.notAfter()); + final var newSigningCert = certificateService.save(createSigningCert(builder, hierarchy.caCert())); + + // Cascading renewal: re-issue OTLP server cert with the new signing cert + LOG.info("Re-issuing OTLP server certificate (signing cert renewed)"); + final var newServerCert = certificateService.save(createServerCert(builder, newSigningCert)); + + collectorsConfigService.save(ensureConfig().toBuilder() + .signingCertId(newSigningCert.id()) + .otlpServerCertId(newServerCert.id()) + .build()); + } else { + final var signingCert = hierarchy.signingCert(); + final var curServerCert = hierarchy.otlpServerCert(); + + if (needsRenewal(curServerCert, now)) { + LOG.info("Renewing OTLP server certificate <{}> (expires {})", curServerCert.fingerprint(), curServerCert.notAfter()); + final var newServerCert = certificateService.save(createServerCert(builder, signingCert)); + collectorsConfigService.save(ensureConfig().toBuilder() + .otlpServerCertId(newServerCert.id()) + .build()); + } + } + } catch (Exception e) { + throw new RuntimeException("Failed to renew certificates", e); + } + } + + /** + * Returns true if the certificate needs renewal based on its remaining lifetime. + */ + @VisibleForTesting + boolean needsRenewal(CertificateEntry cert, Instant now) { + final var totalLifetime = Duration.between(cert.notBefore(), cert.notAfter()); + final var remaining = Duration.between(now, cert.notAfter()); + return remaining.toMillis() < (long) (totalLifetime.toMillis() * RENEWAL_THRESHOLD_RATIO); } /** @@ -191,26 +209,36 @@ public CaHierarchy initializeCa() { try { final var builder = certificateService.builder(); - final CertificateEntry caCert = certificateService.save( - builder.createRootCa(CA_CERT_CN, Algorithm.ED25519, CA_CERT_VALIDITY)); - final CertificateEntry signingCert = certificateService.save( - builder.createIntermediateCa(SIGNING_CERT_CN, caCert, SIGNING_CERT_VALIDITY)); - final CertificateEntry otlpServerCert = certificateService.save( - builder.createEndEntityCert( - OTLP_SERVER_CERT_CN, - signingCert, - KeyUsage.digitalSignature | KeyUsage.keyEncipherment, - KeyPurposeId.id_kp_serverAuth, - OTLP_SERVER_CERT_VALIDITY, - List.of(clusterIdService.getString()) - )); - - return new CaHierarchy(caCert, signingCert, otlpServerCert); + final CertificateEntry caCert = certificateService.save(createRootCert(builder)); + final CertificateEntry signingCert = certificateService.save(createSigningCert(builder, caCert)); + final CertificateEntry serverCert = certificateService.save(createServerCert(builder, signingCert)); + + return new CaHierarchy(caCert, signingCert, serverCert); } catch (Exception e) { throw new RuntimeException("Failed to create Collectors CA hierarchy", e); } } + + private CertificateEntry createRootCert(CertificateBuilder builder) throws Exception { + return builder.createRootCa(CA_CERT_CN, Algorithm.ED25519, CA_CERT_VALIDITY); + } + + private CertificateEntry createSigningCert(CertificateBuilder builder, CertificateEntry issuerCert) throws Exception { + return builder.createIntermediateCa(SIGNING_CERT_CN, issuerCert, SIGNING_CERT_VALIDITY); + } + + private CertificateEntry createServerCert(CertificateBuilder builder, CertificateEntry issuerCert) throws Exception { + return builder.createEndEntityCert( + OTLP_SERVER_CERT_CN, + issuerCert, + KeyUsage.digitalSignature | KeyUsage.keyEncipherment, + KeyPurposeId.id_kp_serverAuth, + OTLP_SERVER_CERT_VALIDITY, + List.of(clusterIdService.getString()) + ); + } + public record CaHierarchy(CertificateEntry caCert, CertificateEntry signingCert, CertificateEntry otlpServerCert) {} diff --git a/graylog2-server/src/main/java/org/graylog/collectors/CollectorCaTrustManager.java b/graylog2-server/src/main/java/org/graylog/collectors/CollectorCaTrustManager.java new file mode 100644 index 000000000000..648943e78614 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog/collectors/CollectorCaTrustManager.java @@ -0,0 +1,169 @@ +/* + * 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.collectors; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.graylog.security.pki.PemUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.net.ssl.SSLEngine; +import javax.net.ssl.X509ExtendedTrustManager; +import java.net.Socket; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.Clock; +import java.time.Instant; +import java.util.Date; + +/** + * Custom trust manager that looks up the trust chain via authority key identifiers. This allows efficient certificate + * lookup with multiple active signing certs. (e.g., cert renewal) + *

+ * Extends {@link X509ExtendedTrustManager} rather than implementing {@link javax.net.ssl.X509TrustManager} because + * Netty uses {@link javax.net.ssl.SSLEngine}-based handshakes. The JDK wraps a plain {@code X509TrustManager} in + * an adapter that adds endpoint identification checks; extending the "Extended" variant avoids that wrapper. + */ +@Singleton +public class CollectorCaTrustManager extends X509ExtendedTrustManager { + private static final Logger LOG = LoggerFactory.getLogger(CollectorCaTrustManager.class); + + private final CollectorCaCache caCache; + private final Clock clock; + + @Inject + public CollectorCaTrustManager(CollectorCaCache caCache, Clock clock) { + this.caCache = caCache; + this.clock = clock; + } + + @Override + public void checkClientTrusted(X509Certificate[] certs, String authType) throws CertificateException { + if (certs == null || certs.length == 0) { + throw new CertificateException("No client certificates provided"); + } + + final var clientCert = certs[0]; + + // Extract the AKI from the client cert to find the issuing certificate by its SKI + final var aki = PemUtils.extractAuthorityKeyIdentifier(clientCert) + .orElseThrow(() -> new CertificateException("Client certificate has no Authority Key Identifier")); + + final var issuerEntry = caCache.getBySubjectKeyIdentifier(aki) + .orElseThrow(() -> new CertificateException("No known issuer for Authority Key Identifier: " + aki)); + + verifyIssuerIsCa(issuerEntry.cert()); + verifySignatureAndValidity(clientCert, issuerEntry.cert()); + verifyEndEntityCert(clientCert); + verifyClientAuthEku(clientCert); + + LOG.debug("Client certificate trusted: subject=<{}>, issuer=<{}>", + clientCert.getSubjectX500Principal(), clientCert.getIssuerX500Principal()); + } + + private void verifyIssuerIsCa(X509Certificate issuerCert) throws CertificateException { + if (issuerCert.getBasicConstraints() < 0) { + throw new CertificateException("Issuer certificate is not a CA"); + } + + final boolean[] keyUsage = issuerCert.getKeyUsage(); + // keyUsage[5] is keyCertSign + if (keyUsage == null || !keyUsage[5]) { + throw new CertificateException("Issuer certificate does not have keyCertSign key usage"); + } + + // Collector certs are capped to the signing cert's remaining lifetime in CertificateBuilder#signCsr, + // so an expired issuer means the collector cert should also have expired. + issuerCert.checkValidity(Date.from(Instant.now(clock))); + + // Verify the issuer chains back to the collectors root CA + try { + final var rootCa = caCache.getCa(); + issuerCert.verify(rootCa.cert().getPublicKey()); + } catch (CertificateException e) { + throw e; + } catch (Exception e) { + throw new CertificateException("Issuer certificate is not signed by the collectors root CA", e); + } + } + + private void verifySignatureAndValidity(X509Certificate clientCert, X509Certificate issuerCert) throws CertificateException { + try { + clientCert.verify(issuerCert.getPublicKey()); + clientCert.checkValidity(Date.from(Instant.now(clock))); + } catch (CertificateException e) { + throw e; + } catch (Exception e) { + throw new CertificateException("Client certificate verification failed", e); + } + } + + private void verifyEndEntityCert(X509Certificate clientCert) throws CertificateException { + final var basicConstraints = clientCert.getBasicConstraints(); + if (basicConstraints >= 0) { + throw new CertificateException("Client certificate must be an end-entity certificate, not a CA"); + } + } + + private void verifyClientAuthEku(X509Certificate clientCert) throws CertificateException { + try { + final var eku = clientCert.getExtendedKeyUsage(); + if (eku == null || !eku.contains(KeyPurposeId.id_kp_clientAuth.getId())) { + throw new CertificateException("Client certificate does not have the clientAuth extended key usage"); + } + } catch (CertificateException e) { + throw e; + } catch (Exception e) { + throw new CertificateException("Failed to check extended key usage", e); + } + } + + @Override + public void checkClientTrusted(X509Certificate[] certs, String authType, Socket socket) throws CertificateException { + checkClientTrusted(certs, authType); + } + + @Override + public void checkClientTrusted(X509Certificate[] certs, String authType, SSLEngine engine) throws CertificateException { + checkClientTrusted(certs, authType); + } + + @Override + public void checkServerTrusted(X509Certificate[] certs, String authType) throws CertificateException { + throw new UnsupportedOperationException("#checkServerTrusted() not implemented"); + } + + @Override + public void checkServerTrusted(X509Certificate[] certs, String authType, Socket socket) throws CertificateException { + throw new UnsupportedOperationException("#checkServerTrusted() not implemented"); + } + + @Override + public void checkServerTrusted(X509Certificate[] certs, String authType, SSLEngine engine) throws CertificateException { + throw new UnsupportedOperationException("#checkServerTrusted() not implemented"); + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + // Return an empty array so the TLS CertificateRequest places no restriction on the + // client's issuer. The actual trust decision happens in checkClientTrusted via SKI lookup, + // which supports multiple signing certs (e.g. during cert renewal). + return new X509Certificate[0]; + } +} diff --git a/graylog2-server/src/main/java/org/graylog/collectors/CollectorTLSUtils.java b/graylog2-server/src/main/java/org/graylog/collectors/CollectorTLSUtils.java new file mode 100644 index 000000000000..93e8e746d267 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog/collectors/CollectorTLSUtils.java @@ -0,0 +1,62 @@ +/* + * 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.collectors; + +import io.netty.handler.ssl.ClientAuth; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.SslProvider; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +@Singleton +public class CollectorTLSUtils { + private final CollectorCaKeyManager keyManager; + private final CollectorCaTrustManager trustManager; + + @Inject + public CollectorTLSUtils(CollectorCaKeyManager keyManager, CollectorCaTrustManager trustManager) { + this.keyManager = keyManager; + this.trustManager = trustManager; + } + + /** + * Creates a new {@link SslContextBuilder} configured for the OTLP server endpoint. + *

+ * The builder is configured with: + *

    + *
  • The OTLP server certificate and private key for server identity
  • + *
  • Client authentication required (mTLS)
  • + *
  • The signing cert as the trust anchor for validating client certificates
  • + *
+ * + * @return a configured SslContextBuilder ready to be built + */ + public SslContextBuilder newServerSslContextBuilder() { + try { + // The Collector only has access to the CA cert, so we need to have the intermediate signing cert + // in the key cert chain. + return SslContextBuilder.forServer(keyManager) + // JDK provider required: BoringSSL (OPENSSL) can load Ed25519 keys but cannot + // complete TLS handshakes — its cipher suite negotiation doesn't recognize Ed25519. + .sslProvider(SslProvider.JDK) + .clientAuth(ClientAuth.REQUIRE) + .trustManager(trustManager); + } catch (Exception e) { + throw new RuntimeException("Failed to create OTLP server SSL context", e); + } + } +} diff --git a/graylog2-server/src/main/java/org/graylog/collectors/CollectorsConfig.java b/graylog2-server/src/main/java/org/graylog/collectors/CollectorsConfig.java index dd0d852076f3..6b01fe04dfcc 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/CollectorsConfig.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/CollectorsConfig.java @@ -81,7 +81,6 @@ public abstract class CollectorsConfig { public abstract Duration collectorExpirationThreshold(); // TODO: Make certificate lifetime configurable in the UI - https://github.com/Graylog2/graylog2-server/issues/25407 - // TODO: Collector cert lifetime can't be higher than the signing cert's lifetime. Validate! @JsonProperty(FIELD_COLLECTOR_CERT_LIFETIME) public abstract Duration collectorCertLifetime(); @@ -103,6 +102,8 @@ public static CollectorsConfig createDefault(String hostname) { return createDefaultBuilder(hostname).build(); } + public abstract Builder toBuilder(); + public static Builder builder() { return Builder.create(); } diff --git a/graylog2-server/src/main/java/org/graylog/collectors/CollectorsConfigService.java b/graylog2-server/src/main/java/org/graylog/collectors/CollectorsConfigService.java index 496de574f5f9..9676cbe7aa8a 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/CollectorsConfigService.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/CollectorsConfigService.java @@ -18,8 +18,11 @@ import jakarta.inject.Inject; import jakarta.inject.Singleton; +import org.graylog.collectors.events.CollectorCaConfigUpdated; +import org.graylog2.events.ClusterEventBus; import org.graylog2.plugin.cluster.ClusterConfigService; +import java.util.Objects; import java.util.Optional; /** @@ -30,10 +33,12 @@ public class CollectorsConfigService { private static final CollectorsConfig DEFAULT_CONFIG = CollectorsConfig.createDefault("localhost"); private final ClusterConfigService clusterConfigService; + private final ClusterEventBus clusterEventBus; @Inject - public CollectorsConfigService(ClusterConfigService clusterConfigService) { + public CollectorsConfigService(ClusterConfigService clusterConfigService, ClusterEventBus clusterEventBus) { this.clusterConfigService = clusterConfigService; + this.clusterEventBus = clusterEventBus; } /** @@ -70,6 +75,17 @@ public int getOpampMaxRequestBodySizeBytes() { * @param config the config object */ public void save(CollectorsConfig config) { + final var existing = get(); + clusterConfigService.write(config); + + // On first-time save (no existing config), there's nothing cached to invalidate, so we skip the event. + existing.ifPresent(c -> { + if (!Objects.equals(c.caCertId(), config.caCertId()) + || !Objects.equals(c.signingCertId(), config.signingCertId()) + || !Objects.equals(c.otlpServerCertId(), config.otlpServerCertId())) { + clusterEventBus.post(new CollectorCaConfigUpdated()); + } + }); } } diff --git a/graylog2-server/src/main/java/org/graylog/collectors/CollectorsModule.java b/graylog2-server/src/main/java/org/graylog/collectors/CollectorsModule.java index 74579386d9be..e1499f089238 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/CollectorsModule.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/CollectorsModule.java @@ -104,6 +104,11 @@ protected void configure() { // CA bind(CollectorCaService.class).in(Scopes.SINGLETON); + bind(CollectorCaCache.class).in(Scopes.SINGLETON); + bind(CollectorCaKeyManager.class).in(Scopes.SINGLETON); + bind(CollectorCaTrustManager.class).in(Scopes.SINGLETON); + bind(CollectorTLSUtils.class).in(Scopes.SINGLETON); + addInitializer(CollectorCaCache.class); // Collectors config bind(CollectorsConfigService.class).asEagerSingleton(); diff --git a/graylog2-server/src/main/java/org/graylog/collectors/events/CollectorCaConfigUpdated.java b/graylog2-server/src/main/java/org/graylog/collectors/events/CollectorCaConfigUpdated.java new file mode 100644 index 000000000000..0fa040e21836 --- /dev/null +++ b/graylog2-server/src/main/java/org/graylog/collectors/events/CollectorCaConfigUpdated.java @@ -0,0 +1,20 @@ +/* + * 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.collectors.events; + +public record CollectorCaConfigUpdated() { +} diff --git a/graylog2-server/src/main/java/org/graylog/collectors/input/transport/CollectorIngestHttpTransport.java b/graylog2-server/src/main/java/org/graylog/collectors/input/transport/CollectorIngestHttpTransport.java index 4d10660779e7..90935881388e 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/input/transport/CollectorIngestHttpTransport.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/input/transport/CollectorIngestHttpTransport.java @@ -24,6 +24,7 @@ import io.netty.handler.ssl.SslContext; import jakarta.inject.Named; import org.graylog.collectors.CollectorCaService; +import org.graylog.collectors.CollectorTLSUtils; import org.graylog.collectors.CollectorsConfig; import org.graylog.collectors.CollectorsConfigService; import org.graylog.collectors.IngestEndpointConfig; @@ -60,7 +61,7 @@ public class CollectorIngestHttpTransport extends AbstractHttpTransport { public static final String NAME = "CollectorIngestHttpTransport"; static final int DEFAULT_HTTP_PORT = 14401; - private final CollectorCaService collectorCaService; + private final CollectorTLSUtils tlsUtils; @AssistedInject public CollectorIngestHttpTransport(@Assisted Configuration configuration, @@ -71,12 +72,12 @@ public CollectorIngestHttpTransport(@Assisted Configuration configuration, LocalMetricRegistry localMetricRegistry, TLSProtocolsConfiguration tlsConfiguration, @Named("trusted_proxies") Set trustedProxies, - CollectorCaService collectorCaService, + CollectorTLSUtils tlsUtils, CollectorsConfigService collectorsConfigService) { super(buildTransportConfig(collectorsConfigService), eventLoopGroup, eventLoopGroupFactory, nettyTransportConfiguration, throughputCounter, localMetricRegistry, tlsConfiguration, trustedProxies, OtlpHttpUtils.LOGS_PATH); - this.collectorCaService = collectorCaService; + this.tlsUtils = tlsUtils; } private static Configuration buildTransportConfig(CollectorsConfigService collectorsConfigService) { @@ -100,7 +101,7 @@ private static Configuration buildTransportConfig(CollectorsConfigService collec @Override protected Callable createSslHandler(MessageInput input) { return () -> { - final SslContext sslContext = collectorCaService.newServerSslContextBuilder().build(); + final SslContext sslContext = tlsUtils.newServerSslContextBuilder().build(); return sslContext.newHandler(PooledByteBufAllocator.DEFAULT); }; } diff --git a/graylog2-server/src/main/java/org/graylog/collectors/periodical/CollectorCaRenewalPeriodical.java b/graylog2-server/src/main/java/org/graylog/collectors/periodical/CollectorCaRenewalPeriodical.java index a5c2617cc38d..4bf870c224c8 100644 --- a/graylog2-server/src/main/java/org/graylog/collectors/periodical/CollectorCaRenewalPeriodical.java +++ b/graylog2-server/src/main/java/org/graylog/collectors/periodical/CollectorCaRenewalPeriodical.java @@ -35,7 +35,11 @@ public CollectorCaRenewalPeriodical(CollectorCaService caService) { @Override public void doRun() { - caService.renewCertificates(); + try { + caService.renewCertificates(); + } catch (Exception e) { + LOG.error("Certificate renewal failed", e); + } } @Override @@ -53,6 +57,11 @@ public boolean startOnThisNode() { return true; } + @Override + public boolean leaderOnly() { + return true; + } + @Override public boolean isDaemon() { return true; @@ -65,7 +74,7 @@ public int getInitialDelaySeconds() { @Override public int getPeriodSeconds() { - return 60 * 60; + return 15 * 60; } @Override diff --git a/graylog2-server/src/main/java/org/graylog/security/pki/CertificateBuilder.java b/graylog2-server/src/main/java/org/graylog/security/pki/CertificateBuilder.java index 0d6db60654a6..310715029950 100644 --- a/graylog2-server/src/main/java/org/graylog/security/pki/CertificateBuilder.java +++ b/graylog2-server/src/main/java/org/graylog/security/pki/CertificateBuilder.java @@ -43,6 +43,8 @@ import org.bouncycastle.pkcs.PKCSException; import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder; import org.graylog2.security.encryption.EncryptedValueService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.StringReader; @@ -50,6 +52,7 @@ import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.security.KeyPair; +import java.security.SecureRandom; import java.security.PrivateKey; import java.security.PublicKey; import java.security.Security; @@ -61,6 +64,7 @@ import java.util.ArrayList; import java.util.Date; import java.util.List; +import java.util.Set; import static org.graylog2.shared.utilities.StringUtils.f; @@ -75,11 +79,14 @@ * for OpAMP enrollment. A unified certificate builder could serve both use cases. */ public class CertificateBuilder { + private static final Logger LOG = LoggerFactory.getLogger(CertificateBuilder.class); static { Security.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider()); } + private static final SecureRandom SERIAL_RNG = new SecureRandom(); + private final EncryptedValueService encryptedValueService; private final String productName; private final Clock clock; @@ -96,6 +103,35 @@ public CertificateBuilder(EncryptedValueService encryptedValueService, String pr this.clock = clock; } + private static BigInteger generateSerialNumber() { + final byte[] bytes = new byte[16]; + SERIAL_RNG.nextBytes(bytes); + bytes[0] |= 0x01; // Guarantee non-zero for RFC 5280 compliance + return new BigInteger(1, bytes); + } + + /** + * Computes the effective notAfter for a certificate, capping to the issuer's remaining lifetime. + *

+ * A signed certificate must never outlive its issuer. When the requested lifetime exceeds the + * issuer's remaining validity, we shorten it. + */ + private Instant capNotAfter(Instant now, Duration validity, X509Certificate issuerCert) { + final var issuerNotAfter = issuerCert.getNotAfter().toInstant(); + final var issuerRemaining = Duration.between(now, issuerNotAfter); + + if (issuerRemaining.isNegative() || issuerRemaining.isZero()) { + throw new IllegalStateException("Issuer certificate has expired"); + } + + if (issuerRemaining.compareTo(validity) < 0) { + LOG.debug("Capping certificate lifetime from {} to {} days (issuer expires {})", + validity.toDays(), issuerRemaining.toDays(), issuerNotAfter); + return now.plus(issuerRemaining); + } + return now.plus(validity); + } + /** * Creates a self-signed root CA certificate. * The certificate includes: @@ -122,7 +158,7 @@ public CertificateEntry createRootCa(String commonName, Algorithm algorithm, Dur final Instant now = Instant.now(clock); final Instant notAfter = now.plus(validity); - final BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis()); + final BigInteger serialNumber = generateSerialNumber(); final JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( subject, @@ -195,8 +231,8 @@ public CertificateEntry createIntermediateCa(String commonName, CertificateEntry final X500Name issuerDn = X500Name.getInstance(issuerCert.getSubjectX500Principal().getEncoded()); final Instant now = Instant.now(clock); - final Instant notAfter = now.plus(validity); - final BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis()); + final Instant notAfter = capNotAfter(now, validity, issuerCert); + final BigInteger serialNumber = generateSerialNumber(); final JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( issuerDn, @@ -335,8 +371,8 @@ public CertificateEntry createEndEntityCert(String commonName, CertificateEntry final X500Name issuerDn = X500Name.getInstance(issuerCert.getSubjectX500Principal().getEncoded()); final Instant now = Instant.now(clock); - final Instant notAfter = now.plus(validity); - final BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis()); + final Instant notAfter = capNotAfter(now, validity, issuerCert); + final BigInteger serialNumber = generateSerialNumber(); final JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( issuerDn, @@ -442,6 +478,7 @@ private CertificateEntry buildEntry(X509Certificate certificate, PrivateKey priv null, // ID assigned on save fingerprint, PemUtils.extractSubjectKeyIdentifier(certificate).orElseThrow(() -> new IllegalArgumentException("Certificate has no SKI")), + PemUtils.extractAuthorityKeyIdentifier(certificate), encryptedValueService.encrypt(privateKeyPem), certificatePem, issuerChain, @@ -472,7 +509,7 @@ public byte[] createCsr(KeyPair keyPair, String commonName) throws IOException, // Detect algorithm from key pair final String keyAlgorithm = keyPair.getPublic().getAlgorithm(); - final String signatureAlgorithm = "Ed25519".equals(keyAlgorithm) ? "Ed25519" : "SHA256withRSA"; + final String signatureAlgorithm = Set.of("Ed25519", "EdDSA").contains(keyAlgorithm) ? "Ed25519" : "SHA256withRSA"; final ContentSigner signer = new JcaContentSignerBuilder(signatureAlgorithm) .setProvider("BC") @@ -538,7 +575,7 @@ public X509Certificate signCsr(byte[] csrPem, CertificateEntry issuer, String su .setProvider("BC") .getPublicKey(csr.getSubjectPublicKeyInfo()); - if (!"Ed25519".equals(publicKey.getAlgorithm())) { + if (!Set.of("Ed25519", "EdDSA").contains(publicKey.getAlgorithm())) { throw new IllegalArgumentException( f("CSR public key must be Ed25519, but was %s", publicKey.getAlgorithm()) ); @@ -562,8 +599,8 @@ public X509Certificate signCsr(byte[] csrPem, CertificateEntry issuer, String su final X500Name issuerDn = X500Name.getInstance(issuerCert.getSubjectX500Principal().getEncoded()); final Instant now = Instant.now(clock); - final Instant notAfter = now.plus(validity); - final BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis()); + final Instant notAfter = capNotAfter(now, validity, issuerCert); + final BigInteger serialNumber = generateSerialNumber(); final JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( issuerDn, @@ -591,6 +628,9 @@ public X509Certificate signCsr(byte[] csrPem, CertificateEntry issuer, String su new ExtendedKeyUsage(KeyPurposeId.id_kp_clientAuth) ); + addSubjectKeyIdentifier(certBuilder, publicKey); + addAuthorityKeyIdentifier(certBuilder, issuerCert); + // Detect algorithm from issuer certificate final Algorithm algorithm = PemUtils.detectAlgorithm(issuerCert); diff --git a/graylog2-server/src/main/java/org/graylog/security/pki/CertificateEntry.java b/graylog2-server/src/main/java/org/graylog/security/pki/CertificateEntry.java index 6058aca11907..6e7e314b247d 100644 --- a/graylog2-server/src/main/java/org/graylog/security/pki/CertificateEntry.java +++ b/graylog2-server/src/main/java/org/graylog/security/pki/CertificateEntry.java @@ -25,6 +25,7 @@ import java.time.Instant; import java.util.List; +import java.util.Optional; /** * MongoDB entity for storing certificates with their private keys and metadata. @@ -40,11 +41,12 @@ public record CertificateEntry( @JsonProperty(FIELD_FINGERPRINT) String fingerprint, - // TODO: subjectKeyIdentifier MUST not be Nullable! - @Nullable @JsonProperty(FIELD_SUBJECT_KEY_IDENTIFIER) String subjectKeyIdentifier, + @JsonProperty(FIELD_AUTHORITY_KEY_IDENTIFIER) + Optional authorityKeyIdentifier, // CA certs don't have an Authority Key Identifier + @JsonProperty(FIELD_PRIVATE_KEY) EncryptedValue privateKey, @@ -75,6 +77,7 @@ public record CertificateEntry( public static final String FIELD_ID = "id"; public static final String FIELD_FINGERPRINT = "fingerprint"; public static final String FIELD_SUBJECT_KEY_IDENTIFIER = "subject_key_identifier"; + public static final String FIELD_AUTHORITY_KEY_IDENTIFIER = "authority_key_identifier"; public static final String FIELD_PRIVATE_KEY = "private_key"; public static final String FIELD_CERTIFICATE = "certificate"; public static final String FIELD_ISSUER_CHAIN = "issuer_chain"; @@ -95,6 +98,7 @@ public CertificateEntry withId(String newId) { newId, fingerprint, subjectKeyIdentifier, + authorityKeyIdentifier, privateKey, certificate, issuerChain, diff --git a/graylog2-server/src/main/java/org/graylog/security/pki/CertificateService.java b/graylog2-server/src/main/java/org/graylog/security/pki/CertificateService.java index 07c88f3f9bb6..6a8f55b4c989 100644 --- a/graylog2-server/src/main/java/org/graylog/security/pki/CertificateService.java +++ b/graylog2-server/src/main/java/org/graylog/security/pki/CertificateService.java @@ -73,6 +73,7 @@ public CertificateService(MongoCollections mongoCollections, Indexes.ascending(CertificateEntry.FIELD_FINGERPRINT), new IndexOptions().unique(true) ); + collection.createIndex(Indexes.ascending(CertificateEntry.FIELD_SUBJECT_KEY_IDENTIFIER)); } /** @@ -133,6 +134,7 @@ private CertificateEntry enrichWithDn(CertificateEntry entry) { entry.id(), entry.fingerprint(), entry.subjectKeyIdentifier(), + entry.authorityKeyIdentifier(), entry.privateKey(), entry.certificate(), entry.issuerChain(), @@ -170,6 +172,18 @@ public Optional findByFingerprint(String fingerprint) { ); } + /** + * Finds a certificate entry by its Subject Key Identifier value. + * + * @param ski the Subject Key Identifier value + * @return an Optional containing the certificate entry if found, or empty if not found + */ + public Optional findBySubjectKeyIdentifier(String ski) { + return Optional.ofNullable( + collection.find(Filters.eq(CertificateEntry.FIELD_SUBJECT_KEY_IDENTIFIER, ski)).first() + ); + } + /** * Returns all certificate entries in the collection. * diff --git a/graylog2-server/src/test/java/org/graylog/collectors/CollectorCaCacheTest.java b/graylog2-server/src/test/java/org/graylog/collectors/CollectorCaCacheTest.java new file mode 100644 index 000000000000..57b1699dda7f --- /dev/null +++ b/graylog2-server/src/test/java/org/graylog/collectors/CollectorCaCacheTest.java @@ -0,0 +1,216 @@ +/* + * 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.collectors; + +import com.google.common.eventbus.EventBus; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.graylog.collectors.events.CollectorCaConfigUpdated; +import org.graylog.security.pki.Algorithm; +import org.graylog.security.pki.CertificateBuilder; +import org.graylog.security.pki.CertificateEntry; +import org.graylog.security.pki.CertificateService; +import org.graylog.security.pki.PemUtils; +import org.graylog.testing.TestClocks; +import org.graylog2.security.encryption.EncryptedValueService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CollectorCaCacheTest { + + private final EncryptedValueService encryptedValueService = new EncryptedValueService("1234567890abcdef"); + private final EventBus eventBus = new EventBus(); + + private CollectorCaService caService; + private CertificateService certService; + private CollectorCaCache cache; + + private CertificateEntry caCert; + private CertificateEntry signingCert; + private CertificateEntry serverCert; + + @BeforeEach + void setUp() throws Exception { + caService = mock(CollectorCaService.class); + certService = mock(CertificateService.class); + + final var certBuilder = new CertificateBuilder(encryptedValueService, "Test", TestClocks.fixedEpoch()); + + caCert = certBuilder.createRootCa("Test CA", Algorithm.ED25519, Duration.ofDays(365)); + signingCert = certBuilder.createIntermediateCa("Test Signing", caCert, Duration.ofDays(365)); + serverCert = certBuilder.createEndEntityCert( + "Test Server", signingCert, + KeyUsage.digitalSignature, KeyPurposeId.id_kp_serverAuth, + Duration.ofDays(365) + ); + + when(caService.getCaCert()).thenReturn(caCert); + when(caService.getSigningCert()).thenReturn(signingCert); + when(caService.getOtlpServerCert()).thenReturn(serverCert); + + cache = new CollectorCaCache(caService, certService, encryptedValueService, eventBus, TestClocks.fixedEpoch()); + } + + @Test + void getReturnsCorrectCertificateForCaKey() { + final var entry = cache.getCa(); + + assertThat(entry).isNotNull(); + assertThat(entry.fingerprint()).isEqualTo(caCert.fingerprint()); + assertThat(entry.cert()).isNotNull(); + assertThat(entry.privateKey()).isNotNull(); + } + + @Test + void getReturnsCorrectCertificateForSigningKey() { + final var entry = cache.getSigning(); + + assertThat(entry).isNotNull(); + assertThat(entry.fingerprint()).isEqualTo(signingCert.fingerprint()); + } + + @Test + void getReturnsCorrectCertificateForServerKey() { + final var entry = cache.getServer(); + + assertThat(entry).isNotNull(); + assertThat(entry.fingerprint()).isEqualTo(serverCert.fingerprint()); + } + + @Test + void getCachesResults() { + final var first = cache.getCa(); + final var second = cache.getCa(); + + assertThat(first).isSameAs(second); + verify(caService, times(1)).getCaCert(); + } + + @Test + void invalidateAllClearsCache() { + cache.getCa(); + verify(caService, times(1)).getCaCert(); + + cache.handleCollectorsConfigEvent(new CollectorCaConfigUpdated()); + + cache.getCa(); + verify(caService, times(2)).getCaCert(); + } + + @Test + void eventBusInvalidatesCache() throws Exception { + cache.startAsync().awaitRunning(); + try { + cache.getSigning(); + verify(caService, times(1)).getSigningCert(); + + eventBus.post(new CollectorCaConfigUpdated()); + + cache.getSigning(); + verify(caService, times(2)).getSigningCert(); + } finally { + cache.stopAsync().awaitTerminated(); + } + } + + @Test + void serviceExceptionPropagates() { + when(caService.getCaCert()).thenThrow(new IllegalStateException("CA not initialized")); + + assertThatThrownBy(() -> cache.getCa()) + .isInstanceOf(RuntimeException.class) + .hasCauseInstanceOf(IllegalStateException.class); + } + + @Test + void eachKeyLoadsIndependently() { + cache.getCa(); + cache.getSigning(); + cache.getServer(); + + verify(caService, times(1)).getCaCert(); + verify(caService, times(1)).getSigningCert(); + verify(caService, times(1)).getOtlpServerCert(); + } + + @Test + void invalidationReloadsUpdatedCert() throws Exception { + cache.getServer(); + + final var certBuilder = new CertificateBuilder(encryptedValueService, "Test", TestClocks.fixedEpoch()); + final var newServerCert = certBuilder.createEndEntityCert( + "New Server", signingCert, + KeyUsage.digitalSignature, KeyPurposeId.id_kp_serverAuth, + Duration.ofDays(365) + ); + when(caService.getOtlpServerCert()).thenReturn(newServerCert); + + cache.handleCollectorsConfigEvent(new CollectorCaConfigUpdated()); + + final var entry = cache.getServer(); + assertThat(entry.fingerprint()).isEqualTo(newServerCert.fingerprint()); + } + + @Test + void getBySubjectKeyIdentifier_returnsCacheEntryForKnownSubjectKeyIdentifier() throws Exception { + when(certService.findBySubjectKeyIdentifier(serverCert.subjectKeyIdentifier())).thenReturn(Optional.of(serverCert)); + + final var result = cache.getBySubjectKeyIdentifier(serverCert.subjectKeyIdentifier()); + + assertThat(result).isPresent(); + assertThat(result.get().cert().getSerialNumber()).isEqualTo(PemUtils.parseCertificate(serverCert.certificate()).getSerialNumber()); + assertThat(result.get().privateKey()).isNotNull(); + } + + @Test + void getBySubjectKeyIdentifier_returnsEmptyForUnknownSubjectKeyIdentifier() { + when(certService.findBySubjectKeyIdentifier("unknown-ski")).thenReturn(Optional.empty()); + + assertThat(cache.getBySubjectKeyIdentifier("unknown-ski")).isEmpty(); + } + + @Test + void getBySubjectKeyIdentifier_rejectsBlankSubjectKeyIdentifier() { + assertThatThrownBy(() -> cache.getBySubjectKeyIdentifier("")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void getBySubjectKeyIdentifier_rejectsNullSubjectKeyIdentifier() { + assertThatThrownBy(() -> cache.getBySubjectKeyIdentifier(null)) + .isInstanceOf(Exception.class); + } + + @Test + void startAndStopLifecycle() throws Exception { + cache.startAsync().awaitRunning(); + assertThat(cache.isRunning()).isTrue(); + + cache.stopAsync().awaitTerminated(); + assertThat(cache.isRunning()).isFalse(); + } +} diff --git a/graylog2-server/src/test/java/org/graylog/collectors/CollectorCaKeyManagerTest.java b/graylog2-server/src/test/java/org/graylog/collectors/CollectorCaKeyManagerTest.java new file mode 100644 index 000000000000..a44f570a5349 --- /dev/null +++ b/graylog2-server/src/test/java/org/graylog/collectors/CollectorCaKeyManagerTest.java @@ -0,0 +1,144 @@ +/* + * 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.collectors; + +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.graylog.security.pki.Algorithm; +import org.graylog.security.pki.CertificateBuilder; +import org.graylog.security.pki.CertificateEntry; +import org.graylog.security.pki.PemUtils; +import org.graylog.testing.TestClocks; +import org.graylog2.security.encryption.EncryptedValueService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CollectorCaKeyManagerTest { + + private final EncryptedValueService encryptedValueService = new EncryptedValueService("1234567890abcdef"); + private final CertificateBuilder certBuilder = new CertificateBuilder(encryptedValueService, "Test", TestClocks.fixedEpoch()); + + private CollectorCaKeyManager keyManager; + private CollectorCaCache.CacheEntry serverEntry; + private CollectorCaCache.CacheEntry signingEntry; + + @BeforeEach + void setUp() throws Exception { + final var caCertEntry = certBuilder.createRootCa("Test CA", Algorithm.ED25519, Duration.ofDays(365)); + final var signingCertEntry = certBuilder.createIntermediateCa("Test Signing", caCertEntry, Duration.ofDays(365)); + final var serverCertEntry = certBuilder.createEndEntityCert( + "Test Server", signingCertEntry, KeyUsage.digitalSignature | KeyUsage.keyEncipherment, + KeyPurposeId.id_kp_serverAuth, Duration.ofDays(30), List.of("cluster-id")); + + serverEntry = cacheEntry(serverCertEntry); + signingEntry = cacheEntry(signingCertEntry); + + final var caCache = mock(CollectorCaCache.class); + when(caCache.getServer()).thenReturn(serverEntry); + when(caCache.getSigning()).thenReturn(signingEntry); + + keyManager = new CollectorCaKeyManager(caCache); + } + + @Test + void chooseServerAlias_returnsAliasForEdDSA() { + assertThat(keyManager.chooseServerAlias("EdDSA", null, null)).isEqualTo("server"); + } + + @Test + void chooseServerAlias_returnsAliasForEd25519() { + assertThat(keyManager.chooseServerAlias("Ed25519", null, null)).isEqualTo("server"); + } + + @Test + void chooseServerAlias_returnsNullForRSA() { + assertThat(keyManager.chooseServerAlias("RSA", null, null)).isNull(); + } + + @Test + void chooseServerAlias_returnsNullForEC() { + assertThat(keyManager.chooseServerAlias("EC", null, null)).isNull(); + } + + @Test + void getCertificateChain_returnsServerAndSigningCertsForServerAlias() { + final X509Certificate[] chain = keyManager.getCertificateChain("server"); + + assertThat(chain).hasSize(2); + assertThat(chain[0]).isEqualTo(serverEntry.cert()); + assertThat(chain[1]).isEqualTo(signingEntry.cert()); + } + + @Test + void getCertificateChain_returnsNullForUnknownAlias() { + assertThat(keyManager.getCertificateChain("unknown")).isNull(); + } + + @Test + void getPrivateKey_returnsKeyForServerAlias() { + assertThat(keyManager.getPrivateKey("server")).isEqualTo(serverEntry.privateKey()); + } + + @Test + void getPrivateKey_returnsNullForUnknownAlias() { + assertThat(keyManager.getPrivateKey("unknown")).isNull(); + } + + @Test + void chooseEngineServerAlias_returnsAliasForEdDSA() { + assertThat(keyManager.chooseEngineServerAlias("EdDSA", null, null)).isEqualTo("server"); + } + + @Test + void chooseEngineServerAlias_returnsAliasForEd25519() { + assertThat(keyManager.chooseEngineServerAlias("Ed25519", null, null)).isEqualTo("server"); + } + + @Test + void chooseEngineServerAlias_returnsNullForRSA() { + assertThat(keyManager.chooseEngineServerAlias("RSA", null, null)).isNull(); + } + + @Test + void getClientAliases_returnsNull() { + assertThat(keyManager.getClientAliases("EdDSA", null)).isNull(); + } + + @Test + void chooseClientAlias_returnsNull() { + assertThat(keyManager.chooseClientAlias(new String[]{"EdDSA"}, null, null)).isNull(); + } + + @Test + void getServerAliases_returnsNull() { + assertThat(keyManager.getServerAliases("EdDSA", null)).isNull(); + } + + private CollectorCaCache.CacheEntry cacheEntry(CertificateEntry entry) throws Exception { + final var cert = PemUtils.parseCertificate(entry.certificate()); + final var privateKey = PemUtils.parsePrivateKey(encryptedValueService.decrypt(entry.privateKey())); + return new CollectorCaCache.CacheEntry(privateKey, cert, entry.fingerprint()); + } +} diff --git a/graylog2-server/src/test/java/org/graylog/collectors/CollectorCaServiceTest.java b/graylog2-server/src/test/java/org/graylog/collectors/CollectorCaServiceTest.java index 33ff80da7f55..9c9e79e4136b 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/CollectorCaServiceTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/CollectorCaServiceTest.java @@ -17,6 +17,7 @@ package org.graylog.collectors; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.eventbus.EventBus; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslContextBuilder; import org.bouncycastle.asn1.ASN1OctetString; @@ -34,6 +35,7 @@ import org.graylog.testing.mongodb.MongoDBTestService; import org.graylog2.bindings.providers.MongoJackObjectMapperProvider; import org.graylog2.database.MongoCollections; +import org.graylog2.events.ClusterEventBus; import org.graylog2.jackson.InputConfigurationBeanDeserializerModifier; import org.graylog2.plugin.cluster.ClusterConfigService; import org.graylog2.plugin.cluster.ClusterIdService; @@ -47,6 +49,8 @@ import java.security.cert.X509Certificate; import java.time.Clock; import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.Collections; import static org.assertj.core.api.Assertions.assertThat; @@ -67,10 +71,11 @@ class CollectorCaServiceTest { private ClusterIdService clusterIdService; private CollectorCaService collectorCaService; private Clock clock = TestClocks.fixedEpoch(); + private EncryptedValueService encryptedValueService; @BeforeEach void setUp(MongoDBTestService mongodb, ClusterConfigService clusterConfigService) { - final EncryptedValueService encryptedValueService = new EncryptedValueService("1234567890abcdef"); + encryptedValueService = new EncryptedValueService("1234567890abcdef"); final ObjectMapper objectMapper = new ObjectMapperProvider( ObjectMapperProvider.class.getClassLoader(), Collections.emptySet(), @@ -86,8 +91,8 @@ void setUp(MongoDBTestService mongodb, ClusterConfigService clusterConfigService certificateService = new CertificateService(mongoCollections, encryptedValueService, CustomizationConfig.empty(), clock); clusterIdService = mock(ClusterIdService.class); when(clusterIdService.getString()).thenReturn("cluster-id"); - collectorsConfigService = new CollectorsConfigService(clusterConfigService); - collectorCaService = new CollectorCaService(certificateService, clusterIdService, collectorsConfigService); + collectorsConfigService = new CollectorsConfigService(clusterConfigService, mock(ClusterEventBus.class)); + collectorCaService = new CollectorCaService(certificateService, clusterIdService, collectorsConfigService, clock); } private void initConfig() { @@ -197,10 +202,120 @@ void otlpServerCert_hasClusterIdAsDnsSan() throws Exception { assertThat(sans).anyMatch(entry -> (int) entry.get(0) == 2 && testClusterId.equals(entry.get(1))); } + @Test + void needsRenewal_returnsFalseWhenCertIsNew() { + initConfig(); + final var signingCert = collectorCaService.getSigningCert(); + + // At epoch (cert creation time), the full lifetime remains + assertThat(collectorCaService.needsRenewal(signingCert, Instant.EPOCH)).isFalse(); + } + + @Test + void needsRenewal_returnsTrueWhenBelowThreshold() { + initConfig(); + final var signingCert = collectorCaService.getSigningCert(); + + // Advance to 85% of signing cert lifetime (5 years) — only 15% remains, below 20% threshold + final var signingLifetimeDays = 5 * 365; + final var at85Percent = Instant.EPOCH.plus(Duration.ofDays((long) (signingLifetimeDays * 0.85))); + assertThat(collectorCaService.needsRenewal(signingCert, at85Percent)).isTrue(); + } + + @Test + void needsRenewal_returnsFalseWhenAboveThreshold() { + initConfig(); + final var signingCert = collectorCaService.getSigningCert(); + + // Advance to 50% of signing cert lifetime — 50% remains, above 20% threshold + final var signingLifetimeDays = 5 * 365; + final var atHalfway = Instant.EPOCH.plus(Duration.ofDays((long) (signingLifetimeDays * 0.5))); + assertThat(collectorCaService.needsRenewal(signingCert, atHalfway)).isFalse(); + } + + @Test + void renewCertificates_doesNothingWhenCertsAreNew() { + initConfig(); + final var configBefore = collectorsConfigService.get().orElseThrow(); + + collectorCaService.renewCertificates(); + + final var configAfter = collectorsConfigService.get().orElseThrow(); + assertThat(configAfter.signingCertId()).isEqualTo(configBefore.signingCertId()); + assertThat(configAfter.otlpServerCertId()).isEqualTo(configBefore.otlpServerCertId()); + } + + @Test + void renewCertificates_renewsSigningCertAndCascadesToServerCert() { + initConfig(); + final var configBefore = collectorsConfigService.get().orElseThrow(); + + // Create a service with a clock past the signing cert's renewal threshold + final var signingLifetimeDays = 5 * 365; + final var futureClock = Clock.fixed( + Instant.EPOCH.plus(Duration.ofDays((long) (signingLifetimeDays * 0.85))), + ZoneOffset.UTC); + final var futureService = new CollectorCaService(certificateService, clusterIdService, collectorsConfigService, futureClock); + + futureService.renewCertificates(); + + final var configAfter = collectorsConfigService.get().orElseThrow(); + // Both signing and server cert should have changed + assertThat(configAfter.signingCertId()).isNotEqualTo(configBefore.signingCertId()); + assertThat(configAfter.otlpServerCertId()).isNotEqualTo(configBefore.otlpServerCertId()); + // CA cert should remain unchanged + assertThat(configAfter.caCertId()).isEqualTo(configBefore.caCertId()); + + // Verify the new signing cert is signed by the CA + final var newSigningCert = certificateService.findById(configAfter.signingCertId()).orElseThrow(); + final var caCert = certificateService.findById(configAfter.caCertId()).orElseThrow(); + assertThat(newSigningCert.authorityKeyIdentifier()).hasValue(caCert.subjectKeyIdentifier()); + + // Verify the new server cert is signed by the new signing cert + final var newServerCert = certificateService.findById(configAfter.otlpServerCertId()).orElseThrow(); + assertThat(newServerCert.authorityKeyIdentifier()).hasValue(newSigningCert.subjectKeyIdentifier()); + } + + @Test + void renewCertificates_renewsOnlyServerCertWhenSigningCertIsFresh() { + initConfig(); + final var configBefore = collectorsConfigService.get().orElseThrow(); + + // Advance past the OTLP server cert threshold (2 years) but not the signing cert threshold (5 years) + final var serverLifetimeDays = 2 * 365; + final var futureClock = Clock.fixed( + Instant.EPOCH.plus(Duration.ofDays((long) (serverLifetimeDays * 0.85))), + ZoneOffset.UTC); + final var futureService = new CollectorCaService(certificateService, clusterIdService, collectorsConfigService, futureClock); + + futureService.renewCertificates(); + + final var configAfter = collectorsConfigService.get().orElseThrow(); + // Only server cert should have changed + assertThat(configAfter.caCertId()).isEqualTo(configBefore.caCertId()); + assertThat(configAfter.signingCertId()).isEqualTo(configBefore.signingCertId()); + assertThat(configAfter.otlpServerCertId()).isNotEqualTo(configBefore.otlpServerCertId()); + + // Verify the new server cert is signed by the existing signing cert + final var newServerCert = certificateService.findById(configAfter.otlpServerCertId()).orElseThrow(); + final var signingCert = certificateService.findById(configAfter.signingCertId()).orElseThrow(); + assertThat(newServerCert.authorityKeyIdentifier()).hasValue(signingCert.subjectKeyIdentifier()); + } + + @Test + void renewCertificates_skipsWhenCaNotInitialized() { + // No initConfig() call — CA is not initialized + collectorCaService.renewCertificates(); + // Should return silently without errors + assertThat(collectorsConfigService.get()).isEmpty(); + } + @Test void newServerSslContextBuilder_returnsConfiguredBuilder() throws Exception { initConfig(); - final SslContextBuilder builder = collectorCaService.newServerSslContextBuilder(); + final var cache = new CollectorCaCache(collectorCaService, certificateService, encryptedValueService, new EventBus(), TestClocks.fixedEpoch()); + final var tlsUtils = new CollectorTLSUtils(new CollectorCaKeyManager(cache), new CollectorCaTrustManager(cache, clock)); + final SslContextBuilder builder = tlsUtils.newServerSslContextBuilder(); assertThat(builder).isNotNull(); // Verify the builder can actually build an SslContext diff --git a/graylog2-server/src/test/java/org/graylog/collectors/CollectorCaTrustManagerTest.java b/graylog2-server/src/test/java/org/graylog/collectors/CollectorCaTrustManagerTest.java new file mode 100644 index 000000000000..93347b495501 --- /dev/null +++ b/graylog2-server/src/test/java/org/graylog/collectors/CollectorCaTrustManagerTest.java @@ -0,0 +1,299 @@ +/* + * 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.collectors; + +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.graylog.security.pki.Algorithm; +import org.graylog.security.pki.CertificateBuilder; +import org.graylog.security.pki.CertificateEntry; +import org.graylog.security.pki.PemUtils; +import org.graylog.testing.TestClocks; +import org.graylog2.security.encryption.EncryptedValueService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CollectorCaTrustManagerTest { + private final EncryptedValueService encryptedValueService = new EncryptedValueService("1234567890abcdef"); + private final CertificateBuilder certBuilder = new CertificateBuilder(encryptedValueService, "Test", TestClocks.fixedEpoch()); + + private CertificateEntry signingCertEntry; + private CollectorCaTrustManager trustManager; + private CollectorCaCache caCache; + + @BeforeEach + void setUp() throws Exception { + final var caCertEntry = certBuilder.createRootCa("Test CA", Algorithm.ED25519, Duration.ofDays(365)); + + signingCertEntry = certBuilder.createIntermediateCa("Test Signing", caCertEntry, Duration.ofDays(365)); + + caCache = mock(CollectorCaCache.class); + // Look up the signing cert by its SKI (matched via the client cert's AKI) + final var signingCert = PemUtils.parseCertificate(signingCertEntry.certificate()); + final var signingCertSki = PemUtils.extractSubjectKeyIdentifier(signingCert).orElseThrow(); + when(caCache.getBySubjectKeyIdentifier(signingCertSki)).thenReturn(Optional.of(cacheEntry(signingCertEntry))); + when(caCache.getSigning()).thenReturn(cacheEntry(signingCertEntry)); + when(caCache.getCa()).thenReturn(cacheEntry(caCertEntry)); + + trustManager = new CollectorCaTrustManager(caCache, TestClocks.fixedEpoch()); + } + + @Test + void checkClientTrusted_acceptsCertSignedBySigningCert() throws Exception { + final var clientCertEntry = certBuilder.createEndEntityCert( + "test-agent", signingCertEntry, KeyUsage.digitalSignature, + KeyPurposeId.id_kp_clientAuth, Duration.ofDays(30)); + final var clientCert = PemUtils.parseCertificate(clientCertEntry.certificate()); + + trustManager.checkClientTrusted(new X509Certificate[]{clientCert}, "Ed25519"); + } + + // The JDK TLS implementation reports "UNKNOWN" as the authType for Ed25519 client certificates + // because it has no well-known auth type label for Ed25519 in its cipher suite mapping. + @Test + void checkClientTrusted_acceptsUnknownAuthType() throws Exception { + final var clientCertEntry = certBuilder.createEndEntityCert( + "test-agent", signingCertEntry, KeyUsage.digitalSignature, + KeyPurposeId.id_kp_clientAuth, Duration.ofDays(30)); + final var clientCert = PemUtils.parseCertificate(clientCertEntry.certificate()); + + trustManager.checkClientTrusted(new X509Certificate[]{clientCert}, "UNKNOWN"); + } + + @Test + void checkClientTrusted_rejectsNullCerts() { + assertThatThrownBy(() -> trustManager.checkClientTrusted(null, "Ed25519")) + .isInstanceOf(CertificateException.class) + .hasMessage("No client certificates provided"); + } + + @Test + void checkClientTrusted_rejectsEmptyCerts() { + assertThatThrownBy(() -> trustManager.checkClientTrusted(new X509Certificate[0], "Ed25519")) + .isInstanceOf(CertificateException.class) + .hasMessage("No client certificates provided"); + } + + @Test + void checkClientTrusted_rejectsCertSignedByUnknownCa() throws Exception { + final var otherCa = certBuilder.createRootCa("Other CA", Algorithm.ED25519, Duration.ofDays(365)); + final var otherSigning = certBuilder.createIntermediateCa("Other Signing", otherCa, Duration.ofDays(365)); + final var untrustedCertEntry = certBuilder.createEndEntityCert( + "rogue-agent", otherSigning, KeyUsage.digitalSignature, + KeyPurposeId.id_kp_clientAuth, Duration.ofDays(30)); + final var untrustedCert = PemUtils.parseCertificate(untrustedCertEntry.certificate()); + + // The AKI of the untrusted cert won't match any known issuer SKI + assertThatThrownBy(() -> trustManager.checkClientTrusted(new X509Certificate[]{untrustedCert}, "Ed25519")) + .isInstanceOf(CertificateException.class) + .hasMessageContaining("No known issuer for Authority Key Identifier"); + } + + @Test + void checkClientTrusted_rejectsCertWithKnownIssuerButBadSignature() throws Exception { + // Create a cert signed by a different CA but forge the AKI to match our signing cert + final var otherCa = certBuilder.createRootCa("Other CA", Algorithm.ED25519, Duration.ofDays(365)); + final var otherSigning = certBuilder.createIntermediateCa("Other Signing", otherCa, Duration.ofDays(365)); + final var forgedCertEntry = certBuilder.createEndEntityCert( + "forged-agent", otherSigning, KeyUsage.digitalSignature, + KeyPurposeId.id_kp_clientAuth, Duration.ofDays(30)); + final var forgedCert = PemUtils.parseCertificate(forgedCertEntry.certificate()); + + // Make the AKI lookup return our signing cert, but the signature won't match + final var forgedAki = PemUtils.extractAuthorityKeyIdentifier(forgedCert).orElseThrow(); + when(caCache.getBySubjectKeyIdentifier(forgedAki)).thenReturn(Optional.of(cacheEntry(signingCertEntry))); + + assertThatThrownBy(() -> trustManager.checkClientTrusted(new X509Certificate[]{forgedCert}, "Ed25519")) + .isInstanceOf(CertificateException.class) + .hasMessageContaining("Client certificate verification failed"); + } + + @Test + void checkClientTrusted_rejectsExpiredCert() throws Exception { + // Create a cert that was valid for 1 day starting at epoch + final var shortLivedCertEntry = certBuilder.createEndEntityCert( + "expired-agent", signingCertEntry, KeyUsage.digitalSignature, + KeyPurposeId.id_kp_clientAuth, Duration.ofDays(1)); + final var expiredCert = PemUtils.parseCertificate(shortLivedCertEntry.certificate()); + + // Use a clock far in the future so the cert is expired + final var futureClock = Clock.fixed(Instant.EPOCH.plus(Duration.ofDays(365)), ZoneOffset.UTC); + final var futureTrustManager = new CollectorCaTrustManager(caCache, futureClock); + + assertThatThrownBy(() -> futureTrustManager.checkClientTrusted(new X509Certificate[]{expiredCert}, "Ed25519")) + .isInstanceOf(CertificateException.class); + } + + @Test + void checkClientTrusted_rejectsCertWithoutClientAuthEku() throws Exception { + final var serverCertEntry = certBuilder.createEndEntityCert( + "server-only", signingCertEntry, KeyUsage.digitalSignature, + KeyPurposeId.id_kp_serverAuth, Duration.ofDays(30)); + final var serverCert = PemUtils.parseCertificate(serverCertEntry.certificate()); + + final var serverCertAki = PemUtils.extractAuthorityKeyIdentifier(serverCert).orElseThrow(); + when(caCache.getBySubjectKeyIdentifier(serverCertAki)).thenReturn(Optional.of(cacheEntry(signingCertEntry))); + + assertThatThrownBy(() -> trustManager.checkClientTrusted(new X509Certificate[]{serverCert}, "Ed25519")) + .isInstanceOf(CertificateException.class) + .hasMessageContaining("clientAuth extended key usage"); + } + + @Test + void checkClientTrusted_rejectsCaCertUsedAsClientCert() throws Exception { + // An intermediate CA cert has basicConstraints isCA=true and should be rejected even + // when signature verification passes. The signing cert's AKI points to the root CA, + // so we mock that lookup to return the root CA entry. + final var signingCert = PemUtils.parseCertificate(signingCertEntry.certificate()); + final var signingCertAki = PemUtils.extractAuthorityKeyIdentifier(signingCert).orElseThrow(); + final var caCacheEntry = caCache.getCa(); + when(caCache.getBySubjectKeyIdentifier(signingCertAki)).thenReturn(Optional.of(caCacheEntry)); + + assertThatThrownBy(() -> trustManager.checkClientTrusted(new X509Certificate[]{signingCert}, "Ed25519")) + .isInstanceOf(CertificateException.class) + .hasMessageContaining("end-entity certificate, not a CA"); + } + + @Test + void checkClientTrusted_rejectsIssuerWithoutCaBasicConstraints() throws Exception { + // Create an end-entity cert and use it as the "issuer" in the SKI lookup. + // Even if the signature would verify, the issuer must be a CA. + final var endEntityEntry = certBuilder.createEndEntityCert( + "not-a-ca", signingCertEntry, KeyUsage.digitalSignature, + KeyPurposeId.id_kp_serverAuth, Duration.ofDays(30)); + + // Create a client cert signed by the real signing cert + final var clientCertEntry = certBuilder.createEndEntityCert( + "test-agent", signingCertEntry, KeyUsage.digitalSignature, + KeyPurposeId.id_kp_clientAuth, Duration.ofDays(30)); + final var clientCert = PemUtils.parseCertificate(clientCertEntry.certificate()); + + // Make the AKI lookup return the end-entity cert instead of the real signing cert + final var clientAki = PemUtils.extractAuthorityKeyIdentifier(clientCert).orElseThrow(); + when(caCache.getBySubjectKeyIdentifier(clientAki)).thenReturn(Optional.of(cacheEntry(endEntityEntry))); + + assertThatThrownBy(() -> trustManager.checkClientTrusted(new X509Certificate[]{clientCert}, "Ed25519")) + .isInstanceOf(CertificateException.class) + .hasMessageContaining("Issuer certificate is not a CA"); + } + + @Test + void checkClientTrusted_rejectsIssuerWithoutKeyCertSign() throws Exception { + // Create a valid client cert signed by the real signing cert + final var clientCertEntry = certBuilder.createEndEntityCert( + "test-agent", signingCertEntry, KeyUsage.digitalSignature, + KeyPurposeId.id_kp_clientAuth, Duration.ofDays(30)); + final var clientCert = PemUtils.parseCertificate(clientCertEntry.certificate()); + + // Create a mock issuer cert that is a CA but lacks keyCertSign + final var fakeIssuer = mock(X509Certificate.class); + when(fakeIssuer.getBasicConstraints()).thenReturn(0); // CA:TRUE + when(fakeIssuer.getKeyUsage()).thenReturn(new boolean[]{true, false, false, false, false, false, false, false, false}); + + final var clientAki = PemUtils.extractAuthorityKeyIdentifier(clientCert).orElseThrow(); + final var fakeEntry = new CollectorCaCache.CacheEntry(null, fakeIssuer, "fake"); + when(caCache.getBySubjectKeyIdentifier(clientAki)).thenReturn(Optional.of(fakeEntry)); + + assertThatThrownBy(() -> trustManager.checkClientTrusted(new X509Certificate[]{clientCert}, "Ed25519")) + .isInstanceOf(CertificateException.class) + .hasMessageContaining("keyCertSign"); + } + + @Test + void checkClientTrusted_rejectsIssuerNotSignedByRootCa() throws Exception { + // Create a separate CA hierarchy not rooted in our trust anchor + final var rogueRoot = certBuilder.createRootCa("Rogue Root", Algorithm.ED25519, Duration.ofDays(365)); + final var rogueSigning = certBuilder.createIntermediateCa("Rogue Signing", rogueRoot, Duration.ofDays(365)); + final var clientCertEntry = certBuilder.createEndEntityCert( + "rogue-agent", rogueSigning, KeyUsage.digitalSignature, + KeyPurposeId.id_kp_clientAuth, Duration.ofDays(30)); + final var clientCert = PemUtils.parseCertificate(clientCertEntry.certificate()); + + // Mock SKI lookup to return the rogue signing cert (valid CA, but wrong root) + final var clientAki = PemUtils.extractAuthorityKeyIdentifier(clientCert).orElseThrow(); + when(caCache.getBySubjectKeyIdentifier(clientAki)).thenReturn(Optional.of(cacheEntry(rogueSigning))); + + assertThatThrownBy(() -> trustManager.checkClientTrusted(new X509Certificate[]{clientCert}, "Ed25519")) + .isInstanceOf(CertificateException.class) + .hasMessageContaining("not signed by the collectors root CA"); + } + + @Test + void checkClientTrusted_rejectsExpiredIssuer() throws Exception { + // Create a valid client cert signed by the real signing cert + final var clientCertEntry = certBuilder.createEndEntityCert( + "test-agent", signingCertEntry, KeyUsage.digitalSignature, + KeyPurposeId.id_kp_clientAuth, Duration.ofDays(30)); + final var clientCert = PemUtils.parseCertificate(clientCertEntry.certificate()); + + // Mock an expired issuer: a CA with keyCertSign, but checkValidity throws + final var expiredIssuer = mock(X509Certificate.class); + when(expiredIssuer.getBasicConstraints()).thenReturn(0); + when(expiredIssuer.getKeyUsage()).thenReturn(new boolean[]{false, false, false, false, false, true, false, false, false}); + doThrow(new java.security.cert.CertificateExpiredException("expired")).when(expiredIssuer).checkValidity(any()); + + final var clientAki = PemUtils.extractAuthorityKeyIdentifier(clientCert).orElseThrow(); + final var fakeEntry = new CollectorCaCache.CacheEntry(null, expiredIssuer, "expired-issuer"); + when(caCache.getBySubjectKeyIdentifier(clientAki)).thenReturn(Optional.of(fakeEntry)); + + assertThatThrownBy(() -> trustManager.checkClientTrusted(new X509Certificate[]{clientCert}, "Ed25519")) + .isInstanceOf(CertificateException.class); + } + + @Test + void checkClientTrusted_rejectsSelfSignedCert() throws Exception { + // A self-signed root CA cert has no AKI, so it should be rejected + final var selfSignedCa = certBuilder.createRootCa("Self Signed", Algorithm.ED25519, Duration.ofDays(365)); + final var selfSignedCert = PemUtils.parseCertificate(selfSignedCa.certificate()); + + assertThatThrownBy(() -> trustManager.checkClientTrusted(new X509Certificate[]{selfSignedCert}, "Ed25519")) + .isInstanceOf(CertificateException.class) + .hasMessageContaining("no Authority Key Identifier"); + } + + @Test + void checkServerTrusted_throwsUnsupportedOperationException() { + assertThatThrownBy(() -> trustManager.checkServerTrusted(new X509Certificate[0], "Ed25519")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void getAcceptedIssuers_returnsEmptyArray() { + assertThat(trustManager.getAcceptedIssuers()).isEmpty(); + } + + private CollectorCaCache.CacheEntry cacheEntry(CertificateEntry entry) throws Exception { + final var cert = PemUtils.parseCertificate(entry.certificate()); + final var privateKey = PemUtils.parsePrivateKey(encryptedValueService.decrypt(entry.privateKey())); + return new CollectorCaCache.CacheEntry(privateKey, cert, entry.fingerprint()); + } +} diff --git a/graylog2-server/src/test/java/org/graylog/collectors/CollectorTLSUtilsIT.java b/graylog2-server/src/test/java/org/graylog/collectors/CollectorTLSUtilsIT.java new file mode 100644 index 000000000000..4ae23cfa0983 --- /dev/null +++ b/graylog2-server/src/test/java/org/graylog/collectors/CollectorTLSUtilsIT.java @@ -0,0 +1,327 @@ +/* + * 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.collectors; + +import com.google.common.eventbus.EventBus; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.MultiThreadIoEventLoopGroup; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.channel.nio.NioIoHandler; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpServerCodec; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.ssl.SslContext; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.graylog.collectors.input.transport.AgentCertChannelHandler; +import org.graylog.security.pki.CertificateBuilder; +import org.graylog.security.pki.CertificateEntry; +import org.graylog.security.pki.CertificateService; +import org.graylog.security.pki.PemUtils; +import org.graylog.testing.cluster.ClusterConfigServiceExtension; +import org.graylog.testing.mongodb.MongoDBExtension; +import org.graylog2.database.MongoCollections; +import org.graylog2.events.ClusterEventBus; +import org.graylog2.plugin.cluster.ClusterConfigService; +import org.graylog2.plugin.cluster.ClusterIdService; +import org.graylog2.security.TrustAllX509TrustManager; +import org.graylog2.security.encryption.EncryptedValueService; +import org.graylog2.web.customization.CustomizationConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509ExtendedKeyManager; +import javax.net.ssl.X509ExtendedTrustManager; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.Principal; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.time.Clock; +import java.time.Duration; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Integration test for {@link CollectorTLSUtils} with a real Netty server. + *

+ * The test creates a three-level CA hierarchy (root CA → signing cert → server cert), + * wires it through mocked {@link CollectorCaService} into a real {@link CollectorCaKeyManager} and + * {@link CollectorCaTrustManager}, then verifies Ed25519 mTLS handshakes succeed end-to-end. + */ +@ExtendWith(MongoDBExtension.class) +@ExtendWith(ClusterConfigServiceExtension.class) +class CollectorTLSUtilsIT { + + private static final String AGENT_INSTANCE_UID = "test-agent-42"; + private static final Duration CERT_VALIDITY = Duration.ofDays(1); + + private PrivateKey agentKey; + private X509Certificate agentCert; + private X509Certificate signingCert; + + private final EncryptedValueService encryptedValueService = new EncryptedValueService("1234567890abcdef"); + + private CollectorCaCache caCache; + private CollectorTLSUtils tlsUtils; + private Channel serverChannel; + private EventLoopGroup bossGroup; + private EventLoopGroup workerGroup; + + @BeforeEach + void setUp(MongoCollections mongoCollections, ClusterConfigService clusterConfigService) throws Exception { + final var certBuilder = new CertificateBuilder(encryptedValueService, "Test", Clock.systemUTC()); + + final var certService = new CertificateService(mongoCollections, encryptedValueService, CustomizationConfig.empty(), Clock.systemUTC()); + final var clusterIdService = mock(ClusterIdService.class); + final var collectorsConfigService = new CollectorsConfigService(clusterConfigService, new ClusterEventBus()); + final var caService = new CollectorCaService(certService, clusterIdService, collectorsConfigService, Clock.systemUTC()); + + when(clusterIdService.getString()).thenReturn(UUID.randomUUID().toString()); + + final var hierarchy = caService.initializeCa(); + + collectorsConfigService.save(CollectorsConfig.createDefaultBuilder("localhost") + .caCertId(hierarchy.caCert().id()) + .signingCertId(hierarchy.signingCert().id()) + .otlpServerCertId(hierarchy.otlpServerCert().id()) + .build()); + + final CertificateEntry agentCertEntry = certBuilder.createEndEntityCert( + AGENT_INSTANCE_UID, + hierarchy.signingCert(), + KeyUsage.digitalSignature, + KeyPurposeId.id_kp_clientAuth, + CERT_VALIDITY + ); + + signingCert = PemUtils.parseCertificate(hierarchy.signingCert().certificate()); + agentKey = PemUtils.parsePrivateKey(encryptedValueService.decrypt(agentCertEntry.privateKey())); + agentCert = PemUtils.parseCertificate(agentCertEntry.certificate()); + + caCache = new CollectorCaCache(caService, certService, encryptedValueService, new EventBus(), Clock.systemUTC()); + caCache.startAsync().awaitRunning(); + final var keyManager = new CollectorCaKeyManager(caCache); + final var trustManager = new CollectorCaTrustManager(caCache, Clock.systemUTC()); + tlsUtils = new CollectorTLSUtils(keyManager, trustManager); + + bossGroup = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory()); + workerGroup = new MultiThreadIoEventLoopGroup(2, NioIoHandler.newFactory()); + } + + @AfterEach + void tearDown() throws Exception { + caCache.stopAsync().awaitTerminated(); + if (serverChannel != null) { + serverChannel.close().sync(); + } + bossGroup.shutdownGracefully().sync(); + workerGroup.shutdownGracefully().sync(); + } + + @Test + void mtlsHandshakeSucceedsWithKeyManagerProvidedCerts() throws Exception { + final int port = startServer(); + final HttpClient client = createMtlsClient(agentKey, agentCert); + + final HttpResponse response = client.send( + HttpRequest.newBuilder() + .uri(URI.create("https://127.0.0.1:" + port + "/test")) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString() + ); + + assertThat(response.statusCode()).isEqualTo(200); + // AgentCertChannelHandler extracts the CN from the client cert + assertThat(response.body()).isEqualTo(AGENT_INSTANCE_UID); + } + + @Test + void mtlsRejectsConnectionWithoutClientCert() throws Exception { + final int port = startServer(); + + final SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, new TrustManager[]{new TrustAllX509TrustManager()}, null); + final HttpClient client = HttpClient.newBuilder().sslContext(sslContext).build(); + + assertThatThrownBy(() -> client.send( + HttpRequest.newBuilder() + .uri(URI.create("https://127.0.0.1:" + port + "/test")) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString() + )).hasCauseInstanceOf(SSLHandshakeException.class); + } + + private int startServer() throws Exception { + final SslContext sslContext = tlsUtils.newServerSslContextBuilder().build(); + + final ServerBootstrap bootstrap = new ServerBootstrap() + .group(bossGroup, workerGroup) + .channel(NioServerSocketChannel.class) + .childHandler(new ChannelInitializer() { + @Override + protected void initChannel(SocketChannel ch) { + final ChannelPipeline pipeline = ch.pipeline(); + pipeline.addLast("ssl", sslContext.newHandler(ch.alloc())); + pipeline.addLast("agent-cert-handler", new AgentCertChannelHandler()); + pipeline.addLast("http-codec", new HttpServerCodec()); + pipeline.addLast("http-aggregator", new HttpObjectAggregator(64 * 1024)); + pipeline.addLast("handler", new EchoAgentUidHandler()); + } + }); + + serverChannel = bootstrap.bind("127.0.0.1", 0).sync().channel(); + return ((InetSocketAddress) serverChannel.localAddress()).getPort(); + } + + private HttpClient createMtlsClient(PrivateKey clientKey, X509Certificate clientCert) throws Exception { + final X509ExtendedKeyManager km = new SimpleKeyManager(clientKey, clientCert, signingCert); + final SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(new KeyManager[]{km}, new TrustManager[]{new TrustAllManager()}, null); + return HttpClient.newBuilder().sslContext(sslContext).build(); + } + + /** + * Simple HTTP handler that echoes the agent instance UID extracted by {@link AgentCertChannelHandler}, + * or "ok" if no UID path is requested at {@code /test}. + */ + private static class EchoAgentUidHandler extends SimpleChannelInboundHandler { + @Override + protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) { + final String uid = ctx.channel().attr(AgentCertChannelHandler.AGENT_INSTANCE_UID).get(); + final byte[] body = (uid != null ? uid : "ok").getBytes(StandardCharsets.UTF_8); + + final DefaultFullHttpResponse response = new DefaultFullHttpResponse( + HttpVersion.HTTP_1_1, HttpResponseStatus.OK, Unpooled.wrappedBuffer(body)); + response.headers().set(HttpHeaderNames.CONTENT_LENGTH, body.length); + ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); + } + } + + private static class SimpleKeyManager extends X509ExtendedKeyManager { + private final PrivateKey privateKey; + private final X509Certificate[] certChain; + + SimpleKeyManager(PrivateKey privateKey, X509Certificate clientCert, X509Certificate issuerCert) { + this.privateKey = privateKey; + this.certChain = new X509Certificate[]{clientCert, issuerCert}; + } + + @Override + public String[] getClientAliases(String keyType, Principal[] issuers) { + return new String[]{"agent"}; + } + + @Override + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { + return "agent"; + } + + @Override + public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { + return "agent"; + } + + @Override + public String[] getServerAliases(String keyType, Principal[] issuers) { + return null; + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { + return null; + } + + @Override + public X509Certificate[] getCertificateChain(String alias) { + return certChain; + } + + @Override + public PrivateKey getPrivateKey(String alias) { + return privateKey; + } + } + + /** + * Trust manager that accepts all certificates and skips hostname verification. + *

+ * Must extend {@link X509ExtendedTrustManager} (not just {@link javax.net.ssl.X509TrustManager}) + * because the JDK wraps plain X509TrustManager in AbstractTrustManagerWrapper which adds + * hostname/IP identity checks. X509ExtendedTrustManager is used directly, bypassing the wrapper. + */ + private static class TrustAllManager extends X509ExtendedTrustManager { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + } + + public void checkServerTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) { + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) { + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) { + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) { + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } +} diff --git a/graylog2-server/src/test/java/org/graylog/collectors/CollectorsConfigServiceTest.java b/graylog2-server/src/test/java/org/graylog/collectors/CollectorsConfigServiceTest.java new file mode 100644 index 000000000000..5e415e0d538d --- /dev/null +++ b/graylog2-server/src/test/java/org/graylog/collectors/CollectorsConfigServiceTest.java @@ -0,0 +1,114 @@ +/* + * 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.collectors; + +import org.graylog.collectors.events.CollectorCaConfigUpdated; +import org.graylog2.events.ClusterEventBus; +import org.graylog2.plugin.cluster.ClusterConfigService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class CollectorsConfigServiceTest { + + private ClusterConfigService clusterConfigService; + private ClusterEventBus clusterEventBus; + private CollectorsConfigService service; + + @BeforeEach + void setUp() { + clusterConfigService = mock(ClusterConfigService.class); + clusterEventBus = mock(ClusterEventBus.class); + service = new CollectorsConfigService(clusterConfigService, clusterEventBus); + } + + private CollectorsConfig configWithCerts(String caCertId, String signingCertId, String serverCertId) { + return CollectorsConfig.createDefaultBuilder("localhost") + .caCertId(caCertId) + .signingCertId(signingCertId) + .otlpServerCertId(serverCertId) + .build(); + } + + @Test + void save_firesEventWhenCaCertIdChanges() { + final var existing = configWithCerts("ca-1", "signing-1", "server-1"); + when(clusterConfigService.get(CollectorsConfig.class)).thenReturn(existing); + + service.save(configWithCerts("ca-2", "signing-1", "server-1")); + + verify(clusterEventBus).post(any(CollectorCaConfigUpdated.class)); + } + + @Test + void save_firesEventWhenSigningCertIdChanges() { + final var existing = configWithCerts("ca-1", "signing-1", "server-1"); + when(clusterConfigService.get(CollectorsConfig.class)).thenReturn(existing); + + service.save(configWithCerts("ca-1", "signing-2", "server-1")); + + verify(clusterEventBus).post(any(CollectorCaConfigUpdated.class)); + } + + @Test + void save_firesEventWhenServerCertIdChanges() { + final var existing = configWithCerts("ca-1", "signing-1", "server-1"); + when(clusterConfigService.get(CollectorsConfig.class)).thenReturn(existing); + + service.save(configWithCerts("ca-1", "signing-1", "server-2")); + + verify(clusterEventBus).post(any(CollectorCaConfigUpdated.class)); + } + + @Test + void save_doesNotFireEventWhenCertIdsUnchanged() { + final var existing = configWithCerts("ca-1", "signing-1", "server-1"); + when(clusterConfigService.get(CollectorsConfig.class)).thenReturn(existing); + + service.save(configWithCerts("ca-1", "signing-1", "server-1")); + + verify(clusterEventBus, never()).post(any()); + } + + @Test + void save_doesNotFireEventWhenNonCertFieldChanges() { + final var existing = configWithCerts("ca-1", "signing-1", "server-1"); + when(clusterConfigService.get(CollectorsConfig.class)).thenReturn(existing); + + // Change only the HTTP port, not cert IDs + final var updated = existing.toBuilder() + .http(new IngestEndpointConfig(true, "localhost", 9999, null)) + .build(); + service.save(updated); + + verify(clusterEventBus, never()).post(any()); + } + + @Test + void save_doesNotFireEventOnFirstSave() { + when(clusterConfigService.get(CollectorsConfig.class)).thenReturn(null); + + service.save(configWithCerts("ca-1", "signing-1", "server-1")); + + verify(clusterEventBus, never()).post(any()); + } +} diff --git a/graylog2-server/src/test/java/org/graylog/collectors/opamp/auth/AgentTokenServiceTest.java b/graylog2-server/src/test/java/org/graylog/collectors/opamp/auth/AgentTokenServiceTest.java index 2b5d71bb16fa..b128b7492d1c 100644 --- a/graylog2-server/src/test/java/org/graylog/collectors/opamp/auth/AgentTokenServiceTest.java +++ b/graylog2-server/src/test/java/org/graylog/collectors/opamp/auth/AgentTokenServiceTest.java @@ -40,6 +40,7 @@ import org.graylog.testing.mongodb.MongoDBTestService; import org.graylog2.bindings.providers.MongoJackObjectMapperProvider; import org.graylog2.database.MongoCollections; +import org.graylog2.events.ClusterEventBus; import org.graylog2.jackson.InputConfigurationBeanDeserializerModifier; import org.graylog2.plugin.cluster.ClusterConfigService; import org.graylog2.plugin.cluster.ClusterIdService; @@ -98,8 +99,8 @@ void setUp(MongoDBTestService mongodb, ClusterConfigService clusterConfigService final var clusterIdService = mock(ClusterIdService.class); when(clusterIdService.getString()).thenReturn(TEST_CLUSTER_ID); collectorInstanceService = new CollectorInstanceService(mongoCollections); - collectorsConfigService = new CollectorsConfigService(clusterConfigService); - collectorCaService = new CollectorCaService(certificateService, clusterIdService, collectorsConfigService); + collectorsConfigService = new CollectorsConfigService(clusterConfigService, mock(ClusterEventBus.class)); + collectorCaService = new CollectorCaService(certificateService, clusterIdService, collectorsConfigService, clock); agentTokenService = new AgentTokenService(collectorInstanceService, clock); } diff --git a/graylog2-server/src/test/java/org/graylog/security/pki/CertificateBuilderTest.java b/graylog2-server/src/test/java/org/graylog/security/pki/CertificateBuilderTest.java index 67798d849936..6940c8f71959 100644 --- a/graylog2-server/src/test/java/org/graylog/security/pki/CertificateBuilderTest.java +++ b/graylog2-server/src/test/java/org/graylog/security/pki/CertificateBuilderTest.java @@ -24,6 +24,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; import java.security.cert.CertPathValidator; import java.security.cert.CertPathValidatorException; import java.security.cert.CertificateFactory; @@ -425,6 +428,236 @@ void createEndEntityCertWithoutSansHasNoSanExtension() throws Exception { assertThat(cert.getSubjectAlternativeNames()).isNull(); } + // End-entity certificate lifetime cap tests + + @Test + void createEndEntityCertCapsLifetimeToIssuerRemainingLifetime() throws Exception { + final CertificateEntry rootCa = builder.createRootCa("Root CA", Algorithm.ED25519, Duration.ofDays(3650)); + final CertificateEntry shortIssuer = builder.createIntermediateCa("Short CA", rootCa, Duration.ofDays(30)); + + final CertificateEntry endEntityCert = builder.createEndEntityCert( + "Server", shortIssuer, KeyUsage.digitalSignature | KeyUsage.keyEncipherment, + KeyPurposeId.id_kp_serverAuth, Duration.ofDays(365)); + + final X509Certificate cert = PemUtils.parseCertificate(endEntityCert.certificate()); + final Duration actualValidity = Duration.between( + cert.getNotBefore().toInstant(), cert.getNotAfter().toInstant()); + assertThat(actualValidity.toDays()).isEqualTo(30); + } + + @Test + void createEndEntityCertUsesRequestedLifetimeWhenIssuerHasEnoughRemaining() throws Exception { + final CertificateEntry rootCa = builder.createRootCa("Root CA", Algorithm.ED25519, Duration.ofDays(3650)); + final CertificateEntry issuer = builder.createIntermediateCa("Signing CA", rootCa, Duration.ofDays(1825)); + + final CertificateEntry endEntityCert = builder.createEndEntityCert( + "Server", issuer, KeyUsage.digitalSignature | KeyUsage.keyEncipherment, + KeyPurposeId.id_kp_serverAuth, Duration.ofDays(365)); + + final X509Certificate cert = PemUtils.parseCertificate(endEntityCert.certificate()); + final Duration actualValidity = Duration.between( + cert.getNotBefore().toInstant(), cert.getNotAfter().toInstant()); + assertThat(actualValidity.toDays()).isEqualTo(365); + } + + @Test + void createEndEntityCertRejectsExpiredIssuer() throws Exception { + final CertificateEntry rootCa = builder.createRootCa("Root CA", Algorithm.ED25519, Duration.ofDays(3650)); + final CertificateEntry shortIssuer = builder.createIntermediateCa("Expired CA", rootCa, Duration.ofDays(1)); + + final var futureBuilder = new CertificateBuilder( + new EncryptedValueService("1234567890abcdef"), "Graylog", + Clock.offset(clock, Duration.ofDays(2))); + + assertThatThrownBy(() -> futureBuilder.createEndEntityCert( + "Server", shortIssuer, KeyUsage.digitalSignature, + KeyPurposeId.id_kp_serverAuth, Duration.ofDays(365))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("expired"); + } + + // CSR signing tests + + @Test + void signCsrCertHasSkiAndAkiMatchingIssuerSki() throws Exception { + final CertificateEntry rootCa = builder.createRootCa("Root CA", Algorithm.ED25519, Duration.ofDays(3650)); + final CertificateEntry intermediateCa = builder.createIntermediateCa("Signing CA", rootCa, Duration.ofDays(1825)); + + final KeyPair agentKeyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + final byte[] csrPem = builder.createCsr(agentKeyPair, "test-agent"); + final X509Certificate signedCert = builder.signCsr(csrPem, intermediateCa, "test-agent", Duration.ofDays(365)); + + final X509Certificate intermediateCert = PemUtils.parseCertificate(intermediateCa.certificate()); + + assertThat(PemUtils.extractSubjectKeyIdentifier(signedCert)).isPresent(); + assertThat(PemUtils.extractAuthorityKeyIdentifier(signedCert)) + .isEqualTo(PemUtils.extractSubjectKeyIdentifier(intermediateCert)); + } + + @Test + void signCsrSetsSubjectCnAndIssuerDn() throws Exception { + final CertificateEntry rootCa = builder.createRootCa("Root CA", Algorithm.ED25519, Duration.ofDays(3650)); + final CertificateEntry issuer = builder.createIntermediateCa("Signing CA", rootCa, Duration.ofDays(1825)); + + final KeyPair agentKeyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + final byte[] csrPem = builder.createCsr(agentKeyPair, "test-agent"); + final X509Certificate cert = builder.signCsr(csrPem, issuer, "my-agent-uid", Duration.ofDays(365)); + + assertThat(cert.getSubjectX500Principal().getName()).contains("CN=my-agent-uid"); + assertThat(cert.getSubjectX500Principal().getName()).contains("O=Graylog"); + assertThat(cert.getIssuerX500Principal().getName()).contains("CN=Signing CA"); + } + + @Test + void signCsrCertIsVerifiableByIssuer() throws Exception { + final CertificateEntry rootCa = builder.createRootCa("Root CA", Algorithm.ED25519, Duration.ofDays(3650)); + final CertificateEntry issuer = builder.createIntermediateCa("Signing CA", rootCa, Duration.ofDays(1825)); + + final KeyPair agentKeyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + final byte[] csrPem = builder.createCsr(agentKeyPair, "test-agent"); + final X509Certificate cert = builder.signCsr(csrPem, issuer, "test-agent", Duration.ofDays(365)); + + final X509Certificate issuerCert = PemUtils.parseCertificate(issuer.certificate()); + assertThatCode(() -> cert.verify(issuerCert.getPublicKey())).doesNotThrowAnyException(); + } + + @Test + void signCsrCertHasBasicConstraintsCaFalse() throws Exception { + final CertificateEntry rootCa = builder.createRootCa("Root CA", Algorithm.ED25519, Duration.ofDays(3650)); + final CertificateEntry issuer = builder.createIntermediateCa("Signing CA", rootCa, Duration.ofDays(1825)); + + final KeyPair agentKeyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + final byte[] csrPem = builder.createCsr(agentKeyPair, "test-agent"); + final X509Certificate cert = builder.signCsr(csrPem, issuer, "test-agent", Duration.ofDays(365)); + + assertThat(cert.getBasicConstraints()).isEqualTo(-1); + } + + @Test + void signCsrCertHasDigitalSignatureKeyUsage() throws Exception { + final CertificateEntry rootCa = builder.createRootCa("Root CA", Algorithm.ED25519, Duration.ofDays(3650)); + final CertificateEntry issuer = builder.createIntermediateCa("Signing CA", rootCa, Duration.ofDays(1825)); + + final KeyPair agentKeyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + final byte[] csrPem = builder.createCsr(agentKeyPair, "test-agent"); + final X509Certificate cert = builder.signCsr(csrPem, issuer, "test-agent", Duration.ofDays(365)); + + final boolean[] keyUsage = cert.getKeyUsage(); + assertThat(keyUsage).isNotNull(); + assertThat(keyUsage[0]).isTrue(); // digitalSignature + assertThat(keyUsage[5]).isFalse(); // keyCertSign must NOT be set + } + + @Test + void signCsrCertHasClientAuthEku() throws Exception { + final CertificateEntry rootCa = builder.createRootCa("Root CA", Algorithm.ED25519, Duration.ofDays(3650)); + final CertificateEntry issuer = builder.createIntermediateCa("Signing CA", rootCa, Duration.ofDays(1825)); + + final KeyPair agentKeyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + final byte[] csrPem = builder.createCsr(agentKeyPair, "test-agent"); + final X509Certificate cert = builder.signCsr(csrPem, issuer, "test-agent", Duration.ofDays(365)); + + assertThat(cert.getExtendedKeyUsage()).contains(KeyPurposeId.id_kp_clientAuth.getId()); + } + + @Test + void signCsrAcceptsEdDSAKeyAlgorithmName() throws Exception { + final CertificateEntry rootCa = builder.createRootCa("Root CA", Algorithm.ED25519, Duration.ofDays(3650)); + final CertificateEntry issuer = builder.createIntermediateCa("Signing CA", rootCa, Duration.ofDays(1825)); + + // The JDK reports "EdDSA" (not "Ed25519") when using the "EdDSA" generator name + final KeyPair edDsaKeyPair = KeyPairGenerator.getInstance("EdDSA").generateKeyPair(); + assertThat(edDsaKeyPair.getPublic().getAlgorithm()).isEqualTo("EdDSA"); + + final byte[] csrPem = builder.createCsr(edDsaKeyPair, "eddsa-agent"); + final X509Certificate cert = builder.signCsr(csrPem, issuer, "eddsa-agent", Duration.ofDays(365)); + + assertThat(cert).isNotNull(); + assertThat(cert.getSubjectX500Principal().getName()).contains("CN=eddsa-agent"); + } + + @Test + void signCsrRejectsRsaKey() throws Exception { + final CertificateEntry rootCa = builder.createRootCa("Root CA", Algorithm.ED25519, Duration.ofDays(3650)); + final CertificateEntry issuer = builder.createIntermediateCa("Signing CA", rootCa, Duration.ofDays(1825)); + + final KeyPair rsaKeyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); + final byte[] csrPem = builder.createCsr(rsaKeyPair, "rsa-agent"); + + assertThatThrownBy(() -> builder.signCsr(csrPem, issuer, "rsa-agent", Duration.ofDays(365))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Ed25519"); + } + + @Test + void signCsrRejectsInvalidCsrSignature() throws Exception { + final CertificateEntry rootCa = builder.createRootCa("Root CA", Algorithm.ED25519, Duration.ofDays(3650)); + final CertificateEntry issuer = builder.createIntermediateCa("Signing CA", rootCa, Duration.ofDays(1825)); + + // Create a CSR with one key pair, then tamper by creating another CSR and mixing PEM + // Simplest: pass garbage PEM + final byte[] invalidCsr = "-----BEGIN CERTIFICATE REQUEST-----\ngarbage\n-----END CERTIFICATE REQUEST-----".getBytes(StandardCharsets.UTF_8); + + assertThatThrownBy(() -> builder.signCsr(invalidCsr, issuer, "bad-agent", Duration.ofDays(365))) + .isInstanceOf(Exception.class); + } + + // CSR signing lifetime cap tests + + @Test + void signCsrCapsLifetimeToIssuerRemainingLifetime() throws Exception { + // Create an issuer with only 30 days of validity + final CertificateEntry rootCa = builder.createRootCa("Root CA", Algorithm.ED25519, Duration.ofDays(3650)); + final CertificateEntry shortLivedIssuer = builder.createIntermediateCa("Short Signing CA", rootCa, Duration.ofDays(30)); + + final KeyPair agentKeyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + final byte[] csrPem = builder.createCsr(agentKeyPair, "test-agent"); + + // Request 365 days, but issuer only has 30 days left + final X509Certificate signedCert = builder.signCsr(csrPem, shortLivedIssuer, "test-agent", Duration.ofDays(365)); + + final Duration actualValidity = Duration.between( + signedCert.getNotBefore().toInstant(), + signedCert.getNotAfter().toInstant() + ); + assertThat(actualValidity.toDays()).isEqualTo(30); + } + + @Test + void signCsrUsesRequestedLifetimeWhenIssuerHasEnoughRemaining() throws Exception { + final CertificateEntry rootCa = builder.createRootCa("Root CA", Algorithm.ED25519, Duration.ofDays(3650)); + final CertificateEntry issuer = builder.createIntermediateCa("Signing CA", rootCa, Duration.ofDays(1825)); + + final KeyPair agentKeyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + final byte[] csrPem = builder.createCsr(agentKeyPair, "test-agent"); + + final X509Certificate signedCert = builder.signCsr(csrPem, issuer, "test-agent", Duration.ofDays(365)); + + final Duration actualValidity = Duration.between( + signedCert.getNotBefore().toInstant(), + signedCert.getNotAfter().toInstant() + ); + assertThat(actualValidity.toDays()).isEqualTo(365); + } + + @Test + void signCsrRejectsExpiredIssuer() throws Exception { + // Create an issuer with 1 day validity, then use a clock past that + final CertificateEntry rootCa = builder.createRootCa("Root CA", Algorithm.ED25519, Duration.ofDays(3650)); + final CertificateEntry shortLivedIssuer = builder.createIntermediateCa("Expired CA", rootCa, Duration.ofDays(1)); + + final var futureBuilder = new CertificateBuilder( + new EncryptedValueService("1234567890abcdef"), "Graylog", + Clock.offset(clock, Duration.ofDays(2))); + + final KeyPair agentKeyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); + final byte[] csrPem = futureBuilder.createCsr(agentKeyPair, "test-agent"); + + assertThatThrownBy(() -> futureBuilder.signCsr(csrPem, shortLivedIssuer, "test-agent", Duration.ofDays(365))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("expired"); + } + // PKIX trust chain validation tests @Test diff --git a/graylog2-server/src/test/java/org/graylog/security/pki/CertificateEntryTest.java b/graylog2-server/src/test/java/org/graylog/security/pki/CertificateEntryTest.java index d005b97e689e..1d0f305aa21a 100644 --- a/graylog2-server/src/test/java/org/graylog/security/pki/CertificateEntryTest.java +++ b/graylog2-server/src/test/java/org/graylog/security/pki/CertificateEntryTest.java @@ -100,6 +100,7 @@ void recordFieldsAreAccessible() { "test-id", "SHA256:abc123", "ski", + Optional.of("aki"), privateKey, "-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----", issuerChain, @@ -112,6 +113,8 @@ void recordFieldsAreAccessible() { assertThat(entry.id()).isEqualTo("test-id"); assertThat(entry.fingerprint()).isEqualTo("SHA256:abc123"); + assertThat(entry.subjectKeyIdentifier()).isEqualTo("ski"); + assertThat(entry.authorityKeyIdentifier()).hasValue("aki"); assertThat(entry.privateKey()).isEqualTo(privateKey); assertThat(entry.certificate()).isEqualTo("-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----"); assertThat(entry.issuerChain()).isEqualTo(issuerChain); @@ -133,6 +136,7 @@ void withIdCreatesNewInstanceWithNewId() { null, "SHA256:abc123", "ski", + Optional.empty(), privateKey, "-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----", List.of(), @@ -168,6 +172,7 @@ void jsonSerializationUsesCorrectPropertyNames() throws Exception { "test-id", "SHA256:abc123", "ski", + Optional.empty(), privateKey, "-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----", List.of("-----BEGIN CERTIFICATE-----\nISSUER\n-----END CERTIFICATE-----"), @@ -204,6 +209,7 @@ void mongoDbRoundTrip() { null, "SHA256:abc123", "ski", + Optional.of("aki"), privateKey, "-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----", List.of("-----BEGIN CERTIFICATE-----\nISSUER\n-----END CERTIFICATE-----"), @@ -220,6 +226,8 @@ void mongoDbRoundTrip() { assertThat(retrieved).isPresent(); assertThat(retrieved.get().id()).isEqualTo(savedId); assertThat(retrieved.get().fingerprint()).isEqualTo("SHA256:abc123"); + assertThat(retrieved.get().subjectKeyIdentifier()).isEqualTo("ski"); + assertThat(retrieved.get().authorityKeyIdentifier()).hasValue("aki"); assertThat(retrieved.get().privateKey()).isEqualTo(privateKey); assertThat(retrieved.get().certificate()).isEqualTo("-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----"); assertThat(retrieved.get().issuerChain()).containsExactly("-----BEGIN CERTIFICATE-----\nISSUER\n-----END CERTIFICATE-----"); diff --git a/graylog2-server/src/test/java/org/graylog/security/pki/CertificateServiceTest.java b/graylog2-server/src/test/java/org/graylog/security/pki/CertificateServiceTest.java index a1c10fe4ff94..1269218c146c 100644 --- a/graylog2-server/src/test/java/org/graylog/security/pki/CertificateServiceTest.java +++ b/graylog2-server/src/test/java/org/graylog/security/pki/CertificateServiceTest.java @@ -100,6 +100,7 @@ void saveReplacesCertificateEntryWithExistingId() { savedId, "SHA256:updated", "ski", + Optional.of("aki"), createEncryptedValue(), "-----BEGIN CERTIFICATE-----\nUPDATED\n-----END CERTIFICATE-----", List.of(), @@ -196,6 +197,23 @@ void saveExtractsDnForIntermediateCa() throws Exception { assertThat(saved.issuerDn()).contains("Root"); } + @Test + void findBySubjectKeyIdentifierReturnsEmptyForNonExistentSki() { + assertThat(certificateService.findBySubjectKeyIdentifier("nonexistent")).isEmpty(); + } + + @Test + void findBySubjectKeyIdentifierReturnsSavedCertificateEntry() throws Exception { + final CertificateEntry saved = certificateService.save( + certificateService.builder().createRootCa("SKI Test CA", Algorithm.ED25519, Duration.ofDays(365))); + + final var result = certificateService.findBySubjectKeyIdentifier(saved.subjectKeyIdentifier()); + + assertThat(result).isPresent(); + assertThat(result.get().id()).isEqualTo(saved.id()); + assertThat(result.get().fingerprint()).isEqualTo(saved.fingerprint()); + } + @Test void integrationTestWithBuilder() throws Exception { // Test the full workflow: create cert with builder, save, retrieve @@ -216,6 +234,7 @@ private CertificateEntry createCertificateEntry(String id, String fingerprint) { id, fingerprint, "ski", + Optional.of("aki"), createEncryptedValue(), "-----BEGIN CERTIFICATE-----\nTEST\n-----END CERTIFICATE-----", List.of("-----BEGIN CERTIFICATE-----\nISSUER\n-----END CERTIFICATE-----"),