Skip to content

Commit 924d742

Browse files
berndclaude
andauthored
Add certificate renewal infrastructure for Collectors (#25537)
* Add SKI and AKI to the Collector certificates * Add CollectorCaCache class * Add CollectorCaCache#getBySubjectKeyIdentifier method * Add custom Key and Trust manager implementations * Add CollectorTLSUtils * Use CollectorTLSUtils in CollectorIngestHttpTransport * Implement CollectorCaService#renewCertificates * Cap collector cert lifetime to issuer's remaining lifetime CertificateBuilder#signCsr now ensures a signed certificate never outlives its issuer. When the requested lifetime exceeds the signing cert's remaining validity, it is automatically capped. * Only run the renewal periodical on leader nodes * Use SecureRandom to generate unique cert serial numbers --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 51d19f4 commit 924d742

24 files changed

Lines changed: 2192 additions & 85 deletions
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/*
2+
* Copyright (C) 2020 Graylog, Inc.
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the Server Side Public License, version 1,
6+
* as published by MongoDB, Inc.
7+
*
8+
* This program is distributed in the hope that it will be useful,
9+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
* Server Side Public License for more details.
12+
*
13+
* You should have received a copy of the Server Side Public License
14+
* along with this program. If not, see
15+
* <http://www.mongodb.com/licensing/server-side-public-license>.
16+
*/
17+
package org.graylog.collectors;
18+
19+
import com.github.benmanes.caffeine.cache.Cache;
20+
import com.github.benmanes.caffeine.cache.Caffeine;
21+
import com.github.benmanes.caffeine.cache.Expiry;
22+
import com.google.common.annotations.VisibleForTesting;
23+
import com.google.common.eventbus.EventBus;
24+
import com.google.common.eventbus.Subscribe;
25+
import com.google.common.util.concurrent.AbstractIdleService;
26+
import jakarta.inject.Inject;
27+
import jakarta.inject.Singleton;
28+
import org.graylog.collectors.events.CollectorCaConfigUpdated;
29+
import org.graylog.security.pki.CertificateEntry;
30+
import org.graylog.security.pki.CertificateService;
31+
import org.graylog.security.pki.PemUtils;
32+
import org.graylog2.security.encryption.EncryptedValueService;
33+
import org.slf4j.Logger;
34+
import org.slf4j.LoggerFactory;
35+
36+
import java.security.PrivateKey;
37+
import java.security.cert.X509Certificate;
38+
import java.time.Clock;
39+
import java.time.Duration;
40+
import java.time.Instant;
41+
import java.util.Optional;
42+
import java.util.function.Supplier;
43+
44+
import static org.graylog2.shared.utilities.StringUtils.requireNonBlank;
45+
46+
/**
47+
* Provides a CA cache that caches {@link CertificateEntry} instances based on their expiration date.
48+
*/
49+
@Singleton
50+
public class CollectorCaCache extends AbstractIdleService {
51+
private static final Logger LOG = LoggerFactory.getLogger(CollectorCaCache.class);
52+
53+
private static final String SERVER_KEY = "_static_:server";
54+
private static final String SIGNING_KEY = "_static_:signing";
55+
private static final String CA_KEY = "_static_:ca";
56+
57+
private final CollectorCaService caService;
58+
private final CertificateService certificateService;
59+
private final EncryptedValueService encryptedValueService;
60+
private final EventBus eventBus;
61+
private final Cache<String, CacheEntry> cache;
62+
63+
public record CacheEntry(PrivateKey privateKey, X509Certificate cert, String fingerprint) {
64+
}
65+
66+
@Inject
67+
public CollectorCaCache(CollectorCaService caService,
68+
CertificateService certificateService,
69+
EncryptedValueService encryptedValueService,
70+
EventBus eventBus,
71+
Clock clock) {
72+
this.caService = caService;
73+
this.certificateService = certificateService;
74+
this.encryptedValueService = encryptedValueService;
75+
this.eventBus = eventBus;
76+
this.cache = Caffeine.newBuilder()
77+
.expireAfter(Expiry.<String, CacheEntry>creating((key, value) ->
78+
Duration.between(Instant.now(clock), value.cert().getNotAfter().toInstant())))
79+
.initialCapacity(3)
80+
.build();
81+
}
82+
83+
/**
84+
* Get entry by certificate Subject Key Identifier.
85+
*
86+
* @param ski the cert Subject Key Identifier value
87+
* @return the cache entry or an empty optional
88+
*/
89+
public Optional<CacheEntry> getBySubjectKeyIdentifier(String ski) {
90+
requireNonBlank(ski, "Subject Key Identifier can't be blank");
91+
92+
return Optional.ofNullable(cache.get(ski, key -> getCacheEntry(
93+
() -> certificateService.findBySubjectKeyIdentifier(ski).orElse(null)
94+
).orElse(null)));
95+
}
96+
97+
/**
98+
* Get the server entry.
99+
*
100+
* @return the server entry
101+
*/
102+
public CacheEntry getServer() {
103+
return cache.get(SERVER_KEY, key -> getCacheEntry(caService::getOtlpServerCert).orElseThrow(() -> new IllegalStateException("Server certificate not found")));
104+
}
105+
106+
/**
107+
* Get the signing entry.
108+
*
109+
* @return the signing entry
110+
*/
111+
public CacheEntry getSigning() {
112+
return cache.get(SIGNING_KEY, key -> getCacheEntry(caService::getSigningCert).orElseThrow(() -> new IllegalStateException("Signing certificate not found")));
113+
}
114+
115+
/**
116+
* Get the CA entry.
117+
*
118+
* @return the CA entry
119+
*/
120+
public CacheEntry getCa() {
121+
return cache.get(CA_KEY, key -> getCacheEntry(caService::getCaCert).orElseThrow(() -> new IllegalStateException("CA certificate not found")));
122+
}
123+
124+
private Optional<CacheEntry> getCacheEntry(Supplier<CertificateEntry> certSupplier) {
125+
try {
126+
final var certEntry = certSupplier.get();
127+
if (certEntry == null) {
128+
return Optional.empty();
129+
}
130+
final var cert = PemUtils.parseCertificate(certEntry.certificate());
131+
final var privateKey = PemUtils.parsePrivateKey(encryptedValueService.decrypt(certEntry.privateKey()));
132+
LOG.debug("Loaded cert <{}>", certEntry.fingerprint());
133+
return Optional.of(new CacheEntry(privateKey, cert, certEntry.fingerprint()));
134+
} catch (Exception e) {
135+
LOG.error("Couldn't load certificate", e);
136+
throw new RuntimeException(e);
137+
}
138+
}
139+
140+
@Override
141+
protected void startUp() throws Exception {
142+
eventBus.register(this);
143+
}
144+
145+
@Override
146+
protected void shutDown() throws Exception {
147+
eventBus.unregister(this);
148+
}
149+
150+
@Subscribe
151+
@VisibleForTesting
152+
void handleCollectorsConfigEvent(CollectorCaConfigUpdated ignored) {
153+
cache.invalidateAll();
154+
}
155+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
* Copyright (C) 2020 Graylog, Inc.
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the Server Side Public License, version 1,
6+
* as published by MongoDB, Inc.
7+
*
8+
* This program is distributed in the hope that it will be useful,
9+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
* Server Side Public License for more details.
12+
*
13+
* You should have received a copy of the Server Side Public License
14+
* along with this program. If not, see
15+
* <http://www.mongodb.com/licensing/server-side-public-license>.
16+
*/
17+
package org.graylog.collectors;
18+
19+
import jakarta.inject.Inject;
20+
import jakarta.inject.Singleton;
21+
import org.slf4j.Logger;
22+
import org.slf4j.LoggerFactory;
23+
24+
import java.util.Set;
25+
26+
import javax.net.ssl.SSLEngine;
27+
import javax.net.ssl.X509ExtendedKeyManager;
28+
import java.net.Socket;
29+
import java.security.Principal;
30+
import java.security.PrivateKey;
31+
import java.security.cert.X509Certificate;
32+
33+
/**
34+
* Custom key manager that dynamically retrieves the server and signing certificates. This behavior is required
35+
* for certificate renewal.
36+
* <p>
37+
* Extends {@link X509ExtendedKeyManager} rather than implementing {@link javax.net.ssl.X509KeyManager} because
38+
* Netty uses {@link javax.net.ssl.SSLEngine}-based handshakes. The JDK wraps a plain {@code X509KeyManager} in
39+
* an adapter that adds endpoint identification checks; extending the "Extended" variant avoids that wrapper.
40+
*/
41+
@Singleton
42+
public class CollectorCaKeyManager extends X509ExtendedKeyManager {
43+
private static final Logger LOG = LoggerFactory.getLogger(CollectorCaKeyManager.class);
44+
private static final String ALIAS = "server";
45+
private static final Set<String> ED25519_KEY_TYPES = Set.of("EdDSA", "Ed25519");
46+
47+
private final CollectorCaCache caCache;
48+
49+
@Inject
50+
public CollectorCaKeyManager(CollectorCaCache caCache) {
51+
this.caCache = caCache;
52+
}
53+
54+
@Override
55+
public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
56+
if (ED25519_KEY_TYPES.contains(keyType)) {
57+
LOG.debug("Returning <{}> as the server alias for key type <{}>", ALIAS, keyType);
58+
return ALIAS;
59+
}
60+
LOG.debug("Returning null for key type <{}>", keyType);
61+
return null;
62+
}
63+
64+
@Override
65+
public X509Certificate[] getCertificateChain(String alias) {
66+
if (ALIAS.equals(alias)) {
67+
final var serverEntry = caCache.getServer();
68+
final var signingEntry = caCache.getSigning();
69+
LOG.debug("Returning certificate chain for alias <{}>: server-cert={} signing-cert={}",
70+
alias, serverEntry.fingerprint(), signingEntry.fingerprint());
71+
return new X509Certificate[]{serverEntry.cert(), signingEntry.cert()};
72+
}
73+
LOG.debug("Returning null certificate chain for alias <{}>", alias);
74+
return null;
75+
}
76+
77+
@Override
78+
public PrivateKey getPrivateKey(String alias) {
79+
if (ALIAS.equals(alias)) {
80+
final var serverEntry = caCache.getServer();
81+
LOG.debug("Returning private key for server certificate <{}>", serverEntry.fingerprint());
82+
return serverEntry.privateKey();
83+
}
84+
LOG.debug("Returning null private key for alias <{}>", alias);
85+
return null;
86+
}
87+
88+
@Override
89+
public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) {
90+
return chooseServerAlias(keyType, issuers, null);
91+
}
92+
93+
@Override
94+
public String[] getClientAliases(String keyType, Principal[] issuers) {
95+
return null;
96+
}
97+
98+
@Override
99+
public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) {
100+
return null;
101+
}
102+
103+
@Override
104+
public String[] getServerAliases(String keyType, Principal[] issuers) {
105+
return null;
106+
}
107+
}

0 commit comments

Comments
 (0)