Skip to content

Commit 9aa3ad6

Browse files
# Add optionality to the CAs
**Problem Statement** The challenge is the dynamic updating and management of certificates in an application that supports mTLS and interacts with client services that are frequently added and removed. Suppose your application needs to support mTLS with two well-known client services, `foo` and `bar`, each having a specific intermediate CA (Certificate Authority). Client services are dynamically installed/removed from the cluster. For improved resiliency/manageability, you do not want to change the deployment of your application whenever a new client service is installed in the cluster: the client CAs must be dynamically added/removed to your server truststore as their services installed/removed from the cluster. **Proposed Solution** This PR aims to address this issue by allowing certificates to be marked as optional. With this feature, the application can start and continue running regardless of the availability of these certificates. **Current Status** This pull request is currently a draft/proof of concept. It does not yet include tests or documentation and is intended to gather feedback. The implementation includes several TODOs, such as: - Extending optionality to the private key (only added to the certificate). - Adding necessary validations. An example of `application.yaml` with optionality would look like: ``` spring.ssl.bundle.pem.server.keystore.certificate=/var/run/secrets/foo/server.crt spring.ssl.bundle.pem.server.keystore.private-key=/var/run/secrets/foo/server.key spring.ssl.bundle.pem.server.keystore.private-key-password=123456 spring.ssl.bundle.pem.server.reload-on-update=true spring.ssl.bundle.pem.server.truststore.certificate=optional:/var/run/secrets/foo/ca.crt ``` **Related Issue** This PR is related to a comment is this issue [spring-projects#38754](spring-projects#38754 (comment))
1 parent 77089a1 commit 9aa3ad6

File tree

10 files changed

+124
-38
lines changed

10 files changed

+124
-38
lines changed

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/BundleContentProperty.java

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
*/
3636
record BundleContentProperty(String name, String value) {
3737

38+
private static final String OPTIONAL_URL_PREFIX = "optional:";
39+
3840
/**
3941
* Return if the property value is PEM content.
4042
* @return if the value is PEM content
@@ -51,13 +53,24 @@ boolean hasValue() {
5153
return StringUtils.hasText(this.value);
5254
}
5355

54-
Path toWatchPath() {
56+
boolean isOptional() {
57+
return this.value.startsWith(OPTIONAL_URL_PREFIX);
58+
}
59+
60+
String getRawValue() {
61+
if (isOptional()) {
62+
return this.value.substring(OPTIONAL_URL_PREFIX.length());
63+
}
64+
return this.value;
65+
}
66+
67+
WatchablePath toWatchPath() {
5568
try {
56-
Resource resource = getResource();
69+
Resource resource = getResource(getRawValue());
5770
if (!resource.isFile()) {
5871
throw new BundleContentNotWatchableException(this);
5972
}
60-
return Path.of(resource.getFile().getAbsolutePath());
73+
return new WatchablePath(Path.of(resource.getFile().getAbsolutePath()), isOptional());
6174
}
6275
catch (Exception ex) {
6376
if (ex instanceof BundleContentNotWatchableException bundleContentNotWatchableException) {
@@ -68,9 +81,9 @@ Path toWatchPath() {
6881
}
6982
}
7083

71-
private Resource getResource() {
84+
private Resource getResource(String value) {
7285
Assert.state(!isPemContent(), "Value contains PEM content");
73-
return new ApplicationResourceLoader().getResource(this.value);
86+
return new ApplicationResourceLoader().getResource(value);
7487
}
7588

7689
}

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/FileWatcher.java

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ class FileWatcher implements Closeable {
7474
* @param paths the files or directories to watch
7575
* @param action the action to take when changes are detected
7676
*/
77-
void watch(Set<Path> paths, Runnable action) {
77+
void watch(Set<WatchablePath> paths, Runnable action) {
7878
Assert.notNull(paths, "Paths must not be null");
7979
Assert.notNull(action, "Action must not be null");
8080
if (paths.isEmpty()) {
@@ -133,7 +133,11 @@ private void onThreadException(Thread thread, Throwable throwable) {
133133
}
134134

135135
void register(Registration registration) throws IOException {
136-
for (Path path : registration.paths()) {
136+
for (WatchablePath watchablePath : registration.paths()) {
137+
Path path = watchablePath.path();
138+
if (watchablePath.optional() && !Files.exists(path)) {
139+
path = path.getParent();
140+
}
137141
if (!Files.isRegularFile(path) && !Files.isDirectory(path)) {
138142
throw new IOException("'%s' is neither a file nor a directory".formatted(path));
139143
}
@@ -210,19 +214,23 @@ public void close() throws IOException {
210214
/**
211215
* An individual watch registration.
212216
*/
213-
private record Registration(Set<Path> paths, Runnable action) {
217+
private record Registration(Set<WatchablePath> paths, Runnable action) {
214218

215219
Registration {
216-
paths = paths.stream().map(Path::toAbsolutePath).collect(Collectors.toSet());
220+
paths = paths.stream().map(watchablePath ->
221+
new WatchablePath(watchablePath.path().toAbsolutePath(), watchablePath.optional()))
222+
.collect(Collectors.toSet());
217223
}
218224

219225
boolean manages(Path file) {
220226
Path absolutePath = file.toAbsolutePath();
221-
return this.paths.contains(absolutePath) || isInDirectories(absolutePath);
227+
return this.paths.stream()
228+
.map(WatchablePath::path)
229+
.anyMatch(absolutePath::equals) || isInDirectories(absolutePath);
222230
}
223231

224232
private boolean isInDirectories(Path file) {
225-
return this.paths.stream().filter(Files::isDirectory).anyMatch(file::startsWith);
233+
return this.paths.stream().map(WatchablePath::path).filter(Files::isDirectory).anyMatch(file::startsWith);
226234
}
227235
}
228236

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/PropertiesSslBundle.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import org.springframework.boot.ssl.pem.PemSslStoreDetails;
3030
import org.springframework.core.style.ToStringCreator;
3131
import org.springframework.util.Assert;
32+
import org.springframework.util.StringUtils;
3233

3334
/**
3435
* {@link SslBundle} backed by {@link JksSslBundleProperties} or
@@ -40,6 +41,8 @@
4041
*/
4142
public final class PropertiesSslBundle implements SslBundle {
4243

44+
private static final String OPTIONAL_URL_PREFIX = "optional:";
45+
4346
private final SslStoreBundle stores;
4447

4548
private final SslBundleKey key;
@@ -100,7 +103,7 @@ public static SslBundle get(PemSslBundleProperties properties) {
100103
PemSslStore keyStore = getPemSslStore("keystore", properties.getKeystore());
101104
if (keyStore != null) {
102105
keyStore = keyStore.withAlias(properties.getKey().getAlias())
103-
.withPassword(properties.getKey().getPassword());
106+
.withPassword(properties.getKey().getPassword());
104107
}
105108
PemSslStore trustStore = getPemSslStore("truststore", properties.getTruststore());
106109
SslStoreBundle storeBundle = new PemSslStoreBundle(keyStore, trustStore);
@@ -118,8 +121,19 @@ private static PemSslStore getPemSslStore(String propertyName, PemSslBundlePrope
118121
}
119122

120123
private static PemSslStoreDetails asPemSslStoreDetails(PemSslBundleProperties.Store properties) {
121-
return new PemSslStoreDetails(properties.getType(), properties.getCertificate(), properties.getPrivateKey(),
122-
properties.getPrivateKeyPassword());
124+
return new PemSslStoreDetails(properties.getType(), getRawCertificate(properties.getCertificate()), properties.getPrivateKey(),
125+
properties.getPrivateKeyPassword(), isCertificateOptional(properties.getCertificate()));
126+
}
127+
128+
private static boolean isCertificateOptional(String certificate) {
129+
return StringUtils.hasText(certificate) && certificate.startsWith(OPTIONAL_URL_PREFIX);
130+
}
131+
132+
private static String getRawCertificate(String certificate) {
133+
if (isCertificateOptional(certificate)) {
134+
return certificate.substring(OPTIONAL_URL_PREFIX.length());
135+
}
136+
return certificate;
123137
}
124138

125139
/**

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/ssl/SslPropertiesBundleRegistrar.java

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
package org.springframework.boot.autoconfigure.ssl;
1818

19-
import java.nio.file.Path;
2019
import java.util.ArrayList;
2120
import java.util.List;
2221
import java.util.Map;
@@ -54,14 +53,14 @@ public void registerBundles(SslBundleRegistry registry) {
5453
}
5554

5655
private <P extends SslBundleProperties> void registerBundles(SslBundleRegistry registry, Map<String, P> properties,
57-
Function<P, SslBundle> bundleFactory, Function<Bundle<P>, Set<Path>> watchedPaths) {
56+
Function<P, SslBundle> bundleFactory, Function<Bundle<P>, Set<WatchablePath>> watchedPaths) {
5857
properties.forEach((bundleName, bundleProperties) -> {
5958
Supplier<SslBundle> bundleSupplier = () -> bundleFactory.apply(bundleProperties);
6059
try {
6160
registry.registerBundle(bundleName, bundleSupplier.get());
6261
if (bundleProperties.isReloadOnUpdate()) {
63-
Supplier<Set<Path>> pathsSupplier = () -> watchedPaths
64-
.apply(new Bundle<>(bundleName, bundleProperties));
62+
Supplier<Set<WatchablePath>> pathsSupplier = () -> watchedPaths
63+
.apply(new Bundle<>(bundleName, bundleProperties));
6564
watchForUpdates(registry, bundleName, pathsSupplier, bundleSupplier);
6665
}
6766
}
@@ -71,7 +70,7 @@ private <P extends SslBundleProperties> void registerBundles(SslBundleRegistry r
7170
});
7271
}
7372

74-
private void watchForUpdates(SslBundleRegistry registry, String bundleName, Supplier<Set<Path>> pathsSupplier,
73+
private void watchForUpdates(SslBundleRegistry registry, String bundleName, Supplier<Set<WatchablePath>> pathsSupplier,
7574
Supplier<SslBundle> bundleSupplier) {
7675
try {
7776
this.fileWatcher.watch(pathsSupplier.get(), () -> registry.updateBundle(bundleName, bundleSupplier.get()));
@@ -81,33 +80,33 @@ private void watchForUpdates(SslBundleRegistry registry, String bundleName, Supp
8180
}
8281
}
8382

84-
private Set<Path> watchedJksPaths(Bundle<JksSslBundleProperties> bundle) {
83+
private Set<WatchablePath> watchedJksPaths(Bundle<JksSslBundleProperties> bundle) {
8584
List<BundleContentProperty> watched = new ArrayList<>();
8685
watched.add(new BundleContentProperty("keystore.location", bundle.properties().getKeystore().getLocation()));
8786
watched
88-
.add(new BundleContentProperty("truststore.location", bundle.properties().getTruststore().getLocation()));
87+
.add(new BundleContentProperty("truststore.location", bundle.properties().getTruststore().getLocation()));
8988
return watchedPaths(bundle.name(), watched);
9089
}
9190

92-
private Set<Path> watchedPemPaths(Bundle<PemSslBundleProperties> bundle) {
91+
private Set<WatchablePath> watchedPemPaths(Bundle<PemSslBundleProperties> bundle) {
9392
List<BundleContentProperty> watched = new ArrayList<>();
9493
watched
95-
.add(new BundleContentProperty("keystore.private-key", bundle.properties().getKeystore().getPrivateKey()));
94+
.add(new BundleContentProperty("keystore.private-key", bundle.properties().getKeystore().getPrivateKey()));
9695
watched
97-
.add(new BundleContentProperty("keystore.certificate", bundle.properties().getKeystore().getCertificate()));
96+
.add(new BundleContentProperty("keystore.certificate", bundle.properties().getKeystore().getCertificate()));
9897
watched.add(new BundleContentProperty("truststore.private-key",
9998
bundle.properties().getTruststore().getPrivateKey()));
10099
watched.add(new BundleContentProperty("truststore.certificate",
101100
bundle.properties().getTruststore().getCertificate()));
102101
return watchedPaths(bundle.name(), watched);
103102
}
104103

105-
private Set<Path> watchedPaths(String bundleName, List<BundleContentProperty> properties) {
104+
private Set<WatchablePath> watchedPaths(String bundleName, List<BundleContentProperty> properties) {
106105
try {
107106
return properties.stream()
108-
.filter(BundleContentProperty::hasValue)
109-
.map(BundleContentProperty::toWatchPath)
110-
.collect(Collectors.toSet());
107+
.filter(BundleContentProperty::hasValue)
108+
.map(BundleContentProperty::toWatchPath)
109+
.collect(Collectors.toSet());
111110
}
112111
catch (BundleContentNotWatchableException ex) {
113112
throw ex.withBundleName(bundleName);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
* Copyright 2012-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.autoconfigure.ssl;
18+
19+
import java.nio.file.Path;
20+
21+
record WatchablePath(Path path, Boolean optional) {
22+
}

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/LoadedPemSslStore.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ private static UncheckedIOException asUncheckedIOException(String message, Excep
5858
}
5959

6060
private static List<X509Certificate> loadCertificates(PemSslStoreDetails details) throws IOException {
61-
PemContent pemContent = PemContent.load(details.certificates());
61+
PemContent pemContent = PemContent.load(details.certificates(), details.optional());
6262
if (pemContent == null) {
6363
return null;
6464
}
@@ -72,6 +72,11 @@ private static PrivateKey loadPrivateKey(PemSslStoreDetails details) throws IOEx
7272
return (pemContent != null) ? pemContent.getPrivateKey(details.privateKeyPassword()) : null;
7373
}
7474

75+
@Override
76+
public boolean optional() {
77+
return this.details.optional();
78+
}
79+
7580
@Override
7681
public String type() {
7782
return this.details.type();

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemContent.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,21 @@ public String toString() {
104104
return this.text;
105105
}
106106

107+
/**
108+
* Load {@link PemContent} from the given content (either the PEM content itself or a
109+
* reference to the resource to load).
110+
* @param content the content to load
111+
* @param isOptional the content to load may be optional
112+
* @return a new {@link PemContent} instance
113+
* @throws IOException on IO error
114+
*/
115+
static PemContent load(String content, Boolean isOptional) throws IOException {
116+
if (isOptional && !Files.exists(Path.of(content))) {
117+
return null;
118+
}
119+
return load(content);
120+
}
121+
107122
/**
108123
* Load {@link PemContent} from the given content (either the PEM content itself or a
109124
* reference to the resource to load).

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStore.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
*/
3434
public interface PemSslStore {
3535

36+
37+
boolean optional();
38+
3639
/**
3740
* The key store type, for example {@code JKS} or {@code PKCS11}. A {@code null} value
3841
* will use {@link KeyStore#getDefaultType()}).
@@ -164,6 +167,11 @@ public PrivateKey privateKey() {
164167
return privateKey;
165168
}
166169

170+
@Override
171+
public boolean optional() {
172+
return false; //TODO
173+
}
174+
167175
};
168176
}
169177

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreBundle.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ public KeyStore getTrustStore() {
8282
}
8383

8484
private static KeyStore createKeyStore(String name, PemSslStore pemSslStore) {
85-
if (pemSslStore == null) {
85+
if (pemSslStore == null || pemSslStore.optional() && pemSslStore.certificates() == null) {
8686
return null;
8787
}
8888
try {

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/ssl/pem/PemSslStoreDetails.java

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,14 @@
3636
* @param privateKey the private key content (either the PEM content itself or a reference
3737
* to the resource to load)
3838
* @param privateKeyPassword a password used to decrypt an encrypted private key
39+
* @param optional certificates/privateKey may be optional
3940
* @author Scott Frederick
4041
* @author Phillip Webb
4142
* @since 3.1.0
4243
* @see PemSslStore#load(PemSslStoreDetails)
4344
*/
4445
public record PemSslStoreDetails(String type, String alias, String password, String certificates, String privateKey,
45-
String privateKeyPassword) {
46+
String privateKeyPassword, boolean optional) {
4647

4748
/**
4849
* Create a new {@link PemSslStoreDetails} instance.
@@ -71,9 +72,10 @@ public record PemSslStoreDetails(String type, String alias, String password, Str
7172
* @param privateKey the private key content (either the PEM content itself or a
7273
* reference to the resource to load)
7374
* @param privateKeyPassword a password used to decrypt an encrypted private key
75+
* @param optional certificates/privateKey may be optional
7476
*/
75-
public PemSslStoreDetails(String type, String certificate, String privateKey, String privateKeyPassword) {
76-
this(type, null, null, certificate, privateKey, privateKeyPassword);
77+
public PemSslStoreDetails(String type, String certificate, String privateKey, String privateKeyPassword, boolean optional) {
78+
this(type, null, null, certificate, privateKey, privateKeyPassword, optional);
7779
}
7880

7981
/**
@@ -86,7 +88,7 @@ public PemSslStoreDetails(String type, String certificate, String privateKey, St
8688
* reference to the resource to load)
8789
*/
8890
public PemSslStoreDetails(String type, String certificate, String privateKey) {
89-
this(type, certificate, privateKey, null);
91+
this(type, certificate, privateKey, null, false);
9092
}
9193

9294
/**
@@ -97,7 +99,7 @@ public PemSslStoreDetails(String type, String certificate, String privateKey) {
9799
*/
98100
public PemSslStoreDetails withAlias(String alias) {
99101
return new PemSslStoreDetails(this.type, alias, this.password, this.certificates, this.privateKey,
100-
this.privateKeyPassword);
102+
this.privateKeyPassword, this.optional);
101103
}
102104

103105
/**
@@ -108,7 +110,7 @@ public PemSslStoreDetails withAlias(String alias) {
108110
*/
109111
public PemSslStoreDetails withPassword(String password) {
110112
return new PemSslStoreDetails(this.type, this.alias, password, this.certificates, this.privateKey,
111-
this.privateKeyPassword);
113+
this.privateKeyPassword, this.optional);
112114
}
113115

114116
/**
@@ -118,7 +120,7 @@ public PemSslStoreDetails withPassword(String password) {
118120
*/
119121
public PemSslStoreDetails withPrivateKey(String privateKey) {
120122
return new PemSslStoreDetails(this.type, this.alias, this.password, this.certificates, privateKey,
121-
this.privateKeyPassword);
123+
this.privateKeyPassword, this.optional);
122124
}
123125

124126
/**
@@ -128,7 +130,7 @@ public PemSslStoreDetails withPrivateKey(String privateKey) {
128130
*/
129131
public PemSslStoreDetails withPrivateKeyPassword(String privateKeyPassword) {
130132
return new PemSslStoreDetails(this.type, this.alias, this.password, this.certificates, this.privateKey,
131-
privateKeyPassword);
133+
privateKeyPassword, this.optional);
132134
}
133135

134136
boolean isEmpty() {

0 commit comments

Comments
 (0)